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 ,
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:
0 commit comments