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 = Self::open_db(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    /// Open the redb file, retrying briefly on `DatabaseAlreadyOpen`.
69    ///
70    /// redb takes an exclusive `flock` for the life of the handle. Indexing
71    /// opens the cache for a short burst and drops it, but two *processes* (e.g.
72    /// a manual `greplm index` racing the daemon's watcher) aren't serialized by
73    /// the in-process index guard, so their open windows can briefly overlap.
74    /// The lock is held only for that burst, so a bounded retry rides out the
75    /// collision instead of failing the whole command.
76    fn open_db(path: &Path) -> Result<Database> {
77        const MAX_ATTEMPTS: u32 = 10;
78        const BACKOFF: std::time::Duration = std::time::Duration::from_millis(25);
79        let mut attempt = 0;
80        loop {
81            match Database::create(path) {
82                Ok(db) => return Ok(db),
83                Err(redb::DatabaseError::DatabaseAlreadyOpen) if attempt + 1 < MAX_ATTEMPTS => {
84                    attempt += 1;
85                    std::thread::sleep(BACKOFF);
86                }
87                Err(e) => return Err(e.into()),
88            }
89        }
90    }
91
92    /// Wipe the cache if the stored schema version doesn't match the current one
93    /// (or is absent, i.e. first run). The data is rebuildable, so this is a
94    /// cache miss rather than an error.
95    fn ensure_schema(&self) -> Result<()> {
96        let stored = {
97            let rtxn = self.db.begin_read()?;
98            let table = rtxn.open_table(META)?;
99            table.get(SCHEMA_KEY)?.map(|v| v.value())
100        };
101        if stored != Some(SCHEMA_VERSION) {
102            self.clear()?;
103            let wtxn = self.db.begin_write()?;
104            {
105                let mut table = wtxn.open_table(META)?;
106                table.insert(SCHEMA_KEY, SCHEMA_VERSION)?;
107            }
108            wtxn.commit()?;
109        }
110        Ok(())
111    }
112
113    pub fn get(&self, path: &str) -> Result<Option<FileRecord>> {
114        let rtxn = self.db.begin_read()?;
115        let table = rtxn.open_table(FILES)?;
116        match table.get(path)? {
117            Some(v) => Ok(Some(postcard::from_bytes(v.value())?)),
118            None => Ok(None),
119        }
120    }
121
122    /// Load all records into a map keyed by path.
123    pub fn load_all(&self) -> Result<std::collections::HashMap<String, FileRecord>> {
124        let rtxn = self.db.begin_read()?;
125        let table = rtxn.open_table(FILES)?;
126        let mut out = std::collections::HashMap::new();
127        for entry in table.iter()? {
128            let (k, v) = entry?;
129            let rec: FileRecord = postcard::from_bytes(v.value())?;
130            out.insert(k.value().to_string(), rec);
131        }
132        Ok(out)
133    }
134
135    /// Apply a batch of inserts and deletes in a single transaction.
136    ///
137    /// Upserts are applied before deletes, so if the same path appears in both,
138    /// the delete wins and the record is removed.
139    pub fn apply(&self, upserts: &[(String, FileRecord)], deletes: &[String]) -> Result<()> {
140        // Serialize outside the write lock to keep the single-writer hold short.
141        let encoded: Vec<(&str, Vec<u8>)> = upserts
142            .iter()
143            .map(|(path, rec)| Ok((path.as_str(), postcard::to_allocvec(rec)?)))
144            .collect::<Result<_>>()?;
145
146        let mut wtxn = self.db.begin_write()?;
147        // The cache is rebuildable, so skip fsync on this hot (watch-event) path;
148        // a crash just costs a re-index, which is the fallback we already support.
149        wtxn.set_durability(Durability::None);
150        {
151            let mut table = wtxn.open_table(FILES)?;
152            for (path, bytes) in &encoded {
153                table.insert(*path, bytes.as_slice())?;
154            }
155            for path in deletes {
156                table.remove(path.as_str())?;
157            }
158        }
159        wtxn.commit()?;
160        Ok(())
161    }
162
163    /// Replace the entire cache contents with `records` in a single transaction.
164    /// Used after compaction, when every live document gets new segment/doc ids.
165    pub fn replace_all(&self, records: &[(String, FileRecord)]) -> Result<()> {
166        let encoded: Vec<(&str, Vec<u8>)> = records
167            .iter()
168            .map(|(path, rec)| Ok((path.as_str(), postcard::to_allocvec(rec)?)))
169            .collect::<Result<_>>()?;
170
171        let wtxn = self.db.begin_write()?;
172        {
173            let mut table = wtxn.open_table(FILES)?;
174            // Drop every existing entry; `retain` propagates iteration errors
175            // instead of silently skipping them, so stale data can't survive.
176            table.retain(|_, _| false)?;
177            for (path, bytes) in &encoded {
178                table.insert(*path, bytes.as_slice())?;
179            }
180        }
181        wtxn.commit()?;
182        Ok(())
183    }
184
185    pub fn clear(&self) -> Result<()> {
186        self.replace_all(&[])
187    }
188}
189
190/// Compute the fast content hash used for change detection.
191pub fn fast_hash(data: &[u8]) -> u64 {
192    xxhash_rust::xxh3::xxh3_64(data)
193}
194
195/// Extract `(inode, mtime_ns, size)` from filesystem metadata.
196#[cfg(unix)]
197pub fn stat_key(meta: &std::fs::Metadata) -> (u64, i64, u64) {
198    use std::os::unix::fs::MetadataExt;
199    let mtime_ns = meta.mtime() * 1_000_000_000 + meta.mtime_nsec();
200    (meta.ino(), mtime_ns, meta.len())
201}
202
203#[cfg(not(unix))]
204pub fn stat_key(meta: &std::fs::Metadata) -> (u64, i64, u64) {
205    let mtime_ns = meta
206        .modified()
207        .ok()
208        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
209        .map(|d| d.as_nanos() as i64)
210        .unwrap_or(0);
211    (0, mtime_ns, meta.len())
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use std::path::PathBuf;
218
219    /// Unique temp file path for an isolated redb instance, cleaned up on drop.
220    struct TempDb(PathBuf);
221
222    impl TempDb {
223        fn new(tag: &str) -> TempDb {
224            let mut p = std::env::temp_dir();
225            let nanos = std::time::SystemTime::now()
226                .duration_since(std::time::UNIX_EPOCH)
227                .unwrap()
228                .as_nanos();
229            p.push(format!("greplm-cache-{tag}-{nanos}.redb"));
230            TempDb(p)
231        }
232        fn path(&self) -> &Path {
233            &self.0
234        }
235    }
236
237    impl Drop for TempDb {
238        fn drop(&mut self) {
239            let _ = std::fs::remove_file(&self.0);
240        }
241    }
242
243    fn rec(seed: u64) -> FileRecord {
244        FileRecord {
245            inode: seed,
246            mtime_ns: seed as i64 * 1_000_000_000 + 7,
247            size: seed * 13,
248            hash: seed.wrapping_mul(0x9E37_79B9_7F4A_7C15),
249            segment_id: seed + 100,
250            doc_id: seed as u32 + 5,
251            symbols: seed as u32 * 2,
252        }
253    }
254
255    fn assert_same(a: &FileRecord, b: &FileRecord) {
256        assert_eq!(a.inode, b.inode);
257        assert_eq!(a.mtime_ns, b.mtime_ns);
258        assert_eq!(a.size, b.size);
259        assert_eq!(a.hash, b.hash);
260        assert_eq!(a.segment_id, b.segment_id);
261        assert_eq!(a.doc_id, b.doc_id);
262        assert_eq!(a.symbols, b.symbols);
263    }
264
265    #[test]
266    fn fresh_db_is_empty_and_get_misses() {
267        let tmp = TempDb::new("fresh");
268        let cache = Cache::open(tmp.path()).unwrap();
269        assert!(cache.get("anything").unwrap().is_none());
270        assert!(cache.load_all().unwrap().is_empty());
271    }
272
273    #[test]
274    fn apply_roundtrips_all_fields() {
275        let tmp = TempDb::new("roundtrip");
276        let cache = Cache::open(tmp.path()).unwrap();
277        let r = rec(42);
278        cache.apply(&[("src/a.rs".into(), r.clone())], &[]).unwrap();
279
280        let got = cache.get("src/a.rs").unwrap().expect("record present");
281        assert_same(&got, &r);
282
283        let all = cache.load_all().unwrap();
284        assert_eq!(all.len(), 1);
285        assert_same(&all["src/a.rs"], &r);
286    }
287
288    #[test]
289    fn apply_upserts_and_deletes() {
290        let tmp = TempDb::new("upsert");
291        let cache = Cache::open(tmp.path()).unwrap();
292        cache
293            .apply(
294                &[
295                    ("a".into(), rec(1)),
296                    ("b".into(), rec(2)),
297                    ("c".into(), rec(3)),
298                ],
299                &[],
300            )
301            .unwrap();
302
303        // Overwrite "a" and delete "b" in one batch.
304        cache
305            .apply(&[("a".into(), rec(99))], &["b".to_string()])
306            .unwrap();
307
308        let all = cache.load_all().unwrap();
309        assert_eq!(all.len(), 2);
310        assert_same(&all["a"], &rec(99));
311        assert!(!all.contains_key("b"));
312        assert_same(&all["c"], &rec(3));
313    }
314
315    #[test]
316    fn replace_all_wipes_then_inserts() {
317        let tmp = TempDb::new("replace");
318        let cache = Cache::open(tmp.path()).unwrap();
319        cache
320            .apply(&[("old1".into(), rec(1)), ("old2".into(), rec(2))], &[])
321            .unwrap();
322
323        cache
324            .replace_all(&[("new1".into(), rec(10)), ("new2".into(), rec(20))])
325            .unwrap();
326
327        let all = cache.load_all().unwrap();
328        assert_eq!(all.len(), 2);
329        assert!(all.contains_key("new1") && all.contains_key("new2"));
330        assert!(!all.contains_key("old1") && !all.contains_key("old2"));
331        assert_same(&all["new1"], &rec(10));
332    }
333
334    #[test]
335    fn clear_empties_everything() {
336        let tmp = TempDb::new("clear");
337        let cache = Cache::open(tmp.path()).unwrap();
338        cache
339            .apply(&[("a".into(), rec(1)), ("b".into(), rec(2))], &[])
340            .unwrap();
341        cache.clear().unwrap();
342        assert!(cache.load_all().unwrap().is_empty());
343
344        // Clear on an already-empty cache is a no-op, not an error.
345        cache.clear().unwrap();
346        assert!(cache.load_all().unwrap().is_empty());
347    }
348
349    #[test]
350    fn data_survives_reopen_with_matching_schema() {
351        let tmp = TempDb::new("persist");
352        {
353            let cache = Cache::open(tmp.path()).unwrap();
354            cache.apply(&[("keep.rs".into(), rec(7))], &[]).unwrap();
355        }
356        // Reopen: matching schema version must NOT wipe existing data.
357        let cache = Cache::open(tmp.path()).unwrap();
358        let got = cache.get("keep.rs").unwrap().expect("survives reopen");
359        assert_same(&got, &rec(7));
360    }
361
362    #[test]
363    fn schema_version_mismatch_wipes_cache() {
364        let tmp = TempDb::new("schema");
365        {
366            let cache = Cache::open(tmp.path()).unwrap();
367            cache.apply(&[("stale.rs".into(), rec(3))], &[]).unwrap();
368            assert_eq!(cache.load_all().unwrap().len(), 1);
369        }
370
371        // Simulate a layout/format change by storing a different schema version,
372        // mimicking an on-disk cache written by an incompatible build.
373        {
374            let db = Database::create(tmp.path()).unwrap();
375            let wtxn = db.begin_write().unwrap();
376            {
377                let mut t = wtxn.open_table(META).unwrap();
378                t.insert(SCHEMA_KEY, SCHEMA_VERSION + 1).unwrap();
379            }
380            wtxn.commit().unwrap();
381        }
382
383        // Opening detects the mismatch and degrades to a clean (empty) cache
384        // rather than surfacing stale/undecodable records.
385        let cache = Cache::open(tmp.path()).unwrap();
386        assert!(
387            cache.load_all().unwrap().is_empty(),
388            "schema mismatch should wipe stale records"
389        );
390
391        // And the cache is usable again afterwards.
392        cache.apply(&[("fresh.rs".into(), rec(8))], &[]).unwrap();
393        assert_same(&cache.get("fresh.rs").unwrap().unwrap(), &rec(8));
394    }
395
396    #[test]
397    fn apply_deletes_only() {
398        let tmp = TempDb::new("delete-only");
399        let cache = Cache::open(tmp.path()).unwrap();
400        cache
401            .apply(&[("a".into(), rec(1)), ("b".into(), rec(2))], &[])
402            .unwrap();
403
404        cache
405            .apply(&[], &["a".to_string(), "b".to_string()])
406            .unwrap();
407
408        assert!(cache.load_all().unwrap().is_empty());
409        assert!(cache.get("a").unwrap().is_none());
410    }
411
412    #[test]
413    fn apply_empty_batch_is_noop() {
414        let tmp = TempDb::new("empty-batch");
415        let cache = Cache::open(tmp.path()).unwrap();
416        cache.apply(&[("a".into(), rec(1))], &[]).unwrap();
417
418        cache.apply(&[], &[]).unwrap();
419
420        let all = cache.load_all().unwrap();
421        assert_eq!(all.len(), 1);
422        assert_same(&all["a"], &rec(1));
423    }
424
425    #[test]
426    fn replace_all_on_empty_cache_is_noop() {
427        let tmp = TempDb::new("replace-empty");
428        let cache = Cache::open(tmp.path()).unwrap();
429        cache.replace_all(&[]).unwrap();
430        assert!(cache.load_all().unwrap().is_empty());
431    }
432
433    #[test]
434    fn missing_schema_key_wipes_legacy_records() {
435        let tmp = TempDb::new("legacy-schema");
436        // Simulate a pre-versioning cache: file records present, no schema key.
437        {
438            let db = Database::create(tmp.path()).unwrap();
439            let wtxn = db.begin_write().unwrap();
440            {
441                let mut files = wtxn.open_table(FILES).unwrap();
442                let bytes = postcard::to_allocvec(&rec(3)).unwrap();
443                files.insert("legacy.rs", bytes.as_slice()).unwrap();
444            }
445            wtxn.commit().unwrap();
446        }
447
448        let cache = Cache::open(tmp.path()).unwrap();
449        assert!(
450            cache.load_all().unwrap().is_empty(),
451            "absent schema key should wipe legacy records"
452        );
453
454        cache.apply(&[("fresh.rs".into(), rec(8))], &[]).unwrap();
455        assert_same(&cache.get("fresh.rs").unwrap().unwrap(), &rec(8));
456    }
457
458    #[test]
459    fn corrupt_record_bytes_surface_as_errors() {
460        let tmp = TempDb::new("corrupt");
461        let valid = rec(1);
462        // Truncate a valid encoding: arbitrary bytes can still decode as nonsense
463        // values without error, but a truncated message must fail.
464        let mut truncated = postcard::to_allocvec(&valid).unwrap();
465        truncated.truncate(truncated.len().saturating_sub(1));
466
467        {
468            let cache = Cache::open(tmp.path()).unwrap();
469            cache
470                .apply(&[("good.rs".into(), valid.clone())], &[])
471                .unwrap();
472        }
473        {
474            let db = Database::create(tmp.path()).unwrap();
475            let wtxn = db.begin_write().unwrap();
476            {
477                let mut table = wtxn.open_table(FILES).unwrap();
478                table.insert("bad.rs", truncated.as_slice()).unwrap();
479            }
480            wtxn.commit().unwrap();
481        }
482
483        let cache = Cache::open(tmp.path()).unwrap();
484        assert!(
485            cache.get("bad.rs").is_err(),
486            "truncated record should fail decode"
487        );
488        assert!(
489            cache.load_all().is_err(),
490            "load_all should fail on undecodable records"
491        );
492        // Unaffected keys remain readable.
493        assert_same(&cache.get("good.rs").unwrap().unwrap(), &valid);
494    }
495
496    #[test]
497    fn stat_key_matches_file_metadata() {
498        let mut path = std::env::temp_dir();
499        let nanos = std::time::SystemTime::now()
500            .duration_since(std::time::UNIX_EPOCH)
501            .unwrap()
502            .as_nanos();
503        path.push(format!("greplm-stat-{nanos}.txt"));
504        let contents = b"greplm stat_key probe";
505        std::fs::write(&path, contents).unwrap();
506
507        let meta = std::fs::metadata(&path).unwrap();
508        let (inode, mtime_ns, size) = stat_key(&meta);
509
510        assert_eq!(size, contents.len() as u64);
511        assert!(
512            mtime_ns > 0,
513            "mtime_ns should reflect file modification time"
514        );
515        #[cfg(unix)]
516        assert!(inode > 0, "unix inode should be non-zero");
517        #[cfg(not(unix))]
518        assert_eq!(inode, 0, "non-unix platforms disable inode detection");
519
520        let _ = std::fs::remove_file(&path);
521    }
522
523    /// Layout guard: postcard is not self-describing, so any change to
524    /// `FileRecord`'s fields (add/remove/reorder/retype) silently changes the
525    /// on-disk encoding. Old caches would then decode to garbage values without
526    /// erroring, breaking change detection. This test pins the exact byte layout
527    /// so such a change fails loudly — and the fix is to update BOTH the expected
528    /// bytes here AND bump `SCHEMA_VERSION` so existing caches are wiped instead
529    /// of misread.
530    #[test]
531    fn file_record_layout_is_pinned() {
532        let r = FileRecord {
533            inode: 1,
534            mtime_ns: 2,
535            size: 3,
536            hash: 4,
537            segment_id: 5,
538            doc_id: 6,
539            symbols: 7,
540        };
541        let encoded = postcard::to_allocvec(&r).unwrap();
542        assert_eq!(
543            encoded,
544            &[0x01, 0x04, 0x03, 0x04, 0x05, 0x06, 0x07],
545            "FileRecord encoding changed: update these bytes AND bump SCHEMA_VERSION \
546             (currently {SCHEMA_VERSION}) so existing caches are wiped, not misread"
547        );
548    }
549
550    #[test]
551    fn fast_hash_is_stable_and_distinguishes() {
552        assert_eq!(fast_hash(b"hello world"), fast_hash(b"hello world"));
553        assert_ne!(fast_hash(b"hello world"), fast_hash(b"hello worle"));
554
555        assert_eq!(fast_hash(b""), fast_hash(b""));
556
557        let large = vec![0xABu8; 1_000_000];
558        assert_eq!(fast_hash(&large), fast_hash(&large));
559        assert_ne!(fast_hash(&large), fast_hash(b"small"));
560    }
561}