1use 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
24const SCHEMA_VERSION: u64 = 2;
27const SCHEMA_KEY: &str = "schema_version";
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct FileRecord {
32 pub inode: u64,
35 pub mtime_ns: i64,
38 pub size: u64,
39 pub hash: u64,
40 pub segment_id: u64,
41 pub doc_id: u32,
42 pub symbols: u32,
46}
47
48pub 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 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 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 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 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 pub fn apply(&self, upserts: &[(String, FileRecord)], deletes: &[String]) -> Result<()> {
140 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 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 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 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
190pub fn fast_hash(data: &[u8]) -> u64 {
192 xxhash_rust::xxh3::xxh3_64(data)
193}
194
195#[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 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 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 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 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 {
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 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 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 {
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 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 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 #[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}