Skip to content

Commit d3f2a7b

Browse files
fix(cypher): make expand_from_bound_terminal OPTIONAL fallback lossless
Signed-off-by: SEPURI-SAI-KRISHNA <saik20533@gmail.com>
1 parent 7814e1b commit d3f2a7b

2 files changed

Lines changed: 195 additions & 15 deletions

File tree

‎src/cypher/cypher.c‎

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4549,21 +4549,43 @@ static void expand_from_bound_terminal(cbm_store_t *store, cbm_pattern_t *patn,
45494549
bool scan_targets =
45504550
!rel_inbound; /* (start)->(term): start = edge source = scan term's inbound */
45514551

4552-
size_t alloc_n = (size_t)*bind_count * (size_t)CYP_GROWTH_10 + SKIP_ONE;
4552+
/* Size this hop's output for BOTH writers without dropping any row: the
4553+
* materialised expansion is capped at max_new = *bind_count * 10 rows (only
4554+
* the WRITE is gated on max_new; for OPTIONAL, match DETECTION below is not,
4555+
* so a full buffer never hides a real neighbour), and the OPTIONAL fallback
4556+
* emits at most one row per source (<= *bind_count). A source either matches
4557+
* (feeds the expansion) or takes the fallback, never both, so the two counts
4558+
* are additive and bounded by max_new + *bind_count. Computed in size_t so
4559+
* the product cannot overflow.
4560+
* The previous "+ SKIP_ONE" sizing tied max_new to the whole buffer, so once
4561+
* the expansion saturated it the fallback guard silently dropped every later
4562+
* OPTIONAL no-match row (a data-loss bug, not an OOB — the fallback stayed
4563+
* in-bounds behind that guard) — exactly the rows
4564+
* `OPTIONAL MATCH ... WHERE <start> IS NULL` is meant to surface. */
4565+
size_t alloc_n = (size_t)*bind_count * (size_t)CYP_GROWTH_10 + (size_t)*bind_count;
45534566
binding_t *new_bindings = malloc(alloc_n * sizeof(binding_t));
45544567
if (!new_bindings) {
45554568
return;
45564569
}
45574570
int new_count = 0;
4558-
int max_new = (int)alloc_n;
4571+
int max_new = *bind_count * CYP_GROWTH_10;
45594572

4560-
for (int bi = 0; bi < *bind_count && new_count < max_new; bi++) {
4573+
for (int bi = 0; bi < *bind_count; bi++) {
45614574
binding_t *b = &(*bindings)[bi];
45624575
cbm_node_t *term = binding_get(b, patn->nodes[1].variable ? patn->nodes[1].variable : "");
45634576
int match_count = 0;
45644577
if (term) {
4578+
/* Only the WRITE is gated on the ceiling (below). For OPTIONAL, match
4579+
* DETECTION is not: `|| opt` keeps the scan running past a full buffer,
4580+
* so match_count counts every real neighbour. If detection stopped at
4581+
* max_new too, a saturated buffer would leave match_count at 0 for a
4582+
* terminal that actually has callers, and the fallback below would
4583+
* fabricate an UNBOUND "dead code" row for live code — worse than
4584+
* dropping a row. Non-OPTIONAL never reads match_count, so its guard
4585+
* reduces to `new_count < max_new` and keeps the cheap early-exit. */
45654586
for (int ti = 0;
4566-
ti < (rel->type_count > 0 ? rel->type_count : 1) && new_count < max_new; ti++) {
4587+
ti < (rel->type_count > 0 ? rel->type_count : 1) && (new_count < max_new || opt);
4588+
ti++) {
45674589
cbm_edge_t *edges = NULL;
45684590
int edge_count = 0;
45694591
if (rel->type_count > 0) {
@@ -4579,7 +4601,7 @@ static void expand_from_bound_terminal(cbm_store_t *store, cbm_pattern_t *patn,
45794601
} else {
45804602
cbm_store_find_edges_by_source(store, term->id, &edges, &edge_count);
45814603
}
4582-
for (int ei = 0; ei < edge_count && new_count < max_new; ei++) {
4604+
for (int ei = 0; ei < edge_count && (new_count < max_new || opt); ei++) {
45834605
int64_t sid = scan_targets ? edges[ei].source_id : edges[ei].target_id;
45844606
cbm_node_t found = {0};
45854607
if (cbm_store_find_node_by_id(store, sid, &found) != CBM_STORE_OK) {
@@ -4589,22 +4611,30 @@ static void expand_from_bound_terminal(cbm_store_t *store, cbm_pattern_t *patn,
45894611
node_fields_free(&found);
45904612
continue;
45914613
}
4592-
binding_t nb = {0};
4593-
binding_copy(&nb, b);
4594-
binding_set(&nb, start_var, &found);
4595-
if (rel->variable) {
4596-
binding_set_edge(&nb, rel->variable, &edges[ei]);
4614+
match_count++;
4615+
if (new_count < max_new) {
4616+
binding_t nb = {0};
4617+
binding_copy(&nb, b);
4618+
binding_set(&nb, start_var, &found);
4619+
if (rel->variable) {
4620+
binding_set_edge(&nb, rel->variable, &edges[ei]);
4621+
}
4622+
new_bindings[new_count++] = nb;
45974623
}
45984624
node_fields_free(&found);
4599-
new_bindings[new_count++] = nb;
4600-
match_count++;
46014625
}
46024626
cbm_store_free_edges(edges, edge_count);
46034627
}
46044628
}
4605-
if (opt && match_count == 0 && new_count < max_new) {
4606-
/* No matching neighbour: keep the row with start_var left UNBOUND so
4607-
* `WHERE <start> IS NULL` correctly identifies the no-edge case. */
4629+
if (opt && match_count == 0) {
4630+
/* GENUINELY no matching neighbour: for OPTIONAL the scan above runs
4631+
* unconditionally, so match_count is the true neighbour count and
4632+
* match_count == 0 here means the terminal really has none — not merely
4633+
* that the buffer filled first. Keep the row with start_var left UNBOUND
4634+
* so `WHERE <start> IS NULL` correctly identifies the no-edge case. The
4635+
* buffer is sized max_new + *bind_count precisely so every fallback row
4636+
* has a slot — no guard needed, and no OPTIONAL no-match row is dropped
4637+
* even after the expansion has saturated max_new. */
46084638
binding_t nb = {0};
46094639
binding_copy(&nb, b);
46104640
new_bindings[new_count++] = nb;

‎tests/test_cypher.c‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,154 @@ TEST(cypher_exec_optional_rel_leaf_fallback_survives) {
675675
PASS();
676676
}
677677

678+
/* Sibling of the leaf-fallback test, but for the BOUND-TERMINAL expansion path
679+
* (expand_from_bound_terminal): the OPTIONAL start var is unbound and the
680+
* terminal is bound, e.g. `MATCH (f) OPTIONAL MATCH (c)-[:CALLS]->(f)`. That
681+
* function sized its hop buffer bind_count*10 + 1 and gated the OPTIONAL
682+
* fallback on `new_count < max_new`, so once one terminal's expansion saturated
683+
* the buffer, every LATER terminal's no-match row was silently dropped — the
684+
* rows `WHERE c IS NULL` is meant to surface. Not an overflow (the guard kept
685+
* the write in bounds) but real data loss. Lossless sizing (bind_count*10 +
686+
* bind_count, fallback ungated) preserves them. */
687+
TEST(cypher_exec_bound_terminal_optional_fallback_survives) {
688+
cbm_store_t *s = cbm_store_open_memory();
689+
cbm_store_upsert_project(s, "test", "/tmp/test");
690+
691+
/* 2 Function nodes (hub + leaf) → bind_count = 2, max_new = 20. The hub has
692+
* 21 incoming CALLS edges (> max_new), so its expansion saturates the write
693+
* buffer; the leaf has none, so it must still yield its OPTIONAL no-match
694+
* row. Under the original "+ SKIP_ONE" sizing that row was dropped once the
695+
* buffer filled; the lossless sizing preserves it regardless of the order in
696+
* which the (unordered) label scan visits the two terminals. */
697+
cbm_node_t hub = {
698+
.project = "test", .label = "Function", .name = "hub", .qualified_name = "test.hub"};
699+
int64_t hub_id = cbm_store_upsert_node(s, &hub);
700+
cbm_node_t leaf = {
701+
.project = "test", .label = "Function", .name = "leaf", .qualified_name = "test.leaf"};
702+
cbm_store_upsert_node(s, &leaf);
703+
704+
/* Callers are non-Function so they do not inflate bind_count. Each CALLS the
705+
* hub (source = caller, target = hub), so from the bound terminal `hub` the
706+
* expansion binds the unbound start `c` to each caller. */
707+
for (int i = 0; i < 21; i++) {
708+
char nm[32];
709+
char qn[48];
710+
snprintf(nm, sizeof(nm), "caller%02d", i);
711+
snprintf(qn, sizeof(qn), "test.caller%02d", i);
712+
cbm_node_t caller = {.project = "test", .label = "Var", .name = nm, .qualified_name = qn};
713+
int64_t cid = cbm_store_upsert_node(s, &caller);
714+
cbm_edge_t e = {.project = "test", .source_id = cid, .target_id = hub_id, .type = "CALLS"};
715+
cbm_store_insert_edge(s, &e);
716+
}
717+
718+
/* max_rows 0 → the 100000 result ceiling, so the output LIMIT does not hide
719+
* the leaf row; we are testing the hop buffer, not the output cap. */
720+
cbm_cypher_result_t r = {0};
721+
int rc = cbm_cypher_execute(
722+
s, "MATCH (f:Function) OPTIONAL MATCH (c)-[:CALLS]->(f) RETURN f.name, c.name", "test", 0,
723+
&r);
724+
ASSERT_EQ(rc, 0);
725+
ASSERT_EQ(r.col_count, 2);
726+
727+
/* The leaf terminal has no incoming CALLS edge, so it must yield one row with
728+
* the start var `c` unbound (""). Before the fix this row was dropped once the
729+
* hub saturated the buffer. Also confirm the hub still expanded to bound rows. */
730+
bool leaf_fallback = false;
731+
bool hub_expanded = false;
732+
for (int i = 0; i < r.row_count; i++) {
733+
const char *f = r.rows[i][0];
734+
const char *c = r.rows[i][1];
735+
if (strcmp(f, "leaf") == 0 && c[0] == '\0') {
736+
leaf_fallback = true;
737+
}
738+
if (strcmp(f, "hub") == 0 && c[0] != '\0') {
739+
hub_expanded = true;
740+
}
741+
}
742+
ASSERT_TRUE(leaf_fallback); /* the bound-terminal OPTIONAL no-match row survived */
743+
ASSERT_TRUE(hub_expanded); /* the expansion still produced bound rows */
744+
745+
cbm_cypher_result_free(&r);
746+
cbm_store_close(s);
747+
PASS();
748+
}
749+
750+
/* Discriminating companion to the test above: preserving OPTIONAL no-match rows
751+
* under saturation must NOT be bought by fabricating them. If match detection is
752+
* gated on the same ceiling as the write, then once one terminal fills the
753+
* buffer, another terminal that genuinely HAS callers is never scanned, its
754+
* match_count stays 0, and the fallback invents an unbound "dead code" row for it
755+
* — reporting live code as dead, which is worse than dropping a row.
756+
*
757+
* The construction is deliberately order-independent: BOTH hubs have enough
758+
* callers to saturate the buffer on their own, so whichever the scan visits
759+
* second is guaranteed to be processed after saturation. Under the gated variant
760+
* that second hub is fabricated as dead; the assertion "no hub with callers is
761+
* dead" then fails no matter which order `find_nodes_by_label` returns (its query
762+
* has no ORDER BY, so the test must not depend on one). The fix makes it pass. */
763+
TEST(cypher_exec_bound_terminal_saturation_no_false_deadcode) {
764+
cbm_store_t *s = cbm_store_open_memory();
765+
cbm_store_upsert_project(s, "test", "/tmp/test");
766+
767+
/* 3 Function terminals → bind_count = 3, max_new = 30. */
768+
cbm_node_t hubA = {
769+
.project = "test", .label = "Function", .name = "hubA", .qualified_name = "test.hubA"};
770+
int64_t hubA_id = cbm_store_upsert_node(s, &hubA);
771+
cbm_node_t hubB = {
772+
.project = "test", .label = "Function", .name = "hubB", .qualified_name = "test.hubB"};
773+
int64_t hubB_id = cbm_store_upsert_node(s, &hubB);
774+
cbm_node_t leaf = {
775+
.project = "test", .label = "Function", .name = "leaf", .qualified_name = "test.leaf"};
776+
cbm_store_upsert_node(s, &leaf);
777+
778+
/* BOTH hubs get 35 callers (> max_new = 30), so either one saturates the write
779+
* buffer by itself; leaf gets none. Callers are non-Function so they don't
780+
* inflate bind_count. */
781+
for (int i = 0; i < 70; i++) {
782+
char nm[32];
783+
char qn[48];
784+
snprintf(nm, sizeof(nm), "caller%02d", i);
785+
snprintf(qn, sizeof(qn), "test.caller%02d", i);
786+
cbm_node_t caller = {.project = "test", .label = "Var", .name = nm, .qualified_name = qn};
787+
int64_t cid = cbm_store_upsert_node(s, &caller);
788+
int64_t tgt = i < 35 ? hubA_id : hubB_id; /* 35 -> hubA, 35 -> hubB */
789+
cbm_edge_t e = {.project = "test", .source_id = cid, .target_id = tgt, .type = "CALLS"};
790+
cbm_store_insert_edge(s, &e);
791+
}
792+
793+
cbm_cypher_result_t r = {0};
794+
int rc = cbm_cypher_execute(
795+
s, "MATCH (f:Function) OPTIONAL MATCH (c)-[:CALLS]->(f) RETURN f.name, c.name", "test", 0,
796+
&r);
797+
ASSERT_EQ(rc, 0);
798+
ASSERT_EQ(r.col_count, 2);
799+
800+
bool hub_expanded = false; /* sanity: the buffer really did fill from a hub */
801+
bool hub_false_deadcode = false; /* the bug: a hub with callers invented as dead */
802+
bool leaf_deadcode = false; /* the lossless property: genuine dead code kept */
803+
for (int i = 0; i < r.row_count; i++) {
804+
const char *f = r.rows[i][0];
805+
const char *c = r.rows[i][1];
806+
bool is_hub = strcmp(f, "hubA") == 0 || strcmp(f, "hubB") == 0;
807+
if (is_hub && c[0] != '\0') {
808+
hub_expanded = true;
809+
}
810+
if (is_hub && c[0] == '\0') {
811+
hub_false_deadcode = true;
812+
}
813+
if (strcmp(f, "leaf") == 0 && c[0] == '\0') {
814+
leaf_deadcode = true;
815+
}
816+
}
817+
ASSERT_TRUE(hub_expanded);
818+
ASSERT_FALSE(hub_false_deadcode); /* live code with callers must never appear as dead */
819+
ASSERT_TRUE(leaf_deadcode); /* genuine no-match row still survives saturation */
820+
821+
cbm_cypher_result_free(&r);
822+
cbm_store_close(s);
823+
PASS();
824+
}
825+
678826
TEST(cypher_exec_where_eq) {
679827
cbm_store_t *s = setup_cypher_store();
680828
cbm_cypher_result_t r = {0};
@@ -3425,6 +3573,8 @@ SUITE(cypher) {
34253573
RUN_TEST(cypher_cross_join_alloc_rejects_overflow);
34263574
RUN_TEST(cypher_exec_optional_rel_saturated_no_overflow);
34273575
RUN_TEST(cypher_exec_optional_rel_leaf_fallback_survives);
3576+
RUN_TEST(cypher_exec_bound_terminal_optional_fallback_survives);
3577+
RUN_TEST(cypher_exec_bound_terminal_saturation_no_false_deadcode);
34283578
RUN_TEST(cypher_issue240_labels_function);
34293579
RUN_TEST(cypher_issue237_distinct_order_limit);
34303580
RUN_TEST(cypher_issue873_distinct_order_limit_dedupes_before_limit);

0 commit comments

Comments
 (0)