Skip to content

Commit 92a27de

Browse files
authored
Merge pull request DeusData#883 from jstar0/fix/cypher-path-semantics
fix(cypher): enforce variable-length path semantics
2 parents 010569f + 7583376 commit 92a27de

5 files changed

Lines changed: 372 additions & 37 deletions

File tree

‎src/cypher/cypher.c‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3180,6 +3180,7 @@ static void process_edges(cbm_store_t *store, cbm_edge_t *edges, int edge_count,
31803180
/* C11 _Thread_local directly: cypher.c stays windows.h-free (compat.h pulls
31813181
* in windows.h, whose legacy `far` macro breaks this file's identifiers). */
31823182
static _Thread_local int g_cypher_depth_clamped = 0;
3183+
static _Thread_local int g_cypher_trail_truncated = 0;
31833184

31843185
static void expand_var_length(cbm_store_t *store, cbm_rel_pattern_t *rel,
31853186
cbm_node_pattern_t *target_node, binding_t *b, cbm_node_t *src,
@@ -3202,7 +3203,11 @@ static void expand_var_length(cbm_store_t *store, cbm_rel_pattern_t *rel,
32023203
}
32033204
cbm_traverse_result_t tr = {0};
32043205
const char *dir = rel->direction ? rel->direction : "outbound";
3205-
cbm_store_bfs(store, src->id, dir, rel->types, rel->type_count, max_depth, CBM_PERCENT, &tr);
3206+
cbm_store_bfs_trail(store, src->id, dir, rel->types, rel->type_count, max_depth, CBM_PERCENT,
3207+
&tr);
3208+
if (tr.truncated) {
3209+
g_cypher_trail_truncated = 1;
3210+
}
32063211
cbm_node_t *bound_to = binding_get(b, to_var);
32073212
int64_t bound_to_id = bound_to ? bound_to->id : 0;
32083213
/* Same contract as process_edges: the budget caps materialisation, never
@@ -4893,6 +4898,7 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec
48934898
cbm_cypher_result_t *out) {
48944899
memset(out, 0, sizeof(*out));
48954900
g_cypher_depth_clamped = 0;
4901+
g_cypher_trail_truncated = 0;
48964902
cypher_deadline_arm(); /* #601: start the wall-clock budget for this query */
48974903
if (max_rows <= 0) {
48984904
max_rows = CYPHER_RESULT_CEILING;
@@ -4963,12 +4969,22 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec
49634969
out->col_count = rb.col_count;
49644970
out->rows = rb.rows;
49654971
out->row_count = rb.row_count;
4966-
if (g_cypher_depth_clamped > 0) {
4972+
if (g_cypher_depth_clamped > 0 || g_cypher_trail_truncated) {
49674973
char wbuf[CBM_SZ_256];
4968-
snprintf(wbuf, sizeof(wbuf),
4969-
"variable-length hop range clamped to the engine ceiling (%d) — an empty "
4970-
"result may mean \"clamped\", not \"no such path\"",
4971-
g_cypher_depth_clamped);
4974+
if (g_cypher_depth_clamped > 0 && g_cypher_trail_truncated) {
4975+
snprintf(wbuf, sizeof(wbuf),
4976+
"variable-length hop range clamped to the engine ceiling (%d) and "
4977+
"traversal budget was exhausted — results may be partial",
4978+
g_cypher_depth_clamped);
4979+
} else if (g_cypher_depth_clamped > 0) {
4980+
snprintf(wbuf, sizeof(wbuf),
4981+
"variable-length hop range clamped to the engine ceiling (%d) — an empty "
4982+
"result may mean \"clamped\", not \"no such path\"",
4983+
g_cypher_depth_clamped);
4984+
} else {
4985+
snprintf(wbuf, sizeof(wbuf),
4986+
"variable-length traversal budget was exhausted — results may be partial");
4987+
}
49724988
out->warning = heap_strdup(wbuf);
49734989
}
49744990

‎src/store/store.c‎

Lines changed: 109 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ enum {
2525
ST_COL_7 = 7,
2626
ST_COL_8 = 8,
2727
ST_COL_9 = 9,
28+
ST_COL_10 = 10,
2829
ST_FOUND = -1,
2930
ST_BUF_16 = 16,
3031
ST_BUF_64 = 64,
@@ -63,6 +64,8 @@ enum {
6364
ST_METHOD_PROP_LEN = 8,
6465
ST_PATH_PROP_LEN = 6,
6566
ST_HANDLER_PROP_LEN = 9,
67+
ST_BFS_CTE_ROW_MULTIPLIER = 8,
68+
ST_BFS_MAX_CTE_ROWS = 4096,
6669
};
6770

6871
#define SLEN(s) (sizeof(s) - 1)
@@ -4673,8 +4676,35 @@ static void bfs_build_types_clause(int edge_type_count, char *buf, int buf_sz) {
46734676
}
46744677
}
46754678

4676-
int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const char **edge_types,
4677-
int edge_type_count, int max_depth, int max_results, cbm_traverse_result_t *out) {
4679+
static int bfs_cte_row_limit(int max_results) {
4680+
int result_budget = max_results > 0 ? max_results : ST_INIT_CAP_16;
4681+
long row_budget = (long)result_budget * ST_BFS_CTE_ROW_MULTIPLIER + SKIP_ONE;
4682+
if (row_budget > ST_BFS_MAX_CTE_ROWS) {
4683+
return ST_BFS_MAX_CTE_ROWS;
4684+
}
4685+
if (row_budget < ST_INIT_CAP_16) {
4686+
return ST_INIT_CAP_16;
4687+
}
4688+
return (int)row_budget;
4689+
}
4690+
4691+
static int bfs_cte_row_limit_for_depth(int max_results, int max_depth) {
4692+
int base = bfs_cte_row_limit(max_results);
4693+
/* Reserve rows across the requested depth range. A result-sized cap can
4694+
* be exhausted by a wide first hop before any deeper match is generated;
4695+
* scaling by depth keeps the bounded traversal useful for *N..N queries
4696+
* while the hard ceiling still protects the SQLite worker. */
4697+
long depth = max_depth > 0 ? max_depth : ST_INIT_CAP_16;
4698+
long scaled = (long)base * depth;
4699+
if (scaled > ST_BFS_MAX_CTE_ROWS) {
4700+
return ST_BFS_MAX_CTE_ROWS;
4701+
}
4702+
return (int)scaled;
4703+
}
4704+
4705+
static int store_bfs(cbm_store_t *s, int64_t start_id, const char *direction,
4706+
const char **edge_types, int edge_type_count, int max_depth, int max_results,
4707+
bool trail, cbm_traverse_result_t *out) {
46784708
memset(out, 0, sizeof(*out));
46794709

46804710
cbm_node_t root = {0};
@@ -4701,32 +4731,59 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
47014731
next_id = "e.target_id";
47024732
}
47034733

4704-
snprintf(sql, sizeof(sql),
4705-
/* SHORTEST-PATH semantics: the UNION dedupes (node, hop) PAIRS,
4706-
* so a single self-loop minted every hop level for every node it
4707-
* could reach — walk-padding that fabricated *k..k Cypher matches
4708-
* of arbitrary length and exploded the row set to nodes x depth
4709-
* (#797). MIN(hop) GROUP BY node returns each node once at its
4710-
* minimal distance. */
4711-
"WITH RECURSIVE bfs(node_id, hop) AS ("
4712-
" SELECT %lld, 0"
4713-
" UNION"
4714-
" SELECT %s, bfs.hop + 1"
4715-
" FROM bfs"
4716-
" JOIN edges e ON %s"
4717-
" WHERE e.type IN (%s) AND bfs.hop < %d"
4718-
")"
4719-
"SELECT n.id, n.project, n.label, n.name, n.qualified_name, "
4720-
"n.file_path, n.start_line, n.end_line, n.properties, MIN(bfs.hop) AS hop "
4721-
"FROM bfs "
4722-
"JOIN nodes n ON n.id = bfs.node_id "
4723-
"WHERE bfs.hop > 0 " /* exclude root at hop 0 (self via a loop still appears) */
4724-
"GROUP BY n.id "
4725-
/* (hop, id) is a unique total order — deterministic pagination
4726-
* watermarks and reproducible trace output depend on it. */
4727-
"ORDER BY hop, n.id "
4728-
"LIMIT %d;",
4729-
(long long)start_id, next_id, join_cond, types_clause, max_depth, max_results);
4734+
int cte_row_limit = 0;
4735+
if (trail) {
4736+
cte_row_limit = bfs_cte_row_limit_for_depth(max_results, max_depth);
4737+
snprintf(sql, sizeof(sql),
4738+
"WITH RECURSIVE bfs(node_id, hop, edge_path) AS ("
4739+
" SELECT %lld, 0, ''"
4740+
" UNION"
4741+
" SELECT %s, bfs.hop + 1,"
4742+
" CASE WHEN bfs.edge_path = '' THEN CAST(e.id AS TEXT)"
4743+
" ELSE bfs.edge_path || ',' || e.id END"
4744+
" FROM bfs"
4745+
" JOIN edges e ON %s"
4746+
" WHERE e.type IN (%s) AND bfs.hop < %d"
4747+
" AND instr(',' || bfs.edge_path || ',', ',' || e.id || ',') = 0"
4748+
/* SQLite's recursive queue is breadth-first by default. A
4749+
* bounded trail budget must reach the requested depth before
4750+
* a wide shallow layer consumes every row, so prioritize the
4751+
* recursive hop (depth-first) while retaining deterministic
4752+
* ordering in the outer result query. */
4753+
" ORDER BY 2 DESC, 1 ASC, 3 ASC"
4754+
" LIMIT %d"
4755+
")"
4756+
"SELECT DISTINCT n.id, n.project, n.label, n.name, n.qualified_name, "
4757+
"n.file_path, n.start_line, n.end_line, n.properties, bfs.hop, "
4758+
"(SELECT count(*) FROM bfs) "
4759+
"FROM bfs JOIN nodes n ON n.id = bfs.node_id "
4760+
"WHERE bfs.hop > 0 ORDER BY bfs.hop, n.id LIMIT %d;",
4761+
(long long)start_id, next_id, join_cond, types_clause, max_depth,
4762+
cte_row_limit + SKIP_ONE, max_results);
4763+
} else {
4764+
snprintf(sql, sizeof(sql),
4765+
/* SHORTEST-PATH semantics: the UNION dedupes (node, hop) pairs.
4766+
* MIN(hop) GROUP BY node returns each node once at its minimal
4767+
* distance, while relationship-trail state remains scoped to
4768+
* Cypher variable-length expansion. */
4769+
"WITH RECURSIVE bfs(node_id, hop) AS ("
4770+
" SELECT %lld, 0"
4771+
" UNION"
4772+
" SELECT %s, bfs.hop + 1"
4773+
" FROM bfs"
4774+
" JOIN edges e ON %s"
4775+
" WHERE e.type IN (%s) AND bfs.hop < %d"
4776+
")"
4777+
"SELECT n.id, n.project, n.label, n.name, n.qualified_name, "
4778+
"n.file_path, n.start_line, n.end_line, n.properties, MIN(bfs.hop) AS hop "
4779+
"FROM bfs "
4780+
"JOIN nodes n ON n.id = bfs.node_id "
4781+
"WHERE bfs.hop > 0 "
4782+
"GROUP BY n.id "
4783+
"ORDER BY hop, n.id "
4784+
"LIMIT %d;",
4785+
(long long)start_id, next_id, join_cond, types_clause, max_depth, max_results);
4786+
}
47304787

47314788
sqlite3_stmt *stmt = NULL;
47324789
rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL);
@@ -4746,6 +4803,7 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
47464803

47474804
int cap = ST_INIT_CAP_16;
47484805
int n = 0;
4806+
int cte_rows = 0;
47494807
cbm_node_hop_t *visited = malloc(cap * sizeof(cbm_node_hop_t));
47504808

47514809
int scan_rc15;
@@ -4756,6 +4814,9 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
47564814
}
47574815
scan_node(stmt, &visited[n].node);
47584816
visited[n].hop = sqlite3_column_int(stmt, ST_COL_9);
4817+
if (trail) {
4818+
cte_rows = sqlite3_column_int(stmt, ST_COL_10);
4819+
}
47594820
n++;
47604821
}
47614822
if (scan_rc15 != SQLITE_DONE) { /* SCANCHK:15:stmt */
@@ -4768,6 +4829,13 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
47684829

47694830
sqlite3_finalize(stmt);
47704831

4832+
if (trail && cte_rows > cte_row_limit) {
4833+
out->truncated = true;
4834+
char limit_buf[16];
4835+
snprintf(limit_buf, sizeof(limit_buf), "%d", cte_row_limit);
4836+
cbm_log_warn("cypher.trail_truncated", "cte_rows", limit_buf, "result", "partial");
4837+
}
4838+
47714839
out->visited = visited;
47724840
out->visited_count = n;
47734841

@@ -4783,6 +4851,19 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
47834851
return CBM_STORE_OK;
47844852
}
47854853

4854+
int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const char **edge_types,
4855+
int edge_type_count, int max_depth, int max_results, cbm_traverse_result_t *out) {
4856+
return store_bfs(s, start_id, direction, edge_types, edge_type_count, max_depth, max_results,
4857+
false, out);
4858+
}
4859+
4860+
int cbm_store_bfs_trail(cbm_store_t *s, int64_t start_id, const char *direction,
4861+
const char **edge_types, int edge_type_count, int max_depth,
4862+
int max_results, cbm_traverse_result_t *out) {
4863+
return store_bfs(s, start_id, direction, edge_types, edge_type_count, max_depth, max_results,
4864+
true, out);
4865+
}
4866+
47864867
/* Multi-source BFS: one recursive CTE anchored on ALL seeds (via a temp
47874868
* table — never a per-seed loop, which re-walks overlapping hub subgraphs
47884869
* seed_count times). Semantics for impact analysis:

‎src/store/store.h‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ typedef struct {
211211
int visited_count;
212212
cbm_edge_info_t *edges;
213213
int edge_count;
214+
/* True when trail expansion hit its recursive-row safety budget. */
215+
bool truncated;
214216
} cbm_traverse_result_t;
215217

