Merge pull request #1385 from DeusData/fix/cypher-optional-no-fabrication

fix(cypher): never fabricate an OPTIONAL "no match" after the budget fills
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-07-31 22:47:36 +02:00
commit d6be58ef9d
2 files changed
+111 -13

No files matched your search

+28 -13
View File
@@ -3082,7 +3082,15 @@ static void process_edges(cbm_store_t *store, cbm_edge_t *edges, int edge_count,
* the result of dead-code queries and produced wrong rows (#627). */
cbm_node_t *bound_to = binding_get(b, to_var);
int64_t bound_to_id = bound_to ? bound_to->id : 0;
for (int ei = 0; ei < edge_count && *new_count < max_new; ei++) {
/* The budget caps MATERIALISATION, not detection: `match_count` must stay
* truthful even after `new_count` hits `max_new`. Gating the loop itself
* (`ei < edge_count && *new_count < max_new`) stopped fetching entirely, so
* a saturated source reported `match_count == 0` despite having neighbours —
* and the OPTIONAL fallback in expand_pattern_rels then emitted an unbound
* "no match" row for it. `WHERE x IS NULL` reads those rows as "nothing
* points here", i.e. it reported live code as dead. Losing rows is
* recoverable; asserting a match does not exist when it does is not. */
for (int ei = 0; ei < edge_count; ei++) {
int64_t tid = inbound ? edges[ei].source_id : edges[ei].target_id;
if (bound_to && tid != bound_to_id) {
continue; /* edge does not reach the already-bound terminal node */
@@ -3099,15 +3107,17 @@ static void process_edges(cbm_store_t *store, cbm_edge_t *edges, int edge_count,
node_fields_free(&found);
continue;
}
binding_t nb = {0};
binding_copy(&nb, b);
binding_set(&nb, to_var, &found);
if (rel_var) {
binding_set_edge(&nb, rel_var, &edges[ei]);
(*match_count)++; /* a real neighbour exists, budget or not */
if (*new_count < max_new) {
binding_t nb = {0};
binding_copy(&nb, b);
binding_set(&nb, to_var, &found);
if (rel_var) {
binding_set_edge(&nb, rel_var, &edges[ei]);
}
new_bindings[(*new_count)++] = nb;
}
node_fields_free(&found);
new_bindings[(*new_count)++] = nb;
(*match_count)++;
}
}
@@ -3141,7 +3151,10 @@ static void expand_var_length(cbm_store_t *store, cbm_rel_pattern_t *rel,
cbm_traverse_result_t tr = {0};
const char *dir = rel->direction ? rel->direction : "outbound";
cbm_store_bfs(store, src->id, dir, rel->types, rel->type_count, max_depth, CBM_PERCENT, &tr);
for (int v = 0; v < tr.visited_count && *new_count < max_new; v++) {
/* Same contract as process_edges: the budget caps materialisation, never
* detection, so a saturated source cannot report match_count == 0 and get a
* fabricated OPTIONAL "no match" row. */
for (int v = 0; v < tr.visited_count; v++) {
cbm_node_hop_t *hop = &tr.visited[v];
if (hop->hop < rel->min_hops) {
continue;
@@ -3152,11 +3165,13 @@ static void expand_var_length(cbm_store_t *store, cbm_rel_pattern_t *rel,
if (!check_inline_props(&hop->node, target_node->props, target_node->prop_count, store)) {
continue;
}
binding_t nb = {0};
binding_copy(&nb, b);
binding_set(&nb, to_var, &hop->node);
new_bindings[(*new_count)++] = nb;
(*match_count)++;
if (*new_count < max_new) {
binding_t nb = {0};
binding_copy(&nb, b);
binding_set(&nb, to_var, &hop->node);
new_bindings[(*new_count)++] = nb;
}
}
cbm_store_traverse_free(&tr);
}
+83
View File
@@ -580,6 +580,88 @@ TEST(cypher_exec_optional_rel_saturated_no_overflow) {
PASS();
}
/* Reproduce-first: after the expansion budget is exhausted, OPTIONAL MATCH must
* not FABRICATE a "no match" row for a source that genuinely has matches.
*
* process_edges / expand_var_length used to carry the budget in the LOOP
* condition (`ei < edge_count && *new_count < max_new`), so once new_count hit
* max_new they stopped iterating entirely and never incremented match_count —
* even though neighbours existed. expand_pattern_rels' ungated fallback then saw
* match_count == 0 and emitted an unbound row. `WHERE b IS NULL` reads that as
* "nothing points here", so a dead-code query reported LIVE code as dead.
*
* Shape: A saturates the budget, B genuinely has no callees, C has 5. Only B may
* appear with an unbound b. Asserting on C specifically is what discriminates —
* the pre-fix code emits C with an empty b, and a `row_count` check would not
* notice. Deterministic: insertion order fixes the scan order (rowid), and every
* count is exact. */
TEST(cypher_exec_optional_saturated_does_not_fabricate_no_match) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* 3 Function nodes → scan_count = 3; max_rows = 3 → bind_cap = 3 and
* max_new = 30. A alone exceeds that, so B and C are reached with the
* budget already spent — the regime that produced the fabrication. */
cbm_node_t a = {.project = "test", .label = "Function", .name = "A", .qualified_name = "test.A"};
cbm_node_t b = {.project = "test", .label = "Function", .name = "B", .qualified_name = "test.B"};
cbm_node_t c = {.project = "test", .label = "Function", .name = "C", .qualified_name = "test.C"};
int64_t a_id = cbm_store_upsert_node(s, &a);
(void)cbm_store_upsert_node(s, &b); /* B: no outgoing CALLS at all */
int64_t c_id = cbm_store_upsert_node(s, &c);
ASSERT_GT(a_id, 0);
ASSERT_GT(c_id, 0);
/* Callees are label Var so they do not inflate scan_count/bind_cap. */
for (int i = 0; i < 40; i++) {
char nm[32];
char qn[48];
snprintf(nm, sizeof(nm), "acallee%d", i);
snprintf(qn, sizeof(qn), "test.acallee%d", i);
cbm_node_t t = {.project = "test", .label = "Var", .name = nm, .qualified_name = qn};
int64_t tid = cbm_store_upsert_node(s, &t);
cbm_edge_t e = {.project = "test", .source_id = a_id, .target_id = tid, .type = "CALLS"};
cbm_store_insert_edge(s, &e);
}
for (int i = 0; i < 5; i++) {
char nm[32];
char qn[48];
snprintf(nm, sizeof(nm), "ccallee%d", i);
snprintf(qn, sizeof(qn), "test.ccallee%d", i);
cbm_node_t t = {.project = "test", .label = "Var", .name = nm, .qualified_name = qn};
int64_t tid = cbm_store_upsert_node(s, &t);
cbm_edge_t e = {.project = "test", .source_id = c_id, .target_id = tid, .type = "CALLS"};
cbm_store_insert_edge(s, &e);
}
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) OPTIONAL MATCH (f)-[:CALLS]->(g) WHERE g IS NULL RETURN f.name",
"test", 3, &r);
ASSERT_EQ(rc, 0);
/* Positive control: B genuinely has no callees, so the query must still find
* it. Without this a "C is absent" assertion could pass on an empty result. */
bool saw_b = false;
bool saw_c = false;
for (int i = 0; i < r.row_count; i++) {
const char *name = r.rows[i][0];
if (name && strcmp(name, "B") == 0) {
saw_b = true;
}
if (name && strcmp(name, "C") == 0) {
saw_c = true;
}
}
ASSERT_TRUE(saw_b);
/* The discriminator: C has 5 callees, so claiming it has none is a
* fabrication. Pre-fix this is exactly what the saturated path emitted. */
ASSERT_FALSE(saw_c);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Arithmetic-boundary companion to the zero-label overflow above: the node
* cross-join sizes its buffer from bind_count * extra_count. As a plain int that
* product wraps past INT_MAX to a negative/garbage malloc size (the large-graph
@@ -3491,6 +3573,7 @@ SUITE(cypher) {
RUN_TEST(cypher_exec_optional_empty_label_no_overflow);
RUN_TEST(cypher_cross_join_alloc_rejects_overflow);
RUN_TEST(cypher_exec_optional_rel_saturated_no_overflow);
RUN_TEST(cypher_exec_optional_saturated_does_not_fabricate_no_match);
RUN_TEST(cypher_exec_optional_rel_leaf_fallback_survives);
RUN_TEST(cypher_issue240_labels_function);
RUN_TEST(cypher_issue237_distinct_order_limit);