Skip to main content

gam_runtime/warm_start/
store.rs

1//! Filesystem store for warm-start entries.
2//!
3//! Each entry is a `(<runid>.json, <runid>.bin)` pair inside a per-key
4//! directory. Writes go through a temp-file → fsync → rename sequence so a
5//! crash mid-write never leaves a half-written entry visible to readers.
6//! Per-entry SHA-256 checksums catch any residual corruption.
7//!
8//! See [`crate::warm_start`] for the public API summary.
9
10use crate::warm_start::key::Fingerprint;
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use std::collections::HashMap;
14use std::fs;
15use std::io::{self, Write as _};
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicU64, Ordering};
18use std::sync::{Arc, Mutex, OnceLock};
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21/// On-disk schema version. Bump on incompatible format changes; old entries
22/// are then ignored at read time and evicted on the next save.
23pub(crate) const SCHEMA_VERSION: u32 = 1;
24
25/// Default disk-budget for the whole warm-start store root (~1 GiB).
26pub(crate) const DEFAULT_SIZE_BUDGET_BYTES: u64 = 1024 * 1024 * 1024;
27
28/// Default TTL — entries untouched for this long are dropped.
29pub(crate) const DEFAULT_TTL_SECS: u64 = 60 * 60 * 24 * 30;
30
31#[derive(Debug, thiserror::Error)]
32pub enum StoreError {
33    #[error("io: {0}")]
34    Io(#[from] io::Error),
35    #[error("json: {0}")]
36    Json(#[from] serde_json::Error),
37}
38
39/// Entry returned from [`WarmStartStore::lookup`].
40#[derive(Debug, Clone)]
41pub struct WarmStartEntry {
42    pub payload: Vec<u8>,
43    pub objective: Option<f64>,
44    pub iteration: Option<u64>,
45    pub written_unix_secs: u64,
46    pub kind: EntryKind,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum EntryKind {
51    /// Mid-fit checkpoint — fit was alive when written.
52    Checkpoint,
53    /// End-of-fit — fit terminated successfully.
54    Final,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58struct OnDiskMeta {
59    schema_version: u32,
60    written_unix_secs: u64,
61    /// Nanosecond component of the write timestamp. Used to break ties in
62    /// LRU eviction so entries written within the same second don't sort
63    /// arbitrarily.
64    #[serde(default)]
65    written_nanos: u32,
66    objective: Option<f64>,
67    iteration: Option<u64>,
68    kind: EntryKind,
69    checksum_hex: String,
70    payload_bytes: u64,
71    /// Set when a lookup has reused this entry. Eviction keeps recently reused
72    /// entries behind never-hit writes when a tight budget forces a choice.
73    #[serde(default)]
74    accessed: bool,
75    /// Last-access timestamp (unix seconds + nanos). Distinct from the
76    /// immutable `written_*` creation stamp: a lookup that reuses this entry
77    /// bumps the access stamp (refreshing its TTL so hot entries survive)
78    /// WITHOUT touching `written_*`. Keeping the two separate is required for
79    /// correctness — `lookup_latest`/`entry_newer` order by the immutable
80    /// creation stamp, so if a read moved `written_*` forward the merely
81    /// *read* entry would masquerade as the most-recently-*written* one. Zero
82    /// (the serde default for entries written before this field existed, and
83    /// for never-reused entries) means "no access newer than creation": TTL
84    /// then falls back to `written_*`.
85    #[serde(default)]
86    accessed_unix_secs: u64,
87    #[serde(default)]
88    accessed_nanos: u32,
89}
90
91/// Effective activity timestamp (nanoseconds since the unix epoch): the more
92/// recent of the immutable creation stamp and the last-access stamp. TTL
93/// expiry is measured from this so a reused entry stays alive, while ordering
94/// (`entry_newer`) keys on `written_*` alone.
95fn meta_activity_nanos(meta: &OnDiskMeta) -> u128 {
96    let written = (meta.written_unix_secs as u128) * 1_000_000_000u128 + meta.written_nanos as u128;
97    let accessed =
98        (meta.accessed_unix_secs as u128) * 1_000_000_000u128 + meta.accessed_nanos as u128;
99    written.max(accessed)
100}
101
102#[derive(Debug, Clone)]
103pub struct StoreOptions {
104    pub size_budget_bytes: u64,
105    pub ttl: Duration,
106}
107
108impl Default for StoreOptions {
109    fn default() -> Self {
110        Self {
111            size_budget_bytes: DEFAULT_SIZE_BUDGET_BYTES,
112            ttl: Duration::from_secs(DEFAULT_TTL_SECS),
113        }
114    }
115}
116
117#[derive(Debug)]
118pub struct WarmStartStore {
119    root: PathBuf,
120    opts: StoreOptions,
121    /// Per-store metadata index. It is populated lazily and shared by clones so
122    /// checkpoint-heavy sessions do not repeatedly open every metadata JSON.
123    index: Arc<Mutex<MetadataIndex>>,
124    /// Approximate sum of bytes written under `root`. Used to throttle the
125    /// full directory-scanning eviction in [`Self::save_overwrite`] — see
126    /// `EVICT_EVERY_N_SAVES`. The counter resyncs to ground truth after every
127    /// triggered sweep. Shared across clones (`Arc`) so the eviction throttle
128    /// survives the per-operation store reuse in
129    /// `solver::persistent_warm_start::persistent_store` — otherwise every
130    /// fit reset the counter and ran a full eviction walk on its first save
131    /// (gam#1114).
132    byte_total: Arc<AtomicU64>,
133    /// Monotonically increasing save counter, shared across clones. Used
134    /// together with `byte_total` to throttle the eviction directory walk.
135    save_counter: Arc<AtomicU64>,
136    /// Root-directory mtime observed at the last completed eviction sweep,
137    /// shared across clones. When a throttled sweep fires while the store is
138    /// comfortably *under* the size budget, the only work left for it is a
139    /// TTL/byte resync over every key dir — an N-dir `read_dir` + `stat` walk
140    /// that, with thousands of fingerprint dirs in a long CI run, dominates
141    /// the per-32-save sweep even after the per-dir listing cache lands (the
142    /// residual #1114 walk). The root dir's mtime is bumped by the OS whenever
143    /// a key dir is created or removed under it, so an unchanged root mtime
144    /// means no key dir was added/dropped since our last sweep; combined with
145    /// a comfortably-under-budget byte total, the size-eviction walk is then a
146    /// guaranteed no-op and is skipped. TTL expiry of *existing* entries does
147    /// not change the root mtime, but it is already performed lazily on every
148    /// `lookup_with` and on the next root-changing save, so skipping it here is
149    /// behaviour-neutral (no entry the gate skips could be returned stale).
150    last_evict_root_mtime: Arc<Mutex<Option<SystemTime>>>,
151    /// Per-store test-only monotonic time offset (nanoseconds) added to every
152    /// `*_now` reading. Always zero in production. Tests mutate it through
153    /// [`Self::test_advance_time`] to simulate elapsed time without
154    /// `thread::sleep`. Lives on the store rather than as a process-wide
155    /// static so parallel tests with their own stores cannot pollute each
156    /// other's clocks — a global clock made `cargo test` non-deterministic
157    /// (gam test infra: one test's +1.5s TTL advance was bumping another
158    /// test's just-saved entry past its 1s TTL on immediate lookup).
159    test_time_offset_ns: AtomicU64,
160}
161
162impl Clone for WarmStartStore {
163    fn clone(&self) -> Self {
164        Self {
165            root: self.root.clone(),
166            opts: self.opts.clone(),
167            index: Arc::clone(&self.index),
168            // Throttle counters are shared across clones so the eviction
169            // directory walk stays throttled to every Nth save across the
170            // whole process, even though `persistent_store` hands out a fresh
171            // clone per save/lookup.
172            byte_total: Arc::clone(&self.byte_total),
173            save_counter: Arc::clone(&self.save_counter),
174            last_evict_root_mtime: Arc::clone(&self.last_evict_root_mtime),
175            test_time_offset_ns: AtomicU64::new(self.test_time_offset_ns.load(Ordering::Relaxed)),
176        }
177    }
178}
179
180impl WarmStartStore {
181    /// Open (or create) a store rooted at `root`.
182    pub fn open(root: PathBuf, opts: StoreOptions) -> Result<Self, StoreError> {
183        fs::create_dir_all(&root)?;
184        Ok(Self {
185            root,
186            opts,
187            index: Arc::new(Mutex::new(MetadataIndex::default())),
188            byte_total: Arc::new(AtomicU64::new(0)),
189            save_counter: Arc::new(AtomicU64::new(0)),
190            last_evict_root_mtime: Arc::new(Mutex::new(None)),
191            test_time_offset_ns: AtomicU64::new(0),
192        })
193    }
194
195    pub fn root(&self) -> &Path {
196        &self.root
197    }
198
199    pub fn options(&self) -> &StoreOptions {
200        &self.opts
201    }
202
203    fn key_dir(&self, key: &Fingerprint) -> PathBuf {
204        self.root.join(key.to_hex())
205    }
206
207    /// Look up the best entry for `key`, or `None` if no valid entry exists.
208    ///
209    /// Selection: lowest `objective` first; ties prefer [`EntryKind::Final`]
210    /// over [`EntryKind::Checkpoint`], then latest `written_unix_secs`. If
211    /// every candidate has `objective = None`, picks the latest write.
212    /// Corrupt or schema-mismatched candidates are silently cleaned up and
213    /// skipped.
214    pub fn lookup(&self, key: &Fingerprint) -> Result<Option<WarmStartEntry>, StoreError> {
215        self.lookup_with(key, LookupMode::Best)
216    }
217
218    /// Look up the newest valid entry for `key`, or `None` if no valid entry
219    /// exists.
220    ///
221    /// Unlike [`Self::lookup`], this deliberately ignores objective values.
222    /// Use this for near-match seed namespaces where entries may come from
223    /// different folds, diseases, or row sets, and objective magnitudes are
224    /// not comparable. Exact-key resume should keep using [`Self::lookup`].
225    pub fn lookup_latest(&self, key: &Fingerprint) -> Result<Option<WarmStartEntry>, StoreError> {
226        self.lookup_with(key, LookupMode::Latest)
227    }
228
229    fn lookup_with(
230        &self,
231        key: &Fingerprint,
232        mode: LookupMode,
233    ) -> Result<Option<WarmStartEntry>, StoreError> {
234        let dir = self.key_dir(key);
235        if !dir.exists() {
236            // A stale in-memory cache entry could outlive its directory if
237            // another process evicted us. Drop it so we don't return data
238            // for a key whose backing files are gone.
239            lookup_cache_invalidate(&LookupCacheKey { fp: *key, mode });
240            self.metadata_index_remove_key(key);
241            return Ok(None);
242        }
243        // Fast path: if the same (key, mode) was looked up before and the
244        // chosen meta file's mtime is unchanged, return the cached entry
245        // without re-reading any JSON or re-checksumming the .bin payload.
246        // A separate writer (this process or another) bumps mtime on
247        // rename → mismatch → we fall through to the slow path. The TTL
248        // cutoff is also re-checked here against `nanos_now()` so a hot
249        // poll loop cannot keep returning an expired entry between eviction
250        // sweeps (eviction is throttled via `EVICT_EVERY_N_SAVES`).
251        let cache_key = LookupCacheKey { fp: *key, mode };
252        let now_nanos = self.nanos_now();
253        if let Some(hit) = lookup_cache_get(&cache_key) {
254            if let Ok(md) = fs::metadata(&hit.meta_path)
255                && md.modified().ok() == Some(hit.meta_mtime)
256            {
257                let expired = self.opts.ttl.as_nanos() > 0
258                    && now_nanos.saturating_sub(hit.write_nanos) >= self.opts.ttl.as_nanos();
259                if !expired {
260                    let entry = self.touch_lookup_hit(&hit.meta_path, hit.entry)?;
261                    return Ok(Some(entry));
262                }
263                lookup_cache_invalidate(&cache_key);
264                let bin = hit.meta_path.with_extension("bin");
265                fs::remove_file(&hit.meta_path).ok();
266                fs::remove_file(&bin).ok();
267                // Removing the entry stales any cached directory listing.
268                self.metadata_index_remove(&hit.meta_path);
269                return Ok(None);
270            }
271            lookup_cache_invalidate(&cache_key);
272        }
273        // Resolve all valid entries for this key directory. `scan_key_dir`
274        // serves the listing from the per-store directory cache when the dir's
275        // mtime is unchanged since the last scan (no re-`read_dir`, no per-file
276        // `stat`, no JSON re-parse), and drops TTL-expired / corrupt entries in
277        // passing — exactly the syscall storm #1114 traced.
278        let mut best: Option<(OnDiskMeta, PathBuf)> = None;
279        for scanned in self.scan_key_dir(&dir, now_nanos) {
280            let take = match best {
281                None => true,
282                Some((ref cur, _)) => mode.better(&scanned.meta, cur),
283            };
284            if take {
285                best = Some((scanned.meta, scanned.meta_path));
286            }
287        }
288        let (meta, meta_path) = match best {
289            Some(b) => b,
290            None => {
291                lookup_cache_invalidate(&cache_key);
292                return Ok(None);
293            }
294        };
295        let bin_path = meta_path.with_extension("bin");
296        let payload = match fs::read(&bin_path) {
297            Ok(v) => v,
298            Err(_) => return Ok(None),
299        };
300        // Validate checksum
301        if checksum_hex(&payload) != meta.checksum_hex {
302            fs::remove_file(&meta_path).ok();
303            fs::remove_file(&bin_path).ok();
304            lookup_cache_invalidate(&cache_key);
305            self.metadata_index_remove(&meta_path);
306            return Ok(None);
307        }
308        let entry = WarmStartEntry {
309            payload,
310            objective: meta.objective,
311            iteration: meta.iteration,
312            written_unix_secs: meta.written_unix_secs,
313            kind: meta.kind,
314        };
315        let (meta, entry) = self.touch_lookup_meta(&meta_path, meta, entry)?;
316        // Record (meta_path, mtime) → entry so subsequent identical lookups
317        // short-circuit until the meta file's mtime changes. The effective
318        // activity stamp (post-touch, so it reflects this very access) is
319        // cached alongside so the fast path can re-apply the TTL cutoff without
320        // re-reading the JSON.
321        if let Ok(md) = fs::metadata(&meta_path)
322            && let Ok(mtime) = md.modified()
323        {
324            let write_nanos = meta_activity_nanos(&meta);
325            lookup_cache_insert(
326                cache_key,
327                CachedLookup {
328                    meta_path: meta_path.clone(),
329                    meta_mtime: mtime,
330                    write_nanos,
331                    entry: entry.clone(),
332                },
333            );
334        }
335        Ok(Some(entry))
336    }
337
338    /// Save a new entry with a fresh run-id. Returns the run-id (caller may
339    /// hand it to [`Self::save_overwrite`] for periodic in-place updates).
340    pub fn save(
341        &self,
342        key: &Fingerprint,
343        payload: &[u8],
344        objective: Option<f64>,
345        iteration: Option<u64>,
346        kind: EntryKind,
347    ) -> Result<String, StoreError> {
348        let run_id = self.fresh_run_id();
349        self.save_overwrite(key, &run_id, payload, objective, iteration, kind)?;
350        Ok(run_id)
351    }
352
353    /// Save under a specific run-id (overwrites an existing entry with the
354    /// same id atomically).
355    pub fn save_overwrite(
356        &self,
357        key: &Fingerprint,
358        run_id: &str,
359        payload: &[u8],
360        objective: Option<f64>,
361        iteration: Option<u64>,
362        kind: EntryKind,
363    ) -> Result<(), StoreError> {
364        // Any new write under this key may change which entry wins both
365        // `LookupMode::Best` and `LookupMode::Latest`, so drop both cached
366        // rows before touching disk. A pure save_overwrite of the same
367        // run_id would also bump mtime and self-invalidate, but a save()
368        // with a fresh run_id leaves the old meta file unchanged — only
369        // explicit invalidation catches that.
370        lookup_cache_invalidate(&LookupCacheKey {
371            fp: *key,
372            mode: LookupMode::Best,
373        });
374        lookup_cache_invalidate(&LookupCacheKey {
375            fp: *key,
376            mode: LookupMode::Latest,
377        });
378        let dir = self.key_dir(key);
379        let pid = std::process::id();
380        // 1. Compute checksum from payload.
381        let checksum = checksum_hex(payload);
382        let objective_finite = objective.filter(|o| o.is_finite());
383        // The meta's `written_unix_secs`/`written_nanos` are captured INSIDE the
384        // write loop — just before the meta_tmp is written, AFTER the bin write
385        // has completed. The stored timestamp drives the TTL contract: an
386        // entry's clock should start ticking from when the entry becomes
387        // (nearly) visible to lookups, not from `save_overwrite`'s entry. On
388        // slow disks the bin write + fsync + rename can take longer than the
389        // entire TTL window itself (the warm-start test fixture pins TTL=1s
390        // while the ext4-backed CI image takes >1s on small writes), so an
391        // up-front stamp causes the entry to be classified as expired the
392        // moment `save_overwrite` returns. Pushing the stamp past the bin
393        // fsync removes that systemic drift from the cost of writing the
394        // entry — only the meta fsync + final rename + dir fsync still
395        // elapse between the stamp and the entry becoming visible.
396
397        // 3. Write both temp files and atomically rename them into place. The
398        //    whole "ensure dir → write temps → rename" sequence is retried once
399        //    as a unit on `ErrorKind::NotFound`, because a concurrent process'
400        //    `evict_overflow` can `remove_dir` this key dir the instant it
401        //    observes it empty (store.rs `evict_overflow`, "Sweep now-empty key
402        //    dirs"). That removal races every write step here: it can vanish the
403        //    dir after `create_dir_all` but before a temp `File::create`, or
404        //    take the dir *and our just-written temps with it* before the
405        //    rename, surfacing as `io: No such file or directory (os error 2)`
406        //    under parallel CV / bootstrap fitting (gam#868). Retrying the
407        //    sequence (not an individual step) is the only correct response: a
408        //    bare rename retry can't recover once the source temp was swept with
409        //    the dir, so we recreate the dir and rewrite the temps from the
410        //    in-memory `payload` / `meta_json` we still hold. A single retry is
411        //    sufficient — the eviction window is one `remove_dir` syscall wide —
412        //    and a second genuine `NotFound` is propagated as before.
413        let nonce = self.nanos_now();
414        let bin_final = dir.join(format!("{run_id}.bin"));
415        let meta_final = dir.join(format!("{run_id}.json"));
416        let mut attempt = 0u8;
417        let build_meta_json = |secs: u64, subsec_nanos: u32| -> Result<Vec<u8>, StoreError> {
418            let meta = OnDiskMeta {
419                schema_version: SCHEMA_VERSION,
420                written_unix_secs: secs,
421                written_nanos: subsec_nanos,
422                objective: objective_finite,
423                iteration,
424                kind,
425                checksum_hex: checksum.clone(),
426                payload_bytes: payload.len() as u64,
427                accessed: false,
428                accessed_unix_secs: 0,
429                accessed_nanos: 0,
430            };
431            Ok(serde_json::to_vec_pretty(&meta)?)
432        };
433        loop {
434            let bin_tmp = dir.join(format!("{run_id}.bin.tmp.{pid}.{nonce}.{attempt}"));
435            let meta_tmp = dir.join(format!("{run_id}.json.tmp.{pid}.{nonce}.{attempt}"));
436            let stamp_fn = || self.unix_now_parts();
437            let build_meta_for_io = |secs: u64, subsec_nanos: u32| -> io::Result<Vec<u8>> {
438                build_meta_json(secs, subsec_nanos)
439                    .map_err(|e| io::Error::other(format!("meta build: {e:?}")))
440            };
441            match write_and_promote_entry(&EntryWrite {
442                dir: &dir,
443                bin_tmp: &bin_tmp,
444                meta_tmp: &meta_tmp,
445                payload,
446                bin_final: &bin_final,
447                meta_final: &meta_final,
448                stamp_fn: &stamp_fn,
449                build_meta_json: &build_meta_for_io,
450            }) {
451                Ok(()) => break,
452                Err(e) if e.kind() == io::ErrorKind::NotFound && attempt == 0 => {
453                    // A sibling process' eviction removed the key dir mid-write.
454                    // Clean up any partial temps, then retry the whole sequence
455                    // once after recreating the dir inside `write_and_promote_entry`.
456                    fs::remove_file(&bin_tmp).ok();
457                    fs::remove_file(&meta_tmp).ok();
458                    attempt += 1;
459                    continue;
460                }
461                Err(e) => {
462                    fs::remove_file(&bin_tmp).ok();
463                    fs::remove_file(&meta_tmp).ok();
464                    fs::remove_file(&bin_final).ok();
465                    return Err(StoreError::Io(e));
466                }
467            }
468        }
469        // Fsync the containing directory so the rename itself is durable
470        // across a power loss / hard crash. fs::File::sync_all on the
471        // payload only guarantees the file content reaches disk; without
472        // also fsyncing the directory inode, the *rename* (which is what
473        // makes the entry visible to lookups) can be lost. Best-effort on
474        // platforms where opening a directory for fsync is not supported.
475        if let Ok(d) = fs::File::open(&dir) {
476            d.sync_all().ok();
477        }
478        self.metadata_index_upsert(&meta_final, &bin_final).ok();
479        // 5. Best-effort eviction; failure here is non-fatal. Throttle the
480        // full directory scan: maintain a process-wide approximate byte
481        // total and only run eviction when the per-save counter wraps
482        // `EVICT_EVERY_N_SAVES` as a drift-resync trigger, or on the very
483        // first save (so a fresh process inheriting a populated store root
484        // sweeps once). The
485        // counter is best-effort: it can drift relative to disk truth
486        // because other processes may write/evict, but every triggered
487        // sweep resyncs it to ground truth.
488        //
489        // The counter throttle alone does NOT bound the store: a burst of up
490        // to `EVICT_EVERY_N_SAVES - 1` saves between two counter-triggered
491        // sweeps can push the footprint arbitrarily far past the budget (e.g.
492        // 31 payloads under a budget that fits a handful). Bound it by also
493        // sweeping whenever the approximate byte total already exceeds the
494        // budget — a single cheap atomic load, so the common under-budget path
495        // still walks the directory only every Nth save, while an over-budget
496        // total forces the very next save to reclaim it. The eviction resyncs
497        // `byte_total` to ground truth, so this fires once per crossing rather
498        // than on every subsequent save.
499        let approx_added = payload.len() as u64 + APPROX_META_BYTES;
500        let new_total = self.byte_total.fetch_add(approx_added, Ordering::Relaxed) + approx_added;
501        let n = self.save_counter.fetch_add(1, Ordering::Relaxed);
502        if n == 0
503            || n.is_multiple_of(EVICT_EVERY_N_SAVES)
504            || new_total > self.opts.size_budget_bytes
505        {
506            self.evict_overflow().ok();
507        }
508        Ok(())
509    }
510
511    /// Drop entries older than TTL, then evict by recorded write-time
512    /// ascending until total bytes ≤ `opts.size_budget_bytes`. Idempotent;
513    /// safe under concurrent processes (worst case some entries are
514    /// double-removed, which is a no-op).
515    ///
516    /// Sort key is the `(written_unix_secs, written_nanos)` recorded in
517    /// each entry's meta, not the filesystem mtime — at second-resolution
518    /// mtime, batches of writes within the same second would sort
519    /// arbitrarily and could evict the most recent entry.
520    pub fn evict_overflow(&self) -> Result<(), StoreError> {
521        // Root-mtime short-circuit. A throttled sweep that fires while the
522        // approximate byte total is comfortably under budget has no size
523        // eviction to do; its only residual work is the TTL/byte resync walk
524        // over every key dir. The root mtime is bumped whenever a key dir is
525        // created/removed beneath it, so if it is unchanged since our last
526        // completed sweep AND we are under budget, no key dir was added or
527        // dropped and the size-eviction walk is provably a no-op — skip the
528        // N-dir `read_dir`+`stat` storm. (TTL expiry of existing entries does
529        // not move the root mtime, but it is already enforced lazily on every
530        // `lookup_with` and on the next root-changing save, so the gate cannot
531        // surface a stale entry.) This trims the residual #1114 walk in long
532        // refit-heavy CI runs where thousands of fingerprint dirs accumulate.
533        let current_root_mtime = fs::metadata(&self.root)
534            .ok()
535            .and_then(|m| m.modified().ok());
536        if self.byte_total.load(Ordering::Relaxed) <= self.opts.size_budget_bytes
537            && let Some(now_mtime) = current_root_mtime
538            && let Ok(last) = self.last_evict_root_mtime.lock()
539            && *last == Some(now_mtime)
540        {
541            return Ok(());
542        }
543        let read_dir = match fs::read_dir(&self.root) {
544            Ok(rd) => rd,
545            Err(_) => return Ok(()),
546        };
547        // Collect (meta_path, bin_path, total_bytes, write_nanos_since_epoch, accessed).
548        let mut all: Vec<(PathBuf, PathBuf, u64, u128, bool)> = Vec::new();
549        let now_nanos = self.nanos_now();
550        for key_dir_entry in read_dir {
551            let key_dir = match key_dir_entry {
552                Ok(e) => e.path(),
553                Err(_) => continue,
554            };
555            if !key_dir.is_dir() {
556                continue;
557            }
558            // `scan_key_dir` reuses the per-store directory-listing cache when
559            // the key dir's mtime is unchanged, so an unchanged dir costs a
560            // single `stat` rather than a `read_dir` + per-file `stat` + JSON
561            // read of every entry. It also sweeps foreign tmp files and drops
562            // corrupt / TTL-expired entries, mirroring the old inline pass.
563            let scanned = self.scan_key_dir(&key_dir, now_nanos);
564            for entry in &scanned {
565                let write_nanos = (entry.meta.written_unix_secs as u128) * 1_000_000_000u128
566                    + entry.meta.written_nanos as u128;
567                let total_bytes = entry.meta_len + entry.bin_len;
568                all.push((
569                    entry.meta_path.clone(),
570                    entry.bin_path.clone(),
571                    total_bytes,
572                    write_nanos,
573                    entry.meta.accessed,
574                ));
575            }
576            // Sweep now-empty key dirs.
577            if scanned.is_empty()
578                && fs::read_dir(&key_dir)
579                    .map(|mut it| it.next().is_none())
580                    .unwrap_or(false)
581            {
582                fs::remove_dir(&key_dir).ok();
583                if let Ok(mut index) = self.index.lock() {
584                    index.by_key_dir.remove(&key_dir);
585                }
586            }
587        }
588        let total: u64 = all.iter().map(|e| e.2).sum();
589        if total <= self.opts.size_budget_bytes {
590            // Resync the approximate byte counter even when no eviction was
591            // needed. Otherwise the in-memory `byte_total` only grows (it
592            // never observes deletions made by sibling processes or
593            // expiration sweeps), so after enough saves `new_total` exceeds
594            // the budget on every call and triggers a full directory walk
595            // on every save instead of every Nth save.
596            self.byte_total.store(total, Ordering::Relaxed);
597            // Record the root mtime observed by this completed under-budget
598            // sweep so a subsequent throttled sweep can short-circuit while
599            // the root is unchanged. Re-read after the walk: any key dir the
600            // walk removed (empty-dir sweep above) bumps the root mtime, and
601            // capturing the post-walk value keeps the gate from skipping a
602            // genuinely-changed root on the next call.
603            if let (Ok(mut last), Some(m)) = (
604                self.last_evict_root_mtime.lock(),
605                fs::metadata(&self.root)
606                    .ok()
607                    .and_then(|m| m.modified().ok()),
608            ) {
609                *last = Some(m);
610            }
611            return Ok(());
612        }
613        all.sort_by(|a, b| {
614            a.4.cmp(&b.4)
615                .then_with(|| a.3.cmp(&b.3))
616                .then_with(|| a.0.cmp(&b.0))
617        });
618        let mut remaining = total;
619        for (meta, bin, bytes, _, _) in all.into_iter() {
620            if remaining <= self.opts.size_budget_bytes {
621                break;
622            }
623            fs::remove_file(&meta).ok();
624            fs::remove_file(&bin).ok();
625            self.metadata_index_remove(&meta);
626            remaining = remaining.saturating_sub(bytes);
627        }
628        // Resync the approximate byte counter to ground truth. Subsequent
629        // saves increment from here until the next sweep.
630        self.byte_total.store(remaining, Ordering::Relaxed);
631        Ok(())
632    }
633}
634
635/// Ensure the key dir exists, write the `.bin` and `.json` temp files, and
636/// atomically rename both into place. Returns the raw `io::Error` (not a
637/// `StoreError`) so the caller can branch on `ErrorKind::NotFound` to retry the
638/// whole sequence after a concurrent eviction removed the dir mid-write
639/// (gam#868). Idempotent across a retry: every path is derived from the caller's
640/// stable args and the temps are rewritten from the in-memory payload, so a
641/// second pass into a freshly recreated dir produces the same final entry.
642///
643/// `.bin` is renamed before `.json` so a meta-pointing-to-missing-bin window is
644/// impossible on the happy path; a reader that catches `.bin`-missing treats the
645/// entry as corrupt and cleans it up.
646struct EntryWrite<'a> {
647    dir: &'a Path,
648    bin_tmp: &'a Path,
649    meta_tmp: &'a Path,
650    payload: &'a [u8],
651    bin_final: &'a Path,
652    meta_final: &'a Path,
653    /// Read the current wall clock as `(unix_secs, subsec_nanos)`. Called
654    /// AFTER the bin write and bin fsync complete (and after the bin rename)
655    /// so the recorded write time tracks when the entry actually becomes
656    /// (nearly) visible, not when `save_overwrite` was first invoked. On
657    /// slow disks the bin fsync can dominate save latency and a pre-write
658    /// stamp would burn TTL the caller never sees.
659    stamp_fn: &'a dyn Fn() -> (u64, u32),
660    /// Build the meta JSON given the captured `(secs, subsec_nanos)`. The
661    /// closure folds those values into `OnDiskMeta` and serializes it.
662    build_meta_json: &'a dyn Fn(u64, u32) -> io::Result<Vec<u8>>,
663}
664
665fn write_and_promote_entry(w: &EntryWrite<'_>) -> io::Result<()> {
666    // Recreate the dir up front: on the first attempt this is the original
667    // `create_dir_all`; on a retry it re-establishes the dir a sibling
668    // process' eviction removed.
669    fs::create_dir_all(w.dir)?;
670    {
671        let mut f = fs::File::create(w.bin_tmp)?;
672        f.write_all(w.payload)?;
673        f.sync_all().ok();
674    }
675    // Promote the bin first so a crash between the two renames leaves an
676    // orphan .bin (cleaned up by `evict_overflow`) rather than a meta
677    // pointing at a missing .bin (which the reader would mark corrupt).
678    fs::rename(w.bin_tmp, w.bin_final)?;
679    // Stamp the meta AFTER the bin promotion. This is the latest moment the
680    // timestamp can still be inlined into the meta JSON. The remaining gap
681    // before the entry is visible to lookups is one meta write+fsync + the
682    // meta rename + the caller's directory fsync — all bounded, so TTL is
683    // measured from a near-visible moment instead of from the entry to
684    // `save_overwrite`.
685    let (secs, subsec_nanos) = (w.stamp_fn)();
686    let meta_json = (w.build_meta_json)(secs, subsec_nanos)?;
687    {
688        let mut f = fs::File::create(w.meta_tmp)?;
689        f.write_all(&meta_json)?;
690        f.sync_all().ok();
691    }
692    if let Err(e) = fs::rename(w.meta_tmp, w.meta_final) {
693        // Roll back the bin we just promoted to avoid orphaning it, then
694        // surface the error so the caller can retry or fail.
695        fs::remove_file(w.bin_final).ok();
696        return Err(e);
697    }
698    Ok(())
699}
700
701/// Conservative meta-JSON size used by the throttled save counter. Real
702/// meta files run ~250-400 bytes after pretty-printing; overestimating
703/// just means the throttle fires slightly earlier, never later.
704const APPROX_META_BYTES: u64 = 512;
705
706/// How [`WarmStartStore::lookup_with`] ranks candidate entries.
707#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
708enum LookupMode {
709    /// Lowest objective wins; ties to [`entry_better`].
710    Best,
711    /// Newest write wins; objectives ignored.
712    Latest,
713}
714
715impl LookupMode {
716    fn better(&self, candidate: &OnDiskMeta, current: &OnDiskMeta) -> bool {
717        match self {
718            LookupMode::Best => entry_better(candidate, current),
719            LookupMode::Latest => entry_newer(candidate, current),
720        }
721    }
722}
723
724#[derive(Clone, Copy, PartialEq, Eq, Hash)]
725struct LookupCacheKey {
726    fp: Fingerprint,
727    mode: LookupMode,
728}
729
730#[derive(Clone)]
731struct CachedLookup {
732    meta_path: PathBuf,
733    meta_mtime: SystemTime,
734    /// Full-precision nanosecond write timestamp from the on-disk meta,
735    /// kept alongside `entry.written_unix_secs` so the fast path can apply
736    /// the same TTL cutoff as `evict_overflow` without re-reading the JSON.
737    write_nanos: u128,
738    entry: WarmStartEntry,
739}
740
741#[derive(Debug, Default)]
742struct MetadataIndex {
743    by_meta_path: HashMap<PathBuf, IndexedMeta>,
744    /// Per-key-directory cached listing, keyed by the directory's mtime.
745    ///
746    /// A key dir's mtime is bumped by the OS whenever an entry is created,
747    /// renamed, or removed inside it (which is exactly when our entries
748    /// change). So a matching `dir_mtime` means the set of `<runid>.{json,bin}`
749    /// pairs is byte-for-byte what we scanned last time, letting
750    /// [`WarmStartStore::scan_key_dir`] return the cached `Vec<ScannedEntry>`
751    /// without a fresh `read_dir` or any per-file `stat`/JSON read. This is
752    /// what kills the metadata-syscall storm in repeated `lookup_with` /
753    /// `evict_overflow` calls within one fit (gam#1114).
754    by_key_dir: HashMap<PathBuf, ScannedDir>,
755}
756
757#[derive(Debug, Clone)]
758struct IndexedMeta {
759    meta_mtime: SystemTime,
760    meta_len: u64,
761    bin_len: u64,
762    meta: OnDiskMeta,
763}
764
765impl IndexedMeta {
766    fn matches(&self, meta_md: &fs::Metadata, bin_md: &fs::Metadata) -> bool {
767        meta_md.modified().ok() == Some(self.meta_mtime)
768            && meta_md.len() == self.meta_len
769            && bin_md.len() == self.bin_len
770    }
771}
772
773/// Cached result of scanning one key directory: its mtime at scan time plus
774/// the resolved entries. Reused verbatim while the dir's mtime is unchanged.
775#[derive(Debug, Clone)]
776struct ScannedDir {
777    dir_mtime: SystemTime,
778    entries: Vec<ScannedEntry>,
779}
780
781/// One resolved `(meta, bin)` pair discovered during a key-dir scan. Carries
782/// everything both the lookup ranker and the eviction sweep need so neither
783/// has to re-`stat` or re-read the files when the dir is unchanged.
784#[derive(Debug, Clone)]
785struct ScannedEntry {
786    meta_path: PathBuf,
787    bin_path: PathBuf,
788    meta_len: u64,
789    bin_len: u64,
790    meta_mtime: Option<SystemTime>,
791    bin_mtime: Option<SystemTime>,
792    meta: OnDiskMeta,
793}
794
795impl ScannedEntry {
796    fn matches_files(&self, meta_md: &fs::Metadata, bin_md: &fs::Metadata) -> bool {
797        meta_md.len() == self.meta_len
798            && bin_md.len() == self.bin_len
799            && meta_md.modified().ok() == self.meta_mtime
800            && bin_md.modified().ok() == self.bin_mtime
801    }
802}
803
804/// True iff a meta with the given (`secs`, `nanos`) write timestamp is older
805/// than `ttl` relative to `now_nanos`. Mirrors the cutoff in
806/// [`WarmStartStore::evict_overflow`] so `lookup_with` cannot return an entry
807/// that the eviction sweep would have dropped.
808/// TTL expiry test. `activity_nanos` is the entry's effective activity stamp
809/// (`meta_activity_nanos`: the more recent of creation and last access), so a
810/// reused entry's TTL restarts from its last lookup rather than its creation.
811const fn meta_expired(activity_nanos: u128, ttl: Duration, now_nanos: u128) -> bool {
812    let ttl_nanos = ttl.as_nanos();
813    if ttl_nanos == 0 {
814        return false;
815    }
816    now_nanos.saturating_sub(activity_nanos) >= ttl_nanos
817}
818
819/// Process-wide in-memory cache for [`WarmStartStore::lookup_with`]. Hot poll
820/// loops hit the same (key, mode) repeatedly between writes, so caching the
821/// resolved entry behind an mtime check eliminates the per-call directory
822/// walk, JSON parse, and SHA-256 recomputation. Mtime mismatch — including
823/// writes from a sibling process — invalidates the row and falls back to
824/// the full slow path.
825fn lookup_cache() -> &'static Mutex<HashMap<LookupCacheKey, CachedLookup>> {
826    static CACHE: OnceLock<Mutex<HashMap<LookupCacheKey, CachedLookup>>> = OnceLock::new();
827    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
828}
829
830const LOOKUP_CACHE_MAX_ENTRIES: usize = 128;
831const LOOKUP_CACHE_MAX_BYTES: usize = 256 * 1024 * 1024;
832
833const fn cached_lookup_resident_bytes(value: &CachedLookup) -> usize {
834    std::mem::size_of::<CachedLookup>().saturating_add(value.entry.payload.capacity())
835}
836
837fn lookup_cache_get(key: &LookupCacheKey) -> Option<CachedLookup> {
838    let guard = lookup_cache().lock().ok()?;
839    guard.get(key).cloned()
840}
841
842fn lookup_cache_insert(key: LookupCacheKey, val: CachedLookup) {
843    if let Ok(mut guard) = lookup_cache().lock() {
844        let new_bytes = cached_lookup_resident_bytes(&val);
845        if new_bytes > LOOKUP_CACHE_MAX_BYTES {
846            return;
847        }
848        let mut resident_bytes: usize = guard.values().map(cached_lookup_resident_bytes).sum();
849        if let Some(old) = guard.remove(&key) {
850            resident_bytes = resident_bytes.saturating_sub(cached_lookup_resident_bytes(&old));
851        }
852        while guard.len() >= LOOKUP_CACHE_MAX_ENTRIES
853            || resident_bytes.saturating_add(new_bytes) > LOOKUP_CACHE_MAX_BYTES
854        {
855            let oldest = guard
856                .iter()
857                .min_by_key(|(_, cached)| cached.write_nanos)
858                .map(|(old_key, _)| *old_key);
859            let Some(oldest) = oldest else {
860                break;
861            };
862            if let Some(old) = guard.remove(&oldest) {
863                resident_bytes = resident_bytes.saturating_sub(cached_lookup_resident_bytes(&old));
864            }
865        }
866        guard.insert(key, val);
867    }
868}
869
870fn lookup_cache_invalidate(key: &LookupCacheKey) {
871    if let Ok(mut guard) = lookup_cache().lock() {
872        guard.remove(key);
873    }
874}
875
876/// Run a full [`WarmStartStore::evict_overflow`] sweep every Nth save. The
877/// budget can briefly overshoot by K-1 payloads, which the next sweep
878/// reclaims. K=32 keeps the amortized cost negligible on hot checkpoint
879/// paths while still bounding worst-case disk drift.
880const EVICT_EVERY_N_SAVES: u64 = 32;
881
882fn parse_tmp_pid(name: &str) -> Option<u32> {
883    // Names look like "<runid>.bin.tmp.<pid>.<nonce>.<attempt>" or
884    // "<runid>.json.tmp.<pid>.<nonce>.<attempt>" (the trailing retry-attempt
885    // suffix is irrelevant here — only the first token after ".tmp." is the pid).
886    let tail = name.split(".tmp.").nth(1)?;
887    let pid_str = tail.split('.').next()?;
888    pid_str.parse::<u32>().ok()
889}
890
891fn read_meta(path: &Path) -> Result<OnDiskMeta, StoreError> {
892    let bytes = fs::read(path)?;
893    let parsed: OnDiskMeta = serde_json::from_slice(&bytes)?;
894    Ok(parsed)
895}
896
897fn entry_better(candidate: &OnDiskMeta, current: &OnDiskMeta) -> bool {
898    match (candidate.objective, current.objective) {
899        (Some(c), Some(d)) => {
900            if (c - d).abs() < 1e-12 {
901                match (candidate.kind, current.kind) {
902                    (EntryKind::Final, EntryKind::Checkpoint) => true,
903                    (EntryKind::Checkpoint, EntryKind::Final) => false,
904                    _ => entry_newer(candidate, current),
905                }
906            } else {
907                c < d
908            }
909        }
910        (Some(_), None) => true,
911        (None, Some(_)) => false,
912        (None, None) => entry_newer(candidate, current),
913    }
914}
915
916fn entry_newer(candidate: &OnDiskMeta, current: &OnDiskMeta) -> bool {
917    let candidate_stamp = (
918        candidate.written_unix_secs,
919        candidate.written_nanos,
920        candidate_kind_rank(candidate.kind),
921    );
922    let current_stamp = (
923        current.written_unix_secs,
924        current.written_nanos,
925        candidate_kind_rank(current.kind),
926    );
927    candidate_stamp > current_stamp
928}
929
930const fn candidate_kind_rank(kind: EntryKind) -> u8 {
931    match kind {
932        EntryKind::Checkpoint => 0,
933        EntryKind::Final => 1,
934    }
935}
936
937fn checksum_hex(payload: &[u8]) -> String {
938    let mut h = Sha256::new();
939    h.update(payload);
940    let out = h.finalize();
941    let mut s = String::with_capacity(out.len() * 2);
942    for b in out.iter() {
943        use std::fmt::Write;
944        write!(&mut s, "{:02x}", b).expect("writing to String is infallible");
945    }
946    s
947}
948
949impl WarmStartStore {
950    fn touch_lookup_hit(
951        &self,
952        meta_path: &Path,
953        entry: WarmStartEntry,
954    ) -> Result<WarmStartEntry, StoreError> {
955        let meta = read_meta(meta_path)?;
956        let (_meta, entry) = self.touch_lookup_meta(meta_path, meta, entry)?;
957        Ok(entry)
958    }
959
960    fn touch_lookup_meta(
961        &self,
962        meta_path: &Path,
963        mut meta: OnDiskMeta,
964        entry: WarmStartEntry,
965    ) -> Result<(OnDiskMeta, WarmStartEntry), StoreError> {
966        let now = self.nanos_now();
967        // Refresh the ACCESS stamp (TTL clock), never the creation stamp: the
968        // creation stamp is the ordering key for `lookup_latest`, so bumping it
969        // on a read would make a merely-read entry win "latest" over a strictly
970        // newer write. Advance strictly past the previous access stamp so a
971        // second touch inside the same nanosecond still moves forward.
972        let old_access =
973            (meta.accessed_unix_secs as u128) * 1_000_000_000u128 + meta.accessed_nanos as u128;
974        let touched = now.max(old_access.saturating_add(1));
975        meta.accessed_unix_secs = (touched / 1_000_000_000u128) as u64;
976        meta.accessed_nanos = (touched % 1_000_000_000u128) as u32;
977        meta.accessed = true;
978        let json = serde_json::to_vec_pretty(&meta)?;
979        let tmp = meta_path.with_extension(format!(
980            "json.touch.tmp.{}.{}",
981            std::process::id(),
982            self.nanos_now()
983        ));
984        {
985            let mut f = fs::File::create(&tmp)?;
986            f.write_all(&json)?;
987            f.sync_all()?;
988        }
989        fs::rename(&tmp, meta_path)?;
990        if let Some(dir) = meta_path.parent()
991            && let Ok(d) = fs::File::open(dir)
992        {
993            d.sync_all().ok();
994        }
995        self.metadata_index_remove(meta_path);
996        // `entry.written_unix_secs` intentionally keeps the immutable creation
997        // stamp — the touch above only advanced the access clock.
998        Ok((meta, entry))
999    }
1000
1001    fn read_meta_indexed(
1002        &self,
1003        path: &Path,
1004        meta_md: &fs::Metadata,
1005        bin_md: &fs::Metadata,
1006    ) -> Result<OnDiskMeta, StoreError> {
1007        if let Ok(index) = self.index.lock()
1008            && let Some(cached) = index.by_meta_path.get(path)
1009            && cached.matches(meta_md, bin_md)
1010        {
1011            return Ok(cached.meta.clone());
1012        }
1013
1014        let meta = read_meta(path)?;
1015        let Some(meta_mtime) = meta_md.modified().ok() else {
1016            return Ok(meta);
1017        };
1018        if let Ok(mut index) = self.index.lock() {
1019            index.by_meta_path.insert(
1020                path.to_path_buf(),
1021                IndexedMeta {
1022                    meta_mtime,
1023                    meta_len: meta_md.len(),
1024                    bin_len: bin_md.len(),
1025                    meta: meta.clone(),
1026                },
1027            );
1028        }
1029        Ok(meta)
1030    }
1031
1032    fn metadata_index_upsert(&self, meta_path: &Path, bin_path: &Path) -> Result<(), StoreError> {
1033        let meta_md = fs::metadata(meta_path)?;
1034        let bin_md = fs::metadata(bin_path)?;
1035        self.read_meta_indexed(meta_path, &meta_md, &bin_md)?;
1036        // A fresh entry just landed in this key dir, so any cached listing for
1037        // the dir is stale. Drop it; the next scan rebuilds and re-caches.
1038        if let Some(parent) = meta_path.parent()
1039            && let Ok(mut index) = self.index.lock()
1040        {
1041            index.by_key_dir.remove(parent);
1042        }
1043        Ok(())
1044    }
1045
1046    fn metadata_index_remove(&self, meta_path: &Path) {
1047        if let Ok(mut index) = self.index.lock() {
1048            index.by_meta_path.remove(meta_path);
1049            if let Some(parent) = meta_path.parent() {
1050                index.by_key_dir.remove(parent);
1051            }
1052        }
1053    }
1054
1055    fn metadata_index_remove_key(&self, key: &Fingerprint) {
1056        let dir = self.key_dir(key);
1057        if let Ok(mut index) = self.index.lock() {
1058            index.by_meta_path.retain(|path, _| !path.starts_with(&dir));
1059            index.by_key_dir.remove(&dir);
1060        }
1061    }
1062
1063    /// Cached listing lookup for one key directory.
1064    ///
1065    /// Returns the cached `Vec<ScannedEntry>` if the directory's current mtime
1066    /// matches the cached scan (no entry added/removed since), otherwise
1067    /// `None` so the caller performs a fresh scan via [`Self::scan_key_dir`].
1068    ///
1069    /// A matching dir mtime guarantees the *set* of files is unchanged, but TTL
1070    /// is wall-clock relative, so an entry valid at scan time can expire while
1071    /// the listing is still cached. The caller re-applies the TTL cutoff to the
1072    /// returned entries; this only proves the file set is stable.
1073    fn cached_dir_scan(&self, dir: &Path, dir_md: &fs::Metadata) -> Option<Vec<ScannedEntry>> {
1074        let dir_mtime = dir_md.modified().ok()?;
1075        let index = self.index.lock().ok()?;
1076        let cached = index.by_key_dir.get(dir)?;
1077        if cached.dir_mtime != dir_mtime {
1078            return None;
1079        }
1080        for entry in &cached.entries {
1081            let meta_md = fs::metadata(&entry.meta_path).ok()?;
1082            let bin_md = fs::metadata(&entry.bin_path).ok()?;
1083            if !entry.matches_files(&meta_md, &bin_md) {
1084                return None;
1085            }
1086        }
1087        Some(cached.entries.clone())
1088    }
1089
1090    fn store_dir_scan(&self, dir: &Path, dir_mtime: SystemTime, entries: &[ScannedEntry]) {
1091        if let Ok(mut index) = self.index.lock() {
1092            index.by_key_dir.insert(
1093                dir.to_path_buf(),
1094                ScannedDir {
1095                    dir_mtime,
1096                    entries: entries.to_vec(),
1097                },
1098            );
1099        }
1100    }
1101
1102    /// Scan one key directory, resolving every valid `(meta, bin)` pair and
1103    /// cleaning up corrupt / orphaned / schema-mismatched files in passing.
1104    ///
1105    /// Serves both [`Self::lookup_with`] and [`Self::evict_overflow`]: when the
1106    /// directory's mtime is unchanged since the previous scan it returns the
1107    /// cached listing without a single `read_dir`, `metadata`, or JSON read —
1108    /// the metadata-syscall storm that #1114 traced. A fresh scan re-caches the
1109    /// listing keyed by the dir mtime observed *after* any cleanup, so a later
1110    /// unchanged call hits the cache. (`now_nanos` drives the TTL drop; expired
1111    /// entries are removed and excluded from the result.)
1112    ///
1113    /// `.tmp.*` files belonging to other processes are swept; same-PID temps
1114    /// (in-flight writes from us) are left alone.
1115    fn scan_key_dir(&self, dir: &Path, now_nanos: u128) -> Vec<ScannedEntry> {
1116        let dir_md = match fs::metadata(dir) {
1117            Ok(m) => m,
1118            Err(_) => return Vec::new(),
1119        };
1120        if let Some(cached) = self.cached_dir_scan(dir, &dir_md) {
1121            // The file set is unchanged, but TTL is wall-clock relative: an
1122            // entry valid when scanned may have expired since. Re-apply the
1123            // cutoff against `now_nanos`, removing any that crossed it. If none
1124            // expired we return the cached listing untouched (the fast path);
1125            // otherwise the removals bump the dir mtime, so we drop the stale
1126            // cache and re-cache the survivors keyed by the post-removal mtime.
1127            let any_expired = cached
1128                .iter()
1129                .any(|e| meta_expired(meta_activity_nanos(&e.meta), self.opts.ttl, now_nanos));
1130            if !any_expired {
1131                return cached;
1132            }
1133            let mut survivors = Vec::with_capacity(cached.len());
1134            for entry in cached {
1135                if meta_expired(meta_activity_nanos(&entry.meta), self.opts.ttl, now_nanos) {
1136                    fs::remove_file(&entry.meta_path).ok();
1137                    fs::remove_file(&entry.bin_path).ok();
1138                    self.metadata_index_remove(&entry.meta_path);
1139                } else {
1140                    survivors.push(entry);
1141                }
1142            }
1143            if let Some(mtime) = fs::metadata(dir).ok().and_then(|m| m.modified().ok()) {
1144                self.store_dir_scan(dir, mtime, &survivors);
1145            }
1146            return survivors;
1147        }
1148        let read_dir = match fs::read_dir(dir) {
1149            Ok(rd) => rd,
1150            Err(_) => return Vec::new(),
1151        };
1152        let mut entries = Vec::new();
1153        let mut mutated = false;
1154        for f in read_dir {
1155            let path = match f {
1156                Ok(e) => e.path(),
1157                Err(_) => continue,
1158            };
1159            let name = match path.file_name().and_then(|s| s.to_str()) {
1160                Some(s) => s,
1161                None => continue,
1162            };
1163            if name.contains(".tmp.") {
1164                if let Some(pid) = parse_tmp_pid(name)
1165                    && pid != std::process::id()
1166                {
1167                    fs::remove_file(&path).ok();
1168                    mutated = true;
1169                }
1170                continue;
1171            }
1172            if path.extension().and_then(|s| s.to_str()) != Some("json") {
1173                continue;
1174            }
1175            let meta_md = match fs::metadata(&path) {
1176                Ok(m) => m,
1177                Err(_) => continue,
1178            };
1179            let bin = path.with_extension("bin");
1180            let bin_md = match fs::metadata(&bin) {
1181                Ok(m) => m,
1182                Err(_) => {
1183                    fs::remove_file(&path).ok();
1184                    self.metadata_index_remove(&path);
1185                    mutated = true;
1186                    continue;
1187                }
1188            };
1189            let meta = match self.read_meta_indexed(&path, &meta_md, &bin_md) {
1190                Ok(m) => m,
1191                Err(_) => {
1192                    fs::remove_file(&path).ok();
1193                    fs::remove_file(&bin).ok();
1194                    self.metadata_index_remove(&path);
1195                    mutated = true;
1196                    continue;
1197                }
1198            };
1199            if meta.schema_version != SCHEMA_VERSION {
1200                fs::remove_file(&path).ok();
1201                fs::remove_file(&bin).ok();
1202                self.metadata_index_remove(&path);
1203                mutated = true;
1204                continue;
1205            }
1206            if meta_expired(meta_activity_nanos(&meta), self.opts.ttl, now_nanos) {
1207                fs::remove_file(&path).ok();
1208                fs::remove_file(&bin).ok();
1209                self.metadata_index_remove(&path);
1210                mutated = true;
1211                continue;
1212            }
1213            entries.push(ScannedEntry {
1214                meta_path: path,
1215                bin_path: bin,
1216                meta_len: meta_md.len(),
1217                bin_len: bin_md.len(),
1218                meta_mtime: meta_md.modified().ok(),
1219                bin_mtime: bin_md.modified().ok(),
1220                meta,
1221            });
1222        }
1223        // Cache keyed by the mtime *after* any cleanup so the next unchanged
1224        // call is a cache hit. If cleanup mutated the dir, re-stat to capture
1225        // the post-mutation mtime; otherwise reuse the mtime we already read.
1226        let final_mtime = if mutated {
1227            fs::metadata(dir).ok().and_then(|m| m.modified().ok())
1228        } else {
1229            dir_md.modified().ok()
1230        };
1231        if let Some(mtime) = final_mtime {
1232            self.store_dir_scan(dir, mtime, &entries);
1233        }
1234        entries
1235    }
1236
1237    fn test_time_offset_ns(&self) -> u64 {
1238        self.test_time_offset_ns.load(Ordering::Relaxed)
1239    }
1240
1241    fn unix_now_parts(&self) -> (u64, u32) {
1242        let base = SystemTime::now()
1243            .duration_since(UNIX_EPOCH)
1244            .map(|d| d.as_nanos())
1245            .unwrap_or(0);
1246        let total = base.saturating_add(u128::from(self.test_time_offset_ns()));
1247        let secs = (total / 1_000_000_000u128) as u64;
1248        let nanos = (total % 1_000_000_000u128) as u32;
1249        (secs, nanos)
1250    }
1251
1252    fn nanos_now(&self) -> u128 {
1253        let base = SystemTime::now()
1254            .duration_since(UNIX_EPOCH)
1255            .map(|d| d.as_nanos())
1256            .unwrap_or(0);
1257        base.saturating_add(u128::from(self.test_time_offset_ns()))
1258    }
1259
1260    fn fresh_run_id(&self) -> String {
1261        let pid = std::process::id();
1262        let nanos = self.nanos_now();
1263        format!("r{pid:x}-{nanos:x}")
1264    }
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269    use super::*;
1270    use crate::warm_start::key::Fingerprinter;
1271
1272    impl WarmStartStore {
1273        /// Advance this store's simulated monotonic clock by `dur`. Only
1274        /// available in tests — production code reads the real wall clock and
1275        /// never mutates the per-store offset.
1276        fn test_advance_time(&self, dur: Duration) {
1277            self.test_time_offset_ns
1278                .fetch_add(dur.as_nanos() as u64, Ordering::Relaxed);
1279        }
1280    }
1281
1282    fn temp_store() -> (tempfile::TempDir, WarmStartStore) {
1283        let dir = tempfile::tempdir().unwrap();
1284        let store = WarmStartStore::open(
1285            dir.path().to_path_buf(),
1286            StoreOptions {
1287                size_budget_bytes: 1024 * 1024,
1288                ttl: Duration::from_secs(60),
1289            },
1290        )
1291        .unwrap();
1292        (dir, store)
1293    }
1294
1295    fn key_for(s: &str) -> Fingerprint {
1296        let mut fp = Fingerprinter::new();
1297        fp.absorb_str(b"test", s);
1298        fp.finalize()
1299    }
1300
1301    #[test]
1302    fn roundtrip_save_then_lookup() {
1303        let (_d, store) = temp_store();
1304        let key = key_for("roundtrip");
1305        store
1306            .save(
1307                &key,
1308                b"hello-warm",
1309                Some(1.5),
1310                Some(7),
1311                EntryKind::Checkpoint,
1312            )
1313            .unwrap();
1314        let got = store.lookup(&key).unwrap().unwrap();
1315        assert_eq!(got.payload, b"hello-warm");
1316        assert_eq!(got.objective, Some(1.5));
1317        assert_eq!(got.iteration, Some(7));
1318        assert_eq!(got.kind, EntryKind::Checkpoint);
1319    }
1320
1321    #[test]
1322    fn lookup_picks_lowest_objective() {
1323        let (_d, store) = temp_store();
1324        let key = key_for("multi");
1325        store
1326            .save(&key, b"worse", Some(3.0), Some(1), EntryKind::Checkpoint)
1327            .unwrap();
1328        store
1329            .save(&key, b"better", Some(1.0), Some(2), EntryKind::Checkpoint)
1330            .unwrap();
1331        store
1332            .save(&key, b"mid", Some(2.0), Some(3), EntryKind::Checkpoint)
1333            .unwrap();
1334        let got = store.lookup(&key).unwrap().unwrap();
1335        assert_eq!(got.payload, b"better");
1336        assert_eq!(got.objective, Some(1.0));
1337    }
1338
1339    #[test]
1340    fn lookup_latest_ignores_objective_ordering() {
1341        let (_d, store) = temp_store();
1342        let key = key_for("latest-vs-best");
1343        store
1344            .save(&key, b"low-objective", Some(1.0), Some(1), EntryKind::Final)
1345            .unwrap();
1346        store.test_advance_time(Duration::from_millis(2));
1347        store
1348            .save(
1349                &key,
1350                b"newer-higher-objective",
1351                Some(10.0),
1352                Some(2),
1353                EntryKind::Checkpoint,
1354            )
1355            .unwrap();
1356
1357        let best = store.lookup(&key).unwrap().unwrap();
1358        assert_eq!(best.payload, b"low-objective");
1359
1360        let latest = store.lookup_latest(&key).unwrap().unwrap();
1361        assert_eq!(latest.payload, b"newer-higher-objective");
1362        assert_eq!(latest.iteration, Some(2));
1363    }
1364
1365    #[test]
1366    fn tiebreak_final_beats_checkpoint() {
1367        let (_d, store) = temp_store();
1368        let key = key_for("tie");
1369        store
1370            .save(&key, b"ckpt", Some(1.0), None, EntryKind::Checkpoint)
1371            .unwrap();
1372        // Same objective, different kind.
1373        store
1374            .save(&key, b"final", Some(1.0), None, EntryKind::Final)
1375            .unwrap();
1376        let got = store.lookup(&key).unwrap().unwrap();
1377        assert_eq!(got.payload, b"final");
1378        assert_eq!(got.kind, EntryKind::Final);
1379    }
1380
1381    #[test]
1382    fn tiebreak_latest_mtime_when_no_objective() {
1383        let (_d, store) = temp_store();
1384        let key = key_for("latest");
1385        store
1386            .save(&key, b"first", None, None, EntryKind::Checkpoint)
1387            .unwrap();
1388        store.test_advance_time(Duration::from_millis(1_100));
1389        store
1390            .save(&key, b"second", None, None, EntryKind::Checkpoint)
1391            .unwrap();
1392        let got = store.lookup(&key).unwrap().unwrap();
1393        assert_eq!(got.payload, b"second");
1394    }
1395
1396    #[test]
1397    fn corrupt_payload_is_cleaned_up() {
1398        let (_d, store) = temp_store();
1399        let key = key_for("corrupt");
1400        store
1401            .save(&key, b"original", Some(0.0), None, EntryKind::Checkpoint)
1402            .unwrap();
1403        // Tamper with the .bin file.
1404        let dir = store.key_dir(&key);
1405        for entry in fs::read_dir(&dir).unwrap() {
1406            let p = entry.unwrap().path();
1407            if p.extension().and_then(|s| s.to_str()) == Some("bin") {
1408                fs::write(&p, b"tampered!").unwrap();
1409            }
1410        }
1411        let got = store.lookup(&key).unwrap();
1412        assert!(got.is_none(), "tampered entry must be rejected");
1413        // The corrupt files should be cleaned up so they don't accumulate.
1414        let remaining: Vec<_> = fs::read_dir(&dir).unwrap().collect();
1415        assert!(remaining.is_empty(), "corrupt entry should be removed");
1416    }
1417
1418    #[test]
1419    fn corrupt_meta_json_is_cleaned_up() {
1420        let (_d, store) = temp_store();
1421        let key = key_for("badjson");
1422        store
1423            .save(&key, b"x", None, None, EntryKind::Checkpoint)
1424            .unwrap();
1425        let dir = store.key_dir(&key);
1426        for entry in fs::read_dir(&dir).unwrap() {
1427            let p = entry.unwrap().path();
1428            if p.extension().and_then(|s| s.to_str()) == Some("json") {
1429                fs::write(&p, b"{not valid json").unwrap();
1430            }
1431        }
1432        let got = store.lookup(&key).unwrap();
1433        assert!(got.is_none());
1434    }
1435
1436    #[test]
1437    fn schema_mismatched_entry_is_cleaned_up() {
1438        let (_d, store) = temp_store();
1439        let key = key_for("schema");
1440        store
1441            .save(&key, b"x", None, None, EntryKind::Checkpoint)
1442            .unwrap();
1443        let dir = store.key_dir(&key);
1444        for entry in fs::read_dir(&dir).unwrap() {
1445            let p = entry.unwrap().path();
1446            if p.extension().and_then(|s| s.to_str()) == Some("json") {
1447                let raw = fs::read(&p).unwrap();
1448                let mut parsed: serde_json::Value = serde_json::from_slice(&raw).unwrap();
1449                parsed["schema_version"] = serde_json::json!(SCHEMA_VERSION + 99);
1450                fs::write(&p, serde_json::to_vec_pretty(&parsed).unwrap()).unwrap();
1451            }
1452        }
1453        assert!(store.lookup(&key).unwrap().is_none());
1454        let remaining: Vec<_> = fs::read_dir(&dir).unwrap().collect();
1455        assert!(
1456            remaining.is_empty(),
1457            "schema-mismatched entry should be removed"
1458        );
1459    }
1460
1461    #[test]
1462    fn schema_mismatched_entry_is_removed_during_save_eviction_path() {
1463        let dir = tempfile::tempdir().unwrap();
1464        let store = WarmStartStore::open(
1465            dir.path().to_path_buf(),
1466            StoreOptions {
1467                size_budget_bytes: 6 * 1024,
1468                ttl: Duration::from_secs(3600),
1469            },
1470        )
1471        .unwrap();
1472        let stale_key = key_for("schema-size-stale");
1473        store
1474            .save(
1475                &stale_key,
1476                &vec![0u8; 4 * 1024],
1477                None,
1478                None,
1479                EntryKind::Checkpoint,
1480            )
1481            .unwrap();
1482
1483        let stale_dir = store.key_dir(&stale_key);
1484        let mut stale_meta = None;
1485        let mut stale_bin = None;
1486        for entry in fs::read_dir(&stale_dir).unwrap() {
1487            let p = entry.unwrap().path();
1488            match p.extension().and_then(|s| s.to_str()) {
1489                Some("json") => {
1490                    let raw = fs::read(&p).unwrap();
1491                    let mut parsed: serde_json::Value = serde_json::from_slice(&raw).unwrap();
1492                    parsed["schema_version"] = serde_json::json!(SCHEMA_VERSION + 99);
1493                    fs::write(&p, serde_json::to_vec_pretty(&parsed).unwrap()).unwrap();
1494                    stale_meta = Some(p);
1495                }
1496                Some("bin") => stale_bin = Some(p),
1497                _ => {}
1498            }
1499        }
1500        let stale_meta = stale_meta.expect("saved entry should have metadata");
1501        let stale_bin = stale_bin.expect("saved entry should have payload");
1502
1503        let fresh_key = key_for("schema-size-fresh");
1504        store
1505            .save(
1506                &fresh_key,
1507                &vec![1u8; 2 * 1024],
1508                None,
1509                None,
1510                EntryKind::Checkpoint,
1511            )
1512            .unwrap();
1513
1514        assert!(
1515            !stale_meta.exists(),
1516            "schema-mismatched metadata should be removed during eviction scan"
1517        );
1518        assert!(
1519            !stale_bin.exists(),
1520            "schema-mismatched payload should be removed during eviction scan"
1521        );
1522
1523        let mut total = 0u64;
1524        for key_dir in fs::read_dir(store.root()).unwrap() {
1525            let key_dir = key_dir.unwrap().path();
1526            if key_dir.is_dir() {
1527                for entry in fs::read_dir(key_dir).unwrap() {
1528                    total += fs::metadata(entry.unwrap().path()).unwrap().len();
1529                }
1530            }
1531        }
1532        assert!(
1533            total <= store.options().size_budget_bytes,
1534            "schema-mismatched bytes must not leak past size accounting (got {total})"
1535        );
1536        assert!(store.lookup(&stale_key).unwrap().is_none());
1537        assert!(store.lookup(&fresh_key).unwrap().is_some());
1538    }
1539
1540    #[test]
1541    fn missing_bin_treated_as_missing() {
1542        let (_d, store) = temp_store();
1543        let key = key_for("nobin");
1544        store
1545            .save(&key, b"x", None, None, EntryKind::Checkpoint)
1546            .unwrap();
1547        let dir = store.key_dir(&key);
1548        for entry in fs::read_dir(&dir).unwrap() {
1549            let p = entry.unwrap().path();
1550            if p.extension().and_then(|s| s.to_str()) == Some("bin") {
1551                fs::remove_file(&p).unwrap();
1552            }
1553        }
1554        assert!(store.lookup(&key).unwrap().is_none());
1555    }
1556
1557    #[test]
1558    fn missing_key_returns_none() {
1559        let (_d, store) = temp_store();
1560        let key = key_for("absent");
1561        assert!(store.lookup(&key).unwrap().is_none());
1562    }
1563
1564    #[test]
1565    fn lru_eviction_under_size_budget() {
1566        let dir = tempfile::tempdir().unwrap();
1567        // Tiny budget: 4 KiB. Each entry payload + meta JSON is ~600 B.
1568        let store = WarmStartStore::open(
1569            dir.path().to_path_buf(),
1570            StoreOptions {
1571                size_budget_bytes: 4 * 1024,
1572                ttl: Duration::from_secs(3600),
1573            },
1574        )
1575        .unwrap();
1576        let mut keys = Vec::new();
1577        for i in 0..20 {
1578            let mut fp = Fingerprinter::new();
1579            fp.absorb_u64(b"i", i);
1580            let key = fp.finalize();
1581            keys.push(key);
1582            let payload = vec![0u8; 256];
1583            store
1584                .save(&key, &payload, Some(i as f64), None, EntryKind::Checkpoint)
1585                .unwrap();
1586        }
1587        // Walk the store root and confirm total bytes is bounded.
1588        let mut total = 0u64;
1589        for kd in fs::read_dir(store.root()).unwrap() {
1590            let kd = kd.unwrap().path();
1591            if kd.is_dir() {
1592                for f in fs::read_dir(&kd).unwrap() {
1593                    total += fs::metadata(f.unwrap().path()).unwrap().len();
1594                }
1595            }
1596        }
1597        assert!(
1598            total <= 8 * 1024,
1599            "eviction failed to bound size (got {total})"
1600        );
1601        // Earliest keys must have been evicted; latest survive.
1602        assert!(store.lookup(&keys[0]).unwrap().is_none());
1603        assert!(store.lookup(keys.last().unwrap()).unwrap().is_some());
1604    }
1605
1606    #[test]
1607    fn ttl_drops_old_entries() {
1608        // Expiration is driven by `test_advance_time` (additive simulated time
1609        // on top of the wall clock), so the TTL itself only needs to be larger
1610        // than any plausible save→lookup wall-time on the CI runner. The
1611        // 1-second TTL the original fixture used was tighter than the worst
1612        // ext4 fsync this image sees (see `save_overwrite`'s late-stamp
1613        // comment), so the first `is_some()` check would flake to "expired"
1614        // before any time advance ever ran. 60 s clears that race with margin.
1615        let dir = tempfile::tempdir().unwrap();
1616        let ttl = Duration::from_secs(60);
1617        let store = WarmStartStore::open(
1618            dir.path().to_path_buf(),
1619            StoreOptions {
1620                size_budget_bytes: 1024 * 1024,
1621                ttl,
1622            },
1623        )
1624        .unwrap();
1625        let key = key_for("ttl");
1626        store
1627            .save(&key, b"x", None, None, EntryKind::Checkpoint)
1628            .unwrap();
1629        assert!(store.lookup(&key).unwrap().is_some());
1630        store.test_advance_time(ttl + Duration::from_secs(5));
1631        // Trigger eviction via a save under an unrelated key.
1632        let other = key_for("ttl-other");
1633        store
1634            .save(&other, b"y", None, None, EntryKind::Checkpoint)
1635            .unwrap();
1636        // Original now expired.
1637        assert!(store.lookup(&key).unwrap().is_none());
1638        assert!(store.lookup(&other).unwrap().is_some());
1639    }
1640
1641    #[test]
1642    fn orphan_temp_files_from_dead_processes_are_swept() {
1643        let (_d, store) = temp_store();
1644        let key = key_for("tmp");
1645        let dir = store.key_dir(&key);
1646        fs::create_dir_all(&dir).unwrap();
1647        // Use PID 1 — never the current process, so it counts as "other".
1648        let orphan_other = dir.join("r0-0.json.tmp.1.0");
1649        let mine = dir.join(format!("r0-0.bin.tmp.{}.0", std::process::id()));
1650        fs::write(&orphan_other, b"orphan").unwrap();
1651        fs::write(&mine, b"mine").unwrap();
1652        store.evict_overflow().unwrap();
1653        assert!(!orphan_other.exists(), "other-PID tmp file should be swept");
1654        assert!(mine.exists(), "same-PID tmp file must be left alone");
1655    }
1656
1657    #[test]
1658    fn tmp_filenames_without_pid_are_skipped() {
1659        // Malformed tmp names (no parseable pid) must not crash the sweep.
1660        let (_d, store) = temp_store();
1661        let key = key_for("malformed");
1662        let dir = store.key_dir(&key);
1663        fs::create_dir_all(&dir).unwrap();
1664        let weird = dir.join("garbage.tmp.notapid.suffix");
1665        fs::write(&weird, b"x").unwrap();
1666        // Must not panic.
1667        store.evict_overflow().unwrap();
1668        assert!(weird.exists());
1669    }
1670
1671    #[test]
1672    fn save_overwrite_keeps_single_entry() {
1673        let (_d, store) = temp_store();
1674        let key = key_for("overwrite");
1675        let id = store
1676            .save(&key, b"v1", Some(2.0), Some(1), EntryKind::Checkpoint)
1677            .unwrap();
1678        store
1679            .save_overwrite(&key, &id, b"v2", Some(1.0), Some(2), EntryKind::Checkpoint)
1680            .unwrap();
1681        // Only one (meta, bin) pair on disk.
1682        let dir = store.key_dir(&key);
1683        let files: Vec<_> = fs::read_dir(&dir).unwrap().collect();
1684        assert_eq!(files.len(), 2, "overwrite should not create a new run-id");
1685        let got = store.lookup(&key).unwrap().unwrap();
1686        assert_eq!(got.payload, b"v2");
1687        assert_eq!(got.objective, Some(1.0));
1688    }
1689
1690    #[test]
1691    fn write_and_promote_recreates_dir_removed_before_write() {
1692        // gam#868: a sibling process' eviction can `remove_dir` the key dir the
1693        // instant it observes it empty, racing every write step in `save`. The
1694        // promote helper must recreate the dir rather than failing with ENOENT.
1695        let (_d, store) = temp_store();
1696        let key = key_for("race-recreate");
1697        let dir = store.key_dir(&key);
1698        // Dir does NOT exist yet (simulates eviction having removed it after a
1699        // prior `create_dir_all`). The helper must create it and succeed.
1700        assert!(!dir.exists());
1701        let bin_tmp = dir.join("r0.bin.tmp.1.0.0");
1702        let meta_tmp = dir.join("r0.json.tmp.1.0.0");
1703        let bin_final = dir.join("r0.bin");
1704        let meta_final = dir.join("r0.json");
1705        let stamp_fn = || (0u64, 0u32);
1706        let build_meta_json = |_: u64, _: u32| -> io::Result<Vec<u8>> { Ok(b"{}".to_vec()) };
1707        write_and_promote_entry(&EntryWrite {
1708            dir: &dir,
1709            bin_tmp: &bin_tmp,
1710            meta_tmp: &meta_tmp,
1711            payload: b"payload",
1712            bin_final: &bin_final,
1713            meta_final: &meta_final,
1714            stamp_fn: &stamp_fn,
1715            build_meta_json: &build_meta_json,
1716        })
1717        .expect("promote into a missing dir must recreate it and succeed");
1718        assert!(bin_final.exists() && meta_final.exists());
1719        assert_eq!(fs::read(&bin_final).unwrap(), b"payload");
1720    }
1721
1722    #[test]
1723    fn save_survives_concurrent_eviction_removing_key_dir() {
1724        // gam#868 end-to-end: hammer the same key with concurrent saves while a
1725        // sibling thread repeatedly runs `evict_overflow` (which `remove_dir`s
1726        // now-empty key dirs). Before the atomic-retry fix, a save whose
1727        // `create_dir_all`→write/rename window straddled a `remove_dir` failed
1728        // with `io: No such file or directory (os error 2)`. Every save must now
1729        // succeed; we assert no save returns an error.
1730        use std::sync::Arc;
1731        use std::sync::atomic::AtomicBool;
1732
1733        let dir = tempfile::tempdir().unwrap();
1734        // Zero size budget forces `evict_overflow` to delete entries (and then
1735        // sweep the emptied key dir) on essentially every sweep, maximizing the
1736        // race window.
1737        let store = Arc::new(
1738            WarmStartStore::open(
1739                dir.path().to_path_buf(),
1740                StoreOptions {
1741                    size_budget_bytes: 0,
1742                    ttl: Duration::from_secs(60),
1743                },
1744            )
1745            .unwrap(),
1746        );
1747        let key = key_for("concurrent-evict");
1748        let stop = Arc::new(AtomicBool::new(false));
1749
1750        let evictor = {
1751            let store = Arc::clone(&store);
1752            let stop = Arc::clone(&stop);
1753            std::thread::spawn(move || {
1754                while !stop.load(Ordering::Relaxed) {
1755                    store.evict_overflow().ok();
1756                }
1757            })
1758        };
1759
1760        let writers: Vec<_> = (0..4)
1761            .map(|w| {
1762                let store = Arc::clone(&store);
1763                std::thread::spawn(move || {
1764                    for i in 0..200u32 {
1765                        let payload = format!("w{w}-i{i}");
1766                        store
1767                            .save(
1768                                &key,
1769                                payload.as_bytes(),
1770                                Some(i as f64),
1771                                Some(i as u64),
1772                                EntryKind::Checkpoint,
1773                            )
1774                            .expect("save must not fail with ENOENT under concurrent eviction");
1775                    }
1776                })
1777            })
1778            .collect();
1779
1780        for h in writers {
1781            h.join().unwrap();
1782        }
1783        stop.store(true, Ordering::Relaxed);
1784        evictor.join().unwrap();
1785    }
1786
1787    #[test]
1788    fn keys_are_isolated() {
1789        let (_d, store) = temp_store();
1790        let a = key_for("a");
1791        let b = key_for("b");
1792        store
1793            .save(&a, b"AAA", Some(1.0), None, EntryKind::Final)
1794            .unwrap();
1795        store
1796            .save(&b, b"BBB", Some(1.0), None, EntryKind::Final)
1797            .unwrap();
1798        assert_eq!(store.lookup(&a).unwrap().unwrap().payload, b"AAA");
1799        assert_eq!(store.lookup(&b).unwrap().unwrap().payload, b"BBB");
1800    }
1801}