216218
/* ── Schema introspection ───────────────────────────────────────── */
@@ -603,6 +605,11 @@ void cbm_store_search_free(cbm_search_output_t *out);
603605
int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const char **edge_types,
604606
int edge_type_count, int max_depth, int max_results, cbm_traverse_result_t *out);
605607

608+
/* Variable-length Cypher traversal with relationship-trail semantics. */
609+
int cbm_store_bfs_trail(cbm_store_t *s, int64_t start_id, const char *direction,
610+
const char **edge_types, int edge_type_count, int max_depth,
611+
int max_results, cbm_traverse_result_t *out);
612+
606613
/* Multi-source BFS from ALL seed ids at once (one CTE, temp-table anchored).
607614
* Seeds are EXCLUDED from the result (impact semantics); MIN(hop) across the
608615
* seed set; canonical (hop,id) order; *truncated set when the max_results

‎tests/test_cypher.c‎

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,15 +1171,24 @@ TEST(cypher_exec_varlength_path_semantics_issue797) {
11711171
ASSERT_EQ(r1.row_count, 1);
11721172
cbm_cypher_result_free(&r1);
11731173

1174-
/* Bug 2: *2..2 from loopy — only the REAL 2-chain (leaf); the self-loop
1175-
* must not be reused to pad paths (relationship uniqueness). */
1174+
/* Bug 2: *2..2 from loopy has two relationship-unique trails: the
1175+
* self-loop followed by e1 reaches mid, and e1 followed by e2 reaches leaf.
1176+
* Reusing the self-loop within one trail remains forbidden. */
11761177
cbm_cypher_result_t r2 = {0};
11771178
ASSERT_EQ(cbm_cypher_execute(s,
11781179
"MATCH (a {name: \"loopy\"})-[:CALLS*2..2]->(b) "
11791180
"RETURN DISTINCT b.name",
11801181
"test", 0, &r2),
11811182
0);
1182-
ASSERT_EQ(r2.row_count, 1); /* leaf only */
1183+
ASSERT_EQ(r2.row_count, 2);
1184+
bool saw_mid = false;
1185+
bool saw_leaf = false;
1186+
for (int i = 0; i < r2.row_count; i++) {
1187+
saw_mid |= strcmp(r2.rows[i][0], "mid") == 0;
1188+
saw_leaf |= strcmp(r2.rows[i][0], "leaf") == 0;
1189+
}
1190+
ASSERT_TRUE(saw_mid);
1191+
ASSERT_TRUE(saw_leaf);
11831192
cbm_cypher_result_free(&r2);
11841193

