Skip to content

Commit fe548db

Browse files
fix(cypher): bound OPTIONAL fallback in expand_pattern_rels to a single ceiling
Signed-off-by: SEPURI-SAI-KRISHNA <saik20533@gmail.com>
1 parent 7d6cdb2 commit fe548db

2 files changed

Lines changed: 140 additions & 4 deletions

File tree

src/cypher/cypher.c

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3221,7 +3221,16 @@ static void expand_pattern_rels(cbm_store_t *store, cbm_pattern_t *pat, binding_
32213221

32223222
bool is_variable_length = (rel->min_hops != SKIP_ONE || rel->max_hops != SKIP_ONE);
32233223

3224-
size_t alloc_n = (size_t)*bind_cap * (size_t)CYP_GROWTH_10 + SKIP_ONE;
3224+
/* One global output ceiling for this hop: up to max_new (bind_cap*10)
3225+
* rows total, whether produced by the expansion helpers or by the
3226+
* OPTIONAL fallback below. The helpers already stop at max_new; holding
3227+
* the fallback to the SAME ceiling means a saturating hub followed by
3228+
* OPTIONAL (no-match) sources truncates at the ceiling instead of writing
3229+
* past the buffer. The old `bind_cap*10 + 1` sized only the expansion plus
3230+
* a single fallback row, so a second fallback after a saturated expansion
3231+
* ran off the end (heap OOB write). */
3232+
int max_new = *bind_cap * CYP_GROWTH_10;
3233+
size_t alloc_n = (size_t)max_new + SKIP_ONE;
32253234
binding_t *new_bindings = malloc(alloc_n * sizeof(binding_t));
32263235
if (!new_bindings) {
32273236
return; /* OOM: leave existing bindings untouched rather than corrupt */
@@ -3240,7 +3249,6 @@ static void expand_pattern_rels(cbm_store_t *store, cbm_pattern_t *pat, binding_
32403249

32413250
int match_count = 0;
32423251

3243-
int max_new = *bind_cap * CYP_GROWTH_10;
32443252
if (is_variable_length) {
32453253
expand_var_length(store, rel, target_node, b, src, to_var, new_bindings, &new_count,
32463254
max_new, &match_count);
@@ -3249,8 +3257,9 @@ static void expand_pattern_rels(cbm_store_t *store, cbm_pattern_t *pat, binding_
32493257
&new_count, max_new, &match_count);
32503258
}
32513259

3252-
/* OPTIONAL MATCH: keep binding with empty target if no matches */
3253-
if (is_optional && match_count == 0) {
3260+
/* OPTIONAL MATCH: keep the binding with the target unbound when there
3261+
* were no matches, held to the same global ceiling as the expansion. */
3262+
if (is_optional && match_count == 0 && new_count < max_new) {
32543263
binding_t nb = {0};
32553264
binding_copy(&nb, b);
32563265
/* Don't set to_var — it remains unbound; projection returns "" */

tests/test_cypher.c

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,131 @@ TEST(cypher_exec_match_all_functions) {
489489
PASS();
490490
}
491491

492+
/* Regression: expand_pattern_rels sized its OPTIONAL-expansion output buffer as
493+
* bind_cap*10 + 1 — room for the bounded expansion (max_new = bind_cap*10) plus
494+
* a SINGLE OPTIONAL fallback row. When one source saturated the expansion to
495+
* max_new and two or more later sources took the OPTIONAL (no-match) path, the
496+
* second fallback write ran past the allocation (ASan: heap-buffer-overflow).
497+
*
498+
* The fix holds the OPTIONAL fallback to the SAME global ceiling (max_new) the
499+
* expansion helpers already respect, so the hop truncates at the ceiling instead
500+
* of growing the buffer. Defined behavior at the ceiling: the query still
501+
* succeeds (rc == 0) with a bounded result — it does not overflow or error.
502+
* Query text is agent-controlled via the MCP query tool. */
503+
TEST(cypher_exec_optional_rel_ceiling_truncates_no_overflow) {
504+
cbm_store_t *s = cbm_store_open_memory();
505+
cbm_store_upsert_project(s, "test", "/tmp/test");
506+
507+
/* 1 hub + 20 leaf Function nodes → bind_cap = 21, max_new = 210. The hub is
508+
* inserted first so it is expanded before the leaves; it saturates the
509+
* expansion to the ceiling, then each leaf reaches the OPTIONAL fallback —
510+
* under the old alloc (211 slots) the 2nd such write overflowed. */
511+
cbm_node_t hub = {
512+
.project = "test", .label = "Function", .name = "hub", .qualified_name = "test.hub"};
513+
int64_t hub_id = cbm_store_upsert_node(s, &hub);
514+
for (int i = 0; i < 20; i++) {
515+
char nm[32];
516+
char qn[48];
517+
snprintf(nm, sizeof(nm), "leaf%02d", i);
518+
snprintf(qn, sizeof(qn), "test.leaf%02d", i);
519+
cbm_node_t leaf = {
520+
.project = "test", .label = "Function", .name = nm, .qualified_name = qn};
521+
cbm_store_upsert_node(s, &leaf);
522+
}
523+
524+
/* Give the hub 300 CALLS edges (> max_new = 210) so its expansion saturates
525+
* the ceiling; targets are non-Function so they don't inflate bind_cap. */
526+
for (int i = 0; i < 300; i++) {
527+
char nm[32];
528+
char qn[48];
529+
snprintf(nm, sizeof(nm), "callee%d", i);
530+
snprintf(qn, sizeof(qn), "test.callee%d", i);
531+
cbm_node_t callee = {.project = "test", .label = "Var", .name = nm, .qualified_name = qn};
532+
int64_t cid = cbm_store_upsert_node(s, &callee);
533+
cbm_edge_t e = {.project = "test", .source_id = hub_id, .target_id = cid, .type = "CALLS"};
534+
cbm_store_insert_edge(s, &e);
535+
}
536+
537+
/* max_rows below the Function count (21) so bind_cap tracks scan_count (21)
538+
* rather than the 100000 result ceiling — the same regime a large repo
539+
* (> ceiling functions) or an agent-supplied small limit hits. */
540+
cbm_cypher_result_t r = {0};
541+
int rc = cbm_cypher_execute(
542+
s, "MATCH (a:Function) OPTIONAL MATCH (a)-[:CALLS]->(b) RETURN a.name", "test", 5, &r);
543+
/* Defined ceiling behavior: bounded success, no overflow (ASan proves the
544+
* latter), result truncated to the LIMIT rather than crashing. */
545+
ASSERT_EQ(rc, 0);
546+
ASSERT_GT(r.row_count, 0);
547+
ASSERT_TRUE(r.row_count <= 5);
548+
549+
cbm_cypher_result_free(&r);
550+
cbm_store_close(s);
551+
PASS();
552+
}
553+
554+
/* Companion to the truncation regression: when the expansion does NOT saturate
555+
* the ceiling, every leaf's OPTIONAL fallback row must survive with its target
556+
* unbound. A `row_count > 0` check is too weak — it can pass on hub rows alone —
557+
* so this asserts a specific leaf appears with an empty b.name, and that a real
558+
* expanded hub row is present too. */
559+
TEST(cypher_exec_optional_rel_leaf_fallback_survives) {
560+
cbm_store_t *s = cbm_store_open_memory();
561+
cbm_store_upsert_project(s, "test", "/tmp/test");
562+
563+
/* 1 hub (2 CALLS edges) + 3 leaves (no edges). Ceiling is huge (default
564+
* max_rows → bind_cap = 100000), so nothing truncates. */
565+
cbm_node_t hub = {
566+
.project = "test", .label = "Function", .name = "hub", .qualified_name = "test.hub"};
567+
int64_t hub_id = cbm_store_upsert_node(s, &hub);
568+
for (int i = 0; i < 3; i++) {
569+
char nm[32];
570+
char qn[48];
571+
snprintf(nm, sizeof(nm), "leaf%d", i);
572+
snprintf(qn, sizeof(qn), "test.leaf%d", i);
573+
cbm_node_t leaf = {
574+
.project = "test", .label = "Function", .name = nm, .qualified_name = qn};
575+
cbm_store_upsert_node(s, &leaf);
576+
}
577+
for (int i = 0; i < 2; i++) {
578+
char nm[32];
579+
char qn[48];
580+
snprintf(nm, sizeof(nm), "callee%d", i);
581+
snprintf(qn, sizeof(qn), "test.callee%d", i);
582+
cbm_node_t callee = {.project = "test", .label = "Var", .name = nm, .qualified_name = qn};
583+
int64_t cid = cbm_store_upsert_node(s, &callee);
584+
cbm_edge_t e = {.project = "test", .source_id = hub_id, .target_id = cid, .type = "CALLS"};
585+
cbm_store_insert_edge(s, &e);
586+
}
587+
588+
cbm_cypher_result_t r = {0};
589+
int rc = cbm_cypher_execute(
590+
s, "MATCH (a:Function) OPTIONAL MATCH (a)-[:CALLS]->(b) RETURN a.name, b.name", "test", 0,
591+
&r);
592+
ASSERT_EQ(rc, 0);
593+
ASSERT_EQ(r.col_count, 2);
594+
595+
/* Scan for a leaf fallback row (a.name = "leaf0", b.name unbound = "") and a
596+
* real expanded hub row (a.name = "hub", b.name non-empty). */
597+
bool leaf_fallback = false;
598+
bool hub_expanded = false;
599+
for (int i = 0; i < r.row_count; i++) {
600+
const char *a = r.rows[i][0];
601+
const char *b = r.rows[i][1];
602+
if (strcmp(a, "leaf0") == 0 && b[0] == '\0') {
603+
leaf_fallback = true;
604+
}
605+
if (strcmp(a, "hub") == 0 && b[0] != '\0') {
606+
hub_expanded = true;
607+
}
608+
}
609+
ASSERT_TRUE(leaf_fallback); /* the OPTIONAL no-match row survived */
610+
ASSERT_TRUE(hub_expanded); /* the expansion still produced bound rows */
611+
612+
cbm_cypher_result_free(&r);
613+
cbm_store_close(s);
614+
PASS();
615+
}
616+
492617
TEST(cypher_exec_where_eq) {
493618
cbm_store_t *s = setup_cypher_store();
494619
cbm_cypher_result_t r = {0};
@@ -3080,6 +3205,8 @@ SUITE(cypher) {
30803205
RUN_TEST(cypher_exec_deadline_aborts_runaway_query_issue601);
30813206
RUN_TEST(cypher_exec_deadline_allows_normal_query_issue601);
30823207
RUN_TEST(cypher_exec_match_all_functions);
3208+
RUN_TEST(cypher_exec_optional_rel_ceiling_truncates_no_overflow);
3209+
RUN_TEST(cypher_exec_optional_rel_leaf_fallback_survives);
30833210
RUN_TEST(cypher_issue240_labels_function);
30843211
RUN_TEST(cypher_issue237_distinct_order_limit);
30853212
RUN_TEST(cypher_issue873_distinct_order_limit_dedupes_before_limit);

0 commit comments

Comments
 (0)