Skip to content

Commit d2cfe11

Browse files
authored
Merge pull request #154 from BeaconBay/fix/tantivy-staleness
fix(ck-engine): rebuild stale tantivy lexical index when the corpus changes
2 parents 1679637 + 6ade20f commit d2cfe11

5 files changed

Lines changed: 202 additions & 117 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ck-cli/tests/integration_tests.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,3 +1222,77 @@ fn test_command_flags_honor_explicit_path() {
12221222
"--clean must remove the index at the given path"
12231223
);
12241224
}
1225+
1226+
/// The tantivy lexical index used to be built once on first --lex and never
1227+
/// refreshed: files added or edited afterwards were invisible to lexical
1228+
/// search. It must now rebuild when the corpus changes.
1229+
#[test]
1230+
#[serial]
1231+
fn test_lexical_search_reflects_file_changes() {
1232+
let temp_dir = TempDir::new().unwrap();
1233+
fs::write(
1234+
temp_dir.path().join("a.txt"),
1235+
"alphaterm appears in the original corpus",
1236+
)
1237+
.unwrap();
1238+
1239+
let status = Command::new(ck_binary())
1240+
.args(["--index", "."])
1241+
.current_dir(temp_dir.path())
1242+
.status()
1243+
.expect("ck --index should run");
1244+
assert!(status.success());
1245+
1246+
// First lexical search builds the tantivy index
1247+
let output = Command::new(ck_binary())
1248+
.args(["--lex", "alphaterm", "."])
1249+
.current_dir(temp_dir.path())
1250+
.env("RUST_LOG", "ck_engine=debug,ck_index=debug")
1251+
.output()
1252+
.expect("ck --lex should run");
1253+
let ck_dir_listing: Vec<String> = walkdir::WalkDir::new(temp_dir.path().join(".ck"))
1254+
.into_iter()
1255+
.filter_map(Result::ok)
1256+
.map(|e| e.path().display().to_string())
1257+
.collect();
1258+
assert!(
1259+
output.status.success(),
1260+
"initial lexical search failed.\nstderr: {}\nstdout: {}\n.ck contents: {:#?}\nmeta: {:?}",
1261+
String::from_utf8_lossy(&output.stderr),
1262+
String::from_utf8_lossy(&output.stdout),
1263+
ck_dir_listing,
1264+
fs::read_to_string(temp_dir.path().join(".ck").join("tantivy_index.meta")),
1265+
);
1266+
1267+
// Add a new file AFTER the lexical index was built
1268+
fs::write(
1269+
temp_dir.path().join("b.txt"),
1270+
"zebraterm only exists in the new file",
1271+
)
1272+
.unwrap();
1273+
1274+
let output = Command::new(ck_binary())
1275+
.args(["--lex", "zebraterm", "."])
1276+
.current_dir(temp_dir.path())
1277+
.output()
1278+
.expect("ck --lex should run");
1279+
let stdout = String::from_utf8_lossy(&output.stdout);
1280+
assert!(
1281+
output.status.success() && stdout.contains("b.txt"),
1282+
"lexical search must see files added after the index was built; stdout: {stdout} stderr: {}",
1283+
String::from_utf8_lossy(&output.stderr)
1284+
);
1285+
1286+
// Modify the original file: removed content must stop matching
1287+
fs::write(temp_dir.path().join("a.txt"), "completely different now").unwrap();
1288+
let output = Command::new(ck_binary())
1289+
.args(["--lex", "alphaterm", "."])
1290+
.current_dir(temp_dir.path())
1291+
.output()
1292+
.expect("ck --lex should run");
1293+
let stdout = String::from_utf8_lossy(&output.stdout);
1294+
assert!(
1295+
!stdout.contains("a.txt"),
1296+
"lexical search returned stale content for a modified file; stdout: {stdout}"
1297+
);
1298+
}

ck-engine/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ keywords = ["search", "engine", "semantic"]
1111
categories = ["algorithms"]
1212

1313
[dependencies]
14+
blake3 = { workspace = true }
1415
ck-core = { workspace = true }
1516
ck-index = { workspace = true }
1617
ck-embed = { workspace = true }

ck-engine/src/lib.rs

Lines changed: 121 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,42 @@ fn process_streaming_line(
785785
}
786786
}
787787