11851194
/* Bug 2 amplifier: no directed path of length 5 exists at all. */
@@ -1798,6 +1807,74 @@ TEST(cypher_exec_var_length_explicit_bound_capped) {
17981807
PASS();
17991808
}
18001809

1810+
/* Pin the relationship-trail contract: a self-loop edge cannot be reused
1811+
* within one variable-length trail, so *2..2 yields no fabricated match. */
1812+
TEST(cypher_exec_var_length_no_reuse_self_loop) {
1813+
cbm_store_t *s = cbm_store_open_memory();
1814+
cbm_store_upsert_project(s, "test", "/tmp/test");
1815+
1816+
cbm_node_t n = {.project = "test",
1817+
.label = "Function",
1818+
.name = "Recursive",
1819+
.qualified_name = "test.Recursive",
1820+
.file_path = "recursive.go"};
1821+
int64_t id = cbm_store_upsert_node(s, &n);
1822+
cbm_edge_t e = {.project = "test", .source_id = id, .target_id = id, .type = "CALLS"};
1823+
cbm_store_insert_edge(s, &e);
1824+
1825+
cbm_cypher_result_t r = {0};
1826+
int rc = cbm_cypher_execute(s,
1827+
"MATCH (f:Function {name: \"Recursive\"})-[:CALLS*2..2]"
1828+
"->(g:Function) RETURN g.name",
1829+
"test", 0, &r);
1830+
ASSERT_EQ(rc, 0);
1831+
ASSERT_EQ(r.row_count, 0);
1832+
1833+
cbm_cypher_result_free(&r);
1834+
cbm_store_close(s);
1835+
PASS();
1836+
}
1837+
1838+
TEST(cypher_exec_var_length_truncation_surfaces_warning) {
1839+
cbm_store_t *s = cbm_store_open_memory();
1840+
cbm_store_upsert_project(s, "test", "/tmp/test");
1841+
1842+
int64_t ids[18];
1843+
for (int i = 0; i < 18; i++) {
1844+
char name[16];
1845+
snprintf(name, sizeof(name), "node-%d", i);
1846+
cbm_node_t node = {.project = "test",
1847+
.label = "Function",
1848+
.name = name,
1849+
.qualified_name = name,
1850+
.file_path = "graph.c"};
1851+
ids[i] = cbm_store_upsert_node(s, &node);
1852+
}
1853+
for (int source = 0; source < 17; source++) {
1854+
for (int target = source + 1; target < 18; target++) {
1855+
cbm_edge_t edge = {.project = "test",
1856+
.source_id = ids[source],
1857+
.target_id = ids[target],
1858+
.type = "CALLS"};
1859+
cbm_store_insert_edge(s, &edge);
1860+
}
1861+
}
1862+
1863+
cbm_cypher_result_t result = {0};
1864+
int rc = cbm_cypher_execute(s,
1865+
"MATCH (a:Function {name: \"node-0\"})-[:CALLS*10..10]->"
1866+
"(b:Function) RETURN b.name",
1867+
"test", 0, &result);
1868+
ASSERT_EQ(rc, 0);
1869+
ASSERT_NOT_NULL(result.warning);
1870+
ASSERT_TRUE(strstr(result.warning, "traversal budget") != NULL);
1871+
ASSERT_TRUE(result.row_count > 0);
1872+
1873+
cbm_cypher_result_free(&result);
1874+
cbm_store_close(s);
1875+
PASS();
1876+
}
1877+
18011878
TEST(cypher_exec_defines_edge) {
18021879
cbm_store_t *s = setup_cypher_store();
18031880
cbm_cypher_result_t r = {0};
@@ -4037,6 +4114,8 @@ SUITE(cypher) {
40374114
RUN_TEST(cypher_exec_variable_length);
40384115
RUN_TEST(cypher_exec_variable_length_repeated_node_var_unifies);
40394116
RUN_TEST(cypher_exec_var_length_explicit_bound_capped);
4117+
RUN_TEST(cypher_exec_var_length_no_reuse_self_loop);
4118+
RUN_TEST(cypher_exec_var_length_truncation_surfaces_warning);
40404119
RUN_TEST(cypher_exec_defines_edge);
40414120
RUN_TEST(cypher_exec_no_results);
40424121
RUN_TEST(cypher_exec_where_numeric);

0 commit comments

Comments
 (0)