Skip to content

Commit 2ec5ff9

Browse files
Your Nameclaude
andcommitted
fix(frontier): chunk IN-clause to stay within SQLite 999-variable limit
Extract query_paths_chunked() helper that splits params into ≤999-item batches and unions results. Fixes PATTERN-DEBT-frontier-in-clause-limit: sessions exploring >999 files/symbols previously got a silently empty frontier. New test seeds 1001 import_edges rows to confirm the boundary is handled correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5923c3d commit 2ec5ff9

1 file changed

Lines changed: 80 additions & 38 deletions

File tree

crates/ci-server/src/tools.rs

Lines changed: 80 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -551,65 +551,67 @@ fn ambiguous_json(candidates: &[CandidateRow]) -> String {
551551
// Frontier computation helper (for session_context)
552552
// ---------------------------------------------------------------------------
553553

554-
fn compute_frontier_entries(
554+
/// Runs `{sql_prefix} (?, ?, ...) AND from_path IS NOT NULL` in chunks of ≤999
555+
/// to stay within SQLite's SQLITE_LIMIT_VARIABLE_NUMBER, accumulating distinct
556+
/// `from_path` values into `out`.
557+
fn query_paths_chunked(
555558
conn: &rusqlite::Connection,
556-
explored_files: &[String],
557-
explored_symbols: &[String],
558-
) -> Vec<FrontierEntry> {
559-
use std::collections::HashSet;
560-
561-
let explored_set: HashSet<&str> = explored_files.iter().map(|s| s.as_str()).collect();
562-
563-
// Set A: files that import any explored file
564-
let mut set_a: HashSet<String> = HashSet::new();
565-
if !explored_files.is_empty() {
566-
let placeholders: String = explored_files
559+
sql_prefix: &str,
560+
params: &[String],
561+
out: &mut std::collections::HashSet<String>,
562+
) {
563+
const CHUNK: usize = 999;
564+
for chunk in params.chunks(CHUNK) {
565+
let placeholders = chunk
567566
.iter()
568567
.enumerate()
569568
.map(|(i, _)| format!("?{}", i + 1))
570569
.collect::<Vec<_>>()
571570
.join(", ");
572-
let sql = format!(
573-
"SELECT DISTINCT from_path FROM import_edges \
574-
WHERE to_path IN ({placeholders}) AND from_path IS NOT NULL"
575-
);
571+
let sql = format!("{sql_prefix} ({placeholders}) AND from_path IS NOT NULL");
576572
if let Ok(mut stmt) = conn.prepare(&sql) {
577573
let _ = stmt
578-
.query_map(rusqlite::params_from_iter(explored_files.iter()), |row| {
574+
.query_map(rusqlite::params_from_iter(chunk.iter()), |row| {
579575
row.get::<_, String>(0)
580576
})
581577
.map(|rows| {
582578
for r in rows.flatten() {
583-
set_a.insert(r);
579+
out.insert(r);
584580
}
585581
});
586582
}
587583
}
584+
}
585+
586+
fn compute_frontier_entries(
587+
conn: &rusqlite::Connection,
588+
explored_files: &[String],
589+
explored_symbols: &[String],
590+
) -> Vec<FrontierEntry> {
591+
use std::collections::HashSet;
592+
593+
let explored_set: HashSet<&str> = explored_files.iter().map(|s| s.as_str()).collect();
594+
595+
// Set A: files that import any explored file
596+
let mut set_a: HashSet<String> = HashSet::new();
597+
if !explored_files.is_empty() {
598+
query_paths_chunked(
599+
conn,
600+
"SELECT DISTINCT from_path FROM import_edges WHERE to_path IN",
601+
explored_files,
602+
&mut set_a,
603+
);
604+
}
588605

589606
// Set B: files containing callers of explored symbols
590607
let mut set_b: HashSet<String> = HashSet::new();
591608
if !explored_symbols.is_empty() {
592-
let placeholders: String = explored_symbols
593-
.iter()
594-
.enumerate()
595-
.map(|(i, _)| format!("?{}", i + 1))
596-
.collect::<Vec<_>>()
597-
.join(", ");
598-
let sql = format!(
599-
"SELECT DISTINCT from_path FROM call_edges \
600-
WHERE to_symbol IN ({placeholders}) AND from_path IS NOT NULL"
609+
query_paths_chunked(
610+
conn,
611+
"SELECT DISTINCT from_path FROM call_edges WHERE to_symbol IN",
612+
explored_symbols,
613+
&mut set_b,
601614
);
602-
if let Ok(mut stmt) = conn.prepare(&sql) {
603-
let _ = stmt
604-
.query_map(rusqlite::params_from_iter(explored_symbols.iter()), |row| {
605-
row.get::<_, String>(0)
606-
})
607-
.map(|rows| {
608-
for r in rows.flatten() {
609-
set_b.insert(r);
610-
}
611-
});
612-
}
613615
}
614616

615617
// Union minus already-explored; tag each with reason
@@ -3102,6 +3104,46 @@ mod tests {
31023104
let _ = std::fs::remove_dir_all(&dir);
31033105
}
31043106

3107+
#[test]
3108+
fn frontier_chunking_handles_over_999_params() {
3109+
let dir = std::env::temp_dir().join(format!("ci_frontier_chunk_{}", std::process::id()));
3110+
let _ = std::fs::remove_dir_all(&dir);
3111+
std::fs::create_dir_all(&dir).unwrap();
3112+
let server = CodeIntelligenceServer::new(dir.clone(), dir.join("index.db")).unwrap();
3113+
3114+
// Seed 1001 import_edges rows: result.rs imports 1001 distinct dep files.
3115+
// Without chunking, querying all 1001 paths as IN-clause params exceeds SQLite's
3116+
// 999-variable limit and silently returns empty; with chunking the result is non-empty.
3117+
{
3118+
let conn = rusqlite::Connection::open(dir.join("index.db")).unwrap();
3119+
for i in 0..1001usize {
3120+
conn.execute(
3121+
"INSERT INTO import_edges (from_path, to_path, module_name) VALUES (?1, ?2, ?3)",
3122+
rusqlite::params!["src/result.rs", format!("src/dep_{i}.rs"), format!("dep_{i}")],
3123+
)
3124+
.unwrap();
3125+
}
3126+
}
3127+
3128+
let explored_files: Vec<String> =
3129+
(0..1001usize).map(|i| format!("src/dep_{i}.rs")).collect();
3130+
let mut out = std::collections::HashSet::new();
3131+
let conn = server.make_read_conn().unwrap();
3132+
query_paths_chunked(
3133+
&conn,
3134+
"SELECT DISTINCT from_path FROM import_edges WHERE to_path IN",
3135+
&explored_files,
3136+
&mut out,
3137+
);
3138+
3139+
assert!(
3140+
out.contains("src/result.rs"),
3141+
"src/result.rs must appear across 999-var chunk boundary, got: {out:?}"
3142+
);
3143+
3144+
let _ = std::fs::remove_dir_all(&dir);
3145+
}
3146+
31053147
#[test]
31063148
fn symbol_info_stays_ambiguous_when_path_does_not_uniquely_resolve() {
31073149
let dir = std::env::temp_dir().join(format!("ci_ambig_path_{}", std::process::id()));

0 commit comments

Comments
 (0)