Skip to main content

greplm_core/
cache.rs

1//! Change-detection cache backed by redb.
2//!
3//! For each indexed file we remember `(inode, mtime, size, content_hash)` plus
4//! where the current version lives `(segment_id, doc_id)`. On a re-index or a
5//! filesystem event we do a cheap `mtime`+`size` pre-check before hashing, and
6//! only re-index when the fast hash actually changed. This rejects spurious
7//! touches and lets us tombstone the stale doc.
8//!
9//! The cache is purely an optimization: it can always be rebuilt by re-indexing.
10//! That property drives two design choices below — a stored schema version that
11//! wipes the cache on any `FileRecord` layout change (degrading to a re-index
12//! instead of a hard deserialize error), and relaxed write durability on the hot
13//! `apply` path (a crash just costs a re-index).
14
15use redb::{Database, Durability, ReadableTable, TableDefinition};
16use serde::{Deserialize, Serialize};
17use std::path::Path;
18
19use crate::error::Result;
20
21const FILES: TableDefinition<&str, &[u8]> = TableDefinition::new("files");
22const META: TableDefinition<&str, u64> = TableDefinition::new("meta");
23
24/// Bump whenever `FileRecord`'s layout or the serialization format changes.
25/// A mismatch at open time wipes the cache so stale records can't be misread.
26const SCHEMA_VERSION: u64 = 2;
27const SCHEMA_KEY: &str = "schema_version";
28
29/// Per-file record used for incremental indexing.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct FileRecord {
32    /// Filesystem inode. Always `0` on non-unix platforms, so any inode-based
33    /// rename/move detection downstream is effectively disabled there.
34    pub inode: u64,
35    /// Modification time in nanoseconds since the unix epoch. `i64` holds current
36    /// epochs comfortably (overflows ~year 2262).
37    pub mtime_ns: i64,
38    pub size: u64,
39    pub hash: u64,
40    pub segment_id: u64,
41    pub doc_id: u32,
42    /// Number of symbols this document contributed. Lets the indexer maintain
43    /// the index-wide symbol count incrementally instead of re-parsing every
44    /// segment on each update.
45    pub symbols: u32,
46}
47
48/// Handle to the on-disk change-detection cache.
49pub struct Cache {
50    db: Database,
51}
52
53impl Cache {
54    pub fn open(path: &Path) -> Result<Cache> {
55        let db = Database::create(path)?;
56        // Ensure the tables exist so read transactions don't fail on a fresh db.
57        let wtxn = db.begin_write()?;
58        {
59            let _ = wtxn.open_table(FILES)?;
60            let _ = wtxn.open_table(META)?;
61        }
62        wtxn.commit()?;
63        let cache = Cache { db };
64        cache.ensure_schema()?;
65        Ok(cache)
66    }
67
68    /// Wipe the cache if the stored schema version doesn't match the current one
69    /// (or is absent, i.e. first run). The data is rebuildable, so this is a
70    /// cache miss rather than an error.
71    fn ensure_schema(&self) -> Result<()> {
72        let stored = {
73            let rtxn = self.db.begin_read()?;
74            let table = rtxn.open_table(META)?;
75            table.get(SCHEMA_KEY)?.map(|v| v.value())
76        };
77        if stored != Some(SCHEMA_VERSION) {
78            self.clear()?;
79            let wtxn = self.db.begin_write()?;
80            {
81                let mut table = wtxn.open_table(META)?;
82                table.insert(SCHEMA_KEY, SCHEMA_VERSION)?;
83            }
84            wtxn.commit()?;
85        }
86        Ok(())
87    }
88
89    pub fn get(&self, path: &str) -> Result<Option<FileRecord>> {
90        let rtxn = self.db.begin_read()?;
91        let table = rtxn.open_table(FILES)?;
92        match table.get(path)? {
93            Some(v) => Ok(Some(postcard::from_bytes(v.value())?)),
94            None => Ok(None),
95        }
96    }
97
98    /// Load all records into a map keyed by path.
99    pub fn load_all(&self) -> Result<std::collections::HashMap<String, FileRecord>> {
100        let rtxn = self.db.begin_read()?;
101        let table = rtxn.open_table(FILES)?;
102        let mut out = std::collections::HashMap::new();
103        for entry in table.iter()? {
104            let (k, v) = entry?;
105            let rec: FileRecord = postcard::from_bytes(v.value())?;
106            out.insert(k.value().to_string(), rec);
107        }
108        Ok(out)
109    }
110
111    /// Apply a batch of inserts and deletes in a single transaction.
112    pub fn apply(&self, upserts: &[(String, FileRecord)], deletes: &[String]) -> Result<()> {
113        // Serialize outside the write lock to keep the single-writer hold short.
114        let encoded: Vec<(&str, Vec<u8>)> = upserts
115            .iter()
116            .map(|(path, rec)| Ok((path.as_str(), postcard::to_allocvec(rec)?)))
117            .collect::<Result<_>>()?;
118
119        let mut wtxn = self.db.begin_write()?;
120        // The cache is rebuildable, so skip fsync on this hot (watch-event) path;
121        // a crash just costs a re-index, which is the fallback we already support.
122        wtxn.set_durability(Durability::None);
123        {
124            let mut table = wtxn.open_table(FILES)?;
125            for (path, bytes) in &encoded {
126                table.insert(*path, bytes.as_slice())?;
127            }
128            for path in deletes {
129                table.remove(path.as_str())?;
130            }
131        }
132        wtxn.commit()?;
133        Ok(())
134    }
135
136    /// Replace the entire cache contents with `records` in a single transaction.
137    /// Used after compaction, when every live document gets new segment/doc ids.
138    pub fn replace_all(&self, records: &[(String, FileRecord)]) -> Result<()> {
139        let encoded: Vec<(&str, Vec<u8>)> = records
140            .iter()
141            .map(|(path, rec)| Ok((path.as_str(), postcard::to_allocvec(rec)?)))
142            .collect::<Result<_>>()?;
143
144        let wtxn = self.db.begin_write()?;
145        {
146            let mut table = wtxn.open_table(FILES)?;
147            // Drop every existing entry; `retain` propagates iteration errors
148            // instead of silently skipping them, so stale data can't survive.
149            table.retain(|_, _| false)?;
150            for (path, bytes) in &encoded {
151                table.insert(*path, bytes.as_slice())?;
152            }
153        }
154        wtxn.commit()?;
155        Ok(())
156    }
157
158    pub fn clear(&self) -> Result<()> {
159        self.replace_all(&[])
160    }
161}
162
163/// Compute the fast content hash used for change detection.
164pub fn fast_hash(data: &[u8]) -> u64 {
165    xxhash_rust::xxh3::xxh3_64(data)
166}
167
168/// Extract `(inode, mtime_ns, size)` from filesystem metadata.
169#[cfg(unix)]
170pub fn stat_key(meta: &std::fs::Metadata) -> (u64, i64, u64) {
171    use std::os::unix::fs::MetadataExt;
172    let mtime_ns = meta.mtime() * 1_000_000_000 + meta.mtime_nsec();
173    (meta.ino(), mtime_ns, meta.len())
174}
175
176#[cfg(not(unix))]
177pub fn stat_key(meta: &std::fs::Metadata) -> (u64, i64, u64) {
178    let mtime_ns = meta
179        .modified()
180        .ok()
181        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
182        .map(|d| d.as_nanos() as i64)
183        .unwrap_or(0);
184    (0, mtime_ns, meta.len())
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use std::path::PathBuf;
191
192    /// Unique temp file path for an isolated redb instance, cleaned up on drop.
193    struct TempDb(PathBuf);
194
195    impl TempDb {
196        fn new(tag: &str) -> TempDb {
197            let mut p = std::env::temp_dir();
198            let nanos = std::time::SystemTime::now()
199                .duration_since(std::time::UNIX_EPOCH)
200                .unwrap()
201                .as_nanos();
202            p.push(format!("greplm-cache-{tag}-{nanos}.redb"));
203            TempDb(p)
204        }
205        fn path(&self) -> &Path {
206            &self.0
207        }
208    }
209
210    impl Drop for TempDb {
211        fn drop(&mut self) {
212            let _ = std::fs::remove_file(&self.0);
213        }
214    }
215
216    fn rec(seed: u64) -> FileRecord {
217        FileRecord {
218            inode: seed,
219            mtime_ns: seed as i64 * 1_000_000_000 + 7,
220            size: seed * 13,
221            hash: seed.wrapping_mul(0x9E37_79B9_7F4A_7C15),
222            segment_id: seed + 100,
223            doc_id: seed as u32 + 5,
224            symbols: seed as u32 * 2,
225        }
226    }
227
228    fn assert_same(a: &FileRecord, b: &FileRecord) {
229        assert_eq!(a.inode, b.inode);
230        assert_eq!(a.mtime_ns, b.mtime_ns);
231        assert_eq!(a.size, b.size);
232        assert_eq!(a.hash, b.hash);
233        assert_eq!(a.segment_id, b.segment_id);
234        assert_eq!(a.doc_id, b.doc_id);
235        assert_eq!(a.symbols, b.symbols);
236    }
237
238    #[test]
239    fn fresh_db_is_empty_and_get_misses() {
240        let tmp = TempDb::new("fresh");
241        let cache = Cache::open(tmp.path()).unwrap();
242        assert!(cache.get("anything").unwrap().is_none());
243        assert!(cache.load_all().unwrap().is_empty());
244    }
245
246    #[test]
247    fn apply_roundtrips_all_fields() {
248        let tmp = TempDb::new("roundtrip");
249        let cache = Cache::open(tmp.path()).unwrap();
250        let r = rec(42);
251        cache.apply(&[("src/a.rs".into(), r.clone())], &[]).unwrap();
252
253        let got = cache.get("src/a.rs").unwrap().expect("record present");
254        assert_same(&got, &r);
255
256        let all = cache.load_all().unwrap();
257        assert_eq!(all.len(), 1);
258        assert_same(&all["src/a.rs"], &r);
259    }
260
261    #[test]
262    fn apply_upserts_and_deletes() {
263        let tmp = TempDb::new("upsert");
264        let cache = Cache::open(tmp.path()).unwrap();
265        cache
266            .apply(
267                &[
268                    ("a".into(), rec(1)),
269                    ("b".into(), rec(2)),
270                    ("c".into(), rec(3)),
271                ],
272                &[],
273            )
274            .unwrap();
275
276        // Overwrite "a" and delete "b" in one batch.
277        cache
278            .apply(&[("a".into(), rec(99))], &["b".to_string()])
279            .unwrap();
280
281        let all = cache.load_all().unwrap();
282        assert_eq!(all.len(), 2);
283        assert_same(&all["a"], &rec(99));
284        assert!(!all.contains_key("b"));
285        assert_same(&all["c"], &rec(3));
286    }
287
288    #[test]
289    fn replace_all_wipes_then_inserts() {
290        let tmp = TempDb::new("replace");
291        let cache = Cache::open(tmp.path()).unwrap();
292        cache
293            .apply(&[("old1".into(), rec(1)), ("old2".into(), rec(2))], &[])
294            .unwrap();
295
296        cache
297            .replace_all(&[("new1".into(), rec(10)), ("new2".into(), rec(20))])
298            .unwrap();
299
300        let all = cache.load_all().unwrap();
301        assert_eq!(all.len(), 2);
302        assert!(all.contains_key("new1") && all.contains_key("new2"));
303        assert!(!all.contains_key("old1") && !all.contains_key("old2"));
304        assert_same(&all["new1"], &rec(10));
305    }
306
307    #[test]
308    fn clear_empties_everything() {
309        let tmp = TempDb::new("clear");
310        let cache = Cache::open(tmp.path()).unwrap();
311        cache
312            .apply(&[("a".into(), rec(1)), ("b".into(), rec(2))], &[])
313            .unwrap();
314        cache.clear().unwrap();
315        assert!(cache.load_all().unwrap().is_empty());
316
317        // Clear on an already-empty cache is a no-op, not an error.
318        cache.clear().unwrap();
319        assert!(cache.load_all().unwrap().is_empty());
320    }
321
322    #[test]
323    fn data_survives_reopen_with_matching_schema() {
324        let tmp = TempDb::new("persist");
325        {
326            let cache = Cache::open(tmp.path()).unwrap();
327            cache.apply(&[("keep.rs".into(), rec(7))], &[]).unwrap();
328        }
329        // Reopen: matching schema version must NOT wipe existing data.
330        let cache = Cache::open(tmp.path()).unwrap();
331        let got = cache.get("keep.rs").unwrap().expect("survives reopen");
332        assert_same(&got, &rec(7));
333    }
334
335    #[test]
336    fn schema_version_mismatch_wipes_cache() {
337        let tmp = TempDb::new("schema");
338        {
339            let cache = Cache::open(tmp.path()).unwrap();
340            cache.apply(&[("stale.rs".into(), rec(3))], &[]).unwrap();
341            assert_eq!(cache.load_all().unwrap().len(), 1);
342        }
343
344        // Simulate a layout/format change by storing a different schema version,
345        // mimicking an on-disk cache written by an incompatible build.
346        {
347            let db = Database::create(tmp.path()).unwrap();
348            let wtxn = db.begin_write().unwrap();
349            {
350                let mut t = wtxn.open_table(META).unwrap();
351                t.insert(SCHEMA_KEY, SCHEMA_VERSION + 1).unwrap();
352            }
353            wtxn.commit().unwrap();
354        }
355
356        // Opening detects the mismatch and degrades to a clean (empty) cache
357        // rather than surfacing stale/undecodable records.
358        let cache = Cache::open(tmp.path()).unwrap();
359        assert!(
360            cache.load_all().unwrap().is_empty(),
361            "schema mismatch should wipe stale records"
362        );
363
364        // And the cache is usable again afterwards.
365        cache.apply(&[("fresh.rs".into(), rec(8))], &[]).unwrap();
366        assert_same(&cache.get("fresh.rs").unwrap().unwrap(), &rec(8));
367    }
368
369    #[test]
370    fn apply_deletes_only() {
371        let tmp = TempDb::new("delete-only");
372        let cache = Cache::open(tmp.path()).unwrap();
373        cache
374            .apply(&[("a".into(), rec(1)), ("b".into(), rec(2))], &[])
375            .unwrap();
376
377        cache
378            .apply(&[], &["a".to_string(), "b".to_string()])
379            .unwrap();
380
381        assert!(cache.load_all().unwrap().is_empty());
382        assert!(cache.get("a").unwrap().is_none());
383    }
384
385    #[test]
386    fn apply_empty_batch_is_noop() {
387        let tmp = TempDb::new("empty-batch");
388        let cache = Cache::open(tmp.path()).unwrap();
389        cache.apply(&[("a".into(), rec(1))], &[]).unwrap();
390
391        cache.apply(&[], &[]).unwrap();
392
393        let all = cache.load_all().unwrap();
394        assert_eq!(all.len(), 1);
395        assert_same(&all["a"], &rec(1));
396    }
397
398    #[test]
399    fn replace_all_on_empty_cache_is_noop() {
400        let tmp = TempDb::new("replace-empty");
401        let cache = Cache::open(tmp.path()).unwrap();
402        cache.replace_all(&[]).unwrap();
403        assert!(cache.load_all().unwrap().is_empty());
404    }
405
406    #[test]
407    fn missing_schema_key_wipes_legacy_records() {
408        let tmp = TempDb::new("legacy-schema");
409        // Simulate a pre-versioning cache: file records present, no schema key.
410        {
411            let db = Database::create(tmp.path()).unwrap();
412            let wtxn = db.begin_write().unwrap();
413            {
414                let mut files = wtxn.open_table(FILES).unwrap();
415                let bytes = postcard::to_allocvec(&rec(3)).unwrap();
416                files.insert("legacy.rs", bytes.as_slice()).unwrap();
417            }
418            wtxn.commit().unwrap();
419        }
420
421        let cache = Cache::open(tmp.path()).unwrap();
422        assert!(
423            cache.load_all().unwrap().is_empty(),
424            "absent schema key should wipe legacy records"
425        );
426
427        cache.apply(&[("fresh.rs".into(), rec(8))], &[]).unwrap();
428        assert_same(&cache.get("fresh.rs").unwrap().unwrap(), &rec(8));
429    }
430
431    #[test]
432    fn corrupt_record_bytes_surface_as_errors() {
433        let tmp = TempDb::new("corrupt");
434        let valid = rec(1);
435        // Truncate a valid encoding: arbitrary bytes can still decode as nonsense
436        // values without error, but a truncated message must fail.
437        let mut truncated = postcard::to_allocvec(&valid).unwrap();
438        truncated.truncate(truncated.len().saturating_sub(1));
439
440        {
441            let cache = Cache::open(tmp.path()).unwrap();
442            cache
443                .apply(&[("good.rs".into(), valid.clone())], &[])
444                .unwrap();
445        }
446        {
447            let db = Database::create(tmp.path()).unwrap();
448            let wtxn = db.begin_write().unwrap();
449            {
450                let mut table = wtxn.open_table(FILES).unwrap();
451                table.insert("bad.rs", truncated.as_slice()).unwrap();
452            }
453            wtxn.commit().unwrap();
454        }
455
456        let cache = Cache::open(tmp.path()).unwrap();
457        assert!(
458            cache.get("bad.rs").is_err(),
459            "truncated record should fail decode"
460        );
461        assert!(
462            cache.load_all().is_err(),
463            "load_all should fail on undecodable records"
464        );
465        // Unaffected keys remain readable.
466        assert_same(&cache.get("good.rs").unwrap().unwrap(), &valid);
467    }
468
469    #[test]
470    fn stat_key_matches_file_metadata() {
471        let mut path = std::env::temp_dir();
472        let nanos = std::time::SystemTime::now()
473            .duration_since(std::time::UNIX_EPOCH)
474            .unwrap()
475            .as_nanos();
476        path.push(format!("greplm-stat-{nanos}.txt"));
477        let contents = b"greplm stat_key probe";
478        std::fs::write(&path, contents).unwrap();
479
480        let meta = std::fs::metadata(&path).unwrap();
481        let (inode, mtime_ns, size) = stat_key(&meta);
482
483        assert_eq!(size, contents.len() as u64);
484        assert!(
485            mtime_ns > 0,
486            "mtime_ns should reflect file modification time"
487        );
488        #[cfg(unix)]
489        assert!(inode > 0, "unix inode should be non-zero");
490        #[cfg(not(unix))]
491        assert_eq!(inode, 0, "non-unix platforms disable inode detection");
492
493        let _ = std::fs::remove_file(&path);
494    }
495
496    #[test]
497    fn fast_hash_is_stable_and_distinguishes() {
498        assert_eq!(fast_hash(b"hello world"), fast_hash(b"hello world"));
499        assert_ne!(fast_hash(b"hello world"), fast_hash(b"hello worle"));
500
501        assert_eq!(fast_hash(b""), fast_hash(b""));
502
503        let large = vec![0xABu8; 1_000_000];
504        assert_eq!(fast_hash(&large), fast_hash(&large));
505        assert_ne!(fast_hash(&large), fast_hash(b"small"));
506    }
507}