788+
/// Name of the metadata file (inside `.ck`) recording the corpus fingerprint
789+
/// the tantivy index was built from, so staleness is detectable.
790+
const TANTIVY_META_FILE: &str = "tantivy_index.meta";
791+
792+
/// Fingerprint of the file set a tantivy index covers: path, mtime and size
793+
/// of every corpus file. Any added, removed, or modified file changes the
794+
/// fingerprint, as does a different exclude-pattern set (it changes the
795+
/// collected file list).
796+
fn lexical_corpus_fingerprint(files: &[PathBuf]) -> String {
797+
let mut entries: Vec<String> = files
798+
.iter()
799+
.map(|f| {
800+
let (mtime, size) = fs::metadata(f)
801+
.map(|m| {
802+
let mtime = m
803+
.modified()
804+
.ok()
805+
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
806+
.map(|d| d.as_nanos())
807+
.unwrap_or(0);
808+
(mtime, m.len())
809+
})
810+
.unwrap_or((0, 0));
811+
format!("{}\x00{}\x00{}", f.display(), mtime, size)
812+
})
813+
.collect();
814+
entries.sort_unstable();
815+
816+
let mut hasher = blake3::Hasher::new();
817+
for entry in &entries {
818+
hasher.update(entry.as_bytes());
819+
hasher.update(b"\n");
820+
}
821+
hasher.finalize().to_hex().to_string()
822+
}
823+
788824
async fn lexical_search(options: &SearchOptions) -> Result<Vec<SearchResult>> {
789825
// Handle both files and directories and reuse nearest existing .ck index up the tree
790826
let index_root = find_nearest_index_root(&options.path).unwrap_or_else(|| {
@@ -802,8 +838,47 @@ async fn lexical_search(options: &SearchOptions) -> Result<Vec<SearchResult>> {
802838

803839
let tantivy_index_path = index_dir.join("tantivy_index");
804840

805-
if !tantivy_index_path.exists() {
806-
return build_tantivy_index(options).await;
841+
// The tantivy index always covers the whole index root (include patterns
842+
// are applied per result at search time below), so corpus membership only
843+
// depends on the root and the exclusion rules.
844+
//
845+
// Collection goes through ck_index::collect_files — the same walker the
846+
// regex and semantic paths use — so gitignore/.ckignore semantics match
847+
// and exclude patterns apply relative to the walk root. The engine-local
848+
// collect_files matched exclude globs against every *absolute* path
849+
// component, so a corpus under e.g. /tmp on Linux matched the default
850+
// "tmp" exclude and silently produced an empty lexical index.
851+
let file_options = ck_core::FileCollectionOptions {
852+
respect_gitignore: options.respect_gitignore,
853+
use_ckignore: options.use_ckignore,
854+
exclude_patterns: options.exclude_patterns.clone(),
855+
};
856+
let corpus = ck_index::collect_files(&index_root, &file_options)?;
857+
let fingerprint = lexical_corpus_fingerprint(&corpus);
858+
let meta_path = index_dir.join(TANTIVY_META_FILE);
859+
let is_fresh = tantivy_index_path.exists()
860+
&& fs::read_to_string(&meta_path)
861+
.map(|stored| stored.trim() == fingerprint)
862+
.unwrap_or(false);
863+
864+
if !is_fresh {
865+
// Serialize with index mutations (and concurrent lexical rebuilds);
866+
// re-check freshness after acquiring in case another process just
867+
// rebuilt the same corpus.
868+
let _lock = ck_index::acquire_index_write_lock(&index_dir)?;
869+
let still_stale = !tantivy_index_path.exists()
870+
|| fs::read_to_string(&meta_path)
871+
.map(|stored| stored.trim() != fingerprint)
872+
.unwrap_or(true);
873+
if still_stale {
874+
tracing::info!(
875+
"Lexical index stale or missing for {}; rebuilding from {} files",
876+
index_root.display(),
877+
corpus.len()
878+
);
879+
build_tantivy_index(&tantivy_index_path, &corpus)?;
880+
fs::write(&meta_path, &fingerprint)?;
881+
}
807882
}
808883

809884
let mut schema_builder = Schema::builder();
@@ -903,37 +978,33 @@ async fn lexical_search(options: &SearchOptions) -> Result<Vec<SearchResult>> {
903978
Ok(results)
904979
}
905980

906-
async fn build_tantivy_index(options: &SearchOptions) -> Result<Vec<SearchResult>> {
907-
// Handle both files and directories by finding the appropriate directory for indexing
908-
let index_root = if options.path.is_file() {
909-
options.path.parent().unwrap_or(&options.path)
910-
} else {
911-
&options.path
912-
};
913-
914-
let index_dir = index_root.join(".ck");
915-
let tantivy_index_path = index_dir.join("tantivy_index");
916-
917-
fs::create_dir_all(&tantivy_index_path)?;
981+
/// (Re)build the tantivy index at `tantivy_index_path` over `files`.
982+
/// Callers must hold the index write lock. Any existing index is replaced —
983+
/// tantivy has no cheap way to diff segments against a changed corpus, and a
984+
/// full text-only rebuild is fast relative to embedding work.
985+
///
986+
/// Searching the result happens in [`lexical_search`]; this function builds
987+
/// only (its previous incarnation duplicated the entire search/read path,
988+
/// which had already drifted — the rebuilt-path copy lost include filtering).
989+
fn build_tantivy_index(tantivy_index_path: &Path, files: &[PathBuf]) -> Result<()> {
990+
if tantivy_index_path.exists() {
991+
fs::remove_dir_all(tantivy_index_path)?;
992+
}
993+
fs::create_dir_all(tantivy_index_path)?;
918994

919995
let mut schema_builder = Schema::builder();
920996
let content_field = schema_builder.add_text_field("content", TEXT | STORED);
921997
let path_field = schema_builder.add_text_field("path", TEXT | STORED);
922998
let schema = schema_builder.build();
923999

924-
let index = Index::create_in_dir(&tantivy_index_path, schema.clone())
1000+
let index = Index::create_in_dir(tantivy_index_path, schema)
9251001
.map_err(|e| CkError::Index(format!("Failed to create tantivy index: {e}")))?;
9261002

9271003
let mut index_writer = index
9281004
.writer(50_000_000)
9291005
.map_err(|e| CkError::Index(format!("Failed to create index writer: {e}")))?;
9301006

931-
let files = filter_files_by_include(
932-
collect_files(index_root, true, &options.exclude_patterns)?,
933-
&options.include_patterns,
934-
);
935-
936-
for file_path in &files {
1007+
for file_path in files {
9371008
if let Ok(content) = fs::read_to_string(file_path) {
9381009
let doc = doc!(
9391010
content_field => content,
@@ -947,100 +1018,7 @@ async fn build_tantivy_index(options: &SearchOptions) -> Result<Vec<SearchResult
9471018
.commit()
9481019
.map_err(|e| CkError::Index(format!("Failed to commit index: {e}")))?;
9491020

950-
// After building, search again with the same options
951-
let tantivy_index_path = index_root.join(".ck").join("tantivy_index");
952-
let mut schema_builder = Schema::builder();
953-
let content_field = schema_builder.add_text_field("content", TEXT | STORED);
954-
let path_field = schema_builder.add_text_field("path", TEXT | STORED);
955-
let _schema = schema_builder.build();
956-
957-
let index = Index::open_in_dir(&tantivy_index_path)
958-
.map_err(|e| CkError::Index(format!("Failed to open tantivy index: {e}")))?;
959-
960-
let reader = index
961-
.reader_builder()
962-
.reload_policy(ReloadPolicy::OnCommitWithDelay)
963-
.try_into()
964-
.map_err(|e| CkError::Index(format!("Failed to create index reader: {e}")))?;
965-
966-
let searcher = reader.searcher();
967-
let query_parser = QueryParser::for_index(&index, vec![content_field]);
968-
969-
let query = query_parser
970-
.parse_query(&options.query)
971-
.map_err(|e| CkError::Search(format!("Failed to parse query: {e}")))?;
972-
973-
let top_docs = if let Some(top_k) = options.top_k {
974-
searcher.search(&query, &TopDocs::with_limit(top_k))?
975-
} else {
976-
searcher.search(&query, &TopDocs::with_limit(100))?
977-
};
978-
979-
// First, collect all results with raw scores
980-
let mut raw_results = Vec::new();
981-
for (_score, doc_address) in top_docs {
982-
let retrieved_doc: TantivyDocument = searcher.doc(doc_address)?;
983-
let path_text = retrieved_doc
984-
.get_first(path_field)
985-
.map(|field_value| field_value.as_str().unwrap_or(""))
986-
.unwrap_or("");
987-
let content_text = retrieved_doc
988-
.get_first(content_field)
989-
.map(|field_value| field_value.as_str().unwrap_or(""))
990-
.unwrap_or("");
991-
992-
let file_path = PathBuf::from(path_text);
993-
let preview = if options.full_section {
994-
content_text.to_string()
995-
} else {
996-
content_text.lines().take(3).collect::<Vec<_>>().join("\n")
997-
};
998-
999-
raw_results.push((
1000-
_score,
1001-
SearchResult {
1002-
file: file_path,
1003-
span: Span {
1004-
byte_start: 0,
1005-
byte_end: content_text.len(),
1006-
line_start: 1,
1007-
line_end: content_text.lines().count(),
1008-
},
1009-
score: _score,
1010-
preview,
1011-
lang: ck_core::Language::from_path(&PathBuf::from(path_text)),
1012-
symbol: None,
1013-
chunk_hash: None,
1014-
index_epoch: None,
1015-
},
1016-
));
1017-
}
1018-
1019-
// Normalize scores to 0-1 range and apply threshold
1020-
let mut results = Vec::new();
1021-
if !raw_results.is_empty() {
1022-
let max_score = raw_results
1023-
.iter()
1024-
.map(|(score, _)| *score)
1025-
.fold(0.0f32, f32::max);
1026-
if max_score > 0.0 {
1027-
for (raw_score, mut result) in raw_results {
1028-
let normalized_score = raw_score / max_score;
1029-
1030-
// Apply threshold filtering with normalized score
1031-
if let Some(threshold) = options.threshold
1032-
&& normalized_score < threshold
1033-
{
1034-
continue;
1035-
}
1036-
1037-
result.score = normalized_score;
1038-
results.push(result);
1039-
}
1040-
}
1041-
}
1042-
1043-
Ok(results)
1021+
Ok(())
10441022
}
10451023

10461024
#[allow(dead_code)]
@@ -1595,6 +1573,34 @@ mod tests {
15951573
assert!((fused[0].score - expected).abs() < 1e-6);
15961574
}
15971575

1576+
#[test]
1577+
fn test_lexical_corpus_fingerprint_tracks_changes() {
1578+
let temp_dir = TempDir::new().unwrap();
1579+
let a = temp_dir.path().join("a.txt");
1580+
let b = temp_dir.path().join("b.txt");
1581+
fs::write(&a, "one").unwrap();
1582+
fs::write(&b, "two").unwrap();
1583+
1584+
let original = lexical_corpus_fingerprint(&[a.clone(), b.clone()]);
1585+
1586+
// Order-insensitive
1587+
assert_eq!(
1588+
original,
1589+
lexical_corpus_fingerprint(&[b.clone(), a.clone()])
1590+
);
1591+
1592+
// Content change (different size) changes the fingerprint
1593+
fs::write(&a, "one but longer").unwrap();
1594+
assert_ne!(
1595+
original,
1596+
lexical_corpus_fingerprint(&[a.clone(), b.clone()])
1597+
);
1598+
1599+
// Removing a file changes the fingerprint
1600+
let shrunk = lexical_corpus_fingerprint(std::slice::from_ref(&a));
1601+
assert_ne!(shrunk, lexical_corpus_fingerprint(&[a, b]));
1602+
}
1603+
15981604
fn create_test_files(dir: &std::path::Path) -> Vec<PathBuf> {
15991605
let files = vec![
16001606
("test1.txt", "hello world rust programming"),

ck-index/src/lib.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,14 +259,17 @@ const INDEX_LOCK_FILE: &str = ".lock";
259259
/// Readers take no lock: every manifest and sidecar write goes through
260260
/// `atomic_write` (temp file + rename), so a reader can never observe a
261261
/// partially written file — only writers conflict with writers.
262-
struct IndexWriteLock {
262+
pub struct IndexWriteLock {
263263
_file: std::fs::File,
264264
}
265265

266266
/// Acquire an exclusive cross-process lock on the index directory, creating
267267
/// the directory if needed. Blocks (with a log message) if another process
268268
/// holds the lock.
269-
fn acquire_index_write_lock(index_dir: &Path) -> Result<IndexWriteLock> {
269+
///
270+
/// Public so other layers writing inside `.ck` (e.g. ck-engine's tantivy
271+
/// index build) serialize with index mutations.
272+
pub fn acquire_index_write_lock(index_dir: &Path) -> Result<IndexWriteLock> {
270273
use fs4::fs_std::FileExt;
271274

272275
fs::create_dir_all(index_dir)?;

0 commit comments

Comments
 (0)