Skip to main content

lean_ctx/core/
cli_cache.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use md5::{Digest, Md5};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::OnceLock;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10const CACHE_TTL_SECS: u64 = 300;
11const MAX_ENTRIES: usize = 200;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CliCacheEntry {
15    pub path: String,
16    pub hash: String,
17    pub line_count: usize,
18    pub original_tokens: usize,
19    pub timestamp: u64,
20    pub read_count: u32,
21    /// Process-lifetime nonce scoping a hit to the process that actually
22    /// delivered the file's full content. A fresh process carries a different
23    /// nonce, so it always misses and receives content instead of a
24    /// `cached … [NL]` stub (#1459). `serde(default)` keeps older on-disk
25    /// entries (written before this field existed) parseable; they simply never
26    /// match and are overwritten on the next read.
27    #[serde(default)]
28    pub nonce: String,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32pub(crate) struct CliCacheStore {
33    pub entries: HashMap<String, CliCacheEntry>,
34    pub total_hits: u64,
35    pub total_reads: u64,
36}
37
38pub enum CacheResult {
39    Hit {
40        entry: CliCacheEntry,
41        file_ref: String,
42    },
43    Miss {
44        content: String,
45    },
46}
47
48fn cache_dir() -> Option<PathBuf> {
49    crate::core::data_dir::lean_ctx_data_dir()
50        .ok()
51        .map(|d| d.join("cli-cache"))
52}
53
54fn cache_file() -> Option<PathBuf> {
55    cache_dir().map(|d| d.join("cache.json"))
56}
57
58fn now_secs() -> u64 {
59    SystemTime::now()
60        .duration_since(UNIX_EPOCH)
61        .unwrap_or_default()
62        .as_secs()
63}
64
65/// A value unique to this process for its whole lifetime, generated once and
66/// reused. Scopes cache hits to the process that first delivered the content:
67/// a one-shot `lean-ctx read` invocation is its own process, so a subsequent
68/// invocation (new nonce) can never be served a stub from a previous run (#1459).
69fn process_nonce() -> &'static str {
70    static NONCE: OnceLock<String> = OnceLock::new();
71    NONCE
72        .get_or_init(|| {
73            let nanos = SystemTime::now()
74                .duration_since(UNIX_EPOCH)
75                .unwrap_or_default()
76                .as_nanos();
77            format!("{}-{}", std::process::id(), nanos)
78        })
79        .as_str()
80}
81
82fn compute_md5(content: &str) -> String {
83    let mut hasher = Md5::new();
84    hasher.update(content.as_bytes());
85    crate::core::agent_identity::hex_encode(&hasher.finalize())
86}
87
88fn normalize_key(path: &str) -> String {
89    crate::core::pathutil::normalize_tool_path(path)
90}
91
92/// One load → modify → save of the store. Concurrent callers — parallel tool
93/// calls in the daemon, a CLI read in another process, tests — serialize
94/// here, so a later write can never drop an earlier caller's entry (lost
95/// update). In-process callers queue on a mutex (they never time out against
96/// each other); other processes are excluded by a file lock on a stable
97/// sidecar, which is bounded so a wedged foreign holder cannot stall a read.
98struct CacheTransaction {
99    path: PathBuf,
100    store: CliCacheStore,
101    // Field order is drop order: the file lock is released first.
102    _lock: crate::core::agents::FileLock,
103    _in_process: std::sync::MutexGuard<'static, ()>,
104}
105
106fn in_process_guard() -> std::sync::MutexGuard<'static, ()> {
107    static STORE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
108    STORE_LOCK
109        .lock()
110        .unwrap_or_else(std::sync::PoisonError::into_inner)
111}
112
113/// How long a transaction waits for the cross-process file lock: the shared
114/// `FileLock` bound.
115#[cfg(not(test))]
116fn lock_timeout() -> std::time::Duration {
117    crate::core::agents::ACQUIRE_TIMEOUT
118}
119
120#[cfg(test)]
121thread_local! {
122    /// Per-thread override so lock-timeout tests wait milliseconds instead of
123    /// the production bound. Thread-local: concurrent tests (and worker
124    /// threads that must wait the full bound) keep the real timeout.
125    static LOCK_TIMEOUT_OVERRIDE: std::cell::Cell<Option<std::time::Duration>> =
126        const { std::cell::Cell::new(None) };
127}
128
129#[cfg(test)]
130fn lock_timeout() -> std::time::Duration {
131    LOCK_TIMEOUT_OVERRIDE
132        .with(std::cell::Cell::get)
133        .unwrap_or(crate::core::agents::ACQUIRE_TIMEOUT)
134}
135
136impl CacheTransaction {
137    pub(crate) fn load() -> std::io::Result<Self> {
138        let in_process = in_process_guard();
139        let path =
140            cache_file().ok_or_else(|| std::io::Error::other("CLI cache directory unavailable"))?;
141        let parent = path.parent().ok_or_else(|| {
142            std::io::Error::new(std::io::ErrorKind::InvalidInput, "CLI cache has no parent")
143        })?;
144        std::fs::create_dir_all(parent)?;
145        // Lock the stable sidecar, not the JSON inode replaced by atomic writes.
146        let lock = crate::core::agents::FileLock::acquire_with_timeout(
147            &path.with_extension("lock"),
148            lock_timeout(),
149        )
150        .map_err(|error| std::io::Error::other(format!("CLI cache lock: {error}")))?;
151        let store = match std::fs::read(&path) {
152            Ok(bytes) => serde_json::from_slice(&bytes)
153                .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?,
154            Err(error) if error.kind() == std::io::ErrorKind::NotFound => CliCacheStore::default(),
155            Err(error) => return Err(error),
156        };
157        Ok(Self {
158            path,
159            store,
160            _lock: lock,
161            _in_process: in_process,
162        })
163    }
164
165    fn save(&self) -> std::io::Result<()> {
166        let bytes = serde_json::to_vec(&self.store).map_err(std::io::Error::other)?;
167        #[cfg(unix)]
168        let permissions = {
169            use std::os::unix::fs::PermissionsExt;
170            Some(std::fs::Permissions::from_mode(0o600))
171        };
172        #[cfg(not(unix))]
173        let permissions = None;
174        // No in-place fallback: failure must preserve the prior cache bytes.
175        crate::core::atomic_fs::try_atomic_write(&self.path, &bytes, permissions.as_ref())
176    }
177}
178
179fn file_ref(key: &str, store: &CliCacheStore) -> String {
180    let keys: Vec<&String> = store.entries.keys().collect();
181    let idx = keys
182        .iter()
183        .position(|k| k.as_str() == key)
184        .unwrap_or(store.entries.len());
185    format!("F{}", idx + 1)
186}
187
188/// Check/update the CLI metadata cache only after the caller has freshly
189/// admitted the source. A hit is metadata-only; the source must be re-read and
190/// re-admitted before this function is called.
191pub fn check_admitted(
192    path: &str,
193    source: &crate::core::context_admission::SourceAdmission,
194) -> CacheResult {
195    let content = &source.text;
196    if !source.storable {
197        return CacheResult::Miss {
198            content: content.clone(),
199        };
200    }
201    let key = normalize_key(path);
202    let hash = compute_md5(content);
203    let line_count = content.lines().count();
204    let original_tokens = crate::core::tokens::count_tokens(content);
205    let nonce = process_nonce();
206    let now = now_secs();
207    let is_hit = |entry: &CliCacheEntry| {
208        entry.hash == hash
209            && entry.nonce == nonce
210            && now.saturating_sub(entry.timestamp) < CACHE_TTL_SECS
211    };
212    let mut transaction = match CacheTransaction::load() {
213        Ok(transaction) => transaction,
214        Err(error) => {
215            // The hit only depends on this process having delivered the same
216            // content; a busy lock must not cost the caller a full re-send.
217            // Atomic writes keep the file readable without the lock.
218            tracing::warn!("CLI cache unavailable, counters not updated: {error}");
219            let store: Option<CliCacheStore> = cache_file()
220                .and_then(|path| std::fs::read(path).ok())
221                .and_then(|bytes| serde_json::from_slice(&bytes).ok());
222            return match store {
223                Some(store) if store.entries.get(&key).is_some_and(is_hit) => CacheResult::Hit {
224                    entry: store.entries[&key].clone(),
225                    file_ref: file_ref(&key, &store),
226                },
227                _ => CacheResult::Miss {
228                    content: content.clone(),
229                },
230            };
231        }
232    };
233    let store = &mut transaction.store;
234
235    store.total_reads = store.total_reads.saturating_add(1);
236
237    if let Some(entry) = store.entries.get_mut(&key)
238        && is_hit(entry)
239    {
240        entry.read_count = entry.read_count.saturating_add(1);
241        entry.timestamp = now;
242        store.total_hits = store.total_hits.saturating_add(1);
243        let result = CacheResult::Hit {
244            entry: entry.clone(),
245            file_ref: file_ref(&key, store),
246        };
247        // The hit stands even if the counters cannot be persisted.
248        if let Err(error) = transaction.save() {
249            tracing::warn!("CLI cache hit counters not saved: {error}");
250        }
251        return result;
252    }
253
254    let entry = CliCacheEntry {
255        path: key.clone(),
256        hash,
257        line_count,
258        original_tokens,
259        timestamp: now,
260        read_count: 1,
261        nonce: nonce.to_string(),
262    };
263    store.entries.insert(key, entry);
264
265    evict_stale(store, now);
266
267    // Cache persistence is optional for a full-content read, never for eviction.
268    let _ = transaction.save();
269    CacheResult::Miss {
270        content: content.clone(),
271    }
272}
273
274/// Compatibility adapter for callers that need the cache to perform its own
275/// read. It still admits the freshly decoded source before any metadata lookup
276/// or persistence; the CLI hot path passes its already-admitted source above.
277#[cfg(any(test, feature = "test-support"))]
278pub fn check_and_read(path: &str) -> CacheResult {
279    let Ok(source) = crate::core::rooted_read::read_file_lossy_admitted_for_cli(path) else {
280        return CacheResult::Miss {
281            content: String::new(),
282        };
283    };
284    check_admitted(path, &source)
285}
286
287pub fn invalidate(path: &str) -> std::io::Result<()> {
288    let key = normalize_key(path);
289    let mut transaction = CacheTransaction::load()?;
290    transaction.store.entries.remove(&key);
291    transaction.save()
292}
293
294pub fn clear() -> std::io::Result<usize> {
295    let mut transaction = CacheTransaction::load()?;
296    let count = transaction.store.entries.len();
297    transaction.store.entries.clear();
298    transaction.save()?;
299    Ok(count)
300}
301
302pub fn clear_project(project_root: &str) -> std::io::Result<usize> {
303    let mut transaction = CacheTransaction::load()?;
304    let store = &mut transaction.store;
305    let prefix = normalize_key(project_root);
306    let before = store.entries.len();
307    store
308        .entries
309        .retain(|key, entry| !key.starts_with(&prefix) && !entry.path.starts_with(&prefix));
310    let removed = before - store.entries.len();
311    transaction.save()?;
312    Ok(removed)
313}
314
315pub fn stats() -> std::io::Result<(u64, u64, usize)> {
316    let transaction = CacheTransaction::load()?;
317    let store = &transaction.store;
318    Ok((store.total_hits, store.total_reads, store.entries.len()))
319}
320
321fn evict_stale(store: &mut CliCacheStore, now: u64) {
322    // A concurrent writer can store an entry stamped after this caller read
323    // `now`. Plain `now - timestamp` then underflowed: a panic in debug builds,
324    // and in release a wrapped, huge age that evicted the other writer's fresh
325    // entry. An entry from the future is simply not stale.
326    store
327        .entries
328        .retain(|_, e| now.saturating_sub(e.timestamp) < CACHE_TTL_SECS);
329
330    if store.entries.len() > MAX_ENTRIES {
331        let mut entries: Vec<(String, u64)> = store
332            .entries
333            .iter()
334            .map(|(k, e)| (k.clone(), e.timestamp))
335            .collect();
336        entries.sort_by_key(|(_, ts)| *ts);
337        let to_remove = store.entries.len() - MAX_ENTRIES;
338        for (key, _) in entries.into_iter().take(to_remove) {
339            store.entries.remove(&key);
340        }
341    }
342}
343
344pub fn format_hit(entry: &CliCacheEntry, file_ref: &str, short_path: &str) -> String {
345    if crate::core::protocol::savings_footer_visible() {
346        format!(
347            "{file_ref} cached {short_path} [{}L {}t] (read #{})",
348            entry.line_count, entry.original_tokens, entry.read_count
349        )
350    } else {
351        format!("cached {short_path} [{}L]", entry.line_count)
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn compute_md5_deterministic() {
361        let h1 = compute_md5("test content");
362        let h2 = compute_md5("test content");
363        assert_eq!(h1, h2);
364        assert_ne!(h1, compute_md5("different"));
365    }
366
367    #[test]
368    fn evict_stale_removes_old_entries() {
369        let mut store = CliCacheStore::default();
370        store.entries.insert(
371            "/old.rs".to_string(),
372            CliCacheEntry {
373                path: "/old.rs".to_string(),
374                hash: "h1".into(),
375                line_count: 10,
376                original_tokens: 50,
377                timestamp: 1000,
378                read_count: 1,
379                nonce: "n1".into(),
380            },
381        );
382        store.entries.insert(
383            "/new.rs".to_string(),
384            CliCacheEntry {
385                path: "/new.rs".to_string(),
386                hash: "h2".into(),
387                line_count: 20,
388                original_tokens: 100,
389                timestamp: now_secs(),
390                read_count: 1,
391                nonce: "n2".into(),
392            },
393        );
394
395        evict_stale(&mut store, now_secs());
396        assert!(!store.entries.contains_key("/old.rs"));
397        assert!(store.entries.contains_key("/new.rs"));
398    }
399
400    #[test]
401    fn evict_respects_max_entries() {
402        let mut store = CliCacheStore::default();
403        let now = now_secs();
404        for i in 0..MAX_ENTRIES + 10 {
405            store.entries.insert(
406                format!("/file_{i}.rs"),
407                CliCacheEntry {
408                    path: format!("/file_{i}.rs"),
409                    hash: format!("h{i}"),
410                    line_count: 1,
411                    original_tokens: 10,
412                    timestamp: now - i as u64,
413                    read_count: 1,
414                    nonce: format!("n{i}"),
415                },
416            );
417        }
418        evict_stale(&mut store, now);
419        assert!(store.entries.len() <= MAX_ENTRIES);
420    }
421
422    #[test]
423    fn format_hit_output() {
424        let _lock = crate::core::data_dir::test_env_lock();
425        let entry = CliCacheEntry {
426            path: "/test.rs".into(),
427            hash: "abc".into(),
428            line_count: 42,
429            original_tokens: 500,
430            timestamp: now_secs(),
431            read_count: 3,
432            nonce: "n".into(),
433        };
434        crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
435        let output = format_hit(&entry, "F1", "test.rs");
436        assert!(output.contains("cached test.rs"));
437        assert!(output.contains("42L"));
438        assert!(!output.contains("F1"));
439        assert!(!output.contains("500t"));
440        assert!(!output.contains("read #3"));
441        crate::test_env::remove_var("LEAN_CTX_SAVINGS_FOOTER");
442    }
443
444    #[test]
445    fn stats_returns_defaults_on_empty() {
446        let s = CliCacheStore::default();
447        assert_eq!(s.total_hits, 0);
448        assert_eq!(s.total_reads, 0);
449        assert!(s.entries.is_empty());
450    }
451
452    #[test]
453    fn cache_result_integration() {
454        let _lock = crate::core::data_dir::test_env_lock();
455
456        let nanos = std::time::SystemTime::now()
457            .duration_since(std::time::UNIX_EPOCH)
458            .unwrap()
459            .as_nanos();
460        let test_data_dir = std::env::temp_dir().join(format!("lean_ctx_cache_iso_{nanos}"));
461        std::fs::create_dir_all(&test_data_dir).unwrap();
462        crate::test_env::set_var("LEAN_CTX_DATA_DIR", &test_data_dir);
463
464        let tmp = test_data_dir.join("test_file.txt");
465        std::fs::write(&tmp, "fn main() {}\n").unwrap();
466        let path_str = tmp.to_str().unwrap();
467
468        invalidate(path_str).unwrap();
469
470        let result = check_and_read(path_str);
471        assert!(matches!(result, CacheResult::Miss { .. }));
472
473        let result2 = check_and_read(path_str);
474        assert!(matches!(result2, CacheResult::Hit { .. }));
475        if let CacheResult::Hit { entry, .. } = result2 {
476            assert_eq!(entry.line_count, 1);
477            assert!(entry.read_count >= 2);
478        }
479
480        invalidate(path_str).unwrap();
481        let result3 = check_and_read(path_str);
482        assert!(matches!(result3, CacheResult::Miss { .. }));
483
484        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
485        let _ = std::fs::remove_dir_all(&test_data_dir);
486    }
487
488    #[test]
489    fn cross_process_entry_is_not_served_as_hit() {
490        // #1459: a persistent entry written by a *different* process (a foreign
491        // nonce) must not be served as a `cached … [NL]` stub — the reader never
492        // received the content, so it must miss and get the real bytes.
493        let _lock = crate::core::data_dir::test_env_lock();
494
495        let nanos = std::time::SystemTime::now()
496            .duration_since(std::time::UNIX_EPOCH)
497            .unwrap()
498            .as_nanos();
499        let test_data_dir = std::env::temp_dir().join(format!("lean_ctx_cache_xproc_{nanos}"));
500        std::fs::create_dir_all(&test_data_dir).unwrap();
501        crate::test_env::set_var("LEAN_CTX_DATA_DIR", &test_data_dir);
502
503        let tmp = test_data_dir.join("test_file.txt");
504        let content = "fn main() {}\n";
505        std::fs::write(&tmp, content).unwrap();
506        let path_str = tmp.to_str().unwrap();
507        let key = normalize_key(path_str);
508
509        // Simulate a prior process that cached this path under its own nonce.
510        let mut transaction = CacheTransaction::load().unwrap();
511        transaction.store.entries.insert(
512            key.clone(),
513            CliCacheEntry {
514                path: key,
515                hash: compute_md5(content),
516                line_count: 1,
517                original_tokens: 4,
518                timestamp: now_secs(),
519                read_count: 1,
520                nonce: "a-different-process".into(),
521            },
522        );
523        transaction.save().unwrap();
524        drop(transaction);
525
526        // A fresh process (different nonce) must miss and receive full content.
527        let result = check_and_read(path_str);
528        assert!(
529            matches!(&result, CacheResult::Miss { content } if content == "fn main() {}\n"),
530            "foreign-process cache entry must not be served as a hit"
531        );
532
533        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
534        let _ = std::fs::remove_dir_all(&test_data_dir);
535    }
536
537    #[test]
538    fn invalidate_waits_for_cache_file_lock() {
539        let _data = crate::core::data_dir::isolated_data_dir();
540        let file = tempfile::NamedTempFile::new().unwrap();
541        std::fs::write(file.path(), "cache contention fixture\n").unwrap();
542        let path = file.path().to_string_lossy().into_owned();
543        let _ = check_and_read(&path);
544        assert!(
545            matches!(check_and_read(&path), CacheResult::Hit { .. }),
546            "premise: the entry must be cached before invalidation"
547        );
548
549        let cache_path = cache_file().expect("isolated cache path");
550        let lock =
551            crate::core::agents::FileLock::acquire(&cache_path.with_extension("lock")).unwrap();
552        let (started_tx, started_rx) = std::sync::mpsc::sync_channel(1);
553        let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
554        let worker_path = path.clone();
555        let worker = std::thread::spawn(move || {
556            let _ = started_tx.send(());
557            invalidate(&worker_path).unwrap();
558            let _ = done_tx.send(());
559        });
560
561        let started = started_rx
562            .recv_timeout(std::time::Duration::from_millis(750))
563            .is_ok();
564        let completed_while_locked = started
565            && done_rx
566                .recv_timeout(std::time::Duration::from_millis(100))
567                .is_ok();
568
569        drop(lock);
570        worker.join().expect("invalidate worker");
571
572        assert!(started, "invalidate worker did not start");
573        assert!(
574            !completed_while_locked,
575            "invalidate completed while cache lock was held"
576        );
577        assert!(matches!(check_and_read(&path), CacheResult::Miss { .. }));
578    }
579
580    /// Shortens this thread's cache-lock wait until the guard drops (with
581    /// `--test-threads 1` libtest reuses one thread for every test).
582    struct ShortLockTimeout;
583
584    impl Drop for ShortLockTimeout {
585        fn drop(&mut self) {
586            LOCK_TIMEOUT_OVERRIDE.with(|cell| cell.set(None));
587        }
588    }
589
590    fn short_lock_timeout() -> ShortLockTimeout {
591        LOCK_TIMEOUT_OVERRIDE.with(|cell| cell.set(Some(std::time::Duration::from_millis(100))));
592        ShortLockTimeout
593    }
594
595    #[test]
596    fn cache_lock_timeout_preserves_bytes_and_still_serves_the_hit() {
597        let _data = crate::core::data_dir::isolated_data_dir();
598        let _short = short_lock_timeout();
599        let file = tempfile::NamedTempFile::new().unwrap();
600        let content = "cache timeout content\n";
601        std::fs::write(file.path(), content).unwrap();
602        let path = file.path().to_str().unwrap();
603        let _ = check_and_read(path);
604        let cache_path = cache_file().unwrap();
605        let before = std::fs::read(&cache_path).unwrap();
606        let _lock =
607            crate::core::agents::FileLock::acquire(&cache_path.with_extension("lock")).unwrap();
608        assert!(invalidate(path).is_err());
609        assert!(
610            matches!(check_and_read(path), CacheResult::Hit { .. }),
611            "a busy lock must not turn this process's hit into a miss"
612        );
613        assert_eq!(std::fs::read(&cache_path).unwrap(), before);
614    }
615
616    #[test]
617    fn cache_lock_timeout_without_an_entry_returns_full_content() {
618        let _data = crate::core::data_dir::isolated_data_dir();
619        let _short = short_lock_timeout();
620        let file = tempfile::NamedTempFile::new().unwrap();
621        let content = "never cached\n";
622        std::fs::write(file.path(), content).unwrap();
623        let path = file.path().to_str().unwrap();
624        let cache_path = cache_file().unwrap();
625        std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
626        let _lock =
627            crate::core::agents::FileLock::acquire(&cache_path.with_extension("lock")).unwrap();
628        assert!(
629            matches!(check_and_read(path), CacheResult::Miss { content: full } if full == content)
630        );
631    }
632
633    #[test]
634    fn malformed_cache_is_not_overwritten_or_reported_as_cleared() {
635        let _data = crate::core::data_dir::isolated_data_dir();
636        let file = tempfile::NamedTempFile::new().unwrap();
637        std::fs::write(file.path(), "uncached content\n").unwrap();
638        let path = file.path().to_str().unwrap();
639        let _ = check_and_read(path);
640        let cache_path = cache_file().unwrap();
641        std::fs::write(&cache_path, "{broken cache").unwrap();
642        assert_eq!(clear().unwrap_err().kind(), std::io::ErrorKind::InvalidData);
643        assert!(clear_project(path).is_err());
644        assert!(invalidate(path).is_err());
645        assert!(stats().is_err());
646        assert!(
647            matches!(check_and_read(path), CacheResult::Miss { content } if content == "uncached content\n")
648        );
649        assert_eq!(std::fs::read(&cache_path).unwrap(), b"{broken cache");
650    }
651
652    #[test]
653    fn concurrent_writers_never_drop_each_others_entries() {
654        // Each writer loads the whole store, changes its own key and writes the
655        // file back. Unserialized, a later write dropped entries that a
656        // concurrent writer had just added (flaked ctx_refactor's
657        // `reformat_jetbrains_scope_invalidates_all_changed_paths`).
658        let data = crate::core::data_dir::isolated_data_dir();
659        let files: Vec<String> = (0..64)
660            .map(|i| {
661                let path = data.path().join(format!("f{i}.rs"));
662                std::fs::write(&path, format!("fn f{i}() {{}}\n")).unwrap();
663                path.to_str().unwrap().to_string()
664            })
665            .collect();
666
667        std::thread::scope(|s| {
668            for chunk in files.chunks(8) {
669                s.spawn(move || {
670                    for path in chunk {
671                        let _ = check_and_read(path);
672                    }
673                });
674            }
675        });
676
677        let store = CacheTransaction::load().expect("cache readable").store;
678        for path in &files {
679            assert!(
680                store.entries.contains_key(&normalize_key(path)),
681                "entry for {path} was lost to a concurrent write"
682            );
683        }
684    }
685}