Skip to main content

kache_store/
store.rs

1use crate::ArtifactPolicy;
2use crate::blob_validation::validate_blob_metadata;
3use anyhow::{Context, Result};
4pub use kache_format::{CachedFile, EntryMeta};
5use rusqlite::{Connection, Error as SqlError, ErrorCode, OptionalExtension, params};
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::time::Duration;
11
12use crate::config::Config;
13
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
15pub struct StorePutResult {
16    pub output_blobs: u32,
17    pub duplicate_blobs: u32,
18    pub new_blobs: u32,
19}
20
21/// An entry whose remote bytes and declared artifact hashes were verified in
22/// the same pass that extracted them. Construction stays inside the remote
23/// transport boundary; the store still re-checks metadata, paths and lengths,
24/// but deliberately does not read every artifact a second time.
25#[derive(Debug, Clone)]
26pub struct VerifiedRestoredEntry {
27    pub cache_key: String,
28    pub meta: EntryMeta,
29}
30
31impl StorePutResult {
32    pub fn is_full_dup(self) -> bool {
33        self.output_blobs > 0 && self.duplicate_blobs == self.output_blobs
34    }
35}
36
37thread_local! {
38    /// `[cache] deferred_durability` of the store last opened on this thread;
39    /// see [`Store::open`].
40    static DEFERRED_DURABILITY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
41}
42
43/// Whether a blob written now must be fsynced before it is published.
44fn durable_writes_now() -> bool {
45    !DEFERRED_DURABILITY.with(|deferred| deferred.get())
46}
47
48/// Mark a blob read-only so accidental writes can't corrupt the shared,
49/// content-addressed copy. Best-effort.
50fn set_blob_readonly(blob: &Path) {
51    let _ = set_blob_readonly_checked(blob);
52}
53
54/// Mark a blob read-only, reporting failure. The hardlink ingest path needs
55/// the result: there the guard is a correctness requirement (the blob shares
56/// an inode with the build's own output), not a courtesy.
57/// Flush a published blob to disk. Published blobs are read-only, and
58/// Windows needs write access to flush a handle, so there the read-only
59/// attribute comes off for the flush and goes straight back on.
60fn fsync_published_blob(blob: &Path) -> std::io::Result<()> {
61    #[cfg(not(windows))]
62    {
63        crate::atomic::fsync_file(blob)
64    }
65    #[cfg(windows)]
66    {
67        let meta = fs::metadata(blob)?;
68        if !meta.permissions().readonly() {
69            return crate::atomic::fsync_file(blob);
70        }
71        let mut writable = meta.permissions();
72        writable.set_readonly(false);
73        fs::set_permissions(blob, writable)?;
74        let flushed = crate::atomic::fsync_file(blob);
75        let _ = set_blob_readonly_checked(blob);
76        flushed
77    }
78}
79
80fn set_blob_readonly_checked(blob: &Path) -> std::io::Result<()> {
81    let meta = fs::metadata(blob)?;
82    let mut perms = meta.permissions();
83    perms.set_readonly(true);
84    fs::set_permissions(blob, perms)
85}
86
87#[cfg(all(test, unix))]
88thread_local! {
89    /// Test-only ingest override: reproduce, on any filesystem, what a Linux
90    /// CoW reflink does to a staged snapshot — independent bytes at the
91    /// *umask*, not at the source's mode.
92    ///
93    /// CI runs on ext4, where `try_reflink` always fails and the `fs::copy`
94    /// fallback carries the permission bits over. That is the blind spot #822
95    /// shipped through: a mode-losing ingest is simply unobservable there, so
96    /// the regression only ever appeared on a developer's btrfs/ZFS box.
97    /// Forcing the emulation makes the contract testable everywhere.
98    ///
99    /// Thread-local rather than a process-wide flag (`link.rs`'s
100    /// `WINDOWS_HARDLINK_RESTORE` is the latter, but it is a real feature
101    /// switch): `cargo test` runs store tests in parallel, and one test's
102    /// emulation must not leak into another's put.
103    ///
104    /// Unix-only: the tests that construct [`ModeDroppingIngest`] are
105    /// `#[cfg(unix)]`, and Windows test builds fail `-D dead-code` otherwise.
106    static FORCE_MODE_DROPPING_INGEST: std::cell::Cell<bool> =
107        const { std::cell::Cell::new(false) };
108}
109
110/// Enable [`FORCE_MODE_DROPPING_INGEST`] for the duration of the guard.
111#[cfg(all(test, unix))]
112struct ModeDroppingIngest;
113
114#[cfg(all(test, unix))]
115impl ModeDroppingIngest {
116    fn enable() -> Self {
117        FORCE_MODE_DROPPING_INGEST.with(|forced| forced.set(true));
118        Self
119    }
120}
121
122#[cfg(all(test, unix))]
123impl Drop for ModeDroppingIngest {
124    fn drop(&mut self) {
125        FORCE_MODE_DROPPING_INGEST.with(|forced| forced.set(false));
126    }
127}
128
129/// Stage `source` at `tmp` the way a Linux CoW reflink would, reporting whether
130/// the emulation was active at all. See [`FORCE_MODE_DROPPING_INGEST`].
131#[cfg(all(test, unix))]
132fn emulate_cow_reflink_ingest(source: &Path, tmp: &Path) -> Result<bool> {
133    if !FORCE_MODE_DROPPING_INGEST.with(std::cell::Cell::get) {
134        return Ok(false);
135    }
136    fs::copy(source, tmp)
137        .with_context(|| format!("emulating a reflink ingest of {}", source.display()))?;
138    // `try_reflink` on Linux opens the destination with `File::create` before
139    // the FICLONE ioctl, so the snapshot lands at `0o666 & !umask` whatever the
140    // source was. The exact value varies with the umask; the only property that
141    // matters here is that it carries no `+x`.
142    use std::os::unix::fs::PermissionsExt;
143    fs::set_permissions(tmp, fs::Permissions::from_mode(0o644))
144        .with_context(|| format!("resetting the emulated staging mode on {}", tmp.display()))?;
145    Ok(true)
146}
147
148#[cfg(not(all(test, unix)))]
149#[inline(always)]
150fn emulate_cow_reflink_ingest(_source: &Path, _tmp: &Path) -> Result<bool> {
151    Ok(false)
152}
153
154// ── Hardlink-fallback reason seams (#835) ─────────────────────────────────────
155//
156// Same pattern as [`FORCE_MODE_DROPPING_INGEST`]: thread-local so parallel
157// `cargo test` workers do not leak into each other. Production stubs are
158// `None`/`false` so the real `link(2)` runs.
159
160#[cfg(test)]
161thread_local! {
162    /// When set, the ingest `hard_link` attempt fails with this errno instead
163    /// of calling `link(2)`, so the reason counter is observable without bind
164    /// mounts.
165    static INJECT_STORE_HARDLINK_ERROR: std::cell::Cell<Option<std::io::ErrorKind>> =
166        const { std::cell::Cell::new(None) };
167    /// When set, the ingest skips the `try_reflink` attempt (pretends CoW is
168    /// unavailable) so the hardlink path is exercised even on APFS/btrfs.
169    static FORCE_STORE_HARDLINK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
170}
171
172/// Enable [`INJECT_STORE_HARDLINK_ERROR`] for the guard's lifetime.
173#[cfg(test)]
174pub(crate) struct InjectStoreHardlinkError {
175    _private: (),
176}
177
178#[cfg(test)]
179impl InjectStoreHardlinkError {
180    pub(crate) fn enable(kind: std::io::ErrorKind) -> Self {
181        INJECT_STORE_HARDLINK_ERROR.with(|slot| slot.set(Some(kind)));
182        Self { _private: () }
183    }
184}
185
186#[cfg(test)]
187impl Drop for InjectStoreHardlinkError {
188    fn drop(&mut self) {
189        INJECT_STORE_HARDLINK_ERROR.with(|slot| slot.set(None));
190    }
191}
192
193/// Enable [`FORCE_STORE_HARDLINK`] for the guard's lifetime: skip reflink so a
194/// same-device `.rlib` put must hardlink, even on CoW filesystems.
195#[cfg(test)]
196pub(crate) struct ForceStoreHardlink {
197    _private: (),
198}
199
200#[cfg(test)]
201impl ForceStoreHardlink {
202    pub(crate) fn enable() -> Self {
203        FORCE_STORE_HARDLINK.with(|slot| slot.set(true));
204        Self { _private: () }
205    }
206}
207
208#[cfg(test)]
209impl Drop for ForceStoreHardlink {
210    fn drop(&mut self) {
211        FORCE_STORE_HARDLINK.with(|slot| slot.set(false));
212    }
213}
214
215#[cfg(test)]
216fn injected_store_hardlink_error() -> Option<std::io::ErrorKind> {
217    INJECT_STORE_HARDLINK_ERROR.with(|slot| slot.get())
218}
219
220#[cfg(not(test))]
221#[inline(always)]
222fn injected_store_hardlink_error() -> Option<std::io::ErrorKind> {
223    None
224}
225
226fn force_store_hardlink() -> bool {
227    #[cfg(test)]
228    {
229        FORCE_STORE_HARDLINK.with(|slot| slot.get())
230    }
231    #[cfg(not(test))]
232    {
233        false
234    }
235}
236
237fn should_try_store_reflink(force_hardlink: bool) -> bool {
238    !force_hardlink
239}
240
241fn allow_store_hardlink(allow_hardlink: bool, is_regular_file: bool) -> bool {
242    allow_hardlink && is_regular_file
243}
244
245/// Attempt the ingest `link(2)`, honouring the test-only error seam. Returns
246/// the io error on failure so callers classify it instead of swallowing it.
247fn try_store_hard_link(source: &Path, tmp: &Path) -> std::io::Result<()> {
248    if let Some(kind) = injected_store_hardlink_error() {
249        return Err(std::io::Error::new(
250            kind,
251            "injected ingest hardlink failure",
252        ));
253    }
254    fs::hard_link(source, tmp)
255}
256
257/// Is `name` exactly a content-blob filename: 64 lowercase hex chars (a
258/// blake3 digest)? Used by the orphan sweep so it only ever unlinks files
259/// that look like a blob — never an in-progress temp (`.{hash}.{pid}.{n}.tmp`)
260/// or any stray file.
261fn is_blob_hash_name(name: &str) -> bool {
262    name.len() == 64
263        && name
264            .bytes()
265            .all(|b| b.is_ascii_digit() || matches!(b, b'a'..=b'f'))
266}
267
268/// Best-effort unlink of a blob file (clears read-only first).
269fn unlink_blob(blob: &Path) {
270    if blob.exists() {
271        if let Ok(meta) = fs::metadata(blob) {
272            let mut perms = meta.permissions();
273            perms.set_readonly(false);
274            let _ = fs::set_permissions(blob, perms);
275        }
276        if fs::remove_file(blob).is_err() && blob.exists() {
277            // Removal can fail transiently on Windows (sharing violation /
278            // delete-pending). The surviving blob may share an inode with a
279            // live build output (insert/restore hardlinks), so re-arm the
280            // read-only guard rather than leaving a writable blob behind.
281            set_blob_readonly(blob);
282        }
283    }
284}
285
286fn hardlink_eligible<P: ArtifactPolicy>(store_name: &str, executable: bool) -> bool {
287    if executable {
288        return false;
289    }
290    #[cfg(windows)]
291    if !crate::link::windows_hardlink_enabled() {
292        return false;
293    }
294    P::allow_hardlink(store_name)
295}
296
297fn source_hardlink_allowed<P: ArtifactPolicy>(
298    allow_source_hardlinks: bool,
299    store_name: &str,
300    executable: bool,
301) -> bool {
302    allow_source_hardlinks && hardlink_eligible::<P>(store_name, executable)
303}
304
305/// How a new blob was staged into the store before publish. Counters are
306/// recorded only when this call actually publishes (`atomic_write_and_replace`
307/// returns `true`); a concurrent winner already accounted for their ingest,
308/// and counting a discarded temp would over-claim zero-copy sharing.
309#[derive(Clone, Copy, Debug)]
310enum StoreIngest {
311    Reflink,
312    Hardlink,
313    Copy(StoreCopyReason),
314}
315
316/// Why an ingest fell back to a copy (#835). `Ineligible` is a policy refusal
317/// (kind-ineligible or cc never shares inodes — `allow_hardlink` false, or a
318/// symlink source); the rest classify the `link(2)` errno. Recorded alongside
319/// `store_copied_bytes` on publish so the report can show *why* zero-copy did
320/// not happen. Observability only: the reason never changes what gets linked.
321#[derive(Clone, Copy, Debug, PartialEq, Eq)]
322enum StoreCopyReason {
323    Ineligible,
324    CrossDevice,
325    Permission,
326    Other,
327}
328
329impl StoreCopyReason {
330    fn from_io_kind(kind: std::io::ErrorKind) -> Self {
331        if kind == std::io::ErrorKind::CrossesDevices {
332            StoreCopyReason::CrossDevice
333        } else if kind == std::io::ErrorKind::PermissionDenied {
334            StoreCopyReason::Permission
335        } else {
336            StoreCopyReason::Other
337        }
338    }
339}
340
341/// Record the ingest copy-fallback reason alongside `store_copied_bytes`.
342/// Pure dispatch so each arm is independently testable (mutant discipline:
343// one test per arm, no skip annotations).
344fn record_store_copy_reason(reason: StoreCopyReason, bytes: u64) {
345    if reason == StoreCopyReason::CrossDevice {
346        crate::opcounts::record_store_copy_cross_device(bytes);
347    } else if reason == StoreCopyReason::Permission {
348        crate::opcounts::record_store_copy_permission(bytes);
349    } else if reason == StoreCopyReason::Ineligible {
350        crate::opcounts::record_store_copy_ineligible(bytes);
351    } else {
352        crate::opcounts::record_store_copy_other(bytes);
353    }
354}
355
356/// Durably materialize `source` into the content-addressed store at `blob`,
357/// unless the blob already exists: clone (or copy) to a unique temp, fsync,
358/// atomic rename, mark read-only. Idempotent — when the blob is present this
359/// is just a `stat`.
360///
361/// The temp is created by a CoW reflink first. Where the filesystem has no
362/// copy-on-write (ext4 without reflink, tmpfs), `allow_hardlink` — decided
363/// per file by [`hardlink_eligible`] — permits a hardlink fallback: the blob
364/// then shares an inode with the build's own output, exactly the state a warm
365/// restore produces for these kinds, and `set_blob_readonly` below applies to
366/// both names. Only when neither zero-copy path is available (or allowed)
367/// does the blob become a genuine second physical copy. On APFS / btrfs /
368/// XFS-with-reflink the reflink wins and the blob shares physical blocks with
369/// the build's output — storing costs ~no extra disk. Whichever path runs is
370/// recorded **after a successful publish** (`record_store_reflinked` /
371/// `record_store_hardlinked` / `record_store_copied`) so `kache report` can
372/// account for disk honestly, mirroring the restore side in `link.rs`.
373/// Counters are best-effort under concurrent put/remove: a phase-2
374/// rematerialize after a reclaim may count the same logical ingest again.
375/// Returns `Ok(true)` when this call published the blob (the caller may then
376/// want to verify its digest), `Ok(false)` when it was already present.
377fn materialize_blob(source: &Path, blob: &Path, allow_hardlink: bool) -> Result<bool> {
378    if blob.is_file() {
379        return Ok(false);
380    }
381    let durable = durable_writes_now();
382    fs::create_dir_all(blob.parent().unwrap()).context("creating blob shard directory")?;
383    let bytes = fs::metadata(source).map(|m| m.len()).unwrap_or(0);
384    let ingest = std::cell::Cell::new(StoreIngest::Copy(StoreCopyReason::Other));
385    let ro_failed = std::cell::Cell::new(false);
386
387    // CoW reflink first; then a hardlink where the artifact kind allows sharing
388    // an inode; only then a real copy. The hardlink is refused for a symlink
389    // source: hashing followed the link, but `hard_link` would link the symlink
390    // itself, and a blob must never be a pointer into mutable external state.
391    //
392    // Hardlink RO is applied in `after_fsync` (not in the write step): Windows
393    // needs a writable handle to flush (#196). On RO failure we demote to a
394    // full copy rather than publishing a writable shared inode.
395    //
396    // The hardlink error is captured, not swallowed with `.is_ok()`: EXDEV
397    // across bind mounts, EPERM, and other errnos are classified into
398    // `StoreCopyReason` so the report can show *why* zero-copy did not happen.
399    // What gets linked is unchanged — a failure still falls back to a copy.
400    let published = match crate::atomic::atomic_write_and_replace_deferrable(
401        blob,
402        true,
403        |tmp| {
404            // `FORCE_STORE_HARDLINK` (test-only) skips the reflink attempt so a
405            // same-device `.rlib` must hardlink even on CoW filesystems.
406            let reflink_ok = should_try_store_reflink(force_store_hardlink())
407                && crate::link::try_reflink(source, tmp).is_ok();
408            if reflink_ok {
409                ingest.set(StoreIngest::Reflink);
410            } else if allow_store_hardlink(
411                allow_hardlink,
412                fs::symlink_metadata(source).is_ok_and(|m| m.file_type().is_file()),
413            ) {
414                match try_store_hard_link(source, tmp) {
415                    Ok(()) => {
416                        ingest.set(StoreIngest::Hardlink);
417                    }
418                    Err(io_err) => {
419                        let reason = StoreCopyReason::from_io_kind(io_err.kind());
420                        {
421                            let io_reason = match reason {
422                                StoreCopyReason::CrossDevice => {
423                                    crate::link::HardlinkIoReason::CrossDevice
424                                }
425                                StoreCopyReason::Permission => {
426                                    crate::link::HardlinkIoReason::Permission
427                                }
428                                StoreCopyReason::Other | StoreCopyReason::Ineligible => {
429                                    crate::link::HardlinkIoReason::Other
430                                }
431                            };
432                            crate::link::warn_hardlink_fallback_once(
433                                source, blob, io_reason, &io_err,
434                            );
435                        }
436                        fs::copy(source, tmp).with_context(|| {
437                            format!("copying {} to blob store", source.display())
438                        })?;
439                        ingest.set(StoreIngest::Copy(reason));
440                    }
441                }
442            } else {
443                fs::copy(source, tmp)
444                    .with_context(|| format!("copying {} to blob store", source.display()))?;
445                ingest.set(StoreIngest::Copy(StoreCopyReason::Ineligible));
446            }
447            Ok(())
448        },
449        |tmp| {
450            if matches!(ingest.get(), StoreIngest::Hardlink)
451                && let Err(e) = set_blob_readonly_checked(tmp)
452            {
453                tracing::debug!(
454                    "read-only guard failed on hardlinked blob temp ({e}); \
455                     falling back to copy: {}",
456                    source.display()
457                );
458                ro_failed.set(true);
459                anyhow::bail!("read-only guard failed on hardlinked blob temp");
460            }
461            Ok(())
462        },
463        durable,
464    ) {
465        Ok(published) => published,
466        Err(_e) if ro_failed.get() => {
467            // Temp already cleaned by atomic_write_and_replace_with.
468            return materialize_blob(source, blob, false);
469        }
470        Err(e) => {
471            // Hardlink path may have marked the source RO via the shared temp
472            // inode; undo that if we never published a blob that shares it.
473            // On Windows, remove_file_robust may also have cleared RO on a
474            // shared published blob — re-arm if the blob is present.
475            if matches!(ingest.get(), StoreIngest::Hardlink) {
476                if blob.is_file() {
477                    set_blob_readonly(blob);
478                } else {
479                    restore_source_writable_if_unshared(source, blob);
480                }
481            }
482            return Err(e);
483        }
484    };
485
486    if published {
487        match ingest.get() {
488            StoreIngest::Reflink => crate::opcounts::record_store_reflinked(bytes),
489            StoreIngest::Hardlink => crate::opcounts::record_store_hardlinked(bytes),
490            StoreIngest::Copy(reason) => {
491                crate::opcounts::record_store_copied(bytes);
492                record_store_copy_reason(reason, bytes);
493            }
494        }
495        set_blob_readonly(blob);
496    } else if matches!(ingest.get(), StoreIngest::Hardlink) {
497        // Concurrent winner already published. Our temp was removed; if the
498        // published blob does not share the source inode, clear the provisional
499        // RO bit we applied before the race was lost. If it does share, re-arm
500        // RO in case Windows cleanup cleared the shared attribute.
501        if paths_share_inode(source, blob) {
502            set_blob_readonly(blob);
503        } else {
504            restore_source_writable_if_unshared(source, blob);
505        }
506    }
507    Ok(published)
508}
509/// Process-wide monotonic counter behind staging file names. Paired with the
510/// pid it makes every in-flight staging path unique *by construction*: two
511/// threads never draw the same nonce, and two live processes never share a
512/// pid. That is what lets [`free_staging_path`] hand the ingest a path that
513/// does not exist yet — see the warning there.
514static STAGE_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
515
516/// How many nonces to try before giving up on finding a free staging name.
517/// Only stale leftovers from a crashed process whose pid has since been
518/// recycled can occupy one, so a handful of attempts is already generous;
519/// the bound keeps a pathological staging directory failing fast instead of
520/// spinning forever.
521const STAGING_NAME_ATTEMPTS: u32 = 16;
522
523/// How long a staging snapshot must sit untouched before a sweep may reclaim
524/// it. A snapshot belonging to a put running in another process is
525/// indistinguishable from a crash leftover, and unlinking one fails that put
526/// at publish time — so the grace has to outlast any plausible in-flight put.
527/// Shared by the daemon's GC sweep and `doctor --repair` so neither can
528/// undercut the other.
529pub const STAGING_SWEEP_GRACE: Duration = Duration::from_secs(3600);
530
531/// How long a key lock file must sit unused before the sweep may unlink it.
532/// Every acquisition rewrites the file, so its mtime is the last claim of the
533/// key. Holding a key lock spans one compile and `BUILD_LOCK_TIMEOUT` is ten
534/// minutes; an hour, the staging and orphan-blob grace, leaves any claim that
535/// is still in flight far behind, and a key untouched that long is not being
536/// contended. The sweep also takes the lock before unlinking, so the grace
537/// only decides how eagerly idle files go, never whether a holder is safe.
538pub const KEY_LOCK_SWEEP_GRACE: Duration = Duration::from_secs(3600);
539
540/// Most key lock files one sweep unlinks. Each costs an open, a lock, two
541/// stats and an unlink under `gc.lock`, tens of microseconds on Linux and a
542/// few hundred on macOS and Windows, so a full batch stays within seconds.
543/// A store with 84k stale locks converges in five sweeps.
544pub const KEY_LOCK_SWEEP_CAP: usize = 20_000;
545
546/// What one [`Store::sweep_stale_key_locks`] pass saw and did.
547#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
548pub struct KeyLockSweepStats {
549    /// `<key>.lock` names found in `store/`.
550    pub seen: usize,
551    /// Lock files unlinked.
552    pub removed: usize,
553}
554
555impl KeyLockSweepStats {
556    /// Key lock files left in `store/` after the pass.
557    pub fn remaining(&self) -> usize {
558        self.seen.saturating_sub(self.removed)
559    }
560}
561
562/// The cache key a name in `store/` locks, or `None` for anything that is
563/// not `<valid key>.lock`: `gc.lock`, `durability.lock`, entry directories.
564fn key_of_lock_name(name: &str) -> Option<&str> {
565    name.strip_suffix(".lock")
566        .filter(|key| kache_format::is_valid_cache_key(key))
567}
568
569/// Old enough to unlink? A modification time ahead of `now` reads as young.
570fn key_lock_is_stale(
571    modified: std::time::SystemTime,
572    now: std::time::SystemTime,
573    min_age: Duration,
574) -> bool {
575    now.duration_since(modified).is_ok_and(|age| age >= min_age)
576}
577
578/// Unlink one key lock file if it is a regular file, stale, and not held.
579/// True when the file is gone. Any failure leaves it for a later sweep; on
580/// Windows that includes an unlink refused because another handle is open
581/// without delete sharing.
582///
583/// Staleness is judged twice. The listing's mtime keeps the sweep from ever
584/// locking a file in recent use, which a claimant would read as contention.
585/// The locked handle's mtime catches a claim that came and went in between.
586/// `after_open` runs between the open and the lock so tests can stage both
587/// that and a path replaced under the handle.
588fn remove_stale_lock_file(
589    path: &Path,
590    min_age: Duration,
591    now: std::time::SystemTime,
592    after_open: impl FnOnce(),
593) -> bool {
594    // A directory or symlink with a lock-shaped name is not ours to remove.
595    let listed_stale = fs::symlink_metadata(path)
596        .is_ok_and(|meta| meta.is_file() && metadata_is_stale(&meta, now, min_age));
597    if !listed_stale {
598        return false;
599    }
600    // No `create`: a name that vanished since the listing stays gone.
601    let Ok(file) = fs::OpenOptions::new().read(true).write(true).open(path) else {
602        return false;
603    };
604    after_open();
605    if !matches!(StoreLock::try_lock_file(&file), Ok(true)) {
606        return false;
607    }
608    let locked_stale = file
609        .metadata()
610        .is_ok_and(|meta| metadata_is_stale(&meta, now, min_age));
611    // Dropping `file` on any return releases the lock.
612    locked_stale && lock_file_is_at_path(&file, path) && fs::remove_file(path).is_ok()
613}
614
615fn metadata_is_stale(meta: &fs::Metadata, now: std::time::SystemTime, min_age: Duration) -> bool {
616    meta.modified()
617        .is_ok_and(|modified| key_lock_is_stale(modified, now, min_age))
618}
619
620/// Pick a staging path that does not exist yet, skipping past any stale
621/// leftover, and return it WITHOUT creating it.
622///
623/// Not creating it is the whole point: `clonefile(2)` (macOS) and `link(2)`
624/// (everywhere) both fail with `EEXIST` when their destination already
625/// exists, so reserving the name with a placeholder file would make both
626/// zero-copy ingests fail and silently demote every put to a full byte copy.
627/// Uniqueness comes from pid + [`STAGE_NONCE`] instead of from `create_new`,
628/// which is stronger than a placeholder anyway: no live stager can draw this
629/// name, so there is nothing to reserve it against.
630///
631/// Extracted from [`Store::stage_blob_from_source`] so the skip-and-retry
632/// branch is unit-testable with injected names.
633fn free_staging_path(mut name_for_nonce: impl FnMut(u64) -> PathBuf) -> std::io::Result<PathBuf> {
634    for _ in 0..STAGING_NAME_ATTEMPTS {
635        let candidate = name_for_nonce(STAGE_NONCE.fetch_add(1, Ordering::Relaxed));
636        match fs::symlink_metadata(&candidate) {
637            // Occupied by a crash leftover: leave it for the staging sweep
638            // and take the next nonce.
639            Ok(_) => continue,
640            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(candidate),
641            // Anything else (unreadable or missing staging directory) is a
642            // real fault, not a collision: surface it instead of spinning.
643            Err(e) => return Err(e),
644        }
645    }
646    Err(std::io::Error::new(
647        std::io::ErrorKind::AlreadyExists,
648        "no free staging name",
649    ))
650}
651
652/// What occupies a blob's content-addressed path right after a publish
653/// rename onto it failed.
654#[derive(Debug, Clone, Copy, PartialEq, Eq)]
655enum PublishDest {
656    /// A regular file: a concurrent publisher won. Same digest, same bytes.
657    File,
658    /// Something that is not a regular file (a directory): no race explains it.
659    Obstructed,
660    /// Nothing readable: absent, or a Windows delete-pending name.
661    Vacant,
662}
663
664fn publish_dest_state(blob: &Path) -> PublishDest {
665    match fs::metadata(blob) {
666        Ok(meta) if meta.is_file() => PublishDest::File,
667        Ok(_) => PublishDest::Obstructed,
668        Err(_) => PublishDest::Vacant,
669    }
670}
671
672/// How a publish rename settled.
673#[derive(Debug, Clone, Copy, PartialEq, Eq)]
674enum PublishRename {
675    /// This call put the blob in place.
676    Published,
677    /// A concurrent publisher's identical blob is in place.
678    LostRace,
679    /// Not in place, and no fault found: left to the put's locked phase.
680    Deferred,
681}
682
683/// Settle one publish rename attempt (#1128).
684///
685/// A published blob is read-only, and on Windows a rename onto a read-only
686/// file fails with ERROR_ACCESS_DENIED, the same code a delete-pending name
687/// gives. The error alone cannot tell "a winner is there" from "a removed
688/// blob is going away", so the destination decides: a winner settles the
689/// publish at once, and only the other states are handed to the retry.
690fn publish_attempt_outcome(
691    renamed: std::io::Result<()>,
692    dest_state: impl FnOnce() -> PublishDest,
693) -> std::io::Result<PublishRename> {
694    match renamed {
695        Ok(()) => Ok(PublishRename::Published),
696        Err(_) if dest_state() == PublishDest::File => Ok(PublishRename::LostRace),
697        Err(e) => Err(e),
698    }
699}
700
701/// Settle a publish whose rename kept failing.
702///
703/// A transient error with the name vacant means the blob was removed after
704/// the last attempt found it in the way. That is a race between two healthy
705/// operations: the put's locked phase re-materializes the blob where no
706/// remover can interleave. Everything else is a real failure.
707fn publish_failure_outcome(
708    err: std::io::Error,
709    transient: bool,
710    dest: PublishDest,
711) -> Result<PublishRename> {
712    match (dest, transient) {
713        (PublishDest::File, _) => Ok(PublishRename::LostRace),
714        (PublishDest::Vacant, true) => Ok(PublishRename::Deferred),
715        _ => Err(err).context("publishing staged blob"),
716    }
717}
718
719/// Rename a staged blob into place on the shared transient-retry budget,
720/// re-reading the destination after every failure. The rename, the probe and
721/// the classifier are passed in so each interleaving can be driven in a test;
722/// production passes `fs::rename`, [`publish_dest_state`] and
723/// `is_transient_rename_error`.
724fn publish_rename(
725    mut rename: impl FnMut() -> std::io::Result<()>,
726    dest_state: impl Fn() -> PublishDest,
727    is_transient: impl Fn(&std::io::Error) -> bool,
728) -> Result<PublishRename> {
729    crate::atomic::retry_transient(
730        || publish_attempt_outcome(rename(), &dest_state),
731        &is_transient,
732    )
733    .or_else(|err| {
734        let transient = is_transient(&err);
735        publish_failure_outcome(err, transient, dest_state())
736    })
737}
738
739/// Whether `a` and `b` name the same inode (hardlinked). Used after a lost
740/// hardlink publish race to decide if the build output still shares the
741/// store blob (keep RO) or is an independent file we marked RO by mistake
742/// (restore writable).
743fn paths_share_inode(a: &Path, b: &Path) -> bool {
744    match (kache_fs::file_identity(a), kache_fs::file_identity(b)) {
745        (Ok(a), Ok(b)) => a == b,
746        _ => false,
747    }
748}
749
750fn metadata_is_readonly_regular(metadata: &fs::Metadata) -> bool {
751    metadata.file_type().is_file() && metadata.permissions().readonly()
752}
753
754/// After a hardlink ingest that did not publish, clear read-only on `source`
755/// unless it still shares an inode with the published `blob` (in which case
756/// RO is the correct shared state, as on warm restore).
757fn restore_source_writable_if_unshared(source: &Path, blob: &Path) {
758    if paths_share_inode(source, blob) {
759        return;
760    }
761    if let Ok(meta) = fs::metadata(source) {
762        let mut perms = meta.permissions();
763        if perms.readonly() {
764            perms.set_readonly(false);
765            let _ = fs::set_permissions(source, perms);
766        }
767    }
768}
769
770pub fn blob_path_in_store_dir(store_dir: &Path, hash: &str) -> PathBuf {
771    // Defensive slice: a malformed hash (e.g. from a hand-edited or malicious
772    // remote `meta.json`) must not panic. Hash shape is validated at the
773    // remote trust boundary (`extract_entry_pack`), so a bad hash never gets
774    // stored; this keeps the local path build panic-free even if one slips
775    // through (#211).
776    let prefix = hash.get(..2).unwrap_or(hash);
777    store_dir.join("blobs").join(prefix).join(hash)
778}
779
780/// Open the index database read-only. No schema work, no WAL/synchronous
781/// pragma churn — `query_only` hard-refuses any accidental write, and the
782/// short busy timeout lets a caller skip a figure the index is too busy to
783/// answer instead of waiting for it.
784pub fn open_index_db_readonly(db_path: &Path) -> Result<Connection> {
785    let db = Connection::open_with_flags(
786        db_path,
787        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
788    )
789    .with_context(|| format!("opening index read-only {}", db_path.display()))?;
790    db.pragma_update(None, "busy_timeout", "25")?;
791    db.pragma_update(None, "query_only", "ON")?;
792    Ok(db)
793}
794
795/// How long the background `tmutil addexclusion` child may run before being
796/// killed. During an active Time Machine backup session, `addexclusion` on a
797/// not-yet-excluded directory can block for minutes (kunobi-ninja/kache#588);
798/// the exclusion is best-effort housekeeping, not worth a lingering child.
799#[cfg(target_os = "macos")]
800const TMUTIL_TIMEOUT: Duration = Duration::from_secs(30);
801
802/// Exclude the cache dir from Spotlight indexing and Time Machine backups.
803///
804/// The Spotlight sentinel is a cheap synchronous file create. The Time Machine
805/// exclusion shells out to `tmutil addexclusion`, which can hang for minutes
806/// while a backup session is active — and this runs on the daemon's startup
807/// path between socket bind and accept loop, so a synchronous call produced a
808/// daemon that listened but never answered (kunobi-ninja/kache#588). Instead:
809/// skip entirely when the exclusion xattr is already present (the warm case —
810/// a syscall, no subprocess), else run `tmutil` on a detached thread with a
811/// 30-second timeout so readiness never gates on backupd.
812///
813/// Returns the background thread's handle so tests can join it; production
814/// callers drop it (the thread never outlives its bounded wait by more than
815/// the child kill).
816#[cfg(target_os = "macos")]
817pub fn exclude_from_indexing(dir: &Path) -> Option<std::thread::JoinHandle<()>> {
818    // Spotlight: .metadata_never_index sentinel
819    let sentinel = dir.join(".metadata_never_index");
820    if !sentinel.exists() {
821        let _ = fs::File::create(&sentinel);
822    }
823
824    if backup_exclusion_xattr_present(dir) {
825        return None;
826    }
827    let dir = dir.display().to_string();
828    std::thread::Builder::new()
829        .name("kache-tmutil".into())
830        .spawn(move || run_tmutil_addexclusion_bounded(&dir))
831        .ok()
832}
833
834/// Does `dir` already carry Time Machine's exclusion xattr
835/// (`com.apple.metadata:com_apple_backup_excludeItem`)? A direct `getxattr`
836/// syscall — unlike `tmutil isexcluded`, it cannot block on backupd. Errors
837/// (including ENOATTR) read as "not excluded", which only costs a redundant
838/// background `tmutil` run.
839#[cfg(target_os = "macos")]
840fn backup_exclusion_xattr_present(dir: &Path) -> bool {
841    use std::os::unix::ffi::OsStrExt;
842    let Ok(path) = std::ffi::CString::new(dir.as_os_str().as_bytes()) else {
843        return false;
844    };
845    let name = c"com.apple.metadata:com_apple_backup_excludeItem";
846    // Size-probe call (null buffer): >= 0 means the xattr exists.
847    let len =
848        unsafe { libc::getxattr(path.as_ptr(), name.as_ptr(), std::ptr::null_mut(), 0, 0, 0) };
849    len >= 0
850}
851
852/// Run `tmutil addexclusion <dir>`, killing the child if it outlives
853/// [`TMUTIL_TIMEOUT`] (it can wedge behind an active backup session, #588).
854/// Best-effort throughout: every failure is debug-logged and swallowed.
855#[cfg(target_os = "macos")]
856fn run_tmutil_addexclusion_bounded(dir: &str) {
857    let child = std::process::Command::new("tmutil")
858        .args(["addexclusion", dir])
859        .stdout(std::process::Stdio::null())
860        .stderr(std::process::Stdio::null())
861        .spawn();
862    let Ok(mut child) = child else {
863        return;
864    };
865    let started = std::time::Instant::now();
866    loop {
867        match child.try_wait() {
868            Ok(Some(_)) => return,
869            Ok(None) if started.elapsed() >= TMUTIL_TIMEOUT => {
870                tracing::debug!(
871                    "tmutil addexclusion still running after {}s (active backup?) — killing it; \
872                     the exclusion will be retried on the next daemon start",
873                    TMUTIL_TIMEOUT.as_secs()
874                );
875                let _ = child.kill();
876                let _ = child.wait();
877                return;
878            }
879            Ok(None) => std::thread::sleep(Duration::from_millis(250)),
880            Err(_) => return,
881        }
882    }
883}
884
885/// Statistics returned by GC operations.
886#[derive(Debug, Clone, Default, Serialize, Deserialize)]
887pub struct GcStats {
888    pub entries_evicted: usize,
889    /// Store-namespace bytes whose blob rows went away. Not filesystem
890    /// reclamation — clones in `target/` can keep the blocks.
891    pub bytes_freed: u64,
892    pub blobs_removed: usize,
893    pub duration_ms: u64,
894    #[serde(default)]
895    pub skipped: bool,
896    /// Entries eviction selected but could not remove because they were
897    /// accessed within [`EVICTION_IDLE_GRACE`] — a live build may be mid-restore
898    /// on them (#326, #182) — or have a durable remote-upload intent whose
899    /// local payload must survive until that intent is retired.
900    ///
901    /// Recorded so the CLI can explain "evicted 0" while the store is over its
902    /// limit. Without it that reads as "GC is broken", which is what #509 was
903    /// filed about and plausibly what turned #497 into a 113 GB bug report.
904    #[serde(default)]
905    pub entries_pinned: usize,
906    /// Entries eviction selected but left in place because unlinking their
907    /// last-ref blobs would not free disk (hardlink or CoW clone still live
908    /// in a worktree). Distinct from [`Self::entries_pinned`].
909    #[serde(default)]
910    pub entries_unreclaimable: usize,
911    /// Best-effort private bytes actually returned by unlinking store names.
912    #[serde(default)]
913    pub disk_bytes_reclaimed: u64,
914    /// Entries eviction selected but failed to remove: an unreadable
915    /// `meta.json` (#276), a SQLite error. Counted rather than only logged, so
916    /// a sweep that keeps failing shows up in its own stats instead of only
917    /// as warning lines nobody reads.
918    #[serde(default)]
919    pub entries_failed: usize,
920    /// The part of [`Self::entries_failed`] that was SQLite write contention
921    /// (`SQLITE_BUSY` / `SQLITE_LOCKED`): the sweep lost the write lock to live
922    /// builds, as opposed to finding a damaged entry.
923    #[serde(default)]
924    pub entries_locked: usize,
925    /// Failed evictions caused by upgrading a stale WAL read snapshot to a
926    /// writer. These fail immediately and cannot be cured by busy_timeout.
927    #[serde(default)]
928    pub entries_busy_snapshot: usize,
929    /// Recently accessed candidates skipped before loading metadata or
930    /// starting a SQLite transaction. Included in entries_pinned.
931    #[serde(default)]
932    pub entries_recent_prefiltered: usize,
933    /// Candidates an automatic sweep kept because the remote delivered them
934    /// within [`IMPORT_PIN`] (#1008). Included in entries_pinned.
935    #[serde(default)]
936    pub entries_import_pinned: usize,
937    /// Time spent in the eviction writes themselves, each entry's removal
938    /// with its busy waits, summed over the run. Next to `entries_locked` it
939    /// shows how much of a sweep went to waiting on builds for the index
940    /// write lock.
941    #[serde(default)]
942    pub evict_write_ms: u64,
943    /// Bytes a size-driven sweep found held by live files outside the store:
944    /// last-reference blobs a target directory still clones or hardlinks.
945    /// Evicting them frees nothing, so the sweep leaves them out of the bytes
946    /// it has to free (kunobi-ninja/kache#1206).
947    #[serde(default)]
948    pub bytes_held: u64,
949    /// What the run's housekeeping did. `None` for a run that did none, such
950    /// as the eviction after an upload.
951    #[serde(default, skip_serializing_if = "Option::is_none")]
952    pub housekeeping: Option<HousekeepingStats>,
953}
954
955/// Counts from [`Store::sweep_housekeeping`], recorded with the GC run so
956/// growth in either structure shows up without a shell on the host.
957#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
958pub struct HousekeepingStats {
959    pub key_locks_removed: usize,
960    /// Key lock files left in `store/`: live entries, recent claims, and
961    /// whatever the per-sweep cap deferred.
962    pub key_locks_remaining: usize,
963    pub predictions_pruned: usize,
964    /// File hash memo rows deleted as not written for a month (#1206).
965    pub file_hashes_pruned: usize,
966}
967
968/// Whether `err` carries SQLite write contention (`SQLITE_BUSY` or
969/// `SQLITE_LOCKED`) anywhere in its cause chain. Bad data and I/O errors are
970/// not contention; GC counts the two apart.
971pub fn is_sqlite_contention(err: &anyhow::Error) -> bool {
972    err.chain().any(|cause| {
973        matches!(
974            cause.downcast_ref::<SqlError>(),
975            Some(SqlError::SqliteFailure(code, _))
976                if matches!(code.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
977        )
978    })
979}
980
981pub fn is_sqlite_busy_snapshot(err: &anyhow::Error) -> bool {
982    err.chain().any(|cause| {
983        matches!(
984            cause.downcast_ref::<SqlError>(),
985            Some(SqlError::SqliteFailure(code, _))
986                if code.extended_code == rusqlite::ffi::SQLITE_BUSY_SNAPSHOT
987        )
988    })
989}
990
991fn record_eviction_failure(stats: &mut GcStats, error: &anyhow::Error) {
992    stats.entries_failed += 1;
993    if is_sqlite_contention(error) {
994        stats.entries_locked += 1;
995        if is_sqlite_busy_snapshot(error) {
996            stats.entries_busy_snapshot += 1;
997        }
998    }
999}
1000
1001/// Registered blob bytes and blob rows an entry removal released — blobs
1002/// whose last reference went away, not the entry's logical size
1003/// (kunobi-ninja/kache#608). Denominated in `blobs` TABLE bytes, the same
1004/// unit as [`ArtifactStore::physical_size`], so eviction's running budget stays
1005/// consistent with its trigger; the file unlink itself is best-effort
1006/// (Windows can defer it), so this is not a guarantee about the disk.
1007#[derive(Debug, Clone, Copy, Default)]
1008pub struct RemovalReclaim {
1009    pub freed_bytes: u64,
1010    pub blobs_unlinked: usize,
1011    pub disk_bytes_reclaimed: u64,
1012}
1013
1014/// One pass of `remove_entry_guarded_with_hooks`: either a settled outcome,
1015/// or the instruction to run again because a republication replaced the
1016/// generation this pass was waiting on.
1017enum RemovalAttempt {
1018    Done(Option<RemovalReclaim>),
1019    Republished,
1020    /// Last-ref blobs are still cloned outside the store; eviction must not
1021    /// drop the entry (kunobi-ninja/kache#725).
1022    Unreclaimable,
1023}
1024
1025/// Outcome of removing an entry while holding its compile lock.
1026#[derive(Debug)]
1027pub enum GuardedRemoval {
1028    Reclaimed(RemovalReclaim),
1029    Skipped,
1030    Unreclaimable,
1031}
1032
1033#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
1034pub struct TrackedTargetRoot {
1035    pub path: PathBuf,
1036    pub workspace_root: PathBuf,
1037    pub first_seen: i64,
1038    pub last_seen: i64,
1039    pub identity: crate::filesystem::PathIdentity,
1040    /// The `rustc -vV` that last built into it, when kache recorded one.
1041    pub rustc: Option<String>,
1042    /// Found through Git, but never observed in a build through kache.
1043    pub discovered: bool,
1044}
1045
1046/// A shadow policy's would-evict set for one size-driven sweep
1047/// (kunobi-ninja/kache#594): the keys it would remove for the same byte
1048/// budget the live policy is sweeping toward.
1049struct ShadowSelection {
1050    policy: &'static str,
1051    victims: std::collections::HashSet<String>,
1052}
1053
1054/// The shadow's victims the live sweep left in the store, minus those any
1055/// policy would have kept: a durable upload's backing entry, a recent
1056/// access or import, or blobs a live file still holds. Those are kept by
1057/// the sweep, not by the ranking, so a flipped policy would keep them too.
1058fn shadow_only_victims<'a>(
1059    shadow: &ShadowSelection,
1060    evicted: &std::collections::HashSet<&str>,
1061    by_key: &std::collections::HashMap<&str, &'a crate::eviction::EntryFeatures>,
1062    durable_upload_keys: &std::collections::HashSet<String>,
1063    held: &std::collections::HashMap<String, u64>,
1064) -> Vec<&'a crate::eviction::EntryFeatures> {
1065    let mut victims: Vec<_> = shadow
1066        .victims
1067        .iter()
1068        .filter(|key| !evicted.contains(key.as_str()))
1069        .filter(|key| !durable_upload_keys.contains(*key) && !held.contains_key(*key))
1070        .filter_map(|key| by_key.get(key.as_str()).copied())
1071        .filter(|features| !features.recently_accessed && !features.recently_imported)
1072        .collect();
1073    victims.sort_by(|a, b| a.key.cmp(&b.key));
1074    victims
1075}
1076
1077/// Post-eviction demand, split by whether the shadow policy agreed with the
1078/// live one about each evicted entry (kunobi-ninja/kache#594).
1079#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1080pub struct ShadowDemandSplit {
1081    /// Evicted entries the shadow policy would also have evicted.
1082    pub agreed: usize,
1083    /// …of which were later asked for again.
1084    pub agreed_demanded: usize,
1085    /// Evicted entries the shadow policy would have KEPT.
1086    pub shadow_kept: usize,
1087    /// …of which were later asked for again — the shadow's saves, had it
1088    /// been live.
1089    pub shadow_kept_demanded: usize,
1090}
1091
1092/// Evictions one policy made, and the compile time later spent rebuilding
1093/// the ones the build asked for again (kunobi-ninja/kache#594).
1094#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1095pub struct PolicyCost {
1096    pub evicted: u64,
1097    /// Logical bytes of the evicted entries.
1098    pub bytes: u64,
1099    pub demanded_compile_time_ms: u64,
1100}
1101
1102/// The live policy against the value-density shadow over the same sweeps:
1103/// see [`Store::shadow_counterfactual`].
1104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1105pub struct ShadowCounterfactual {
1106    pub live: PolicyCost,
1107    pub shadow: PolicyCost,
1108}
1109
1110/// Statistics returned by [`ArtifactStore::sweep_orphan_blobs`].
1111#[derive(Debug, Clone, Copy, Default)]
1112pub struct OrphanSweepStats {
1113    /// Blob-shaped files inspected on disk.
1114    pub scanned: usize,
1115    /// Orphan blobs (no `blobs` row) unlinked.
1116    pub removed: usize,
1117    /// Bytes reclaimed by the sweep.
1118    pub bytes_reclaimed: u64,
1119}
1120
1121/// Difference between the derived SQLite blob index and committed entry
1122/// metadata, which is the store's authoritative reference graph (#819).
1123#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1124pub struct BlobIndexDrift {
1125    /// `entry_blobs` rows that are missing, stale, or have the wrong count.
1126    pub entry_mappings: usize,
1127    /// `blobs` rows that are missing, stale, or have the wrong size/refcount.
1128    pub blobs: usize,
1129}
1130
1131impl BlobIndexDrift {
1132    pub fn total(self) -> usize {
1133        self.entry_mappings + self.blobs
1134    }
1135}
1136
1137#[derive(Default)]
1138struct AuthoritativeBlobIndex {
1139    entry_mappings: std::collections::BTreeMap<(String, String), i64>,
1140    blobs: std::collections::BTreeMap<String, (i64, i64)>,
1141}
1142
1143/// The local content-addressed store.
1144pub struct ArtifactStore<P: ArtifactPolicy> {
1145    policy: std::marker::PhantomData<P>,
1146    config: Config,
1147    db: Connection,
1148    /// Write slice and pause for eviction sweeps: [`EVICTION_WRITE_SLICE`]
1149    /// and [`EVICTION_WRITE_PAUSE`] outside tests.
1150    eviction_pacing: (Duration, Duration),
1151    /// Opened by [`Self::open_read_only`]: lookups and restores only.
1152    read_only: bool,
1153}
1154
1155/// How recently an entry must have been accessed for eviction to treat it as
1156/// "pinned by a live build" and skip it (kunobi-ninja/kache#326, #182).
1157///
1158/// A cache hit bumps `last_accessed` immediately before the wrapper hardlinks
1159/// the entry's blobs into the build, so any entry touched within this window may
1160/// be **mid-restore**. The window only has to outlast a single restore
1161/// (hardlink/reflink/read — milliseconds; once linked, the target file owns its
1162/// own inode and is immune to a later blob unlink), so 2 minutes is generous
1163/// headroom on a slow disk while staying far below any sensible cache lifetime.
1164pub const EVICTION_IDLE_GRACE: Duration = Duration::from_secs(120);
1165
1166/// How long an automatic sweep keeps an entry the remote delivered, used or
1167/// not (kunobi-ninja/kache#1008). A CI job imports a warm set, then runs
1168/// several cargo commands; an upload between them used to evict whatever the
1169/// job had not touched for [`EVICTION_IDLE_GRACE`], and the next command
1170/// downloaded it again or missed. Six hours covers the longest GitHub-hosted
1171/// job, and bounds how long a long-lived daemon keeps imports nobody used.
1172pub const IMPORT_PIN: Duration = Duration::from_secs(6 * 3600);
1173
1174/// Who started a sweep. Automatic sweeps (after an upload, on a size hint,
1175/// the daemon's timer, the detached worker) keep recent imports; a sweep the
1176/// user asked for does not, so `kache gc` can always get back under budget.
1177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1178pub enum SweepOrigin {
1179    Automatic,
1180    Requested,
1181}
1182
1183impl SweepOrigin {
1184    /// Seconds within which an import is kept, or `None` when imports get
1185    /// no protection beyond [`EVICTION_IDLE_GRACE`].
1186    fn import_pin_secs(self) -> Option<i64> {
1187        match self {
1188            SweepOrigin::Automatic => Some(IMPORT_PIN.as_secs() as i64),
1189            SweepOrigin::Requested => None,
1190        }
1191    }
1192}
1193
1194/// A hit re-stamps `last_accessed` only when the previous stamp is at least
1195/// this old: well inside [`EVICTION_IDLE_GRACE`], so a restore in flight is
1196/// still pinned, and rare enough that concurrent hits stop contending for
1197/// the index's write lock.
1198pub const HIT_STAMP_INTERVAL: Duration = Duration::from_secs(30);
1199
1200/// Write-lock time an eviction sweep may spend on removals before it pauses.
1201///
1202/// SQLite's write lock is not fair. A build's `put` that finds it taken
1203/// sleeps in the busy handler, polling at most every 100 ms, while a sweep
1204/// takes the lock again microseconds after each commit. Without pauses, a
1205/// build's `put` waited for most of the sweep.
1206pub const EVICTION_WRITE_SLICE: Duration = Duration::from_millis(50);
1207
1208/// How long an eviction sweep stands off the write lock after each
1209/// [`EVICTION_WRITE_SLICE`], or after it lost the lock to another writer.
1210/// Longer than the busy handler's 100 ms poll interval, so every waiting
1211/// writer polls at least once while the lock is free.
1212pub const EVICTION_WRITE_PAUSE: Duration = Duration::from_millis(150);
1213
1214/// Paces an eviction sweep's writes so build processes waiting on the index
1215/// write lock get it between slices.
1216#[derive(Debug)]
1217struct EvictionWritePacer {
1218    slice: Duration,
1219    pause: Duration,
1220    held: Duration,
1221}
1222
1223impl EvictionWritePacer {
1224    fn new(slice: Duration, pause: Duration) -> Self {
1225        Self {
1226            slice,
1227            pause,
1228            held: Duration::ZERO,
1229        }
1230    }
1231
1232    /// Record a removal that wrote to the index. Returns the pause to take
1233    /// once the sweep has spent a full slice writing.
1234    fn after_write(&mut self, took: Duration) -> Option<Duration> {
1235        self.held += took;
1236        if self.held < self.slice {
1237            return None;
1238        }
1239        self.held = Duration::ZERO;
1240        Some(self.pause)
1241    }
1242
1243    /// Another writer holds the lock: stand off for a full pause.
1244    fn after_contention(&mut self) -> Duration {
1245        self.held = Duration::ZERO;
1246        self.pause
1247    }
1248}
1249
1250/// Entries backfilled with their rebuild cost per GC sweep
1251/// (kunobi-ninja/kache#594).
1252///
1253/// The backfill runs while the daemon holds the store mutex, so an unbounded
1254/// pass is the thing to avoid: measured on a real 52k-entry store, reading
1255/// every `meta.json` is ~6 s (~0.11 ms per entry). At this batch size one
1256/// sweep adds roughly a second — negligible against a sweep that already scans
1257/// the whole store — and a 50k-entry store converges in a handful of sweeps
1258/// rather than dozens.
1259const COMPILE_TIME_BACKFILL_BATCH: i64 = 10_000;
1260
1261/// How long a post-eviction demand record is kept (kunobi-ninja/kache#594).
1262///
1263/// A tombstone earns its keep by answering "was this key wanted again soon
1264/// after we dropped it". Two weeks comfortably covers the branch-switch and
1265/// dependency-bump cycles that make a key go permanently dead, after which the
1266/// row is only consuming space. One row is ~100 bytes, so even a store
1267/// evicting tens of thousands of entries a fortnight stays in the low
1268/// megabytes.
1269pub const TOMBSTONE_RETENTION_DAYS: u64 = 14;
1270
1271const BUILD_LOCK_TIMEOUT: Duration = Duration::from_secs(600);
1272const BUILD_LOCK_POLL_INTERVAL: Duration = Duration::from_millis(100);
1273
1274/// How long a waiter sleeps before its next `try_lock`: 1 ms, doubling up to
1275/// [`BUILD_LOCK_POLL_INTERVAL`]. A fixed 100 ms poll cost every waiter half
1276/// of that on each hand-off; in a six-job cold cell that was 350 s of sleep
1277/// per cell, more than the compiles being waited for.
1278pub fn lock_poll_interval(attempt: u32) -> Duration {
1279    Duration::from_millis(1u64 << attempt.min(7)).min(BUILD_LOCK_POLL_INTERVAL)
1280}
1281
1282/// Cross-process advisory lock held through an open file handle.
1283///
1284/// Lock files persist after release. Unlinking an advisory lock file can split
1285/// contenders across the unlinked inode and a newly-created inode, allowing
1286/// two processes to both believe they hold the same lock. Only
1287/// [`Store::sweep_stale_key_locks`] unlinks one, and only while holding it;
1288/// every acquisition then checks that the path still names the file it
1289/// locked, and starts over when it does not.
1290pub struct StoreLock {
1291    file: fs::File,
1292}
1293
1294/// Lock guard for a cache key. Dropping it releases the OS lock.
1295pub type KeyLock = StoreLock;
1296
1297/// Lock guard for store-wide GC. Dropping it releases the OS lock.
1298pub type GcLock = StoreLock;
1299
1300/// How often one acquisition reopens a lock file that was unlinked under it.
1301/// Each retry needs the sweep to unlink the same path again, and a file this
1302/// process just created is younger than [`KEY_LOCK_SWEEP_GRACE`], so the
1303/// second open already settles; the bound only keeps a broken filesystem
1304/// from spinning.
1305const LOCK_OPEN_ATTEMPTS: u32 = 4;
1306
1307/// Does `path` still name the file `handle` refers to?
1308///
1309/// No: the path is gone or names another file, so the next contender will
1310/// lock something else and this handle excludes nobody. A handle with no
1311/// identity to compare (a platform without one) counts as current; the sweep
1312/// never unlinks there, see [`lock_file_is_at_path`].
1313fn lock_is_current(
1314    handle: std::io::Result<kache_fs::InodeId>,
1315    at_path: std::io::Result<kache_fs::InodeId>,
1316) -> bool {
1317    match (handle, at_path) {
1318        (Ok(handle), Ok(at_path)) => handle == at_path,
1319        (Ok(_), Err(_)) => false,
1320        (Err(_), _) => true,
1321    }
1322}
1323
1324/// The sweep's stricter form: both identities known and equal.
1325fn lock_file_is_at_path(file: &fs::File, path: &Path) -> bool {
1326    match (
1327        kache_fs::handle_identity(file),
1328        kache_fs::file_identity(path),
1329    ) {
1330        (Ok(handle), Ok(at_path)) => handle == at_path,
1331        _ => false,
1332    }
1333}
1334
1335impl StoreLock {
1336    fn open(path: &Path) -> Result<fs::File> {
1337        let parent = path
1338            .parent()
1339            .ok_or_else(|| anyhow::anyhow!("lock file has no parent: {}", path.display()))?;
1340        fs::create_dir_all(parent)?;
1341        Ok(fs::OpenOptions::new()
1342            .read(true)
1343            .write(true)
1344            .create(true)
1345            .truncate(false)
1346            .open(path)?)
1347    }
1348
1349    fn finish(mut file: fs::File) -> Result<Self> {
1350        // Diagnostic only: lock ownership is determined exclusively by the OS.
1351        use std::io::{Seek, SeekFrom, Write};
1352        file.set_len(0)?;
1353        file.seek(SeekFrom::Start(0))?;
1354        write!(file, "{}", std::process::id())?;
1355        Ok(Self { file })
1356    }
1357
1358    /// Open, lock, then confirm the locked file is the one at `path`.
1359    ///
1360    /// `lock` returns false when the file is held elsewhere. `after_open`
1361    /// runs between the open and the lock, where an unlink by the sweep does
1362    /// its damage; production passes a no-op and tests stage the race there.
1363    fn acquire_current(
1364        path: &Path,
1365        mut lock: impl FnMut(&fs::File) -> Result<bool>,
1366        mut after_open: impl FnMut(),
1367    ) -> Result<Option<Self>> {
1368        for _ in 0..LOCK_OPEN_ATTEMPTS {
1369            let file = Self::open(path)?;
1370            after_open();
1371            if !lock(&file)? {
1372                return Ok(None);
1373            }
1374            if lock_is_current(
1375                kache_fs::handle_identity(&file),
1376                kache_fs::file_identity(path),
1377            ) {
1378                return Ok(Some(Self::finish(file)?));
1379            }
1380            // Closing the handle releases the lock on the unlinked file.
1381        }
1382        anyhow::bail!(
1383            "lock file {} was replaced {LOCK_OPEN_ATTEMPTS} times while acquiring it",
1384            path.display()
1385        )
1386    }
1387
1388    fn try_lock_file(file: &fs::File) -> Result<bool> {
1389        match file.try_lock() {
1390            Ok(()) => Ok(true),
1391            Err(std::fs::TryLockError::WouldBlock) => Ok(false),
1392            Err(std::fs::TryLockError::Error(e)) => Err(e.into()),
1393        }
1394    }
1395
1396    fn acquire(path: &Path) -> Result<Self> {
1397        let lock = Self::acquire_current(
1398            path,
1399            |file| {
1400                file.lock()?;
1401                Ok(true)
1402            },
1403            || {},
1404        )?;
1405        lock.ok_or_else(|| anyhow::anyhow!("blocking lock on {} reported busy", path.display()))
1406    }
1407
1408    pub fn try_acquire(path: &Path) -> Result<Option<Self>> {
1409        Self::acquire_current(path, Self::try_lock_file, || {})
1410    }
1411
1412    fn wait_until_available(path: &Path, timeout: Duration) -> Result<bool> {
1413        let start = std::time::Instant::now();
1414        let mut attempt = 0;
1415        loop {
1416            if let Some(lock) = Self::try_acquire(path)? {
1417                drop(lock);
1418                return Ok(true);
1419            }
1420            if start.elapsed() >= timeout {
1421                return Ok(false);
1422            }
1423            std::thread::sleep(
1424                lock_poll_interval(attempt).min(timeout.saturating_sub(start.elapsed())),
1425            );
1426            attempt += 1;
1427        }
1428    }
1429}
1430
1431impl Drop for StoreLock {
1432    fn drop(&mut self) {
1433        let _ = self.file.unlock();
1434    }
1435}
1436
1437/// Result of claiming responsibility for a cache miss.
1438pub enum BuildClaim {
1439    /// This process owns the key and may compile it.
1440    Acquired(KeyLock),
1441    /// A peer committed the key after the caller's cache lookup.
1442    Committed(Box<EntryMeta>),
1443    /// Another process currently owns the key.
1444    Contended,
1445}
1446
1447/// How aggressively a local cache hit re-hashes its blobs against their content
1448/// address before serving them, to catch silent on-disk corruption / bit rot /
1449/// a memo collision before it reaches the compiler as a wrong artifact
1450/// (kunobi-ninja/kache#332).
1451#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1452pub enum VerifyRestores {
1453    /// Never re-hash (size check only). Default — verifying every hit costs an
1454    /// extra full read per blob.
1455    Off,
1456    /// Re-hash a deterministic 1-in-N fraction of hits, for cheap always-on
1457    /// background coverage that amortizes the cost across many restores.
1458    Sampled,
1459    /// Re-hash every blob on every hit.
1460    Always,
1461}
1462
1463/// One in this many hits is verified under [`VerifyRestores::Sampled`] (~6%).
1464const VERIFY_SAMPLE_RATE: u64 = 16;
1465
1466/// Rolling counter that drives `Sampled` selection. Process-global so coverage
1467/// accrues over time (temporal sampling) rather than always (not) checking the
1468/// same entries.
1469static VERIFY_SAMPLE_COUNTER: AtomicU64 = AtomicU64::new(0);
1470
1471/// Parse the restore-verification mode from `KACHE_VERIFY_RESTORES`. Read per
1472/// call (cheap, off the hot path) so tests can toggle it. Back-compatible: the
1473/// old boolean `1`/`true` maps to `Always`, unset/`0`/`false`/`off` to `Off`.
1474pub fn verify_restores_mode() -> VerifyRestores {
1475    parse_verify_restores(std::env::var("KACHE_VERIFY_RESTORES").ok().as_deref())
1476}
1477
1478/// Pure mapping from the env value to a mode (split out so it can be unit-tested
1479/// without touching process env).
1480fn parse_verify_restores(value: Option<&str>) -> VerifyRestores {
1481    match value {
1482        Some(v) if v.eq_ignore_ascii_case("sampled") => VerifyRestores::Sampled,
1483        Some(v)
1484            if v.eq_ignore_ascii_case("always") || v == "1" || v.eq_ignore_ascii_case("true") =>
1485        {
1486            VerifyRestores::Always
1487        }
1488        _ => VerifyRestores::Off,
1489    }
1490}
1491
1492/// Whether THIS hit should be content-verified, given the configured mode.
1493/// `Sampled` advances the rolling counter so ~1/[`VERIFY_SAMPLE_RATE`] of hits
1494/// verify.
1495fn should_verify_this_restore(mode: VerifyRestores) -> bool {
1496    match mode {
1497        VerifyRestores::Off => false,
1498        VerifyRestores::Always => true,
1499        VerifyRestores::Sampled => VERIFY_SAMPLE_COUNTER
1500            .fetch_add(1, Ordering::Relaxed)
1501            .is_multiple_of(VERIFY_SAMPLE_RATE),
1502    }
1503}
1504
1505/// Optional cap (bytes) on the compiler diagnostics stored in an entry, from
1506/// `KACHE_MAX_DIAGNOSTICS_BYTES`. `None` (default) stores them in full — a cache
1507/// hit replays exactly what the compile emitted, so warning gates behave
1508/// identically on a hit vs a miss (kunobi-ninja/kache#336). The cap is an opt-in
1509/// safety valve against a pathological stream (e.g. a noisy proc-macro) bloating
1510/// `meta.json`, accepting reduced fidelity only above the chosen size.
1511fn max_diagnostics_bytes() -> Option<usize> {
1512    std::env::var("KACHE_MAX_DIAGNOSTICS_BYTES")
1513        .ok()
1514        .and_then(|v| v.parse::<usize>().ok())
1515        .filter(|&n| n > 0)
1516}
1517
1518/// Truncate diagnostics to `max` bytes (at a UTF-8 char boundary) with a marker
1519/// noting how much was dropped. Returns the input unchanged when under the cap
1520/// or uncapped (kunobi-ninja/kache#336).
1521fn cap_diagnostics(s: &str, max: Option<usize>) -> String {
1522    match max {
1523        Some(limit) if s.len() > limit => {
1524            let mut end = limit;
1525            while end > 0 && !s.is_char_boundary(end) {
1526                end -= 1;
1527            }
1528            let omitted = s.len() - end;
1529            format!(
1530                "{}\n[kache: diagnostics truncated, {omitted} bytes omitted (#336)]\n",
1531                &s[..end]
1532            )
1533        }
1534        _ => s.to_string(),
1535    }
1536}
1537
1538fn zero_byte_is_valid_output<P: ArtifactPolicy>(store_name: &str, crate_types: &[String]) -> bool {
1539    P::allow_empty(store_name, crate_types)
1540}
1541
1542/// Length-prefix a field before folding it into a hasher, so adjacent fields
1543/// cannot be transposed without changing the digest.
1544fn fold_field(h: &mut blake3::Hasher, bytes: &[u8]) {
1545    h.update(&(bytes.len() as u64).to_le_bytes());
1546    h.update(bytes);
1547}
1548
1549fn emit_kinds_for_files<P: ArtifactPolicy>(files: &[CachedFile]) -> Vec<String> {
1550    let mut kinds: Vec<String> = files
1551        .iter()
1552        .filter_map(|f| P::emit_kind(&f.name))
1553        .map(str::to_string)
1554        .collect();
1555    kinds.sort();
1556    kinds.dedup();
1557    kinds
1558}
1559
1560/// Compute a LOCAL content-dedup hash for an entry (the `content_hash` column,
1561/// used only by `evict_duplicate_entries`; never crosses the remote wire).
1562///
1563/// Folds a deterministically sorted list of `(relative name, content hash, size,
1564/// exec-bit)`, each field length-prefixed. The previous version folded only the
1565/// bare blob hashes and truncated to 16 hex, so two distinct entries differing
1566/// only by a name↔hash transposition or by which file carried the exec-bit could
1567/// collide — and dedup-by-content would then keep the wrong survivor
1568/// (kunobi-ninja/kache#324). Returns the full blake3 hex.
1569fn compute_content_hash(files: &[CachedFile]) -> String {
1570    let mut sorted: Vec<&CachedFile> = files.iter().collect();
1571    sorted.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.hash.cmp(&b.hash)));
1572    let mut h = blake3::Hasher::new();
1573    for f in &sorted {
1574        fold_field(&mut h, f.name.as_bytes());
1575        fold_field(&mut h, f.hash.as_bytes());
1576        fold_field(&mut h, &f.size.to_le_bytes());
1577        fold_field(&mut h, &[u8::from(f.executable)]);
1578    }
1579    h.finalize().to_hex().to_string()
1580}
1581
1582const STORE_OPEN_MAX_ATTEMPTS: u32 = 6;
1583const STORE_OPEN_RETRY_DELAYS_MS: [u64; 5] = [25, 50, 100, 200, 250];
1584
1585fn sqlite_open_retry_delay(attempt: u32) -> Duration {
1586    let idx = attempt.saturating_sub(1) as usize;
1587    Duration::from_millis(*STORE_OPEN_RETRY_DELAYS_MS.get(idx).unwrap_or(&250))
1588}
1589
1590fn is_retryable_sqlite_open_error(err: &SqlError) -> bool {
1591    match err {
1592        SqlError::SqliteFailure(code, _) => matches!(
1593            code.code,
1594            ErrorCode::CannotOpen
1595                | ErrorCode::DatabaseBusy
1596                | ErrorCode::DatabaseLocked
1597                | ErrorCode::SystemIoFailure
1598        ),
1599        _ => false,
1600    }
1601}
1602
1603fn initialize_db(db: &Connection) -> rusqlite::Result<()> {
1604    db.pragma_update(None, "journal_mode", "WAL")?;
1605    db.pragma_update(None, "synchronous", "NORMAL")?;
1606    // Let concurrent writers retry for up to 5 s instead of failing immediately
1607    // with SQLITE_BUSY -- critical when 300+ wrapper processes hit the DB in parallel.
1608    db.pragma_update(None, "busy_timeout", "5000")?;
1609
1610    // Every statement below is a no-op on a current index, yet each one still
1611    // opens a write transaction, so every wrapper process queued behind
1612    // whichever miss was storing (hundreds of milliseconds per hit in a
1613    // contended cell). The generation stamped after the DDL says the schema
1614    // is current; bump [`INDEX_SCHEMA_GENERATION`] whenever a statement is
1615    // added or changed below. It cannot vouch for the memo rule columns
1616    // ([`crate::file_hash::has_rule_columns`]), so those are checked too.
1617    // Both checks only read.
1618    let generation: i64 = db.query_row("PRAGMA user_version", [], |row| row.get(0))?;
1619    if generation == INDEX_SCHEMA_GENERATION && crate::file_hash::has_rule_columns(db)? {
1620        return Ok(());
1621    }
1622
1623    db.execute_batch(
1624        "CREATE TABLE IF NOT EXISTS entries (
1625            cache_key TEXT PRIMARY KEY,
1626            crate_name TEXT NOT NULL,
1627            size INTEGER NOT NULL DEFAULT 0,
1628            created_at TEXT NOT NULL DEFAULT (datetime('now')),
1629            last_accessed TEXT NOT NULL DEFAULT (datetime('now')),
1630            hit_count INTEGER NOT NULL DEFAULT 0,
1631            committed INTEGER NOT NULL DEFAULT 0
1632        );",
1633    )?;
1634
1635    // Migrations (idempotent -- ignore "duplicate column" errors)
1636    let _ = db.execute_batch("ALTER TABLE entries ADD COLUMN crate_type TEXT NOT NULL DEFAULT ''");
1637    let _ = db.execute_batch("ALTER TABLE entries ADD COLUMN profile TEXT NOT NULL DEFAULT ''");
1638    let _ =
1639        db.execute_batch("ALTER TABLE entries ADD COLUMN num_features INTEGER NOT NULL DEFAULT 0");
1640    let _ = db.execute_batch("ALTER TABLE entries ADD COLUMN content_hash TEXT");
1641    // Whether the entry's blobs and metadata were fsynced (deferred
1642    // durability). Rows from before the column were always flushed on put.
1643    let _ = db.execute_batch("ALTER TABLE entries ADD COLUMN durable INTEGER NOT NULL DEFAULT 1");
1644    // What a miss on this entry would cost to rebuild (kunobi-ninja/kache#594).
1645    // Recorded in every entry's meta.json since long before this column, so
1646    // pre-existing rows are backfilled by `backfill_compile_times` rather than
1647    // being stuck at the 0 default. Eviction cannot see meta.json, so without
1648    // this column the cache has no way to weigh what it is about to destroy.
1649    let _ = db
1650        .execute_batch("ALTER TABLE entries ADD COLUMN compile_time_ms INTEGER NOT NULL DEFAULT 0");
1651    // Cache-key recipe version for targeted reclamation after a key bump
1652    // (kunobi-ninja/kache#750). Legacy rows are `0` = unknown and remain usable
1653    // until the user explicitly requests a stale-schema sweep.
1654    let _ =
1655        db.execute_batch("ALTER TABLE entries ADD COLUMN key_schema INTEGER NOT NULL DEFAULT 0");
1656
1657    db.execute_batch(
1658        "CREATE TABLE IF NOT EXISTS blobs (
1659            hash     TEXT PRIMARY KEY,
1660            size     INTEGER NOT NULL,
1661            refcount INTEGER NOT NULL DEFAULT 1
1662        );",
1663    )?;
1664
1665    // Which blobs each entry references (kunobi-ninja/kache#608). The mapping
1666    // otherwise lives only in per-entry meta.json files, which eviction cannot
1667    // afford to read for every candidate on every sweep. `refs` counts
1668    // references per *file*, not per unique hash (an entry listing the same
1669    // hash twice holds two of the blob's refcounts — see `adopt`/`remove`),
1670    // so "this entry holds the blob's last references" is `refs = refcount`.
1671    // Equality deliberately fails closed if refcounts and mappings ever drift
1672    // (e.g. the same-key republication races of #670): a drifted blob is
1673    // simply not counted reclaimable, never over-promised.
1674    // Pre-existing rows are backfilled by `backfill_entry_blobs` from the GC
1675    // sweep; ranking treats a not-yet-backfilled entry as it did before #608.
1676    db.execute_batch(
1677        "CREATE TABLE IF NOT EXISTS entry_blobs (
1678            cache_key TEXT NOT NULL,
1679            hash      TEXT NOT NULL,
1680            refs      INTEGER NOT NULL DEFAULT 1,
1681            PRIMARY KEY (cache_key, hash)
1682        );
1683        CREATE INDEX IF NOT EXISTS idx_entry_blobs_hash ON entry_blobs(hash);",
1684    )?;
1685    // Answers "has this store ever held this unit" in one probe; a cold
1686    // compile uses it to skip the dep-info pre-pass (see
1687    // `FileHashCache::has_entry_for_unit`). `unit_id` is Cargo's `-C metadata`
1688    // hash, recorded after the put by the rustc wrapper; a row without one
1689    // stands for every unit of its crate name.
1690    let _ = db.execute_batch("ALTER TABLE entries ADD COLUMN unit_id TEXT NOT NULL DEFAULT ''");
1691    // When the remote delivered this entry, in unix seconds; NULL for an
1692    // entry this machine built. Automatic eviction keeps recent imports
1693    // (kunobi-ninja/kache#1008, see [`IMPORT_PIN`]).
1694    let _ = db.execute_batch("ALTER TABLE entries ADD COLUMN imported_at INTEGER");
1695    db.execute_batch(
1696        "CREATE INDEX IF NOT EXISTS idx_entries_crate_name ON entries(crate_name);
1697         CREATE INDEX IF NOT EXISTS idx_entries_crate_unit ON entries(crate_name, unit_id);",
1698    )?;
1699
1700    // Post-eviction demand tracking (kunobi-ninja/kache#594).
1701    //
1702    // The question a cache eviction policy must answer is "will this key be
1703    // requested again", and a snapshot of the live store cannot answer it: the
1704    // entries it evicted are exactly the ones missing from it. So record what
1705    // was evicted, with the features the decision was made on, and mark the
1706    // row if a later lookup asks for that key. `demanded_at` NULL means "not
1707    // (yet) asked for since eviction".
1708    db.execute_batch(
1709        "CREATE TABLE IF NOT EXISTS eviction_tombstones (
1710            cache_key       TEXT PRIMARY KEY,
1711            evicted_at      TEXT NOT NULL DEFAULT (datetime('now')),
1712            policy          TEXT NOT NULL DEFAULT '',
1713            size            INTEGER NOT NULL DEFAULT 0,
1714            hit_count       INTEGER NOT NULL DEFAULT 0,
1715            idle_hours      REAL NOT NULL DEFAULT 0,
1716            compile_time_ms INTEGER NOT NULL DEFAULT 0,
1717            demanded_at     TEXT
1718        );",
1719    )?;
1720    // Shadow-policy verdict per eviction (kunobi-ninja/kache#594): which
1721    // candidate policy shadowed the sweep, and whether it agreed this entry
1722    // should go. NULL on rows from sweeps without a shadow. Idempotent
1723    // migrations, same pattern as the entries columns above.
1724    let _ = db.execute_batch("ALTER TABLE eviction_tombstones ADD COLUMN shadow_policy TEXT");
1725    let _ =
1726        db.execute_batch("ALTER TABLE eviction_tombstones ADD COLUMN shadow_would_evict INTEGER");
1727    // The other side of the shadow comparison (kunobi-ninja/kache#594): the
1728    // entries the shadow would have evicted that the live policy kept. Only
1729    // the first such sweep per key is kept, since that is when the shadow
1730    // would have removed it. Whether the build used one afterwards is read
1731    // later from `entries.hit_count` (or its tombstone), so the hit path
1732    // gains no write.
1733    db.execute_batch(
1734        "CREATE TABLE IF NOT EXISTS shadow_victims (
1735            cache_key       TEXT PRIMARY KEY,
1736            swept_at        TEXT NOT NULL DEFAULT (datetime('now')),
1737            shadow_policy   TEXT NOT NULL,
1738            size            INTEGER NOT NULL DEFAULT 0,
1739            hit_count       INTEGER NOT NULL DEFAULT 0,
1740            compile_time_ms INTEGER NOT NULL DEFAULT 0
1741        );",
1742    )?;
1743
1744    db.execute_batch(
1745        "CREATE TABLE IF NOT EXISTS incremental_dirs (
1746            path      TEXT PRIMARY KEY,
1747            last_seen TEXT NOT NULL DEFAULT (datetime('now'))
1748        );",
1749    )?;
1750
1751    // Machine-local build-output provenance (kunobi-ninja/kache#725). These
1752    // absolute paths stay in the local SQLite index and are never exported to
1753    // remote manifests or artifact metadata.
1754    db.execute_batch(
1755        "CREATE TABLE IF NOT EXISTS target_roots (
1756            path           TEXT PRIMARY KEY,
1757            workspace_root TEXT NOT NULL,
1758            first_seen     INTEGER NOT NULL DEFAULT (unixepoch()),
1759            last_seen      INTEGER NOT NULL DEFAULT (unixepoch()),
1760            device         TEXT NOT NULL,
1761            inode          TEXT NOT NULL,
1762            rustc          TEXT,
1763            discovered     INTEGER NOT NULL DEFAULT 0
1764        );
1765        CREATE INDEX IF NOT EXISTS idx_target_roots_last_seen
1766            ON target_roots(last_seen);",
1767    )?;
1768    // The `rustc -vV` a target was last built by, so a new checkout's build
1769    // can be seeded only from targets its own compiler built.
1770    let _ = db.execute_batch("ALTER TABLE target_roots ADD COLUMN rustc TEXT");
1771    let _ = db
1772        .execute_batch("ALTER TABLE target_roots ADD COLUMN discovered INTEGER NOT NULL DEFAULT 0");
1773
1774    crate::file_hash::ensure_file_hash_cache_schema(db)?;
1775    db.pragma_update(None, "user_version", INDEX_SCHEMA_GENERATION)?;
1776
1777    Ok(())
1778}
1779
1780/// The `user_version` an index carries once every statement of
1781/// [`initialize_db`] has run. Bump it with any schema change.
1782///
1783/// 3: the boolean env-use memo became the versioned `source_env_dep_uses`.
1784/// 4: `idx_entries_crate_name`, the crate-presence probe behind deferred
1785///    discovery (#1117 added the index without bumping the generation, so an
1786///    index from before it never gained the index and the probe scanned).
1787/// 5: `entries.unit_id` and `idx_entries_crate_unit`, so the probe can tell
1788///    two units of one crate name apart (every build script is one name).
1789/// 6: `entries.imported_at`, so automatic eviction can keep what the remote
1790///    delivered for the job still running (#1008).
1791/// 7: `target_roots.rustc`, the compiler a tracked target was built by.
1792/// 8: `target_roots.discovered`, separating Git discovery from build activity.
1793/// 9: `file_hashes.rule` (`file_hash::FILE_HASH_RULE`), and for the C/C++
1794///    memo `cc_memo_inputs.proof`, `cc_mapped_hashes.rule` and
1795///    `cc_asm_scans.rule` (`cc_memo::CC_MEMO_RULE`). Adding them empties
1796///    those tables. The columns decide that, not this number, which an
1797///    older release stamps back each time it opens the index; nothing such
1798///    a release records is served. An index can carry 9 and still lack a
1799///    column (`file_hash::has_rule_columns`), so the open checks them too.
1800const INDEX_SCHEMA_GENERATION: i64 = 9;
1801
1802/// Raise the refcount of every blob `cache_key` maps to at least the
1803/// references all mappings hold on it. Run before giving this key's
1804/// references back.
1805///
1806/// A mapping can exist for references nobody counted (an older backfill
1807/// mapped legacy entries before their migration). Subtracting such a mapping
1808/// from a count that only covers the other owners would take one of theirs,
1809/// and the blob would be reclaimed while they still need it. Flooring first
1810/// can only retain too much, which the blob-index reconcile corrects.
1811fn floor_blob_refs_at_mappings(
1812    conn: &rusqlite::Connection,
1813    cache_key: &str,
1814) -> rusqlite::Result<usize> {
1815    conn.execute(
1816        "UPDATE blobs
1817         SET refcount = MAX(refcount, (
1818             SELECT SUM(refs) FROM entry_blobs WHERE hash = blobs.hash
1819         ))
1820         WHERE hash IN (
1821             SELECT hash FROM entry_blobs WHERE cache_key = ?1
1822         )",
1823        params![cache_key],
1824    )
1825}
1826
1827/// Give back every blob reference `cache_key`'s current mapping holds, then
1828/// drop the mapping. Blob rows released to zero are deleted so they do not
1829/// read as index drift; their files stay for the orphan sweep, or for the
1830/// caller's own re-reference. The floor below already keeps the subtraction
1831/// at or above zero; `MAX(0, ..)` stays as a second guard.
1832///
1833/// The mapping is the only record of what a generation holds once its
1834/// meta.json has been replaced, so dropping it without this release strands
1835/// the refcounts: no entry owns them and no eviction can reach them.
1836///
1837/// Call it before taking the new generation's references. A mapping can
1838/// name a blob that has no row (nothing was ever counted for it); released
1839/// afterwards, that mapping would consume the reference just taken and
1840/// delete the new generation's row.
1841fn release_entry_blob_refs(conn: &rusqlite::Connection, cache_key: &str) -> rusqlite::Result<()> {
1842    floor_blob_refs_at_mappings(conn, cache_key)?;
1843    conn.execute(
1844        "UPDATE blobs
1845         SET refcount = MAX(0, refcount - COALESCE((
1846             SELECT refs FROM entry_blobs
1847             WHERE cache_key = ?1 AND hash = blobs.hash
1848         ), 0))
1849         WHERE hash IN (
1850             SELECT hash FROM entry_blobs WHERE cache_key = ?1
1851         )",
1852        params![cache_key],
1853    )?;
1854    conn.execute(
1855        "DELETE FROM blobs
1856         WHERE refcount <= 0 AND hash IN (
1857             SELECT hash FROM entry_blobs WHERE cache_key = ?1
1858         )",
1859        params![cache_key],
1860    )?;
1861    conn.execute(
1862        "DELETE FROM entry_blobs WHERE cache_key = ?1",
1863        params![cache_key],
1864    )?;
1865    Ok(())
1866}
1867
1868/// Whether any file `meta` lists still sits in the entry directory rather
1869/// than in the blob store.
1870fn has_unmigrated_artifacts(entry_dir: &Path, meta: &EntryMeta) -> bool {
1871    meta.files.iter().any(|f| entry_dir.join(&f.name).exists())
1872}
1873
1874/// Replace `cache_key`'s rows in `entry_blobs` with one row per unique hash
1875/// in `files`, `refs` counting per-file references (kunobi-ninja/kache#608).
1876/// Must run inside the caller's registration transaction so the mapping
1877/// commits atomically with the entry row and the blob refcounts it mirrors.
1878///
1879/// A publisher that may be replacing a generation calls
1880/// [`release_entry_blob_refs`] first, before it takes its own references.
1881fn record_entry_blobs(
1882    conn: &rusqlite::Connection,
1883    cache_key: &str,
1884    files: &[CachedFile],
1885) -> rusqlite::Result<()> {
1886    conn.execute(
1887        "DELETE FROM entry_blobs WHERE cache_key = ?1",
1888        params![cache_key],
1889    )?;
1890    for file in files {
1891        conn.execute(
1892            "INSERT INTO entry_blobs (cache_key, hash, refs) VALUES (?1, ?2, 1)
1893             ON CONFLICT(cache_key, hash) DO UPDATE SET refs = refs + 1",
1894            params![cache_key, file.hash],
1895        )?;
1896    }
1897    Ok(())
1898}
1899
1900pub fn open_index_db(db_path: &Path) -> Result<Connection> {
1901    open_index_db_reporting_recovery(db_path).map(|(db, _)| db)
1902}
1903
1904/// Like [`open_index_db`], but also reports whether the index had to be
1905/// recreated from scratch.
1906///
1907/// [`ArtifactStore::open`] needs to know: a freshly quarantined index has no rows, while
1908/// the blobs and every entry's `meta.json` are still on disk, so it can rebuild
1909/// the rows instead of silently presenting a cold cache (#415). Callers that
1910/// only need a connection use the wrapper above.
1911pub fn open_index_db_reporting_recovery(db_path: &Path) -> Result<(Connection, bool)> {
1912    match try_open_index_db(db_path) {
1913        Ok(db) => Ok((db, false)),
1914        // The index is a derived, rebuildable cache — the blobs plus each
1915        // entry's meta.json are the source of truth — so a corrupt index must
1916        // not brick every command (the #412 report: macOS + Linux writing one
1917        // WAL index on a shared home dir left it SQLITE_CORRUPT, and every
1918        // command then hard-failed). Recover under a lock (#415).
1919        Err(err) if is_corruption_error(&err) => recover_corrupt_index(db_path, &err),
1920        Err(err) => Err(err.into()),
1921    }
1922}
1923
1924/// Recover a corrupt index: quarantine the unusable files and recreate a fresh,
1925/// empty index so stats/report/compiles degrade gracefully instead of bricking
1926/// every command.
1927///
1928/// Returns `(connection, recovered)`. `recovered == true` tells [`ArtifactStore::open`]
1929/// the row set was lost and should be rebuilt from the entry `meta.json` files
1930/// still on disk, so the user does not silently drop to a cold cache (#415).
1931///
1932/// Serialized by a cross-process lock so two processes that both observed the
1933/// corrupt DB cannot clobber each other — without it, one could heal and write
1934/// entries while the other then renames that healthy DB aside (re-emptying it
1935/// and orphaning the just-written blobs). Under the lock we re-check first: a
1936/// peer may have already healed it, in which case we simply open the fresh DB
1937/// and report `false`, because that peer owns the rebuild.
1938fn recover_corrupt_index(db_path: &Path, err: &SqlError) -> Result<(Connection, bool)> {
1939    // OS file lock, released automatically when the handle drops / the process
1940    // exits. Best-effort: on any lock failure we proceed unlocked, still guarded
1941    // by the re-check below.
1942    let _lock = acquire_index_recovery_lock(db_path);
1943
1944    // Re-check under the lock: a peer may have healed it while we waited.
1945    match try_open_index_db(db_path) {
1946        Ok(db) => return Ok((db, false)),
1947        Err(e) if is_corruption_error(&e) => {} // still corrupt: we heal it
1948        Err(e) => return Err(e.into()),
1949    }
1950
1951    let quarantined = quarantine_corrupt_index(db_path)
1952        .with_context(|| format!("quarantining corrupt index {}", db_path.display()))?;
1953    tracing::warn!(
1954        path = %db_path.display(),
1955        quarantined = %quarantined.display(),
1956        "index database is corrupt ({err}); quarantined it and recreated an empty index. \
1957         Rebuilding the entry rows from the store; run `kache doctor` to inspect."
1958    );
1959    let db = try_open_index_db(db_path)
1960        .map_err(anyhow::Error::from)
1961        .with_context(|| {
1962            format!(
1963                "recreating index database after quarantine {}",
1964                db_path.display()
1965            )
1966        })?;
1967    Ok((db, true))
1968}
1969
1970/// Best-effort blocking lock that serializes index recovery across processes.
1971/// Returns the locked file handle (the lock lives as long as it is held); on any
1972/// error returns `None` and the caller proceeds unlocked.
1973fn acquire_index_recovery_lock(db_path: &Path) -> Option<fs::File> {
1974    let lock_path = index_sidecar_path(db_path, ".recovery-lock");
1975    let file = fs::OpenOptions::new()
1976        .write(true)
1977        .create(true)
1978        .truncate(false)
1979        .open(&lock_path)
1980        .ok()?;
1981    file.lock().ok()?;
1982    Some(file)
1983}
1984
1985/// Open the index DB, retrying only *transient* open failures. Returns the raw
1986/// [`SqlError`] so [`open_index_db`] can distinguish corruption (which it
1987/// self-heals) from a genuine failure.
1988fn try_open_index_db(db_path: &Path) -> std::result::Result<Connection, SqlError> {
1989    let mut last_error: Option<SqlError> = None;
1990
1991    for attempt in 1..=STORE_OPEN_MAX_ATTEMPTS {
1992        match Connection::open(db_path).and_then(|db| {
1993            initialize_db(&db)?;
1994            Ok(db)
1995        }) {
1996            Ok(db) => return Ok(db),
1997            Err(err)
1998                if attempt < STORE_OPEN_MAX_ATTEMPTS && is_retryable_sqlite_open_error(&err) =>
1999            {
2000                let delay = sqlite_open_retry_delay(attempt);
2001                tracing::debug!(
2002                    path = %db_path.display(),
2003                    attempt,
2004                    ?delay,
2005                    "retrying transient SQLite open failure: {err}"
2006                );
2007                last_error = Some(err);
2008                std::thread::sleep(delay);
2009            }
2010            Err(err) => {
2011                last_error = Some(err);
2012                break;
2013            }
2014        }
2015    }
2016
2017    Err(last_error.expect("try_open_index_db must record an error before returning"))
2018}
2019
2020/// Whether a SQLite error means the database file itself is unusable
2021/// (`SQLITE_CORRUPT` / `SQLITE_NOTADB`) — the rebuildable-index case
2022/// [`open_index_db`] self-heals, as opposed to a transient open failure.
2023fn is_corruption_error(err: &SqlError) -> bool {
2024    matches!(
2025        err,
2026        SqlError::SqliteFailure(code, _)
2027            if matches!(code.code, ErrorCode::DatabaseCorrupt | ErrorCode::NotADatabase)
2028    )
2029}
2030
2031/// Move a corrupt index and its WAL/SHM sidecars aside (to
2032/// `<name>.corrupt-<millis>-<pid>`) so a fresh index can be created in place.
2033/// The corrupt files are kept, not deleted, for forensics. The pid suffix keeps
2034/// concurrent self-healers from colliding on the same quarantine name.
2035fn quarantine_corrupt_index(db_path: &Path) -> Result<PathBuf> {
2036    let millis = std::time::SystemTime::now()
2037        .duration_since(std::time::UNIX_EPOCH)
2038        .map(|d| d.as_millis())
2039        .unwrap_or(0);
2040    let file_name = db_path
2041        .file_name()
2042        .and_then(|n| n.to_str())
2043        .unwrap_or("index.db");
2044    let quarantine = db_path.with_file_name(format!(
2045        "{file_name}.corrupt-{millis}-{}",
2046        std::process::id()
2047    ));
2048    fs::rename(db_path, &quarantine)
2049        .with_context(|| format!("renaming corrupt index {} aside", db_path.display()))?;
2050    // Best-effort: move the WAL/SHM sidecars too so the fresh DB starts clean.
2051    for ext in ["-wal", "-shm"] {
2052        let from = index_sidecar_path(db_path, ext);
2053        if from.exists() {
2054            let _ = fs::rename(&from, index_sidecar_path(&quarantine, ext));
2055        }
2056    }
2057    Ok(quarantine)
2058}
2059
2060/// One retained corrupt index file, including WAL/SHM sidecars.
2061#[derive(Debug, PartialEq, Eq)]
2062pub struct QuarantinedIndexFile {
2063    pub path: PathBuf,
2064    pub bytes: u64,
2065}
2066
2067/// List forensic copies without opening or modifying the index. Ordinary GC
2068/// leaves these copies intact; an explicit full cache clear removes them.
2069pub fn quarantined_index_files(db_path: &Path) -> Result<Vec<QuarantinedIndexFile>> {
2070    let parent = db_path.parent().context("index path has no parent")?;
2071    let prefix = format!(
2072        "{}.corrupt-",
2073        db_path
2074            .file_name()
2075            .and_then(|n| n.to_str())
2076            .context("invalid index filename")?
2077    );
2078    let entries = match fs::read_dir(parent) {
2079        Ok(entries) => entries,
2080        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
2081        Err(error) => return Err(error.into()),
2082    };
2083    let mut files = Vec::new();
2084    for entry in entries {
2085        let entry = entry?;
2086        let name = entry.file_name();
2087        let Some(suffix) = name.to_str().and_then(|n| n.strip_prefix(&prefix)) else {
2088            continue;
2089        };
2090        if !is_quarantine_suffix(suffix) {
2091            continue;
2092        }
2093        // Do not follow links or delete similarly named directories.
2094        let metadata = fs::symlink_metadata(entry.path())?;
2095        if metadata.is_file() {
2096            files.push(QuarantinedIndexFile {
2097                path: entry.path(),
2098                bytes: metadata.len(),
2099            });
2100        }
2101    }
2102    files.sort_by(|a, b| a.path.cmp(&b.path));
2103    Ok(files)
2104}
2105
2106/// Another explicit cleaner may unlink a listed copy before we reach it.
2107/// Missing copies are already cleaned; other failures must reach the caller.
2108fn remove_quarantined_index_files(files: &[QuarantinedIndexFile]) -> Result<()> {
2109    for file in files {
2110        match fs::remove_file(&file.path) {
2111            Ok(()) => {}
2112            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2113            Err(error) => return Err(error.into()),
2114        }
2115    }
2116    Ok(())
2117}
2118
2119fn is_quarantine_suffix(suffix: &str) -> bool {
2120    let suffix = suffix
2121        .strip_suffix("-wal")
2122        .or_else(|| suffix.strip_suffix("-shm"))
2123        .unwrap_or(suffix);
2124    let Some((millis, pid)) = suffix.split_once('-') else {
2125        return false;
2126    };
2127    [millis, pid]
2128        .into_iter()
2129        .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
2130}
2131
2132/// The path of a SQLite sidecar (`-wal` / `-shm`): the suffix is appended to the
2133/// whole DB filename, not its extension.
2134fn index_sidecar_path(db_path: &Path, suffix: &str) -> PathBuf {
2135    let mut name = db_path
2136        .file_name()
2137        .map(|n| n.to_os_string())
2138        .unwrap_or_default();
2139    name.push(suffix);
2140    db_path.with_file_name(name)
2141}
2142
2143/// Outcome of [`ArtifactStore::rebuild_index_from_store`].
2144#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2145pub struct RebuildStats {
2146    /// Entries whose rows were reconstructed from `meta.json`.
2147    pub entries_rebuilt: usize,
2148    /// Entry dirs that could not be registered: unreadable or unparseable
2149    /// `meta.json`, a missing or wrong-sized blob, or a row that already existed.
2150    pub entries_skipped: usize,
2151    /// Blob references registered (one per `meta.files` element, not per
2152    /// unique hash).
2153    pub blobs_registered: usize,
2154}
2155
2156/// One prior build of a crate on this machine, from the local store's index
2157/// (kunobi-ninja/kache#617). Replaces a bare `(key, crate, dir)` tuple so the
2158/// planner can rank by rebuild cost and size.
2159#[derive(Debug, Clone, PartialEq, Eq)]
2160pub struct CrateHistoryEntry {
2161    pub cache_key: String,
2162    pub crate_name: String,
2163    pub entry_dir: PathBuf,
2164    /// `None` when the index has no value: these columns default to 0 for rows
2165    /// predating their migrations, and a 0 read as a real measurement would
2166    /// rank an un-backfilled entry as worthless.
2167    pub compile_time_ms: Option<u64>,
2168    pub size_bytes: Option<u64>,
2169}
2170
2171/// A non-positive SQLite column value means "not recorded", not "zero".
2172fn positive_or_none(value: i64) -> Option<u64> {
2173    (value > 0).then_some(value as u64)
2174}
2175
2176impl<P: ArtifactPolicy> ArtifactStore<P> {
2177    pub fn open(config: impl Into<Config>) -> Result<Self> {
2178        let config = config.into();
2179        fs::create_dir_all(&config.cache_dir)
2180            .with_context(|| format!("creating cache directory {}", config.cache_dir.display()))?;
2181        let store_dir = config.store_dir();
2182        fs::create_dir_all(&store_dir)
2183            .with_context(|| format!("creating store directory {}", store_dir.display()))?;
2184
2185        let db_path = config.index_db_path();
2186        let (db, recovered) = open_index_db_reporting_recovery(&db_path)
2187            .with_context(|| format!("opening index database {}", db_path.display()))?;
2188
2189        // The blob ingest helpers are free functions; they read the flush
2190        // policy of the store last opened on this thread. A thread that never
2191        // opened a store flushes inline.
2192        DEFERRED_DURABILITY.with(|deferred| deferred.set(config.deferred_durability));
2193        let store = Self {
2194            policy: std::marker::PhantomData,
2195            config: config.clone(),
2196            db,
2197            eviction_pacing: (EVICTION_WRITE_SLICE, EVICTION_WRITE_PAUSE),
2198            read_only: false,
2199        };
2200
2201        // A quarantined index comes back empty, but the blobs and every entry's
2202        // meta.json are still on disk, so the row set is reconstructible. Without
2203        // this the user silently drops from a warm cache to a cold one and
2204        // recompiles (or re-downloads) everything the old index knew about (#415).
2205        //
2206        // Best-effort: a rebuild failure must not turn a recovered-but-empty
2207        // index back into a hard open failure, which is the exact brick-every-
2208        // command behaviour recovery exists to prevent.
2209        if recovered {
2210            match store.rebuild_index_from_store() {
2211                Ok(stats) if stats.entries_rebuilt > 0 || stats.entries_skipped > 0 => {
2212                    tracing::warn!(
2213                        rebuilt = stats.entries_rebuilt,
2214                        skipped = stats.entries_skipped,
2215                        blobs = stats.blobs_registered,
2216                        "rebuilt the index from the store after corruption"
2217                    );
2218                }
2219                Ok(_) => {}
2220                Err(e) => tracing::warn!(
2221                    "could not rebuild the index from the store after corruption: {e:#}"
2222                ),
2223            }
2224        }
2225
2226        Ok(store)
2227    }
2228
2229    /// Open another process's store for lookups and restores on a read-only
2230    /// mount, such as a CI container given the host's store through a `:ro`
2231    /// volume. The mount must enforce filesystem protection: SQLite can create
2232    /// WAL side files on a writable mount even with a read-only connection.
2233    ///
2234    /// A WAL-mode index needs existing `index.db-wal` and `index.db-shm` files.
2235    /// Missing files are refused before opening, but this check cannot prevent
2236    /// their owner from removing them concurrently. Do not use SQLite's
2237    /// immutable mode here: it would hide later commits in the live WAL.
2238    ///
2239    /// [`Self::get`] stamps no hit and evicts or migrates nothing. A damaged
2240    /// or legacy entry is a miss. Mutators return an error, or do nothing for
2241    /// best-effort maintenance and memo writes. The thread's durability policy,
2242    /// which [`Self::open`] sets, is left alone.
2243    pub fn open_read_only(config: impl Into<Config>) -> Result<Self> {
2244        let config = config.into();
2245        let db_path = config.index_db_path();
2246        let mut header = [0u8; 20];
2247        {
2248            use std::io::Read;
2249            let mut file = fs::File::open(&db_path)
2250                .with_context(|| format!("read-only store: no index at {}", db_path.display()))?;
2251            file.read_exact(&mut header).with_context(|| {
2252                format!(
2253                    "read-only store: reading the header of {}",
2254                    db_path.display()
2255                )
2256            })?;
2257        }
2258        // Byte 18 of the SQLite header is the file format write version: 2 is WAL.
2259        if header[18] == 2 {
2260            for suffix in ["-wal", "-shm"] {
2261                let mut side = db_path.clone().into_os_string();
2262                side.push(suffix);
2263                let side = PathBuf::from(side);
2264                if !side.is_file() {
2265                    anyhow::bail!(
2266                        "read-only store: {} is in WAL mode and {} is missing, so its owner is not \
2267                         running; a read-only open would create it",
2268                        db_path.display(),
2269                        side.display()
2270                    );
2271                }
2272            }
2273        }
2274        let db = open_index_db_readonly(&db_path)?;
2275        db.busy_timeout(Duration::from_secs(2))?;
2276        // An index that cannot be read fails here, once, not as a miss per unit.
2277        db.query_row("SELECT 1 FROM entries LIMIT 1", [], |_| Ok(()))
2278            .optional()
2279            .with_context(|| format!("read-only store: querying {}", db_path.display()))?;
2280        Ok(Self {
2281            policy: std::marker::PhantomData,
2282            config,
2283            db,
2284            eviction_pacing: (EVICTION_WRITE_SLICE, EVICTION_WRITE_PAUSE),
2285            read_only: true,
2286        })
2287    }
2288
2289    /// Whether this store was opened by [`Self::open_read_only`].
2290    pub fn is_read_only(&self) -> bool {
2291        self.read_only
2292    }
2293
2294    fn refuse_write(&self, what: &str) -> Result<()> {
2295        if self.read_only {
2296            anyhow::bail!(
2297                "read-only store {}: refusing to {what}",
2298                self.config.cache_dir.display()
2299            );
2300        }
2301        Ok(())
2302    }
2303
2304    /// [`Self::get`] on a read-only store: the same checks. What the read-write
2305    /// path would repair (evict a damaged entry, migrate a legacy one) is a
2306    /// miss, and no hit is stamped.
2307    fn get_read_only(&self, cache_key: &str) -> Result<Option<EntryMeta>> {
2308        if !self.contains(cache_key) {
2309            return Ok(None);
2310        }
2311        let entry_dir = self.entry_dir(cache_key);
2312        // The owner may evict the entry between the two reads: a miss.
2313        let Ok(content) = fs::read_to_string(entry_dir.join("meta.json")) else {
2314            return Ok(None);
2315        };
2316        let Ok(meta) = serde_json::from_str::<EntryMeta>(&content) else {
2317            return Ok(None);
2318        };
2319        if meta.files.iter().any(|f| entry_dir.join(&f.name).exists()) {
2320            return Ok(None);
2321        }
2322        let verify_content =
2323            !self.entry_is_durable(cache_key) || should_verify_this_restore(verify_restores_mode());
2324        for cached_file in &meta.files {
2325            let blob = self.blob_path(&cached_file.hash);
2326            if validate_blob_metadata(&blob, cached_file.size).is_err() {
2327                return Ok(None);
2328            }
2329            if verify_content
2330                && !matches!(crate::file_hash::hash_file(&blob), Ok(ref h) if *h == cached_file.hash)
2331            {
2332                return Ok(None);
2333            }
2334        }
2335        Ok(Some(meta))
2336    }
2337
2338    /// Persistent-cache lookup for one file's content hash — DB read only, no
2339    /// blake3. Lets the daemon's `HashFiles` path release the store lock before
2340    /// the expensive file read (#281). See [`crate::file_hash::FileHashLookup`].
2341    pub fn file_hash_lookup(&self, path: &Path) -> crate::file_hash::FileHashLookup {
2342        self.file_hash_cache().lookup_cached(path)
2343    }
2344
2345    /// Record a freshly-computed file content hash (the miss arm of
2346    /// [`Self::file_hash_lookup`]); best-effort.
2347    pub fn file_hash_record(&self, fingerprint: &crate::file_hash::FileFingerprint, hash: &str) {
2348        if self.read_only {
2349            return;
2350        }
2351        self.file_hash_cache().record_cached(fingerprint, hash);
2352    }
2353
2354    /// Associate an already-known content hash with the exact fingerprint the
2355    /// caller observed it at, avoiding a redundant read when the file becomes a
2356    /// compiler input (kunobi-ninja/kache#540). Unlike
2357    /// [`Self::record_known_file_hash`] this never re-stats the path, so a file
2358    /// overwritten between observation and this call cannot inherit the old
2359    /// content's hash.
2360    pub fn record_verified_file_hash(
2361        &self,
2362        fingerprint: &crate::file_hash::FileFingerprint,
2363        hash: &str,
2364    ) {
2365        if self.read_only {
2366            return;
2367        }
2368        self.file_hash_cache().record_verified(fingerprint, hash);
2369    }
2370
2371    /// [`Self::record_verified_file_hash`] for every restored file of one
2372    /// hit, in one transaction with a short wait: the rows only save a later
2373    /// hash, so a busy index (a miss's store transaction elsewhere) drops them
2374    /// instead of stalling the hit.
2375    pub fn record_verified_file_hashes(
2376        &self,
2377        restored: &[(crate::file_hash::FileFingerprint, &str)],
2378    ) {
2379        if self.read_only || restored.is_empty() {
2380            return;
2381        }
2382        let _ = self.db.busy_timeout(std::time::Duration::from_millis(100));
2383        let written = (|| -> rusqlite::Result<()> {
2384            self.db.execute_batch("BEGIN IMMEDIATE")?;
2385            for (fingerprint, hash) in restored {
2386                self.file_hash_cache().record_verified(fingerprint, hash);
2387            }
2388            self.db.execute_batch("COMMIT")
2389        })();
2390        let _ = self.db.busy_timeout(std::time::Duration::from_millis(5000));
2391        if let Err(error) = written {
2392            let _ = self.db.execute_batch("ROLLBACK");
2393            tracing::debug!("restored file hashes not memoised (index busy): {error}");
2394        }
2395    }
2396
2397    /// Associate a stable file with its already-known content hash, avoiding a
2398    /// redundant read when it becomes a compiler input. Call this only after
2399    /// every store-side operation that may change the file's fingerprint and
2400    /// while the compiler-owned output is stable.
2401    pub fn record_known_file_hash(&self, path: &Path, hash: &str) {
2402        if self.read_only {
2403            return;
2404        }
2405        if let crate::file_hash::FileHashLookup::NeedsHash(fingerprint) =
2406            self.file_hash_cache().lookup_cached(path)
2407        {
2408            self.file_hash_cache().record_cached(&fingerprint, hash);
2409        }
2410    }
2411
2412    /// Borrow the persistent memo without exposing the index connection.
2413    pub fn file_hash_cache(&self) -> crate::file_hash::FileHashCache<'_> {
2414        crate::file_hash::FileHashCache::Borrowed(&self.db)
2415    }
2416
2417    /// Check if a committed entry exists for this cache key.
2418    pub fn contains(&self, cache_key: &str) -> bool {
2419        let entry_dir = self.entry_dir(cache_key);
2420        let meta_path = entry_dir.join("meta.json");
2421
2422        if !meta_path.exists() {
2423            return false;
2424        }
2425
2426        // Check if it's committed in the database
2427        self.db
2428            .query_row(
2429                "SELECT committed FROM entries WHERE cache_key = ?1",
2430                params![cache_key],
2431                |row| row.get::<_, bool>(0),
2432            )
2433            .unwrap_or(false)
2434    }
2435
2436    /// An entry's `meta.json` as written, with none of [`Store::get`]'s work:
2437    /// no blob verification, hit accounting or eviction. For a caller that
2438    /// just stored the entry and wants its file list.
2439    pub fn stored_meta(&self, cache_key: &str) -> Option<EntryMeta> {
2440        let content = fs::read(self.entry_dir(cache_key).join("meta.json")).ok()?;
2441        serde_json::from_slice(&content).ok()
2442    }
2443
2444    /// Load metadata for a cached entry and record a hit.
2445    pub fn get(&self, cache_key: &str) -> Result<Option<EntryMeta>> {
2446        if self.read_only {
2447            return self.get_read_only(cache_key);
2448        }
2449        if !self.contains(cache_key) {
2450            // If we previously evicted this key, this miss is the demand
2451            // signal an eviction policy needs and a live-store snapshot can
2452            // never show (kunobi-ninja/kache#594). Read-only unless a
2453            // not-yet-demanded tombstone actually matches.
2454            self.note_tombstone_demand(cache_key);
2455            return Ok(None);
2456        }
2457
2458        let entry_dir = self.entry_dir(cache_key);
2459        let meta_path = entry_dir.join("meta.json");
2460        let content = fs::read_to_string(&meta_path).context("reading entry meta.json")?;
2461        let meta: EntryMeta = serde_json::from_str(&content).context("parsing entry meta.json")?;
2462
2463        // Lazy migration: if legacy artifacts still live in the entry dir, migrate them
2464        let needs_migration = meta.files.iter().any(|f| entry_dir.join(&f.name).exists());
2465        if needs_migration && let Err(e) = self.migrate_entry_to_blobs(&meta) {
2466            tracing::warn!(
2467                "lazy migration failed for {}: {e}",
2468                &cache_key[..16.min(cache_key.len())]
2469            );
2470        }
2471
2472        // Decide once per hit whether to content-verify, so all of an entry's
2473        // blobs are checked together (or none) and `Sampled` advances its
2474        // counter once per hit, not once per blob (kunobi-ninja/kache#332).
2475        // An entry stored without an fsync (deferred durability) is verified
2476        // byte for byte until the flush lands: a crash between the two could
2477        // have left a blob whose size is right and whose bytes are not.
2478        let pending_durability = !self.entry_is_durable(cache_key);
2479        let verify_content =
2480            pending_durability || should_verify_this_restore(verify_restores_mode());
2481
2482        // Verify all cached blobs still exist on disk and match expected size
2483        for cached_file in &meta.files {
2484            let blob = self.blob_path(&cached_file.hash);
2485            if let Err(error) = validate_blob_metadata(&blob, cached_file.size) {
2486                tracing::warn!(
2487                    "cache entry {} file {} has invalid blob metadata ({error:#}), evicting",
2488                    cache_key.get(..16).unwrap_or(cache_key),
2489                    cached_file.name,
2490                );
2491                let _ = self.remove_entry(cache_key);
2492                return Ok(None);
2493            }
2494
2495            // Content verification (KACHE_VERIFY_RESTORES=off|sampled|always):
2496            // re-hash the blob against its content address to catch silent
2497            // corruption / bit rot before it reaches the compiler. A mismatch is
2498            // routed through the same evict-and-miss path as a missing blob, so
2499            // the build recompiles rather than consuming a poisoned artifact.
2500            // `sampled` amortizes the extra read across ~1/16 of hits; `always`
2501            // checks every hit; `off` (default) relies on the size check above
2502            // (kunobi-ninja/kache#332).
2503            if verify_content {
2504                match crate::file_hash::hash_file(&blob) {
2505                    Ok(actual) if actual == cached_file.hash => {}
2506                    Ok(actual) => {
2507                        tracing::warn!(
2508                            "cache entry {} file {} content mismatch (expected {}, got {}), evicting",
2509                            cache_key.get(..16).unwrap_or(cache_key),
2510                            cached_file.name,
2511                            &cached_file.hash[..16.min(cached_file.hash.len())],
2512                            &actual[..16.min(actual.len())],
2513                        );
2514                        let _ = self.remove_entry(cache_key);
2515                        return Ok(None);
2516                    }
2517                    Err(e) => {
2518                        tracing::warn!(
2519                            "cache entry {} file {} unreadable for verification ({e}), evicting",
2520                            cache_key.get(..16).unwrap_or(cache_key),
2521                            cached_file.name,
2522                        );
2523                        let _ = self.remove_entry(cache_key);
2524                        return Ok(None);
2525                    }
2526                }
2527            }
2528        }
2529
2530        // Update access time and hit count. The stamp pins the entry against
2531        // eviction for [`EVICTION_IDLE_GRACE`]; one that is already fresh
2532        // needs no write, and in a contended cell every hit's write would
2533        // queue behind the misses' store transactions. Hit counts therefore
2534        // count at most one hit per entry per [`HIT_STAMP_INTERVAL`].
2535        let age_seconds: Option<i64> = self
2536            .db
2537            .query_row(
2538                "SELECT strftime('%s', 'now') - strftime('%s', last_accessed) FROM entries \
2539                 WHERE cache_key = ?1",
2540                params![cache_key],
2541                |row| row.get(0),
2542            )
2543            .optional()?;
2544        if age_seconds.is_none_or(|age| age >= HIT_STAMP_INTERVAL.as_secs() as i64) {
2545            self.db.execute(
2546                "UPDATE entries SET last_accessed = datetime('now'), hit_count = hit_count + 1 \
2547                 WHERE cache_key = ?1",
2548                params![cache_key],
2549            )?;
2550        }
2551
2552        Ok(Some(meta))
2553    }
2554
2555    /// Acquire a build lock for a cache key. Returns None if another process holds it.
2556    pub fn try_lock(&self, cache_key: &str) -> Result<Option<KeyLock>> {
2557        self.refuse_write("acquire a build lock")?;
2558        StoreLock::try_acquire(&self.entry_dir(cache_key).with_extension("lock"))
2559    }
2560
2561    /// Claim a cache miss, re-checking the store after acquiring the key lock.
2562    ///
2563    /// The re-check closes the window where a peer can commit and release its
2564    /// lock between this process's cache lookup and lock acquisition.
2565    pub fn claim_build(&self, cache_key: &str) -> Result<BuildClaim> {
2566        let Some(lock) = self.try_lock(cache_key)? else {
2567            return Ok(BuildClaim::Contended);
2568        };
2569        match self.get(cache_key)? {
2570            Some(meta) if meta.files.is_empty() => {
2571                tracing::warn!("cache entry {cache_key} has no files, evicting before build");
2572                self.remove_entry(cache_key)?;
2573                Ok(BuildClaim::Acquired(lock))
2574            }
2575            Some(meta) => Ok(BuildClaim::Committed(Box::new(meta))),
2576            None => Ok(BuildClaim::Acquired(lock)),
2577        }
2578    }
2579
2580    /// Acquire the cross-process GC lock so concurrent GC drivers — a manual
2581    /// `kache gc`, the daemon's periodic sweep, `maybe_evict_after_upload`, or a
2582    /// second daemon — don't double-scan and contend. Returns `None` if another
2583    /// GC already holds it (the caller should skip).
2584    pub fn try_gc_lock(&self) -> Result<Option<GcLock>> {
2585        self.refuse_write("acquire a GC lock")?;
2586        StoreLock::try_acquire(&self.config.store_dir().join("gc.lock"))
2587    }
2588
2589    /// The lock a durability flusher holds while it drains entries stored
2590    /// without an fsync. A wrapper that finds it held knows a flusher is
2591    /// already running for this store.
2592    pub fn try_durability_flush_lock(&self) -> Result<Option<StoreLock>> {
2593        self.refuse_write("acquire a durability lock")?;
2594        StoreLock::try_acquire(&self.config.store_dir().join("durability.lock"))
2595    }
2596
2597    /// Block until the cross-process GC lock is held.
2598    ///
2599    /// Durable upload-intent publication uses the same lock as every
2600    /// production GC driver: either GC finishes first and publication
2601    /// revalidates that the payload survived, or the intent becomes durable
2602    /// before GC snapshots its protected keys.
2603    pub fn acquire_gc_lock(&self) -> Result<GcLock> {
2604        self.refuse_write("acquire a GC lock")?;
2605        StoreLock::acquire(&self.config.store_dir().join("gc.lock"))
2606    }
2607
2608    /// Wait for a cache key to become committed (another process is building it).
2609    pub fn wait_for_committed(&self, cache_key: &str) -> Result<bool> {
2610        if self.contains(cache_key) {
2611            return Ok(true);
2612        }
2613        self.wait_for_committed_with_timeout(cache_key, BUILD_LOCK_TIMEOUT)
2614    }
2615
2616    /// [`Store::wait_for_committed`] with a caller-chosen bound. `false` when
2617    /// the holder kept the key past `timeout` or released it without storing.
2618    pub fn wait_for_committed_with_timeout(
2619        &self,
2620        cache_key: &str,
2621        timeout: Duration,
2622    ) -> Result<bool> {
2623        let lock_path = self.entry_dir(cache_key).with_extension("lock");
2624        let _ = StoreLock::wait_until_available(&lock_path, timeout)?;
2625        Ok(self.contains(cache_key))
2626    }
2627
2628    /// Store compilation outputs under the cache key.
2629    ///
2630    /// Artifact files are stored in the content-addressed blob store
2631    /// (`store/blobs/{hash[0..2]}/{hash}`). The entry directory only
2632    /// contains `meta.json`. Identical content is deduplicated via
2633    /// reference counting in the `blobs` table.
2634    #[cfg(any(test, feature = "test-support"))]
2635    pub fn put(
2636        &self,
2637        cache_key: &str,
2638        crate_name: &str,
2639        crate_types: &[String],
2640        features: &[String],
2641        target: &str,
2642        profile: &str,
2643        output_files: &[(PathBuf, String)], // (source_path, filename_in_store)
2644        stdout: &str,
2645        stderr: &str,
2646    ) -> Result<StorePutResult> {
2647        self.put_with_compile_time(
2648            cache_key,
2649            crate_name,
2650            crate_types,
2651            features,
2652            target,
2653            profile,
2654            output_files,
2655            stdout,
2656            stderr,
2657            0,
2658        )
2659    }
2660
2661    pub fn put_with_compile_time(
2662        &self,
2663        cache_key: &str,
2664        crate_name: &str,
2665        crate_types: &[String],
2666        features: &[String],
2667        target: &str,
2668        profile: &str,
2669        output_files: &[(PathBuf, String)], // (source_path, filename_in_store)
2670        stdout: &str,
2671        stderr: &str,
2672        compile_time_ms: u64,
2673    ) -> Result<StorePutResult> {
2674        self.put_with_compile_time_policy(
2675            cache_key,
2676            crate_name,
2677            crate_types,
2678            features,
2679            target,
2680            profile,
2681            output_files,
2682            stdout,
2683            stderr,
2684            compile_time_ms,
2685            true,
2686        )
2687    }
2688
2689    /// Store outputs without ever sharing the compiler output inode with the
2690    /// read-only blob. Reflinks remain eligible because they provide CoW
2691    /// isolation; the fallback is a byte copy rather than a hardlink.
2692    pub fn put_with_compile_time_independent(
2693        &self,
2694        cache_key: &str,
2695        crate_name: &str,
2696        crate_types: &[String],
2697        features: &[String],
2698        target: &str,
2699        profile: &str,
2700        output_files: &[(PathBuf, String)],
2701        stdout: &str,
2702        stderr: &str,
2703        compile_time_ms: u64,
2704    ) -> Result<StorePutResult> {
2705        self.put_with_compile_time_policy(
2706            cache_key,
2707            crate_name,
2708            crate_types,
2709            features,
2710            target,
2711            profile,
2712            output_files,
2713            stdout,
2714            stderr,
2715            compile_time_ms,
2716            false,
2717        )
2718    }
2719
2720    #[allow(clippy::too_many_arguments)]
2721    fn put_with_compile_time_policy(
2722        &self,
2723        cache_key: &str,
2724        crate_name: &str,
2725        crate_types: &[String],
2726        features: &[String],
2727        target: &str,
2728        profile: &str,
2729        output_files: &[(PathBuf, String)],
2730        stdout: &str,
2731        stderr: &str,
2732        compile_time_ms: u64,
2733        allow_source_hardlinks: bool,
2734    ) -> Result<StorePutResult> {
2735        self.refuse_write("store an entry")?;
2736        let entry_dir = self.entry_dir(cache_key);
2737
2738        // Phase 1: stage every output into a private snapshot and hash THE
2739        // SNAPSHOT — never the live build output — before any committed entry
2740        // can reference it. The digest is computed over exactly the bytes
2741        // that will be published under it, so a post-build mutator (strip,
2742        // codesign, wasm post-processing) changing the file between staging
2743        // and hashing cannot store content X under address H(Y) (review
2744        // finding #3). No DB writes happen here, so a crash leaves at most an
2745        // unpublished staging file (`sweep_stale_staging`) or orphan blob
2746        // files (`sweep_orphan_blobs`), never a half-registered entry.
2747        // `sources` is kept so Phase 2 can re-materialize a blob if a
2748        // concurrent remove unlinks it.
2749        let mut cached_files = Vec::new();
2750        let mut sources: Vec<(PathBuf, bool)> = Vec::new();
2751        let mut seen_output_blobs = std::collections::HashSet::new();
2752        let mut put_result = StorePutResult::default();
2753        let mut total_size = 0u64;
2754        for (source_path, store_name) in output_files {
2755            // Every writer funnels through here, and the index checks over
2756            // committed metadata read these names back, so the same predicate
2757            // gates both: a name the verifier would call corrupt metadata must
2758            // never reach `meta.json` in the first place.
2759            anyhow::ensure!(
2760                kache_format::is_safe_stored_artifact_name(store_name),
2761                "refusing to cache an artifact with an unsafe name: {store_name}"
2762            );
2763            // The mode is read from the compiler's output, never from the
2764            // staging snapshot taken below. That snapshot is authoritative for
2765            // *bytes* — hashing it rather than the live output is the whole
2766            // point of #822 — but it is not authoritative for permissions:
2767            // `stage_blob_from_source` prefers `try_reflink`, whose Linux
2768            // implementation creates the temp with `File::create` before the
2769            // FICLONE ioctl, so a 0o755 binary reads back at the umask on every
2770            // CoW filesystem (btrfs, XFS-with-reflink, ZFS >= 2.2, bcachefs).
2771            // `fs::copy` and macOS `clonefile` happen to preserve it, which is
2772            // why ext4 CI and macOS did not notice #822 undoing #648's restore
2773            // fix: recorded `executable: false`, a `harness = false` test binary
2774            // classifies as `Other("rustc:unknown")`, restores via `Hardlink`
2775            // with no 0o755, and cargo fails the run with "Permission denied
2776            // (os error 13)".
2777            //
2778            // A failed stat is an error rather than a silent `false`, because
2779            // `false` is precisely the wrong value this guards against — and
2780            // staging opens the same path one line below regardless.
2781            let executable = fs::metadata(source_path)
2782                .map(|meta| crate::filesystem::is_executable(&meta))
2783                .with_context(|| format!("stating compiler output for {store_name}"))?;
2784            let use_source_hardlink =
2785                source_hardlink_allowed::<P>(allow_source_hardlinks, store_name, executable);
2786
2787            let (staged, ingest) = self.stage_blob_from_source(source_path, use_source_hardlink)?;
2788            let staged_meta = match fs::metadata(&staged) {
2789                Ok(meta) => meta,
2790                Err(e) => {
2791                    Self::discard_staged_blob(&staged);
2792                    return Err(anyhow::Error::new(e)
2793                        .context(format!("stating staged blob for {store_name}")));
2794                }
2795            };
2796            let size = staged_meta.len();
2797            if size == 0 && !zero_byte_is_valid_output::<P>(store_name, crate_types) {
2798                Self::discard_staged_blob(&staged);
2799                anyhow::bail!("refusing to cache zero-byte artifact: {}", store_name);
2800            }
2801            total_size += size;
2802
2803            let hash = crate::file_hash::hash_file(&staged)?;
2804            if seen_output_blobs.insert(hash.clone()) {
2805                put_result.output_blobs += 1;
2806                if self.blob_path(&hash).is_file() {
2807                    put_result.duplicate_blobs += 1;
2808                } else {
2809                    put_result.new_blobs += 1;
2810                }
2811            }
2812
2813            self.publish_staged_blob(&staged, ingest, &hash, size)?;
2814
2815            cached_files.push(CachedFile {
2816                name: store_name.clone(),
2817                size,
2818                hash,
2819                executable,
2820            });
2821            sources.push((source_path.clone(), use_source_hardlink));
2822        }
2823
2824        let content_hash = compute_content_hash(&cached_files);
2825
2826        // Record which rustc `--emit` kinds this entry actually contains, derived
2827        // from the stored output files (kunobi-ninja/kache#325). Lookup rejects an
2828        // entry that doesn't cover what the invocation's `--emit` requested.
2829        let emit_kinds = emit_kinds_for_files::<P>(&cached_files);
2830
2831        // Capture the compiler's diagnostics so a cache hit can replay them
2832        // verbatim — warning gates / `-D warnings` then behave identically on a
2833        // hit vs a miss (kunobi-ninja/kache#336). Optionally capped against
2834        // pathological streams; uncapped by default for full fidelity.
2835        let diag_cap = max_diagnostics_bytes();
2836
2837        // Write metadata (only meta.json in the entry directory)
2838        let meta = EntryMeta {
2839            cache_key: cache_key.to_string(),
2840            key_schema: kache_format::CACHE_KEY_VERSION,
2841            crate_name: crate_name.to_string(),
2842            crate_types: crate_types.to_vec(),
2843            files: cached_files,
2844            stdout: cap_diagnostics(stdout, diag_cap),
2845            stderr: cap_diagnostics(stderr, diag_cap),
2846            features: features.to_vec(),
2847            target: target.to_string(),
2848            profile: profile.to_string(),
2849            compile_time_ms,
2850            emit_kinds,
2851        };
2852        let meta_json =
2853            serde_json::to_string_pretty(&meta).context("serializing entry metadata")?;
2854        let meta_path = entry_dir.join("meta.json");
2855
2856        // Phase 2: register the entry and all of its blob references in a single
2857        // transaction, flipping `committed = 1` only once every blob is durable
2858        // on disk. Either the whole entry (with correct refcounts) becomes
2859        // visible, or none of it does — no refcount drift, no half-written row.
2860        //
2861        // `meta.json` is written INSIDE this transaction (#670), after the
2862        // write lock is held, so its appearance on disk is serialized against
2863        // `remove_entry_guarded`'s locked cleanup pass. Written before the
2864        // lock, a fresh meta could land between a racing removal's committed
2865        // row delete and its cleanup pass — whose republication check sees no
2866        // row for this key yet and deletes the directory, fresh meta included
2867        // — stranding this put's committed row with no artifacts and leaking
2868        // its refcounts until doctor or an index rebuild.
2869        let crate_type_str = crate_types.join(",");
2870        let num_features = features.len() as i64;
2871        let tx = self.db.unchecked_transaction()?;
2872        // A prior generation of this cache_key may still hold blob
2873        // references, most commonly a stranded row whose removal was
2874        // refused (#276) and that this put is about to overwrite via
2875        // INSERT OR REPLACE. Release them in this same transaction, before
2876        // this generation's increments and whatever the row's `committed`
2877        // state: a committed-but-stranded row is exactly the shape that
2878        // funnels back into put. Pre-#608 rows whose mapping the GC
2879        // backfill hasn't materialized yet still slip through (there is
2880        // nothing to decrement by); those remain `doctor --repair` /
2881        // reconcile territory.
2882        release_entry_blob_refs(&tx, cache_key)?;
2883        for (file, (source, use_source_hardlink)) in meta.files.iter().zip(sources.iter()) {
2884            let inserted = tx.execute(
2885                "INSERT OR IGNORE INTO blobs (hash, size, refcount) VALUES (?1, ?2, 1)",
2886                params![file.hash, file.size as i64],
2887            )?;
2888            if inserted == 0 {
2889                tx.execute(
2890                    "UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
2891                    params![file.hash],
2892                )?;
2893            }
2894            // Race guard: the INSERT/UPDATE above holds the write lock, and
2895            // `remove_entry` only unlinks a blob while holding that same lock,
2896            // so a concurrent reclaim cannot interleave here. If a remove
2897            // unlinked this blob between Phase 1 and now, re-materialize it
2898            // before we commit a reference to it — and verify the digest,
2899            // since the re-ingest reads the LIVE source (review finding #3).
2900            self.rematerialize_and_verify(source, &file.hash, &file.name, *use_source_hardlink)?;
2901        }
2902        record_entry_blobs(&tx, cache_key, &meta.files)?;
2903        // The write lock is held from the statements above (record_entry_blobs
2904        // always issues at least the DELETE). Materialize meta.json under it:
2905        // a concurrent removal's cleanup pass takes the same lock before it
2906        // deletes anything, so it runs either entirely before this write (this
2907        // put then re-creates the directory) or entirely after this
2908        // transaction commits (its republication check then sees this row and
2909        // leaves the directory alone). The staged write + atomic rename means
2910        // no reader — locked or not — can ever observe a truncated or
2911        // partially written meta.json, and the rename's parent-directory
2912        // fsync makes the new name durable alongside the contents.
2913        fs::create_dir_all(&entry_dir).context("creating entry directory")?;
2914        let durable = self.durable_writes();
2915        crate::atomic::atomic_replace_deferrable(&meta_path, meta_json.as_bytes(), durable)
2916            .context("writing entry metadata")?;
2917        tx.execute(
2918            "INSERT OR REPLACE INTO entries (cache_key, crate_name, crate_type, profile, num_features, size, content_hash, compile_time_ms, key_schema, committed, durable) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1, ?10)",
2919            params![cache_key, crate_name, crate_type_str, profile, num_features, total_size as i64, content_hash, compile_time_ms as i64, kache_format::CACHE_KEY_VERSION, durable],
2920        )?;
2921        tx.commit()?;
2922
2923        // Both ingest phases may hardlink a compiler output to the immutable
2924        // blob and mark the shared inode read-only, changing ctime. Seed only
2925        // after the transaction's final rematerialization. Independent (C/C++)
2926        // puts use disposable staging paths and deliberately do not seed them.
2927        if allow_source_hardlinks {
2928            for (file, (source, _)) in meta.files.iter().zip(&sources) {
2929                // The Rust wrapper expands dep-info back to absolute paths
2930                // immediately after `put`, so it is not stable here.
2931                if P::stable_after_store(&file.name) {
2932                    self.record_known_file_hash(source, &file.hash);
2933                }
2934            }
2935        }
2936
2937        Ok(put_result)
2938    }
2939
2940    /// Import a remotely downloaded entry into the database.
2941    ///
2942    /// Downloaded entries arrive as tar archives extracted into the entry
2943    /// directory (old format: artifact files alongside meta.json). This
2944    /// method moves the artifact files into the content-addressed blob
2945    /// store and records them in the `blobs` table, leaving only
2946    /// `meta.json` in the entry directory.
2947    pub fn import_downloaded_entry(&self, cache_key: &str) -> Result<()> {
2948        self.refuse_write("import an entry")?;
2949        let entry_dir = self.entry_dir(cache_key);
2950        let meta_path = entry_dir.join("meta.json");
2951        let content = fs::read_to_string(&meta_path).context("reading downloaded meta.json")?;
2952        let meta: EntryMeta =
2953            serde_json::from_str(&content).context("parsing downloaded meta.json")?;
2954
2955        // Remote `meta.json` is untrusted (a shared / MITM'd bucket can poison
2956        // its `files[]`), so validate the trust boundary before any field reaches
2957        // path construction, the blob store, or the user's `target/` (#211).
2958        let short_key = cache_key.get(..16).unwrap_or(cache_key);
2959        for cached_file in &meta.files {
2960            // C: a malformed `hash` becomes a shard path component (`&hash[..2]`)
2961            // — reject anything that isn't a 64-char blake3 hex digest so it can
2962            // never panic a slice or escape the blob shard.
2963            if !kache_format::is_blob_hash(&cached_file.hash) {
2964                anyhow::bail!(
2965                    "downloaded entry {short_key}: rejecting file {} — malformed blob hash {:?}",
2966                    cached_file.name,
2967                    cached_file.hash,
2968                );
2969            }
2970            // B: a `name` that is absolute or contains `..` escapes the entry dir
2971            // on join — require a single normal component.
2972            if !kache_format::is_safe_artifact_name(&cached_file.name) {
2973                anyhow::bail!(
2974                    "downloaded entry {short_key}: rejecting unsafe artifact name {:?}",
2975                    cached_file.name,
2976                );
2977            }
2978
2979            let file_path = entry_dir.join(&cached_file.name);
2980            if !file_path.is_file() {
2981                anyhow::bail!(
2982                    "downloaded entry {short_key} missing file: {}",
2983                    cached_file.name
2984                );
2985            }
2986            let file_meta = fs::metadata(&file_path).with_context(|| {
2987                format!("downloaded entry {short_key}: stat {}", cached_file.name)
2988            })?;
2989            if file_meta.len() != cached_file.size {
2990                anyhow::bail!(
2991                    "downloaded entry {short_key} file {} size mismatch (expected {}, got {})",
2992                    cached_file.name,
2993                    cached_file.size,
2994                    file_meta.len(),
2995                );
2996            }
2997            // A: re-hash the bytes and reject if they don't match the claimed
2998            // address. Size-only is insufficient for untrusted content — a
2999            // same-length substituted/corrupted object would otherwise be
3000            // installed under its claimed hash and hardlinked into the build as
3001            // if content-verified. blake3 is fast; do it before any rename/INSERT.
3002            let actual = crate::file_hash::hash_file(&file_path).with_context(|| {
3003                format!(
3004                    "downloaded entry {short_key}: hashing {} for trust-boundary check",
3005                    cached_file.name
3006                )
3007            })?;
3008            if actual != cached_file.hash {
3009                anyhow::bail!(
3010                    "downloaded entry {short_key}: content hash mismatch for {} \
3011                     (claimed {}, actual {})",
3012                    cached_file.name,
3013                    cached_file.hash,
3014                    actual,
3015                );
3016            }
3017        }
3018
3019        // Phase 1: move each *new* blob into the content-addressed store and make
3020        // it durable. For blobs that already exist (shared), keep the downloaded
3021        // copy in the entry dir for now — it's the fallback Phase 2 restores from
3022        // if a concurrent remove unlinks the blob.
3023        for cached_file in &meta.files {
3024            let blob = self.blob_path(&cached_file.hash);
3025            if !blob.is_file() {
3026                let file_path = entry_dir.join(&cached_file.name);
3027                fs::create_dir_all(blob.parent().unwrap())
3028                    .context("creating blob shard directory")?;
3029                fs::rename(&file_path, &blob).with_context(|| {
3030                    format!(
3031                        "moving downloaded artifact {} to blob store",
3032                        file_path.display()
3033                    )
3034                })?;
3035                crate::atomic::fsync_file(&blob).context("flushing downloaded blob to disk")?;
3036                set_blob_readonly(&blob);
3037            }
3038        }
3039
3040        let total_size: u64 = meta.files.iter().map(|f| f.size).sum();
3041
3042        let content_hash = compute_content_hash(&meta.files);
3043
3044        // Phase 2: register blob references and the entry row atomically, so the
3045        // entry only becomes visible once every blob is in place. The write lock
3046        // the INSERT/UPDATE holds also serializes us against `remove_entry`'s
3047        // unlink, so we can safely restore a blob a concurrent remove reclaimed.
3048        let crate_type_str = meta.crate_types.join(",");
3049        let num_features = meta.features.len() as i64;
3050        let tx = self.db.unchecked_transaction()?;
3051        // A second download of a committed key (two daemons, a retried
3052        // prefetch) replaces a generation that already holds references.
3053        release_entry_blob_refs(&tx, cache_key)?;
3054        for cached_file in &meta.files {
3055            let inserted = tx.execute(
3056                "INSERT OR IGNORE INTO blobs (hash, size, refcount) VALUES (?1, ?2, 1)",
3057                params![cached_file.hash, cached_file.size as i64],
3058            )?;
3059            if inserted == 0 {
3060                tx.execute(
3061                    "UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
3062                    params![cached_file.hash],
3063                )?;
3064            }
3065            let blob = self.blob_path(&cached_file.hash);
3066            if !blob.is_file() {
3067                // A concurrent remove unlinked this shared blob; restore it from
3068                // the downloaded copy kept in Phase 1 (still under the lock).
3069                let file_path = entry_dir.join(&cached_file.name);
3070                if !file_path.is_file() {
3071                    anyhow::bail!(
3072                        "downloaded blob {} vanished during import",
3073                        &cached_file.hash[..16.min(cached_file.hash.len())]
3074                    );
3075                }
3076                fs::create_dir_all(blob.parent().unwrap())
3077                    .context("creating blob shard directory")?;
3078                fs::rename(&file_path, &blob).with_context(|| {
3079                    format!(
3080                        "restoring downloaded artifact {} to blob store",
3081                        file_path.display()
3082                    )
3083                })?;
3084                crate::atomic::fsync_file(&blob).context("flushing downloaded blob to disk")?;
3085                set_blob_readonly(&blob);
3086            }
3087        }
3088        record_entry_blobs(&tx, cache_key, &meta.files)?;
3089        tx.execute(
3090            "INSERT OR REPLACE INTO entries (cache_key, crate_name, crate_type, profile, num_features, size, content_hash, compile_time_ms, key_schema, committed, imported_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1, unixepoch())",
3091            params![cache_key, meta.crate_name, crate_type_str, meta.profile, num_features, total_size as i64, content_hash, meta.compile_time_ms as i64, meta.key_schema],
3092        )?;
3093        tx.commit()?;
3094
3095        // Remove any downloaded duplicates kept as fallbacks but not needed
3096        // (their blob already existed and survived).
3097        for cached_file in &meta.files {
3098            let file_path = entry_dir.join(&cached_file.name);
3099            if file_path.is_file() {
3100                let _ = fs::remove_file(&file_path);
3101            }
3102        }
3103
3104        Ok(())
3105    }
3106
3107    /// Import a restored entry into the local store.
3108    ///
3109    /// This is the format-agnostic seam future remote layouts should call.
3110    /// A failed import must not leave an uncommitted `meta.json` behind: daemon
3111    /// download waiters use the extracted directory as a wake-up hint, and the
3112    /// residue could otherwise be mistaken for a published entry. Cleanup is
3113    /// serialized with Store publishers and preserves any committed generation
3114    /// that won the race.
3115    pub fn import_restored_entry(&self, cache_key: &str) -> Result<()> {
3116        self.refuse_write("import an entry")?;
3117        match self.import_downloaded_entry(cache_key) {
3118            Ok(()) => Ok(()),
3119            Err(import_error) => match self.discard_uncommitted_restored_entry(cache_key) {
3120                Ok(()) => Err(import_error),
3121                Err(cleanup_error) => Err(import_error.context(format!(
3122                    "also failed to discard uncommitted restored entry: {cleanup_error:#}"
3123                ))),
3124            },
3125        }
3126    }
3127
3128    /// Remove extraction residue only when no committed row owns this key.
3129    ///
3130    /// An immediate transaction acquires SQLite's cross-process writer lock
3131    /// before the row check and keeps it through directory removal. A concurrent
3132    /// publisher therefore either commits first (and is preserved) or publishes
3133    /// after the stale directory is gone.
3134    fn discard_uncommitted_restored_entry(&self, cache_key: &str) -> Result<()> {
3135        self.discard_uncommitted_restored_entry_inner(cache_key, || {}, || {})
3136    }
3137
3138    fn discard_uncommitted_restored_entry_inner(
3139        &self,
3140        cache_key: &str,
3141        before_write_lock: impl FnOnce(),
3142        after_write_lock: impl FnOnce(),
3143    ) -> Result<()> {
3144        if !kache_format::is_valid_cache_key(cache_key) {
3145            anyhow::bail!("refusing to discard invalid restored cache key");
3146        }
3147
3148        // The test hooks make both sides of lock acquisition observable
3149        // without weakening the production lock or relying on scheduler
3150        // sleeps.
3151        before_write_lock();
3152        let tx = rusqlite::Transaction::new_unchecked(
3153            &self.db,
3154            rusqlite::TransactionBehavior::Immediate,
3155        )?;
3156        after_write_lock();
3157        let committed: i64 = tx.query_row(
3158            "SELECT EXISTS(SELECT 1 FROM entries WHERE cache_key = ?1 AND committed = 1)",
3159            params![cache_key],
3160            |row| row.get(0),
3161        )?;
3162        if committed == 0 {
3163            let entry_dir = self.entry_dir(cache_key);
3164            match fs::remove_dir_all(&entry_dir) {
3165                Ok(()) => {}
3166                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3167                Err(error) => {
3168                    return Err(error).with_context(|| {
3169                        format!(
3170                            "removing uncommitted restored entry {}",
3171                            entry_dir.display()
3172                        )
3173                    });
3174                }
3175            }
3176        }
3177        tx.commit()?;
3178        Ok(())
3179    }
3180
3181    /// Install one already-verified artifact into the content-addressed blob
3182    /// store. A missing source is a distinct integrity race, not a generic
3183    /// rename failure, so keep that decision directly testable.
3184    fn install_verified_blob(&self, entry_dir: &Path, file: &CachedFile) -> Result<()> {
3185        let blob = self.blob_path(&file.hash);
3186        if !blob.is_file() {
3187            let artifact = entry_dir.join(&file.name);
3188            if !artifact.is_file() {
3189                anyhow::bail!("verified restored blob vanished during batch import");
3190            }
3191            fs::create_dir_all(blob.parent().expect("blob path has a parent"))?;
3192            fs::rename(&artifact, &blob)?;
3193            crate::atomic::fsync_file(&blob)?;
3194            set_blob_readonly(&blob);
3195        }
3196        Ok(())
3197    }
3198
3199    /// Import already stream-verified restored entries with one SQLite
3200    /// transaction for the whole batch.
3201    ///
3202    /// Artifact content is not hashed again: callers may only construct
3203    /// [`VerifiedRestoredEntry`] after extraction verified each byte against
3204    /// `meta.files[].hash`. This method still performs a complete metadata and
3205    /// on-disk length preflight before moving blobs or opening the transaction.
3206    pub fn import_verified_restored_entries(
3207        &self,
3208        entries: &[VerifiedRestoredEntry],
3209    ) -> Result<usize> {
3210        self.refuse_write("import entries")?;
3211        let mut cache_keys = std::collections::HashSet::new();
3212        for entry in entries {
3213            if !kache_format::is_valid_cache_key(&entry.cache_key)
3214                || entry.meta.cache_key != entry.cache_key
3215            {
3216                anyhow::bail!("verified restore has an invalid cache-key binding");
3217            }
3218            if entry.meta.key_schema != kache_format::CACHE_KEY_VERSION {
3219                anyhow::bail!(
3220                    "verified restore {} uses incompatible key schema {}",
3221                    &entry.cache_key[..16],
3222                    entry.meta.key_schema
3223                );
3224            }
3225            if !kache_format::is_valid_crate_name(&entry.meta.crate_name) {
3226                anyhow::bail!("verified restore has an unsafe crate name");
3227            }
3228            if !cache_keys.insert(entry.cache_key.as_str()) {
3229                anyhow::bail!("verified restore batch contains a duplicate cache key");
3230            }
3231
3232            let entry_dir = self.entry_dir(&entry.cache_key);
3233            let meta_bytes = fs::read(entry_dir.join("meta.json"))
3234                .context("reading stream-verified entry metadata")?;
3235            let disk_meta: EntryMeta = serde_json::from_slice(&meta_bytes)
3236                .context("parsing stream-verified entry metadata")?;
3237            if disk_meta != entry.meta {
3238                anyhow::bail!("verified restore metadata changed after extraction");
3239            }
3240
3241            let mut artifact_names = std::collections::HashSet::new();
3242            for file in &entry.meta.files {
3243                if !kache_format::is_safe_artifact_name(&file.name)
3244                    || !kache_format::is_valid_cache_key(&file.hash)
3245                    || !artifact_names.insert(file.name.as_str())
3246                {
3247                    anyhow::bail!(
3248                        "verified restore contains unsafe or duplicate artifact metadata"
3249                    );
3250                }
3251                let artifact = entry_dir.join(&file.name);
3252                let actual_size = fs::metadata(&artifact)
3253                    .with_context(|| format!("stat verified artifact {}", file.name))?
3254                    .len();
3255                if actual_size != file.size {
3256                    anyhow::bail!(
3257                        "verified artifact {} size mismatch (expected {}, got {})",
3258                        file.name,
3259                        file.size,
3260                        actual_size
3261                    );
3262                }
3263            }
3264        }
3265
3266        // Make every content-addressed blob durable before the database can
3267        // advertise a reference to it. Existing blobs leave the extracted copy
3268        // in place as the in-transaction race fallback below.
3269        for entry in entries {
3270            let entry_dir = self.entry_dir(&entry.cache_key);
3271            for file in &entry.meta.files {
3272                let blob = self.blob_path(&file.hash);
3273                if !blob.is_file() {
3274                    let artifact = entry_dir.join(&file.name);
3275                    fs::create_dir_all(blob.parent().expect("blob path has a parent"))
3276                        .context("creating verified blob shard directory")?;
3277                    fs::rename(&artifact, &blob).with_context(|| {
3278                        format!(
3279                            "moving verified artifact {} to blob store",
3280                            artifact.display()
3281                        )
3282                    })?;
3283                    crate::atomic::fsync_file(&blob)
3284                        .context("flushing verified restored blob to disk")?;
3285                    set_blob_readonly(&blob);
3286                }
3287            }
3288        }
3289
3290        let tx = self.db.unchecked_transaction()?;
3291        let mut imported = 0usize;
3292        for entry in entries {
3293            let meta = &entry.meta;
3294            let total_size: u64 = meta.files.iter().map(|file| file.size).sum();
3295            let crate_type = meta.crate_types.join(",");
3296            let content_hash = compute_content_hash(&meta.files);
3297            // A crash or legacy importer may have left an uncommitted row.
3298            // It is not a cache hit and must not permanently block a verified
3299            // replacement through INSERT OR IGNORE. Undo any partial mapping
3300            // bookkeeping in this same transaction before replacing it.
3301            tx.execute(
3302                "UPDATE blobs
3303                 SET refcount = MAX(0, refcount - COALESCE((
3304                     SELECT refs FROM entry_blobs
3305                     WHERE cache_key = ?1 AND hash = blobs.hash
3306                 ), 0))
3307                 WHERE hash IN (
3308                     SELECT hash FROM entry_blobs WHERE cache_key = ?1
3309                 ) AND EXISTS (
3310                     SELECT 1 FROM entries
3311                     WHERE cache_key = ?1 AND committed = 0
3312                 )",
3313                params![entry.cache_key],
3314            )?;
3315            tx.execute(
3316                "DELETE FROM entry_blobs
3317                 WHERE cache_key = ?1 AND EXISTS (
3318                     SELECT 1 FROM entries
3319                     WHERE cache_key = ?1 AND committed = 0
3320                 )",
3321                params![entry.cache_key],
3322            )?;
3323            tx.execute(
3324                "DELETE FROM entries WHERE cache_key = ?1 AND committed = 0",
3325                params![entry.cache_key],
3326            )?;
3327            tx.execute("DELETE FROM blobs WHERE refcount <= 0", [])?;
3328            let inserted = tx.execute(
3329                "INSERT OR IGNORE INTO entries (cache_key, crate_name, crate_type, profile, num_features, size, content_hash, compile_time_ms, key_schema, committed, imported_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 0, unixepoch())",
3330                params![
3331                    entry.cache_key,
3332                    meta.crate_name,
3333                    crate_type,
3334                    meta.profile,
3335                    meta.features.len() as i64,
3336                    total_size as i64,
3337                    content_hash,
3338                    meta.compile_time_ms as i64,
3339                    meta.key_schema
3340                ],
3341            )?;
3342            if inserted == 0 {
3343                continue;
3344            }
3345            // No row owned this key, so a mapping still recorded for it is
3346            // a leftover whose references nothing else will release.
3347            release_entry_blob_refs(&tx, &entry.cache_key)?;
3348
3349            let entry_dir = self.entry_dir(&entry.cache_key);
3350            for file in &meta.files {
3351                let added = tx.execute(
3352                    "INSERT OR IGNORE INTO blobs (hash, size, refcount) VALUES (?1, ?2, 1)",
3353                    params![file.hash, file.size as i64],
3354                )?;
3355                if added == 0 {
3356                    tx.execute(
3357                        "UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
3358                        params![file.hash],
3359                    )?;
3360                }
3361                self.install_verified_blob(&entry_dir, file)?;
3362            }
3363            record_entry_blobs(&tx, &entry.cache_key, &meta.files)?;
3364            tx.execute(
3365                "UPDATE entries SET committed = 1 WHERE cache_key = ?1",
3366                params![entry.cache_key],
3367            )?;
3368            imported += 1;
3369        }
3370        tx.commit()?;
3371
3372        for entry in entries {
3373            let entry_dir = self.entry_dir(&entry.cache_key);
3374            for file in &entry.meta.files {
3375                let artifact = entry_dir.join(&file.name);
3376                if artifact.is_file() {
3377                    let _ = fs::remove_file(artifact);
3378                }
3379            }
3380        }
3381        Ok(imported)
3382    }
3383
3384    /// Read the authoritative blob reference graph from committed entry
3385    /// metadata. The caller must hold SQLite's write lock so a publisher or
3386    /// remover cannot change the row/meta pairing during the scan (#819).
3387    /// A strict call fails on the first entry that does not verify; with
3388    /// `drop_unverifiable` those entries are left out and returned instead.
3389    fn authoritative_blob_index(
3390        &self,
3391        conn: &Connection,
3392        drop_unverifiable: bool,
3393    ) -> Result<(AuthoritativeBlobIndex, Vec<String>)> {
3394        let keys: Vec<String> = {
3395            let mut stmt = conn
3396                .prepare("SELECT cache_key FROM entries WHERE committed = 1 ORDER BY cache_key")?;
3397            stmt.query_map([], |row| row.get(0))?
3398                .collect::<Result<Vec<_>, _>>()?
3399        };
3400        let mut index = AuthoritativeBlobIndex::default();
3401        let mut dropped = Vec::new();
3402        for key in keys {
3403            let files = match self.verified_entry_files(&key) {
3404                Ok(files) => files,
3405                Err(error) if drop_unverifiable => {
3406                    tracing::warn!("removing an entry that failed verification: {error:#}");
3407                    dropped.push(key);
3408                    continue;
3409                }
3410                Err(error) => return Err(error),
3411            };
3412            for file in files {
3413                *index
3414                    .entry_mappings
3415                    .entry((key.clone(), file.hash.clone()))
3416                    .or_insert(0) += 1;
3417                match index.blobs.entry(file.hash.clone()) {
3418                    std::collections::btree_map::Entry::Vacant(slot) => {
3419                        slot.insert((file.size as i64, 1));
3420                    }
3421                    std::collections::btree_map::Entry::Occupied(mut slot) => {
3422                        let (size, refs) = slot.get_mut();
3423                        if *size != file.size as i64 {
3424                            anyhow::bail!(
3425                                "blob {} has conflicting sizes in committed metadata",
3426                                file.hash
3427                            );
3428                        }
3429                        *refs += 1;
3430                    }
3431                }
3432            }
3433        }
3434        Ok((index, dropped))
3435    }
3436
3437    /// The files a committed entry's `meta.json` names, each checked against
3438    /// its blob on disk. An error means the entry cannot be restored as
3439    /// recorded: its metadata is missing or unreadable, names an unsafe file,
3440    /// or points at a blob that is gone or has another size.
3441    fn verified_entry_files(&self, key: &str) -> Result<Vec<CachedFile>> {
3442        let meta_path = self.entry_dir(key).join("meta.json");
3443        let content = fs::read_to_string(&meta_path)
3444            .with_context(|| format!("entry {key}: reading authoritative meta.json"))?;
3445        let meta: EntryMeta = serde_json::from_str(&content)
3446            .with_context(|| format!("entry {key}: parsing authoritative meta.json"))?;
3447        for file in &meta.files {
3448            if !kache_format::is_blob_hash(&file.hash)
3449                || !kache_format::is_safe_stored_artifact_name(&file.name)
3450            {
3451                anyhow::bail!("entry {key}: invalid blob metadata");
3452            }
3453            let blob_path = self.blob_path(&file.hash);
3454            let actual_size = fs::metadata(&blob_path)
3455                .with_context(|| format!("entry {key}: reading blob {}", file.hash))?
3456                .len();
3457            if actual_size != file.size {
3458                anyhow::bail!(
3459                    "entry {key}: blob {} size mismatch (expected {}, got {})",
3460                    file.hash,
3461                    file.size,
3462                    actual_size
3463                );
3464            }
3465        }
3466        Ok(meta.files)
3467    }
3468
3469    fn indexed_blob_graph(&self, conn: &Connection) -> Result<AuthoritativeBlobIndex> {
3470        let entry_mappings = {
3471            let mut stmt = conn.prepare("SELECT cache_key, hash, refs FROM entry_blobs")?;
3472            stmt.query_map([], |row| Ok(((row.get(0)?, row.get(1)?), row.get(2)?)))?
3473                .collect::<Result<std::collections::BTreeMap<_, _>, _>>()?
3474        };
3475        let blobs = {
3476            let mut stmt = conn.prepare("SELECT hash, size, refcount FROM blobs")?;
3477            stmt.query_map([], |row| Ok((row.get(0)?, (row.get(1)?, row.get(2)?))))?
3478                .collect::<Result<std::collections::BTreeMap<_, _>, _>>()?
3479        };
3480        Ok(AuthoritativeBlobIndex {
3481            entry_mappings,
3482            blobs,
3483        })
3484    }
3485
3486    fn compare_blob_indexes(
3487        expected: &AuthoritativeBlobIndex,
3488        actual: &AuthoritativeBlobIndex,
3489    ) -> BlobIndexDrift {
3490        let entry_mappings = expected
3491            .entry_mappings
3492            .keys()
3493            .chain(actual.entry_mappings.keys())
3494            .collect::<std::collections::BTreeSet<_>>()
3495            .into_iter()
3496            .filter(|key| expected.entry_mappings.get(*key) != actual.entry_mappings.get(*key))
3497            .count();
3498        let blobs = expected
3499            .blobs
3500            .keys()
3501            .chain(actual.blobs.keys())
3502            .collect::<std::collections::BTreeSet<_>>()
3503            .into_iter()
3504            .filter(|hash| expected.blobs.get(*hash) != actual.blobs.get(*hash))
3505            .count();
3506        BlobIndexDrift {
3507            entry_mappings,
3508            blobs,
3509        }
3510    }
3511
3512    /// Verify that `entry_blobs` and `blobs` exactly match committed entry
3513    /// metadata. The write lock makes the filesystem/SQLite comparison stable.
3514    pub fn blob_index_drift(&self) -> Result<BlobIndexDrift> {
3515        self.db.execute_batch("BEGIN IMMEDIATE")?;
3516        let result = (|| {
3517            let (expected, _) = self.authoritative_blob_index(&self.db, false)?;
3518            let actual = self.indexed_blob_graph(&self.db)?;
3519            Ok(Self::compare_blob_indexes(&expected, &actual))
3520        })();
3521        match result {
3522            Ok(drift) => {
3523                self.db.execute_batch("COMMIT")?;
3524                Ok(drift)
3525            }
3526            Err(error) => {
3527                let _ = self.db.execute_batch("ROLLBACK");
3528                Err(error)
3529            }
3530        }
3531    }
3532
3533    /// Cheap check for drift between `blobs` and `entry_blobs` alone: plain
3534    /// reads, no `meta.json` and no write lock. See
3535    /// [`crate::BlobRefcountDrift`] for what it can and cannot see.
3536    pub fn blob_refcount_drift(&self) -> Result<crate::BlobRefcountDrift> {
3537        Ok(crate::blob_refcount_drift(&self.db)?)
3538    }
3539
3540    /// Rebuild only the derived blob graph from committed entry metadata.
3541    /// Physical orphan reclamation deliberately happens after this transaction
3542    /// through [`Self::sweep_orphan_blobs`], never while SQL can roll back.
3543    pub fn reconcile_blob_index(&self) -> Result<BlobIndexDrift> {
3544        self.reconcile_blob_index_with(false)
3545            .map(|(drift, _)| drift)
3546    }
3547
3548    /// [`Self::reconcile_blob_index`] that removes, in the same transaction,
3549    /// every committed entry whose metadata or blobs fail verification, and
3550    /// returns their keys. Such an entry can never be served: a lookup evicts
3551    /// it on sight. Kept, it makes the strict rebuild refuse the whole store.
3552    /// Blobs only those entries referenced lose their rows and are left to
3553    /// [`Self::sweep_orphan_blobs`].
3554    pub fn reconcile_blob_index_dropping_unverifiable(
3555        &self,
3556    ) -> Result<(BlobIndexDrift, Vec<String>)> {
3557        self.reconcile_blob_index_with(true)
3558    }
3559
3560    fn reconcile_blob_index_with(
3561        &self,
3562        drop_unverifiable: bool,
3563    ) -> Result<(BlobIndexDrift, Vec<String>)> {
3564        self.refuse_write("reconcile the blob index")?;
3565        self.db.execute_batch("BEGIN IMMEDIATE")?;
3566        let result = (|| -> Result<(BlobIndexDrift, Vec<String>)> {
3567            let (expected, dropped) = self.authoritative_blob_index(&self.db, drop_unverifiable)?;
3568            for key in &dropped {
3569                self.db
3570                    .execute("DELETE FROM entries WHERE cache_key = ?1", params![key])?;
3571                let entry_dir = self.entry_dir(key);
3572                if let Err(error) = fs::remove_dir_all(&entry_dir)
3573                    && error.kind() != std::io::ErrorKind::NotFound
3574                {
3575                    return Err(error).with_context(|| {
3576                        format!("entry {key}: removing an entry that failed verification")
3577                    });
3578                }
3579            }
3580            let actual = self.indexed_blob_graph(&self.db)?;
3581            let drift = Self::compare_blob_indexes(&expected, &actual);
3582            if drift.total() == 0 {
3583                return Ok((drift, dropped));
3584            }
3585
3586            self.db.execute("DELETE FROM entry_blobs", [])?;
3587            self.db.execute("DELETE FROM blobs", [])?;
3588            for ((cache_key, hash), refs) in &expected.entry_mappings {
3589                self.db.execute(
3590                    "INSERT INTO entry_blobs (cache_key, hash, refs) VALUES (?1, ?2, ?3)",
3591                    params![cache_key, hash, refs],
3592                )?;
3593            }
3594            for (hash, (size, refcount)) in &expected.blobs {
3595                self.db.execute(
3596                    "INSERT INTO blobs (hash, size, refcount) VALUES (?1, ?2, ?3)",
3597                    params![hash, size, refcount],
3598                )?;
3599            }
3600            Ok((drift, dropped))
3601        })();
3602        match result {
3603            Ok(outcome) => {
3604                self.db.execute_batch("COMMIT")?;
3605                Ok(outcome)
3606            }
3607            Err(error) => {
3608                let _ = self.db.execute_batch("ROLLBACK");
3609                Err(error)
3610            }
3611        }
3612    }
3613
3614    /// Rebuild the `entries` and `blobs` rows by scanning the store's per-entry
3615    /// `meta.json` files (kunobi-ninja/kache#415).
3616    ///
3617    /// The index is derived state: the blobs plus each entry's `meta.json` are
3618    /// the source of truth. So when the index is lost — quarantined after
3619    /// corruption, or deleted — the cache itself is still on disk and the rows
3620    /// can be reconstructed. Without this, recovery is needlessly lossy: a
3621    /// warm 100 GB cache silently becomes cold and every artifact is recompiled
3622    /// or re-downloaded even though the bytes never went anywhere.
3623    ///
3624    /// Only registers an entry when **every** file it claims resolves to a blob
3625    /// that is present and the right size. A partially-present entry is skipped
3626    /// rather than registered, because a registered entry pointing at a missing
3627    /// blob is a false hit — strictly worse than a miss.
3628    ///
3629    /// Idempotent, so it is safe to run on a populated index: entry rows are
3630    /// `INSERT OR IGNORE`d, and an entry already present contributes no blob
3631    /// refcounts. That matters because otherwise re-running would inflate every
3632    /// refcount and permanently leak blobs past their last referrer.
3633    ///
3634    /// Deliberately does **not** re-hash blob contents. This is local, already
3635    /// content-addressed data, not the untrusted remote payload
3636    /// `import_downloaded_entry` validates; hashing a whole store would make
3637    /// recovery cost hours. `doctor --verify --checksums` remains the surface
3638    /// for content verification.
3639    pub fn rebuild_index_from_store(&self) -> Result<RebuildStats> {
3640        self.refuse_write("rebuild the index")?;
3641        let store_dir = self.config.store_dir();
3642        let mut stats = RebuildStats::default();
3643
3644        let dir = match fs::read_dir(&store_dir) {
3645            Ok(dir) => dir,
3646            // No store dir yet (fresh cache): nothing to rebuild, not an error.
3647            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(stats),
3648            Err(e) => {
3649                return Err(e).with_context(|| format!("scanning store {}", store_dir.display()));
3650            }
3651        };
3652
3653        for entry in dir {
3654            let entry = match entry {
3655                Ok(e) => e,
3656                Err(e) => {
3657                    tracing::debug!("skipping unreadable store dir entry: {e}");
3658                    continue;
3659                }
3660            };
3661            let path = entry.path();
3662            if !path.is_dir() {
3663                continue;
3664            }
3665            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
3666                continue;
3667            };
3668            // `blobs/` is the content-addressed store, a sibling of the entry
3669            // dirs rather than one of them.
3670            if name == "blobs" {
3671                continue;
3672            }
3673            // Entry dirs are named by cache key. Anything else under store/ is
3674            // not ours to interpret, and an unvalidated name would be a path
3675            // component we then join (see `is_valid_cache_key`).
3676            if !kache_format::is_valid_cache_key(name) {
3677                continue;
3678            }
3679
3680            match self.rebuild_one_entry(name, &path) {
3681                Ok(Some(blobs)) => {
3682                    stats.entries_rebuilt += 1;
3683                    stats.blobs_registered += blobs;
3684                }
3685                Ok(None) => stats.entries_skipped += 1,
3686                Err(e) => {
3687                    tracing::debug!(
3688                        "skipping entry {} during index rebuild: {e:#}",
3689                        &name[..16.min(name.len())]
3690                    );
3691                    stats.entries_skipped += 1;
3692                }
3693            }
3694        }
3695
3696        Ok(stats)
3697    }
3698
3699    /// Register one entry dir's rows. Returns the number of blob references
3700    /// registered, or `None` when the entry is not fully present on disk.
3701    fn rebuild_one_entry(&self, cache_key: &str, entry_dir: &Path) -> Result<Option<usize>> {
3702        let meta_path = entry_dir.join("meta.json");
3703        if !meta_path.is_file() {
3704            return Ok(None);
3705        }
3706        let content = fs::read_to_string(&meta_path).context("reading entry meta.json")?;
3707        let meta: EntryMeta = serde_json::from_str(&content).context("parsing entry meta.json")?;
3708
3709        // Validate the whole entry before writing anything, so a half-present
3710        // entry never lands as a row that would resolve to a missing blob.
3711        for file in &meta.files {
3712            if !kache_format::is_blob_hash(&file.hash)
3713                || !kache_format::is_safe_stored_artifact_name(&file.name)
3714            {
3715                return Ok(None);
3716            }
3717            let blob = self.blob_path(&file.hash);
3718            match fs::metadata(&blob) {
3719                Ok(m) if m.len() == file.size => {}
3720                // Present but the wrong length, or absent: either way this entry
3721                // cannot be served, so do not advertise it.
3722                _ => return Ok(None),
3723            }
3724        }
3725
3726        let total_size: u64 = meta.files.iter().map(|f| f.size).sum();
3727        let content_hash = compute_content_hash(&meta.files);
3728        let crate_type_str = meta.crate_types.join(",");
3729        let num_features = meta.features.len() as i64;
3730
3731        let tx = self.db.unchecked_transaction()?;
3732        // Claim the entry row first. If it is already there, a concurrent or
3733        // earlier rebuild owns this entry's refcounts and we must not add more.
3734        let inserted = tx.execute(
3735            "INSERT OR IGNORE INTO entries (cache_key, crate_name, crate_type, profile, num_features, size, content_hash, compile_time_ms, key_schema, committed) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)",
3736            params![
3737                cache_key,
3738                meta.crate_name,
3739                crate_type_str,
3740                meta.profile,
3741                num_features,
3742                total_size as i64,
3743                content_hash,
3744                meta.compile_time_ms as i64,
3745                meta.key_schema
3746            ],
3747        )?;
3748        if inserted == 0 {
3749            tx.commit()?;
3750            return Ok(None);
3751        }
3752        // As in the batch import: a mapping without an entry row.
3753        release_entry_blob_refs(&tx, cache_key)?;
3754
3755        // One reference per *file*, not per unique hash: `remove_entry` decrements
3756        // once per `meta.files` element, so an entry listing the same hash twice
3757        // must hold two references or removal would drop it below zero.
3758        for file in &meta.files {
3759            let added = tx.execute(
3760                "INSERT OR IGNORE INTO blobs (hash, size, refcount) VALUES (?1, ?2, 1)",
3761                params![file.hash, file.size as i64],
3762            )?;
3763            if added == 0 {
3764                tx.execute(
3765                    "UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
3766                    params![file.hash],
3767                )?;
3768            }
3769        }
3770        record_entry_blobs(&tx, cache_key, &meta.files)?;
3771        tx.commit()?;
3772
3773        Ok(Some(meta.files.len()))
3774    }
3775
3776    /// Look up cache keys for the given crate names (most recent per crate).
3777    pub fn keys_for_crates(&self, crate_names: &[String]) -> Result<Vec<CrateHistoryEntry>> {
3778        if crate_names.is_empty() {
3779            return Ok(Vec::new());
3780        }
3781        let placeholders: Vec<&str> = crate_names.iter().map(|_| "?").collect();
3782        let sql = format!(
3783            "SELECT cache_key, crate_name, compile_time_ms, size FROM entries WHERE committed = 1 AND crate_name IN ({}) ORDER BY last_accessed DESC",
3784            placeholders.join(",")
3785        );
3786        let mut stmt = self.db.prepare(&sql)?;
3787        let params: Vec<&dyn rusqlite::ToSql> = crate_names
3788            .iter()
3789            .map(|n| n as &dyn rusqlite::ToSql)
3790            .collect();
3791        let rows = stmt.query_map(params.as_slice(), |row| {
3792            let key: String = row.get(0)?;
3793            let cn: String = row.get(1)?;
3794            let compile_time_ms: i64 = row.get(2)?;
3795            let size: i64 = row.get(3)?;
3796            Ok((key, cn, compile_time_ms, size))
3797        })?;
3798        let mut results = Vec::new();
3799        for row in rows {
3800            let (cache_key, crate_name, compile_time_ms, size) = row?;
3801            let entry_dir = self.entry_dir(&cache_key);
3802            results.push(CrateHistoryEntry {
3803                cache_key,
3804                crate_name,
3805                entry_dir,
3806                // Both columns default to 0 for rows written before their
3807                // migrations, so 0 has to mean "unknown" rather than "free to
3808                // fetch and worthless to have" (kunobi-ninja/kache#617).
3809                compile_time_ms: positive_or_none(compile_time_ms),
3810                size_bytes: positive_or_none(size),
3811            });
3812        }
3813        Ok(results)
3814    }
3815
3816    /// Resolve the filesystem path for a content-addressed blob.
3817    /// Layout: store/blobs/{first 2 hex chars}/{full hash}
3818    pub fn blob_path(&self, hash: &str) -> PathBuf {
3819        blob_path_in_store_dir(&self.config.store_dir(), hash)
3820    }
3821
3822    /// Return the content-addressed blob whose inode a read-only output still
3823    /// shares, if any.
3824    ///
3825    /// Older C/C++ restores could hardlink a build output to a read-only store
3826    /// blob. Passing that pathname to a compiler as root can truncate the blob
3827    /// and corrupt every cache entry that references it. This check is strictly
3828    /// read-only; callers fail closed instead of trying a racy path-based unlink.
3829    pub fn matching_readonly_blob_inode(
3830        store_dir: &Path,
3831        output: &Path,
3832    ) -> Result<Option<PathBuf>> {
3833        let Ok(initial) = fs::metadata(output) else {
3834            return Ok(None);
3835        };
3836        if !metadata_is_readonly_regular(&initial) {
3837            return Ok(None);
3838        }
3839
3840        let hash = crate::file_hash::hash_file(output)
3841            .with_context(|| format!("hashing possible legacy output {}", output.display()))?;
3842        let blob = blob_path_in_store_dir(store_dir, &hash);
3843        if !blob.is_file() {
3844            return Ok(None);
3845        }
3846
3847        // Recheck after hashing. A concurrent path swap can only make this
3848        // check fail closed; no pathname is ever mutated here.
3849        let Ok(current) = fs::metadata(output) else {
3850            return Ok(None);
3851        };
3852        if !metadata_is_readonly_regular(&current) {
3853            return Ok(None);
3854        }
3855        Ok(paths_share_inode(output, &blob).then_some(blob))
3856    }
3857
3858    /// Directory containing all blobs.
3859    #[cfg(any(test, feature = "test-support"))]
3860    pub fn blobs_dir(&self) -> PathBuf {
3861        self.config.store_dir().join("blobs")
3862    }
3863
3864    /// Directory holding in-progress put-phase staging files
3865    /// ([`Store::stage_blob_from_source`]). Lives under the store root but
3866    /// outside `blobs/`, so [`Self::sweep_orphan_blobs`] (which only considers
3867    /// hash-named files inside blob shards) never sees it; stale entries are
3868    /// reclaimed by [`Store::sweep_stale_staging`].
3869    fn staging_dir(&self) -> PathBuf {
3870        self.config.store_dir().join("staging")
3871    }
3872
3873    /// Stage one put-phase artifact into a private snapshot under the store.
3874    ///
3875    /// The snapshot — not the live build output — is what gets hashed and
3876    /// published, which is what upholds the content-address invariant: a file
3877    /// that changes after this point cannot end up stored under another file's
3878    /// digest (review finding #3). Ingest order mirrors [`materialize_blob`]:
3879    /// reflink first, then hardlink where the kind allows inode sharing, then a
3880    /// real copy. The returned path must be consumed by
3881    /// [`Self::publish_staged_blob`] or removed by [`discard_staged_blob`].
3882    ///
3883    /// Hardlink read-only semantics match `materialize_blob`: the guard is
3884    /// applied only after the fsync (Windows needs a writable handle to flush,
3885    /// #196), and a failed demotes to a full copy rather than publishing a
3886    /// writable shared inode.
3887    ///
3888    /// The staging path is chosen but NOT created ([`free_staging_path`]): the
3889    /// reflink and hardlink ingests can only write to a destination that does
3890    /// not exist yet, so pre-creating it would cost a full byte copy per
3891    /// artifact on every filesystem.
3892    ///
3893    /// **The snapshot's bytes are faithful; its mode is not.** Only the hardlink
3894    /// ingest shares the source's inode, and only `fs::copy` promises to carry
3895    /// the permission bits over. [`crate::link::try_reflink`] on Linux creates
3896    /// the destination with `File::create` before the FICLONE ioctl, so a
3897    /// reflinked snapshot of a 0o755 binary lands at the umask instead — which
3898    /// is how #822 silently reverted #648. Anything permission-shaped
3899    /// (`CachedFile::executable`) must be read from the source, never from
3900    /// here.
3901    fn stage_blob_from_source(
3902        &self,
3903        source: &Path,
3904        allow_hardlink: bool,
3905    ) -> Result<(PathBuf, StoreIngest)> {
3906        let dir = self.staging_dir();
3907        fs::create_dir_all(&dir)
3908            .with_context(|| format!("creating staging directory {}", dir.display()))?;
3909
3910        // Unique by construction (pid + process-wide nonce), so the path can
3911        // be left free for the zero-copy ingests below.
3912        let pid = std::process::id();
3913        let tmp = free_staging_path(|nonce| dir.join(format!("stage-{pid}-{nonce}.tmp")))
3914            .with_context(|| format!("reserving a staging name in {}", dir.display()))?;
3915
3916        let stage = |tmp: &Path, allow_hardlink: bool| -> Result<(StoreIngest, bool)> {
3917            // Short-circuit: the real reflink is only attempted when no test
3918            // emulation claimed the staging slot, and never when the
3919            // force-hardlink seam (test-only) pretends CoW is unavailable so a
3920            // same-device `.rlib` must hardlink even on APFS/btrfs.
3921            let reflink_ok = should_try_store_reflink(force_store_hardlink())
3922                && (emulate_cow_reflink_ingest(source, tmp)?
3923                    || crate::link::try_reflink(source, tmp).is_ok());
3924            let ingest = if reflink_ok {
3925                StoreIngest::Reflink
3926            } else if allow_store_hardlink(
3927                allow_hardlink,
3928                fs::symlink_metadata(source).is_ok_and(|m| m.file_type().is_file()),
3929            ) {
3930                // Refused for symlink sources: hashing followed the link, but a
3931                // hardlink would link the symlink itself — a pointer into mutable
3932                // external state, never valid for a blob (same rule as
3933                // `materialize_blob`). That refusal records as `Ineligible`
3934                // below; only a real `link(2)` errno records as
3935                // CrossDevice/Permission/Other.
3936                match try_store_hard_link(source, tmp) {
3937                    Ok(()) => StoreIngest::Hardlink,
3938                    Err(io_err) => {
3939                        let reason = StoreCopyReason::from_io_kind(io_err.kind());
3940                        {
3941                            let io_reason = match reason {
3942                                StoreCopyReason::CrossDevice => {
3943                                    crate::link::HardlinkIoReason::CrossDevice
3944                                }
3945                                StoreCopyReason::Permission => {
3946                                    crate::link::HardlinkIoReason::Permission
3947                                }
3948                                StoreCopyReason::Other | StoreCopyReason::Ineligible => {
3949                                    crate::link::HardlinkIoReason::Other
3950                                }
3951                            };
3952                            // Staging links the build output into
3953                            // `<store>/staging`: EXDEV here means the build
3954                            // tree and the cache are on different mounts.
3955                            crate::link::warn_hardlink_fallback_once(
3956                                source,
3957                                &self.staging_dir(),
3958                                io_reason,
3959                                &io_err,
3960                            );
3961                        }
3962                        fs::copy(source, tmp).with_context(|| {
3963                            format!("copying {} into store staging", source.display())
3964                        })?;
3965                        StoreIngest::Copy(reason)
3966                    }
3967                }
3968            } else {
3969                fs::copy(source, tmp)
3970                    .with_context(|| format!("copying {} into store staging", source.display()))?;
3971                StoreIngest::Copy(StoreCopyReason::Ineligible)
3972            };
3973            if self.durable_writes() {
3974                crate::atomic::fsync_file(tmp).context("flushing staged blob")?;
3975            }
3976            let mut ro_guard_failed = false;
3977            if matches!(ingest, StoreIngest::Hardlink) && set_blob_readonly_checked(tmp).is_err() {
3978                // The guard is a correctness requirement on a shared inode; a
3979                // failure demotes to a full copy rather than publishing a
3980                // writable shared blob (same recovery as `materialize_blob`).
3981                ro_guard_failed = true;
3982            }
3983            Ok((ingest, ro_guard_failed))
3984        };
3985
3986        match stage(&tmp, allow_hardlink) {
3987            Ok((ingest, false)) => Ok((tmp, ingest)),
3988            Ok((_ingest, true)) => {
3989                // Hardlink succeeded but the read-only guard did not. The temp
3990                // shares the source inode and we may have flipped it read-only:
3991                // discard the temp (clearing the shared RO bit), restore the
3992                // source writable if the blob never got published under it, and
3993                // restage as an independent copy.
3994                Self::drop_tmp_restore_source(source, &tmp);
3995                self.stage_blob_from_source(source, false).map_err(|_| {
3996                    anyhow::anyhow!("read-only guard failed on hardlinked staging temp")
3997                })
3998            }
3999            Err(first_err) => {
4000                unlink_blob(&tmp);
4001                Err(first_err)
4002            }
4003        }
4004    }
4005
4006    /// Discard a hardlinked staging temp and undo any read-only bit it may have
4007    /// left on the shared source inode.
4008    fn drop_tmp_restore_source(source: &Path, tmp: &Path) {
4009        unlink_blob(tmp);
4010        // `restore_source_writable_if_unshared` already no-ops when the two
4011        // paths still share an inode; after the unlink they never do, so the
4012        // call is unconditional by construction.
4013        restore_source_writable_if_unshared(source, tmp);
4014    }
4015
4016    /// Publish a staged snapshot onto its content-addressed path. Idempotent:
4017    /// when the blob already exists the staged file is discarded and `Ok(false)`
4018    /// is returned. The staged bytes are exactly what was hashed, so a rename
4019    /// onto `blob_path(hash)` can never contradict the recorded digest.
4020    ///
4021    /// `Ok(false)` also covers a publish that lost to a concurrent removal
4022    /// ([`PublishRename::Deferred`]): the blob is then absent, and the put's
4023    /// locked phase re-materializes it before committing a reference.
4024    fn publish_staged_blob(
4025        &self,
4026        staged: &Path,
4027        ingest: StoreIngest,
4028        hash: &str,
4029        size_bytes: u64,
4030    ) -> Result<bool> {
4031        let blob = self.blob_path(hash);
4032        let outcome = Self::publish_staged_blob_with(
4033            staged,
4034            &blob,
4035            || fs::rename(staged, &blob),
4036            crate::atomic::is_transient_rename_error,
4037        )?;
4038        if outcome != PublishRename::Published {
4039            return Ok(false);
4040        }
4041        if self.durable_writes() {
4042            let _ = crate::atomic::fsync_dir(blob.parent().unwrap());
4043        }
4044        match ingest {
4045            StoreIngest::Reflink => crate::opcounts::record_store_reflinked(size_bytes),
4046            StoreIngest::Hardlink => crate::opcounts::record_store_hardlinked(size_bytes),
4047            StoreIngest::Copy(reason) => {
4048                crate::opcounts::record_store_copied(size_bytes);
4049                record_store_copy_reason(reason, size_bytes);
4050            }
4051        }
4052        set_blob_readonly(&blob);
4053        Ok(true)
4054    }
4055
4056    /// The rename half of [`Self::publish_staged_blob`], with the rename and
4057    /// its transient classifier passed in so a test can stage the Windows
4058    /// failures. The staged file is gone afterwards unless it became the blob.
4059    fn publish_staged_blob_with(
4060        staged: &Path,
4061        blob: &Path,
4062        rename: impl FnMut() -> std::io::Result<()>,
4063        is_transient: impl Fn(&std::io::Error) -> bool,
4064    ) -> Result<PublishRename> {
4065        if blob.is_file() {
4066            Self::discard_staged_blob(staged);
4067            return Ok(PublishRename::LostRace);
4068        }
4069        fs::create_dir_all(blob.parent().unwrap()).context("creating blob shard directory")?;
4070        // This runs outside the SQLite write lock, so a concurrent remove can
4071        // unlink the blob, and a concurrent put can publish it, at any point.
4072        // `publish_rename` waits out the states that clear on their own and
4073        // keeps the staged file alive between attempts.
4074        let outcome = publish_rename(rename, || publish_dest_state(blob), is_transient);
4075        if !matches!(outcome, Ok(PublishRename::Published)) {
4076            tracing::debug!("{} not published by this put: {outcome:?}", blob.display());
4077            Self::discard_staged_blob(staged);
4078        }
4079        outcome
4080    }
4081
4082    /// Discard a staging snapshot (best effort; the staging sweep reclaims any
4083    /// file this fails on).
4084    fn discard_staged_blob(staged: &Path) {
4085        unlink_blob(staged);
4086    }
4087
4088    /// Phase-2 race recovery: if a concurrent remove unlinked this blob after
4089    /// phase 1, re-materialize it from the live source — but only under its
4090    /// recorded digest. The re-ingest reads the source, which may have been
4091    /// mutated since phase 1's snapshot; storing those bytes under the old
4092    /// address would poison the store, so a mismatch bails (rolling back the
4093    /// transaction) instead.
4094    fn rematerialize_and_verify(
4095        &self,
4096        source: &Path,
4097        hash: &str,
4098        store_name: &str,
4099        allow_hardlink: bool,
4100    ) -> Result<()> {
4101        let blob_path = self.blob_path(hash);
4102        if materialize_blob(source, &blob_path, allow_hardlink)? {
4103            let actual = crate::file_hash::hash_file(&blob_path)?;
4104            if actual != hash {
4105                anyhow::bail!(
4106                    "re-materialized blob for {} hashes to {} but entry records {}; \
4107                     refusing to commit",
4108                    store_name,
4109                    actual,
4110                    hash
4111                );
4112            }
4113        }
4114        Ok(())
4115    }
4116
4117    /// Reclaim crash-orphaned staging files older than `min_age`. A put killed
4118    /// between staging and publish leaves its snapshot here; unlike an orphaned
4119    /// blob it has no DB row to consult, so age is the only liveness signal —
4120    /// see [`STAGING_SWEEP_GRACE`] for why every caller wants the same one.
4121    pub fn sweep_stale_staging(&self, min_age: Duration) -> OrphanSweepStats {
4122        if self.read_only {
4123            return OrphanSweepStats::default();
4124        }
4125        let mut stats = OrphanSweepStats::default();
4126        let dir = self.staging_dir();
4127        let Ok(entries) = fs::read_dir(&dir) else {
4128            return stats;
4129        };
4130        let now = std::time::SystemTime::now();
4131        for entry in entries.flatten() {
4132            let Ok(meta) = entry.metadata() else { continue };
4133            if !meta.is_file() {
4134                continue;
4135            }
4136            let age_ok = meta
4137                .modified()
4138                .ok()
4139                .and_then(|m| now.duration_since(m).ok())
4140                .is_some_and(|age| age >= min_age);
4141            if !age_ok {
4142                continue;
4143            }
4144            stats.scanned += 1;
4145            let size = meta.len();
4146            // Staging temps may be hardlinked (and therefore read-only); clear
4147            // that before unlinking. Counted only when the file is really gone,
4148            // so Windows sharing violations don't over-claim reclaimed bytes.
4149            let removed = (|| -> std::io::Result<()> {
4150                let mut perms = meta.permissions();
4151                perms.set_readonly(false);
4152                fs::set_permissions(entry.path(), perms)?;
4153                fs::remove_file(entry.path())
4154            })()
4155            .is_ok();
4156            if removed {
4157                stats.removed += 1;
4158                stats.bytes_reclaimed += size;
4159            }
4160        }
4161        stats
4162    }
4163
4164    /// Unlink key lock files nobody needs: no entry row for the key, unused
4165    /// for `min_age`, and not held. At most `cap` per call; the rest wait for
4166    /// the next sweep. The caller holds `gc.lock`, so one sweeper runs at a
4167    /// time.
4168    ///
4169    /// Each file is unlinked while this process holds its OS lock, and its
4170    /// age is read from the locked handle, so a claim that slipped in before
4171    /// the lock is seen. A contender that opened the file before the unlink
4172    /// finds the path changed once it gets the lock and reopens
4173    /// ([`StoreLock::acquire_current`]).
4174    pub fn sweep_stale_key_locks(
4175        &self,
4176        min_age: Duration,
4177        cap: usize,
4178    ) -> Result<KeyLockSweepStats> {
4179        self.sweep_stale_key_locks_at(min_age, cap, std::time::SystemTime::now())
4180    }
4181
4182    fn sweep_stale_key_locks_at(
4183        &self,
4184        min_age: Duration,
4185        cap: usize,
4186        now: std::time::SystemTime,
4187    ) -> Result<KeyLockSweepStats> {
4188        self.refuse_write("sweep key locks")?;
4189        let mut stats = KeyLockSweepStats::default();
4190        let Ok(names) = fs::read_dir(self.config.store_dir()) else {
4191            return Ok(stats);
4192        };
4193        let live: std::collections::HashSet<String> = self
4194            .db
4195            .prepare("SELECT cache_key FROM entries")?
4196            .query_map([], |row| row.get(0))?
4197            .collect::<rusqlite::Result<_>>()?;
4198        for name in names.flatten() {
4199            let name = name.file_name();
4200            let Some(key) = name.to_str().and_then(key_of_lock_name) else {
4201                continue;
4202            };
4203            stats.seen += 1;
4204            // Past the cap the walk only counts, which costs no stat.
4205            if stats.removed >= cap || live.contains(key) {
4206                continue;
4207            }
4208            if remove_stale_lock_file(&self.config.store_dir().join(&name), min_age, now, || {}) {
4209                stats.removed += 1;
4210            }
4211        }
4212        Ok(stats)
4213    }
4214
4215    /// The per-key structures nothing else bounds: stale key lock files,
4216    /// unused input predictions and old file hash rows. The caller holds `gc.lock`. A pass that fails
4217    /// is logged and counts as zero; the next sweep tries again.
4218    pub fn sweep_housekeeping(&self) -> HousekeepingStats {
4219        if self.read_only {
4220            return HousekeepingStats::default();
4221        }
4222        let locks = self
4223            .sweep_stale_key_locks(KEY_LOCK_SWEEP_GRACE, KEY_LOCK_SWEEP_CAP)
4224            .unwrap_or_else(|error| {
4225                tracing::warn!("gc: key lock sweep failed: {error:#}");
4226                KeyLockSweepStats::default()
4227            });
4228        let predictions_pruned = self
4229            .file_hash_cache()
4230            .prune_input_predictions()
4231            .unwrap_or_else(|error| {
4232                tracing::warn!("gc: input prediction pruning failed: {error}");
4233                0
4234            });
4235        let file_hashes_pruned =
4236            self.file_hash_cache()
4237                .prune_file_hashes()
4238                .unwrap_or_else(|error| {
4239                    tracing::warn!("gc: file hash pruning failed: {error}");
4240                    0
4241                });
4242        // After the prune, so the one-time copy is as small as it gets.
4243        match self.file_hash_cache().rebuild_file_hashes_without_rowid() {
4244            Ok(true) => tracing::info!("gc: rebuilt file_hashes without rowid"),
4245            Ok(false) => {}
4246            Err(error) => tracing::warn!("gc: file hash table rebuild failed: {error}"),
4247        }
4248        HousekeepingStats {
4249            key_locks_removed: locks.removed,
4250            key_locks_remaining: locks.remaining(),
4251            predictions_pruned,
4252            file_hashes_pruned,
4253        }
4254    }
4255
4256    /// Cache dir this store was opened with (`blobs/`, `index.db`, `store/`).
4257    pub fn cache_dir(&self) -> &std::path::Path {
4258        &self.config.cache_dir
4259    }
4260
4261    /// Get the directory for a cache entry.
4262    /// Whether puts flush on the build path. Off under deferred durability,
4263    /// where [`Store::flush_durability`] flushes later.
4264    fn durable_writes(&self) -> bool {
4265        !self.config.deferred_durability
4266    }
4267
4268    /// Whether an entry's blobs and metadata have reached disk. Unknown rows
4269    /// read as durable: the size check and the verification policy still apply.
4270    fn entry_is_durable(&self, cache_key: &str) -> bool {
4271        self.db
4272            .query_row(
4273                "SELECT durable FROM entries WHERE cache_key = ?1",
4274                params![cache_key],
4275                |row| row.get::<_, bool>(0),
4276            )
4277            .unwrap_or(true)
4278    }
4279
4280    /// Entries stored without an fsync that no flush has reached yet.
4281    pub fn pending_durability(&self) -> Result<u64> {
4282        Ok(self.db.query_row(
4283            "SELECT count(*) FROM entries WHERE committed = 1 AND durable = 0",
4284            [],
4285            |row| row.get::<_, i64>(0),
4286        )? as u64)
4287    }
4288
4289    /// Flush up to `limit` entries stored without an fsync: every blob and
4290    /// its shard directory, then the entry's `meta.json` and directory, and
4291    /// mark them durable. An entry whose blob went missing meanwhile is
4292    /// evicted instead. Returns how many entries were flushed.
4293    pub fn flush_durability(&self, limit: usize) -> Result<usize> {
4294        self.refuse_write("flush durability")?;
4295        let keys: Vec<String> = {
4296            let mut stmt = self.db.prepare_cached(
4297                "SELECT cache_key FROM entries WHERE committed = 1 AND durable = 0
4298                 ORDER BY created_at LIMIT ?1",
4299            )?;
4300            let rows = stmt.query_map(params![limit as i64], |row| row.get::<_, String>(0))?;
4301            rows.collect::<Result<_, _>>()?
4302        };
4303        let mut flushed = 0;
4304        for key in keys {
4305            if self.flush_entry_durability(&key)? {
4306                flushed += 1;
4307            }
4308        }
4309        Ok(flushed)
4310    }
4311
4312    /// Flush one entry stored without an fsync and mark it durable. `Ok(false)`
4313    /// when the entry was already durable or had to be evicted.
4314    pub fn flush_entry_durability(&self, cache_key: &str) -> Result<bool> {
4315        self.refuse_write("flush entry durability")?;
4316        if self.entry_is_durable(cache_key) {
4317            return Ok(false);
4318        }
4319        let entry_dir = self.entry_dir(cache_key);
4320        let meta_path = entry_dir.join("meta.json");
4321        let meta: EntryMeta = match fs::read_to_string(&meta_path)
4322            .ok()
4323            .and_then(|json| serde_json::from_str(&json).ok())
4324        {
4325            Some(meta) => meta,
4326            None => {
4327                tracing::warn!(
4328                    "cache entry {} has no readable meta.json to flush, evicting",
4329                    cache_key.get(..16).unwrap_or(cache_key)
4330                );
4331                let _ = self.remove_entry(cache_key);
4332                return Ok(false);
4333            }
4334        };
4335        for file in &meta.files {
4336            let blob = self.blob_path(&file.hash);
4337            if let Err(error) = fsync_published_blob(&blob) {
4338                // A blob that is gone takes the entry with it; anything else
4339                // (a busy handle, a transient IO error) leaves the entry
4340                // pending, to be flushed by a later worker or by GC. An
4341                // unflushed entry is still served, with its bytes verified.
4342                if error.kind() == std::io::ErrorKind::NotFound {
4343                    tracing::warn!(
4344                        "cache entry {} blob {} vanished before it was flushed, evicting",
4345                        cache_key.get(..16).unwrap_or(cache_key),
4346                        file.name
4347                    );
4348                    let _ = self.remove_entry(cache_key);
4349                } else {
4350                    tracing::debug!(
4351                        "cache entry {} blob {} could not be flushed ({error}); still pending",
4352                        cache_key.get(..16).unwrap_or(cache_key),
4353                        file.name
4354                    );
4355                }
4356                return Ok(false);
4357            }
4358            if let Some(parent) = blob.parent() {
4359                let _ = crate::atomic::fsync_dir(parent);
4360            }
4361        }
4362        fsync_published_blob(&meta_path).context("flushing entry metadata")?;
4363        let _ = crate::atomic::fsync_dir(&entry_dir);
4364        self.db.execute(
4365            "UPDATE entries SET durable = 1 WHERE cache_key = ?1",
4366            params![cache_key],
4367        )?;
4368        Ok(true)
4369    }
4370
4371    pub fn entry_dir(&self, cache_key: &str) -> PathBuf {
4372        self.config.store_dir().join(cache_key)
4373    }
4374
4375    /// Calculate the total size of the store.
4376    pub fn total_size(&self) -> Result<u64> {
4377        let size: i64 =
4378            self.db
4379                .query_row("SELECT COALESCE(SUM(size), 0) FROM entries", [], |row| {
4380                    row.get(0)
4381                })?;
4382        Ok(size as u64)
4383    }
4384
4385    /// Registered blob content bytes: `SUM(blobs.size)`, each deduplicated
4386    /// blob counted once. This — not [`Self::total_size`]'s logical
4387    /// per-entry sum — is what `max_size` bounds and what size pressure is
4388    /// measured against: the two diverge by exactly the dedup savings, which
4389    /// is largest in the cross-clone/worktree stores kache is aimed at
4390    /// (kunobi-ninja/kache#608). Not literally every byte under the cache
4391    /// dir: SQLite, meta.json files, and any blob whose best-effort unlink
4392    /// was deferred sit outside this sum.
4393    pub fn physical_size(&self) -> Result<u64> {
4394        let size: i64 =
4395            self.db
4396                .query_row("SELECT COALESCE(SUM(size), 0) FROM blobs", [], |row| {
4397                    row.get(0)
4398                })?;
4399        Ok(size as u64)
4400    }
4401
4402    /// Every indexed blob of at least `min_size` bytes, as `(hash, size)`.
4403    /// One query, where listing the blob directories would stat every file.
4404    pub fn blobs_at_least(&self, min_size: u64) -> Result<Vec<(String, u64)>> {
4405        let mut stmt = self
4406            .db
4407            .prepare("SELECT hash, size FROM blobs WHERE size >= ?1")?;
4408        let rows = stmt.query_map([i64::try_from(min_size).unwrap_or(i64::MAX)], |row| {
4409            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
4410        })?;
4411        let mut blobs = Vec::new();
4412        for row in rows {
4413            let (hash, size) = row?;
4414            blobs.push((hash, u64::try_from(size).unwrap_or(0)));
4415        }
4416        Ok(blobs)
4417    }
4418
4419    /// Get the number of entries in the store.
4420    pub fn entry_count(&self) -> Result<usize> {
4421        let count: i64 = self
4422            .db
4423            .query_row("SELECT COUNT(*) FROM entries", [], |row| row.get(0))?;
4424        Ok(count as usize)
4425    }
4426
4427    /// Record which unit a freshly stored entry came from, by Cargo's
4428    /// `-C metadata` hash. Every cache key folds that hash in, so a store
4429    /// with no row for (crate name, unit) holds no key the unit can produce;
4430    /// the crate-presence probe behind deferred discovery reads it.
4431    pub fn record_entry_unit(&self, cache_key: &str, unit: &str) -> Result<()> {
4432        self.refuse_write("record a unit")?;
4433        if unit.is_empty() {
4434            return Ok(());
4435        }
4436        self.db.execute(
4437            "UPDATE entries SET unit_id = ?2 WHERE cache_key = ?1",
4438            params![cache_key, unit],
4439        )?;
4440        Ok(())
4441    }
4442
4443    /// Remember an incremental compilation directory seen by the wrapper.
4444    pub fn remember_incremental_dir(&self, path: &Path) -> Result<()> {
4445        self.refuse_write("record an incremental directory")?;
4446        let path = path.to_string_lossy().into_owned();
4447        self.db.execute(
4448            "INSERT OR REPLACE INTO incremental_dirs (path, last_seen) VALUES (?1, datetime('now'))",
4449            params![path],
4450        )?;
4451        Ok(())
4452    }
4453
4454    /// Remember a Cargo target root without putting absolute paths in cache
4455    /// entries or remote data. Updates are debounced to keep compiler-wrapper
4456    /// writes off the hot path.
4457    pub fn remember_target_root(&self, target: &Path, workspace_root: &Path) -> Result<()> {
4458        self.remember_target_root_built_by(target, workspace_root, None)
4459    }
4460
4461    /// [`Self::remember_target_root`], also recording the `rustc -vV` that
4462    /// built into it. `None` keeps whatever was recorded.
4463    pub fn remember_target_root_built_by(
4464        &self,
4465        target: &Path,
4466        workspace_root: &Path,
4467        rustc: Option<&str>,
4468    ) -> Result<()> {
4469        self.refuse_write("record a target root")?;
4470        if !crate::filesystem::target_root_is_safe(target, workspace_root) {
4471            return Ok(());
4472        }
4473        let target = crate::filesystem::registry_path(target)?;
4474        let workspace_root = crate::filesystem::registry_path(workspace_root)?;
4475        let Some(identity) = crate::filesystem::directory_identity(&target) else {
4476            return Ok(());
4477        };
4478        // The upsert below refuses to touch a fresh, unchanged row, but even
4479        // a refused upsert takes the index's write lock; read first so a
4480        // warm target directory costs one query per invocation, not a wait
4481        // behind whichever miss is storing.
4482        let fresh: Option<bool> = self
4483            .db
4484            .query_row(
4485                "SELECT discovered = 0 AND last_seen > unixepoch() - 300
4486                    AND workspace_root = ?2 AND device = ?3 AND inode = ?4
4487                    AND (?5 IS NULL OR rustc IS ?5)
4488                 FROM target_roots WHERE path = ?1",
4489                params![
4490                    target.to_string_lossy(),
4491                    workspace_root.to_string_lossy(),
4492                    identity.device.to_string(),
4493                    identity.inode.to_string(),
4494                    rustc,
4495                ],
4496                |row| row.get(0),
4497            )
4498            .optional()?;
4499        if fresh == Some(true) {
4500            return Ok(());
4501        }
4502        // The row and the removal of its aliases commit together, so another
4503        // registration cannot slip between them and lose its row to ours.
4504        let in_transaction = !self.db.is_autocommit();
4505        if !in_transaction {
4506            self.db.execute_batch("BEGIN IMMEDIATE")?;
4507        }
4508        let written = self.record_target_root(&target, &workspace_root, identity, rustc);
4509        if !in_transaction {
4510            finish_transaction(&self.db, written.is_ok())?;
4511        }
4512        written
4513    }
4514
4515    /// The upsert and alias cleanup of [`Self::remember_target_root_built_by`].
4516    fn record_target_root(
4517        &self,
4518        target: &Path,
4519        workspace_root: &Path,
4520        identity: crate::filesystem::PathIdentity,
4521        rustc: Option<&str>,
4522    ) -> Result<()> {
4523        // The directory may have been replaced or moved since its identity
4524        // was read; a newer registration of it then wins.
4525        if crate::filesystem::directory_identity(target) != Some(identity) {
4526            return Ok(());
4527        }
4528        self.db.execute(
4529            "INSERT INTO target_roots
4530                (path, workspace_root, first_seen, last_seen, device, inode, rustc, discovered)
4531             VALUES (?1, ?2, unixepoch(), unixepoch(), ?3, ?4, ?5, 0)
4532             ON CONFLICT(path) DO UPDATE SET
4533                workspace_root = excluded.workspace_root,
4534                last_seen = unixepoch(),
4535                device = excluded.device,
4536                inode = excluded.inode,
4537                rustc = COALESCE(excluded.rustc, target_roots.rustc),
4538                discovered = 0
4539             WHERE target_roots.last_seen <= unixepoch() - 300
4540                OR target_roots.workspace_root != excluded.workspace_root
4541                OR target_roots.device != excluded.device
4542                OR target_roots.inode != excluded.inode
4543                OR (excluded.rustc IS NOT NULL AND target_roots.rustc IS NOT excluded.rustc)
4544                OR target_roots.discovered != 0",
4545            params![
4546                target.to_string_lossy(),
4547                workspace_root.to_string_lossy(),
4548                identity.device.to_string(),
4549                identity.inode.to_string(),
4550                rustc,
4551            ],
4552        )?;
4553        // One row per directory. Git may report a canonical /private path
4554        // while Cargo used a /var or /tmp spelling, and rows recorded before
4555        // paths were normalized may spell it through `..`. The build row is
4556        // authoritative; discard every alias by inode.
4557        self.db.execute(
4558            "DELETE FROM target_roots
4559             WHERE path != ?1 AND device = ?2 AND inode = ?3",
4560            params![
4561                target.to_string_lossy(),
4562                identity.device.to_string(),
4563                identity.inode.to_string(),
4564            ],
4565        )?;
4566        // A registration that got here wrote its row; prune the registry.
4567        self.db.execute(
4568            "DELETE FROM target_roots WHERE last_seen < unixepoch() - 15552000",
4569            [],
4570        )?;
4571        self.db.execute(
4572            "DELETE FROM target_roots WHERE path IN (
4573                SELECT path FROM target_roots
4574                ORDER BY discovered ASC, last_seen DESC, path ASC
4575                LIMIT -1 OFFSET 2048
4576            )",
4577            [],
4578        )?;
4579        Ok(())
4580    }
4581
4582    /// Record a Cargo target found in a sibling Git worktree without claiming
4583    /// that a kache build used it. Never refresh an existing row's last use.
4584    pub fn remember_discovered_target_root(
4585        &self,
4586        target: &Path,
4587        workspace_root: &Path,
4588    ) -> Result<bool> {
4589        self.refuse_write("record a discovered target root")?;
4590        if !crate::filesystem::target_root_is_safe(target, workspace_root) {
4591            return Ok(false);
4592        }
4593        let target = crate::filesystem::registry_path(target)?;
4594        let workspace_root = crate::filesystem::registry_path(workspace_root)?;
4595        let Some(identity) = crate::filesystem::directory_identity(&target) else {
4596            return Ok(false);
4597        };
4598        let inserted = self.db.execute(
4599            "INSERT INTO target_roots
4600                (path, workspace_root, first_seen, last_seen, device, inode, discovered)
4601             VALUES (?1, ?2, unixepoch(), unixepoch(), ?3, ?4, 1)
4602             ON CONFLICT(path) DO UPDATE SET
4603                workspace_root = excluded.workspace_root,
4604                first_seen = unixepoch(),
4605                last_seen = unixepoch(),
4606                device = excluded.device,
4607                inode = excluded.inode
4608             WHERE target_roots.discovered != 0 AND (
4609                target_roots.workspace_root != excluded.workspace_root OR
4610                target_roots.device != excluded.device OR
4611                target_roots.inode != excluded.inode
4612             )",
4613            params![
4614                target.to_string_lossy(),
4615                workspace_root.to_string_lossy(),
4616                identity.device.to_string(),
4617                identity.inode.to_string(),
4618            ],
4619        )?;
4620        let inserted = inserted != 0;
4621        // A Git repository can expose many worktrees, but it must not crowd
4622        // targets whose builds kache actually observed out of the registry.
4623        self.db.execute(
4624            "DELETE FROM target_roots WHERE path IN (
4625                SELECT path FROM target_roots
4626                ORDER BY discovered ASC, last_seen DESC, path ASC
4627                LIMIT -1 OFFSET 2048
4628            )",
4629            [],
4630        )?;
4631        Ok(inserted)
4632    }
4633
4634    pub fn tracked_target_roots(&self, stale_hours: u64) -> Result<Vec<TrackedTargetRoot>> {
4635        let stale_seconds = stale_hours.saturating_mul(3600).min(i64::MAX as u64) as i64;
4636        let mut stmt = self.db.prepare(
4637            "SELECT path, workspace_root, first_seen, last_seen, device, inode, rustc, discovered
4638             FROM target_roots
4639             WHERE last_seen <= unixepoch() - ?1
4640             ORDER BY last_seen ASC, path ASC",
4641        )?;
4642        let rows = stmt.query_map(params![stale_seconds], |row| {
4643            let device: String = row.get(4)?;
4644            let inode: String = row.get(5)?;
4645            Ok((
4646                row.get::<_, String>(0)?,
4647                row.get::<_, String>(1)?,
4648                row.get::<_, i64>(2)?,
4649                row.get::<_, i64>(3)?,
4650                device,
4651                inode,
4652                row.get::<_, Option<String>>(6)?,
4653                row.get::<_, bool>(7)?,
4654            ))
4655        })?;
4656        let mut targets = Vec::new();
4657        for row in rows {
4658            let (path, workspace_root, first_seen, last_seen, device, inode, rustc, discovered) =
4659                row?;
4660            let (Ok(device), Ok(inode)) = (device.parse::<u64>(), inode.parse::<u64>()) else {
4661                continue;
4662            };
4663            let path = PathBuf::from(path);
4664            let recorded = crate::filesystem::PathIdentity { device, inode };
4665            let identity = self.renumbered_device(&path, recorded);
4666            targets.push(TrackedTargetRoot {
4667                path,
4668                workspace_root: PathBuf::from(workspace_root),
4669                first_seen,
4670                last_seen,
4671                identity,
4672                rustc,
4673                discovered,
4674            });
4675        }
4676        Ok(targets)
4677    }
4678
4679    /// `recorded`, with the device number `path` has now when only that
4680    /// changed. macOS can number an APFS volume differently after a restart,
4681    /// so the same directory, at the same path with the same inode, would
4682    /// otherwise read as replaced: hidden from `kache targets` and forgotten
4683    /// by cleanup. A replaced directory gets a new inode and stays replaced.
4684    /// The row is updated too, unless the store is read-only.
4685    fn renumbered_device(
4686        &self,
4687        path: &Path,
4688        recorded: crate::filesystem::PathIdentity,
4689    ) -> crate::filesystem::PathIdentity {
4690        let Some(now) = crate::filesystem::directory_identity(path) else {
4691            return recorded;
4692        };
4693        if !crate::filesystem::renumbered(recorded, now) {
4694            return recorded;
4695        }
4696        if !self.read_only
4697            && let Err(error) = self.db.execute(
4698                "UPDATE target_roots SET device = ?1
4699                 WHERE path = ?2 AND device = ?3 AND inode = ?4",
4700                params![
4701                    now.device.to_string(),
4702                    path.to_string_lossy(),
4703                    recorded.device.to_string(),
4704                    recorded.inode.to_string(),
4705                ],
4706            )
4707        {
4708            tracing::debug!(
4709                "could not record the new device of target root {}: {error:#}",
4710                path.display()
4711            );
4712        }
4713        now
4714    }
4715
4716    /// When a build or Git discovery last recorded `path`, if it is tracked.
4717    pub fn target_root_last_seen(&self, path: &Path) -> Result<Option<i64>> {
4718        Ok(self
4719            .db
4720            .query_row(
4721                "SELECT last_seen FROM target_roots WHERE path = ?1",
4722                params![path.to_string_lossy()],
4723                |row| row.get(0),
4724            )
4725            .optional()?)
4726    }
4727
4728    pub fn forget_target_root(&self, path: &Path) -> Result<()> {
4729        self.refuse_write("forget a target root")?;
4730        self.db.execute(
4731            "DELETE FROM target_roots WHERE path = ?1",
4732            params![path.to_string_lossy()],
4733        )?;
4734        Ok(())
4735    }
4736
4737    /// Forget the rows that record the directory `identity` names, under any
4738    /// spelling of its path, once a clean removed it. A row whose path still
4739    /// exists stays: that is another directory, such as one a build created
4740    /// since, that the filesystem gave the same inode.
4741    pub fn forget_target_roots_with_identity(
4742        &self,
4743        identity: crate::filesystem::PathIdentity,
4744    ) -> Result<()> {
4745        self.refuse_write("forget a target root")?;
4746        // Check and delete in one write transaction: a build that recreates
4747        // the path either registered first, so the path exists and its row
4748        // stays, or waits and records a fresh row after this one is gone.
4749        let in_transaction = !self.db.is_autocommit();
4750        if !in_transaction {
4751            self.db.execute_batch("BEGIN IMMEDIATE")?;
4752        }
4753        let forgotten = self.forget_gone_rows_with_identity(identity);
4754        if !in_transaction {
4755            finish_transaction(&self.db, forgotten.is_ok())?;
4756        }
4757        forgotten
4758    }
4759
4760    fn forget_gone_rows_with_identity(
4761        &self,
4762        identity: crate::filesystem::PathIdentity,
4763    ) -> Result<()> {
4764        let paths: Vec<String> = {
4765            let mut stmt = self
4766                .db
4767                .prepare("SELECT path FROM target_roots WHERE device = ?1 AND inode = ?2")?;
4768            stmt.query_map(
4769                params![identity.device.to_string(), identity.inode.to_string()],
4770                |row| row.get(0),
4771            )?
4772            .collect::<std::result::Result<_, _>>()?
4773        };
4774        for path in paths {
4775            let gone = std::fs::symlink_metadata(&path)
4776                .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound);
4777            if gone {
4778                self.forget_target_root_with_identity(Path::new(&path), identity)?;
4779            }
4780        }
4781        Ok(())
4782    }
4783
4784    /// [`Self::forget_target_root`], only while the row still records
4785    /// `identity`. A build may record a new directory at `path` after the
4786    /// caller read the row; that row stays.
4787    pub fn forget_target_root_with_identity(
4788        &self,
4789        path: &Path,
4790        identity: crate::filesystem::PathIdentity,
4791    ) -> Result<()> {
4792        self.refuse_write("forget a target root")?;
4793        self.db.execute(
4794            "DELETE FROM target_roots WHERE path = ?1 AND device = ?2 AND inode = ?3",
4795            params![
4796                path.to_string_lossy(),
4797                identity.device.to_string(),
4798                identity.inode.to_string(),
4799            ],
4800        )?;
4801        Ok(())
4802    }
4803
4804    /// Remove registered incremental directories and prune stale registry rows.
4805    pub fn clean_registered_incremental_dirs(&self) -> Result<usize> {
4806        self.refuse_write("clean incremental directories")?;
4807        let paths: Vec<String> = {
4808            let mut stmt = self
4809                .db
4810                .prepare("SELECT path FROM incremental_dirs ORDER BY last_seen ASC")?;
4811            stmt.query_map([], |row| row.get(0))?
4812                .collect::<Result<Vec<_>, _>>()?
4813        };
4814
4815        let mut cleaned = 0;
4816        for path_str in paths {
4817            let path = PathBuf::from(&path_str);
4818            if !path.exists() {
4819                self.db.execute(
4820                    "DELETE FROM incremental_dirs WHERE path = ?1",
4821                    params![path_str],
4822                )?;
4823                continue;
4824            }
4825
4826            if !path.is_dir() {
4827                tracing::warn!(
4828                    "registered incremental path is not a directory, pruning: {}",
4829                    path.display()
4830                );
4831                self.db.execute(
4832                    "DELETE FROM incremental_dirs WHERE path = ?1",
4833                    params![path_str],
4834                )?;
4835                continue;
4836            }
4837
4838            match fs::remove_dir_all(&path) {
4839                Ok(()) => {
4840                    self.db.execute(
4841                        "DELETE FROM incremental_dirs WHERE path = ?1",
4842                        params![path_str],
4843                    )?;
4844                    cleaned += 1;
4845                }
4846                Err(e) => {
4847                    tracing::warn!(
4848                        "failed to remove registered incremental dir {}: {}",
4849                        path.display(),
4850                        e
4851                    );
4852                }
4853            }
4854        }
4855
4856        Ok(cleaned)
4857    }
4858
4859    /// Materialize every entry's eviction-relevant features in one pass.
4860    ///
4861    /// Selection used to be three separate `SELECT`s embedded in three removal
4862    /// loops; it is now a pure function over these features
4863    /// (kunobi-ninja/kache#595). The size-pressure sweep already loaded every
4864    /// row, so this is the same I/O shape it always had.
4865    pub fn eviction_candidates(&self) -> Result<Vec<crate::eviction::EntryFeatures>> {
4866        self.eviction_candidates_for(SweepOrigin::Requested)
4867    }
4868
4869    /// [`Self::eviction_candidates`], with each entry's import protection
4870    /// judged for a sweep started by `origin`.
4871    pub fn eviction_candidates_for(
4872        &self,
4873        origin: SweepOrigin,
4874    ) -> Result<Vec<crate::eviction::EntryFeatures>> {
4875        let mut stmt = self.db.prepare(
4876            "SELECT cache_key, size, hit_count, content_hash, committed,
4877                    (julianday('now') - julianday(last_accessed)) * 24.0,
4878                    compile_time_ms,
4879                    (SELECT COALESCE(SUM(b.size), 0)
4880                       FROM entry_blobs eb JOIN blobs b ON b.hash = eb.hash
4881                      WHERE eb.cache_key = entries.cache_key
4882                        AND eb.refs = b.refcount),
4883                    EXISTS(SELECT 1 FROM entry_blobs eb2
4884                            WHERE eb2.cache_key = entries.cache_key),
4885                    last_accessed >= datetime('now', ?1),
4886                    COALESCE(imported_at >= unixepoch() - ?2, 0)
4887             FROM entries",
4888        )?;
4889        let rows = stmt
4890            .query_map(
4891                params![
4892                    format!("-{} seconds", EVICTION_IDLE_GRACE.as_secs()),
4893                    origin.import_pin_secs()
4894                ],
4895                |row| {
4896                    // Bytes this entry would actually free: blobs where it holds
4897                    // every remaining reference (#608). Entries not yet backfilled
4898                    // into entry_blobs report None and rank on logical size as
4899                    // before.
4900                    let has_blob_rows: bool = row.get(8)?;
4901                    let reclaimable_bytes = if has_blob_rows {
4902                        Some(row.get::<_, i64>(7)?)
4903                    } else {
4904                        None
4905                    };
4906                    Ok(crate::eviction::EntryFeatures {
4907                        key: row.get(0)?,
4908                        size: row.get(1)?,
4909                        hit_count: row.get(2)?,
4910                        content_hash: row.get(3)?,
4911                        committed: row.get(4)?,
4912                        // NULL/unparseable timestamps yield NULL from julianday();
4913                        // treat those as "just accessed" so a malformed row is
4914                        // never evicted ahead of a genuinely stale one.
4915                        idle_hours: row.get::<_, Option<f64>>(5)?.unwrap_or(0.0),
4916                        compile_time_ms: row.get(6)?,
4917                        reclaimable_bytes,
4918                        recently_accessed: row.get(9)?,
4919                        recently_imported: row.get(10)?,
4920                    })
4921                },
4922            )?
4923            .collect::<Result<Vec<_>, _>>()?;
4924        Ok(rows)
4925    }
4926
4927    /// Cache keys whose local payload backs a durable upload intent.
4928    ///
4929    /// The spool file is the durability boundary: once `<key>.json` exists,
4930    /// every eviction policy must retain that entry until the upload path
4931    /// retires the file. Read errors abort the sweep rather than treating an
4932    /// unreadable spool as empty and destroying data needed for replay.
4933    fn durable_upload_keys(&self) -> Result<std::collections::HashSet<String>> {
4934        let dir = self.config.upload_spool_dir();
4935        let entries = match fs::read_dir(&dir) {
4936            Ok(entries) => entries,
4937            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
4938                return Ok(std::collections::HashSet::new());
4939            }
4940            Err(error) => {
4941                return Err(error).with_context(|| format!("reading {}", dir.display()));
4942            }
4943        };
4944        Self::durable_upload_keys_from_names(
4945            entries.map(|entry| entry.map(|entry| entry.file_name())),
4946            self.config.upload_spool_max_jobs,
4947        )
4948        .with_context(|| format!("reading {}", dir.display()))
4949    }
4950
4951    fn durable_upload_keys_from_names<I>(
4952        names: I,
4953        max_jobs: usize,
4954    ) -> Result<std::collections::HashSet<String>>
4955    where
4956        I: IntoIterator<Item = std::io::Result<std::ffi::OsString>>,
4957    {
4958        let mut keys = std::collections::HashSet::new();
4959        for (index, file_name) in names.into_iter().enumerate() {
4960            if index >= max_jobs {
4961                anyhow::bail!("upload spool exceeds {max_jobs} jobs; refusing eviction");
4962            }
4963            let file_name = file_name.context("reading upload spool entry")?;
4964            let Some(file_name) = file_name.to_str() else {
4965                continue;
4966            };
4967            let Some(key) = file_name.strip_suffix(".json") else {
4968                continue;
4969            };
4970            if kache_format::is_valid_cache_key(key) {
4971                keys.insert(key.to_string());
4972            }
4973        }
4974        Ok(keys)
4975    }
4976
4977    /// Remove a policy's selection, in order, under the active/durable pin guards.
4978    ///
4979    /// This is the *mechanism* half: the grace check, blob refcount decrement,
4980    /// and refuse-on-corrupt-meta guard all live in `remove_entry_guarded` and
4981    /// are deliberately not reachable from a policy. `stop_at` bounds a
4982    /// size-driven sweep; `None` removes everything selected.
4983    fn apply_eviction(
4984        &self,
4985        order: &[String],
4986        by_key: &std::collections::HashMap<&str, &crate::eviction::EntryFeatures>,
4987        policy: &str,
4988        stop_at: Option<(u64, u64)>,
4989        shadow: Option<&ShadowSelection>,
4990        durable_upload_keys: &std::collections::HashSet<String>,
4991        held: &std::collections::HashMap<String, u64>,
4992        stop: &mut dyn FnMut() -> bool,
4993    ) -> GcStats {
4994        let mut stats = GcStats::default();
4995        let mut eviction_writes = std::time::Duration::ZERO;
4996        let (slice, pause) = self.eviction_pacing;
4997        let mut pacer = EvictionWritePacer::new(slice, pause);
4998        let (mut current_size, target) = match stop_at {
4999            Some((current, target)) => (current, Some(target)),
5000            None => (0, None),
5001        };
5002        let mut evicted = std::collections::HashSet::new();
5003        // The shadow's victims are stamped with the sweep's start, so the
5004        // sweep's own tombstones are never older than them.
5005        let swept_at: Option<String> = shadow.and_then(|_| {
5006            self.db
5007                .query_row("SELECT datetime('now')", [], |row| row.get(0))
5008                .ok()
5009        });
5010
5011        for key in order {
5012            if stop() {
5013                break;
5014            }
5015            if let Some(target) = target
5016                && current_size <= target
5017            {
5018                break;
5019            }
5020            if durable_upload_keys.contains(key) {
5021                stats.entries_pinned += 1;
5022                tracing::debug!(
5023                    key = key.as_str(),
5024                    "gc: retaining entry that backs a durable upload intent"
5025                );
5026                continue;
5027            }
5028            let features = by_key.get(key.as_str()).copied();
5029            if features.is_some_and(|f| f.recently_accessed) {
5030                stats.entries_pinned += 1;
5031                stats.entries_recent_prefiltered += 1;
5032                continue;
5033            }
5034            if features.is_some_and(|f| f.recently_imported) {
5035                stats.entries_pinned += 1;
5036                stats.entries_import_pinned += 1;
5037                continue;
5038            }
5039            // Found held by this sweep's probe, and counted there: its
5040            // removal would be refused.
5041            if held.contains_key(key) {
5042                continue;
5043            }
5044            let write_started = std::time::Instant::now();
5045            let removal = self.remove_entry_guarded(key, Some(EVICTION_IDLE_GRACE));
5046            let took = write_started.elapsed();
5047            eviction_writes += took;
5048            // Every removal that returns Ok held the index write lock, also
5049            // when it kept the entry (pinned, or still linked into a target
5050            // directory), so each one counts toward the write slice.
5051            if removal.is_ok()
5052                && let Some(pause) = pacer.after_write(took)
5053            {
5054                std::thread::sleep(pause);
5055            }
5056            match removal {
5057                Ok(GuardedRemoval::Reclaimed(reclaim)) => {
5058                    stats.entries_evicted += 1;
5059                    evicted.insert(key.as_str());
5060                    // Budget on bytes the removal *actually* freed on disk, not
5061                    // the entry's logical size: evicting an entry whose blobs
5062                    // are all shared frees nothing, and the sweep must keep
5063                    // going rather than stop believing it reached the target
5064                    // (#608).
5065                    stats.bytes_freed += reclaim.freed_bytes;
5066                    stats.disk_bytes_reclaimed += reclaim.disk_bytes_reclaimed;
5067                    stats.blobs_removed += reclaim.blobs_unlinked;
5068                    current_size = current_size.saturating_sub(reclaim.freed_bytes);
5069                    // Telemetry, deliberately outside remove_entry_guarded so
5070                    // the removal mechanism stays free of it (#595). Recorded
5071                    // after the fact rather than in the delete transaction: a
5072                    // tombstone lost to a crash costs one observation, not
5073                    // correctness.
5074                    if let Some(f) = features {
5075                        let verdict = shadow.map(|s| (s.policy, s.victims.contains(key.as_str())));
5076                        self.record_tombstone(f, policy, verdict);
5077                    }
5078                }
5079                // Pinned by a recent access — a live build may be mid-restore
5080                // on it (kunobi-ninja/kache#326, #182) — or lost the removal
5081                // race to a concurrent remover. Leave it for next round, but
5082                // count it so the caller can say *why* nothing was evicted
5083                // instead of reporting a bare "0" (#509).
5084                Ok(GuardedRemoval::Skipped) => {
5085                    stats.entries_pinned += 1;
5086                    continue;
5087                }
5088                Ok(GuardedRemoval::Unreclaimable) => {
5089                    stats.entries_unreclaimable += 1;
5090                    continue;
5091                }
5092                Err(e) => {
5093                    // A corrupt entry (unloadable meta.json) refuses removal to
5094                    // avoid leaking blob refcounts (#276); skip it and keep
5095                    // evicting the rest rather than aborting the whole sweep.
5096                    record_eviction_failure(&mut stats, &e);
5097                    tracing::warn!("gc: skipping eviction of {key}: {e:#}");
5098                    if is_sqlite_contention(&e) {
5099                        std::thread::sleep(pacer.after_contention());
5100                    }
5101                    continue;
5102                }
5103            }
5104        }
5105        stats.evict_write_ms = eviction_writes.as_millis() as u64;
5106        if let (Some(shadow), Some(swept_at)) = (shadow, swept_at) {
5107            let kept = shadow_only_victims(shadow, &evicted, by_key, durable_upload_keys, held);
5108            self.record_shadow_victims(shadow.policy, &swept_at, &kept);
5109        }
5110        stats
5111    }
5112
5113    /// Run one eviction policy over the current store.
5114    ///
5115    /// `stop_at` is `Some((current_size, target))` for size-driven sweeps and
5116    /// `None` when the policy's whole selection should be removed.
5117    ///
5118    /// Size-driven sweeps are shadowed by the #594 value-density candidate:
5119    /// it ranks the same candidate set for the same byte budget, and each
5120    /// tombstone records whether it agreed — while the live policy alone
5121    /// decides what actually goes. The demand stream then compares the two
5122    /// on real reuse, the evidence step 5 of #594 is gated on.
5123    fn evict_with(
5124        &self,
5125        policy: &dyn crate::eviction::EvictionPolicy,
5126        stop_at: Option<(u64, u64)>,
5127        probe_held_bytes: bool,
5128        origin: SweepOrigin,
5129    ) -> Result<GcStats> {
5130        self.evict_with_stop(policy, stop_at, probe_held_bytes, origin, &mut || false)
5131    }
5132
5133    fn evict_with_stop(
5134        &self,
5135        policy: &dyn crate::eviction::EvictionPolicy,
5136        stop_at: Option<(u64, u64)>,
5137        probe_held_bytes: bool,
5138        origin: SweepOrigin,
5139        stop: &mut dyn FnMut() -> bool,
5140    ) -> Result<GcStats> {
5141        let candidates = self.eviction_candidates_for(origin)?;
5142        let order = policy.select(&candidates);
5143        if order.is_empty() {
5144            return Ok(GcStats::default());
5145        }
5146        // Rebuild cost about to be destroyed. The current policy does not
5147        // consider this when ranking (#594) — surfacing it is how we find out
5148        // whether that matters in practice, on real stores, before changing
5149        // any behavior. `0` for entries not yet backfilled.
5150        let selected: std::collections::HashSet<&str> = order.iter().map(|k| k.as_str()).collect();
5151        let cost_ms: i64 = candidates
5152            .iter()
5153            .filter(|e| selected.contains(e.key.as_str()))
5154            .map(|e| e.compile_time_ms)
5155            .sum();
5156        tracing::debug!(
5157            policy = policy.name(),
5158            candidates = candidates.len(),
5159            selected = order.len(),
5160            selected_compile_time_ms = cost_ms,
5161            "gc: eviction selection"
5162        );
5163        // Duplicate eviction has a byte budget too, but only the size pass
5164        // probes the whole store for external retainers.
5165        let held = if probe_held_bytes {
5166            self.held_by_live_files(&candidates)?
5167        } else {
5168            std::collections::HashMap::new()
5169        };
5170        let bytes_held: u64 = held.values().sum();
5171        let stop_at = stop_at.map(|(current, target)| (current.saturating_sub(bytes_held), target));
5172        let shadow = stop_at.map(|(current, target)| {
5173            use crate::eviction::EvictionPolicy as _;
5174            let candidate = crate::eviction::ValueDensityPolicy;
5175            let shadow_order = candidate.select(&candidates);
5176            ShadowSelection {
5177                policy: candidate.name(),
5178                victims: crate::eviction::would_evict_for_budget(
5179                    &candidates,
5180                    &shadow_order,
5181                    current.saturating_sub(target),
5182                ),
5183            }
5184        });
5185        let by_key: std::collections::HashMap<&str, &crate::eviction::EntryFeatures> =
5186            candidates.iter().map(|e| (e.key.as_str(), e)).collect();
5187        let durable_upload_keys = self.durable_upload_keys()?;
5188        let mut stats = self.apply_eviction(
5189            &order,
5190            &by_key,
5191            policy.name(),
5192            stop_at,
5193            shadow.as_ref(),
5194            &durable_upload_keys,
5195            &held,
5196            stop,
5197        );
5198        stats.bytes_held = bytes_held;
5199        stats.entries_unreclaimable += held.len();
5200        Ok(stats)
5201    }
5202
5203    /// Per entry, the bytes of blobs it holds the last reference to that a
5204    /// live file outside the store (a clone or hardlink in a target
5205    /// directory) holds as well. Evicting the entry would unlink those
5206    /// names and free nothing (kunobi-ninja/kache#725), so a size-driven
5207    /// sweep counts them apart instead of evicting everything else trying to
5208    /// get under a budget they keep it over (kunobi-ninja/kache#1206).
5209    ///
5210    /// Empty under `gc_evict_shared`, which evicts such entries anyway.
5211    fn held_by_live_files(
5212        &self,
5213        candidates: &[crate::eviction::EntryFeatures],
5214    ) -> Result<std::collections::HashMap<String, u64>> {
5215        let mut held = std::collections::HashMap::new();
5216        if self.config.gc_evict_shared {
5217            return Ok(held);
5218        }
5219        let keys: std::collections::HashSet<&str> =
5220            candidates.iter().map(|entry| entry.key.as_str()).collect();
5221        // Read every row first: the probes below touch the filesystem, and a
5222        // read statement left open across them would pin the WAL snapshot.
5223        let last_refs = {
5224            let mut stmt = self.db.prepare(
5225                "SELECT eb.cache_key, b.hash, b.size, b.refcount, eb.refs
5226                 FROM entry_blobs eb JOIN blobs b ON b.hash = eb.hash",
5227            )?;
5228            stmt.query_map([], |row| {
5229                Ok((
5230                    row.get::<_, String>(0)?,
5231                    row.get::<_, String>(1)?,
5232                    row.get::<_, i64>(2)?,
5233                    row.get::<_, i64>(3)?,
5234                    row.get::<_, i64>(4)?,
5235                ))
5236            })?
5237            .collect::<rusqlite::Result<Vec<_>>>()?
5238            .into_iter()
5239            .filter(|(key, _, _, refcount, refs)| {
5240                keys.contains(key.as_str()) && holds_last_reference(*refcount, *refs)
5241            })
5242            .collect::<Vec<_>>()
5243        };
5244        for (key, hash, size, _, _) in last_refs {
5245            if crate::filesystem::blob_has_external_retainer(&self.blob_path(&hash)) {
5246                *held.entry(key).or_insert(0) += size.max(0) as u64;
5247            }
5248        }
5249        Ok(held)
5250    }
5251
5252    /// Weighted eviction: remove entries with lowest priority score until under the size limit.
5253    /// Prefers evicting old, rarely-accessed entries that actually free bytes.
5254    ///
5255    /// Fires at `max_size` and evicts down to 90% of it — a real hysteresis
5256    /// band, not the single 90% line that used to serve as both trigger and
5257    /// target. The threshold lives here rather than at each call site so
5258    /// `kache gc`, the daemon's periodic sweep, and the post-upload check all
5259    /// get the same band (see [`crate::eviction::over_eviction_trigger`]).
5260    pub fn evict(&self) -> Result<GcStats> {
5261        self.evict_for(SweepOrigin::Requested)
5262    }
5263
5264    /// [`Self::evict`] for a sweep started by `origin`.
5265    pub fn evict_for(&self, origin: SweepOrigin) -> Result<GcStats> {
5266        self.refuse_write("evict")?;
5267        let target = crate::eviction::eviction_target(self.config.max_size);
5268        // Trigger, budget, and stop condition are all physical bytes on disk
5269        // (`SUM(blobs.size)`), not the logical `SUM(entries.size)`: on a
5270        // dedup-heavy store the logical figure over-reports by exactly the
5271        // dedup savings, firing GC while the disk is comfortable and
5272        // destroying rebuild value without reclaiming space (#608).
5273        let size_before = self.physical_size()?;
5274        if !crate::eviction::over_eviction_trigger(size_before, self.config.max_size) {
5275            return Ok(GcStats::default());
5276        }
5277
5278        // The ranking is computed once and walked while deleting: each entry's
5279        // score is independent of the others, so the order stays valid as rows
5280        // disappear. The walk subtracts the bytes each removal actually freed
5281        // (last-reference blobs), so the stop condition tracks the physical
5282        // store without re-querying. A removal that frees less than its
5283        // ranked `reclaimable_bytes` promised (a twin evicted earlier in the
5284        // same sweep) only makes the sweep continue longer — never stop early.
5285        self.evict_with(
5286            &crate::eviction::SizePressurePolicy,
5287            Some((size_before, target)),
5288            true,
5289            origin,
5290        )
5291    }
5292
5293    /// Reclaim eligible entries even below the cache size budget. The caller
5294    /// holds `gc.lock` and stops on measured free space or a work deadline.
5295    /// Recent accesses, imports, upload intents and external retainers keep
5296    /// the same protection as other automatic sweeps.
5297    pub fn evict_for_disk_pressure(&self, mut stop: impl FnMut() -> bool) -> Result<GcStats> {
5298        self.refuse_write("evict")?;
5299        if stop() {
5300            return Ok(GcStats::default());
5301        }
5302        self.evict_with_stop(
5303            &crate::eviction::SizePressurePolicy,
5304            None,
5305            false,
5306            SweepOrigin::Automatic,
5307            &mut stop,
5308        )
5309    }
5310
5311    /// Evict entries older than the given duration.
5312    pub fn evict_older_than(&self, hours: u64) -> Result<GcStats> {
5313        self.refuse_write("evict")?;
5314        self.evict_with(
5315            &crate::eviction::OlderThanPolicy { hours },
5316            None,
5317            false,
5318            SweepOrigin::Requested,
5319        )
5320    }
5321
5322    /// Remove entries written by a different (or unknown legacy) cache-key
5323    /// recipe while retaining every entry from the running recipe.
5324    ///
5325    /// This is deliberately explicit rather than part of ordinary GC: rows
5326    /// created before key-schema recording use `0`, and an upgrade must not
5327    /// discard a still-reachable cache merely because its metadata predates
5328    /// this field. `kache gc --stale-schema` is the user's opt-in boundary.
5329    pub fn evict_stale_key_schemas(&self, current_schema: u32) -> Result<GcStats> {
5330        self.refuse_write("evict")?;
5331        let keys = {
5332            let mut stmt = self.db.prepare(
5333                "SELECT cache_key FROM entries
5334                 WHERE committed = 1 AND key_schema != ?1
5335                 ORDER BY cache_key",
5336            )?;
5337            stmt.query_map(params![current_schema], |row| row.get::<_, String>(0))?
5338                .collect::<rusqlite::Result<Vec<_>>>()?
5339        };
5340        let candidates = self.eviction_candidates()?;
5341        let by_key = candidates
5342            .iter()
5343            .map(|entry| (entry.key.as_str(), entry))
5344            .collect::<std::collections::HashMap<_, _>>();
5345        let durable_upload_keys = self.durable_upload_keys()?;
5346        Ok(self.apply_eviction(
5347            &keys,
5348            &by_key,
5349            "stale_schema",
5350            None,
5351            None,
5352            &durable_upload_keys,
5353            &std::collections::HashMap::new(),
5354            &mut || false,
5355        ))
5356    }
5357
5358    /// Evict duplicate entries that share the same content_hash.
5359    /// Keeps the most recently accessed entry for each content_hash group
5360    /// (consistent with LRU eviction policy).
5361    /// Returns GcStats with eviction metrics.
5362    ///
5363    /// Gated on the same size-pressure trigger as [`Self::evict`]. Reclaiming
5364    /// space is the only justification for spending a duplicate key's hit
5365    /// history, so a comfortable store declines the sweep. Once triggered,
5366    /// the same physical-byte target bounds this pass; the following ordinary
5367    /// size sweep recomputes pressure if duplicate removal was insufficient.
5368    pub fn evict_duplicate_entries(&self) -> Result<GcStats> {
5369        self.evict_duplicate_entries_for(SweepOrigin::Requested)
5370    }
5371
5372    /// [`Self::evict_duplicate_entries`] for a sweep started by `origin`.
5373    pub fn evict_duplicate_entries_for(&self, origin: SweepOrigin) -> Result<GcStats> {
5374        self.refuse_write("evict")?;
5375        let size_before = self.physical_size()?;
5376        if !crate::eviction::over_eviction_trigger(size_before, self.config.max_size) {
5377            return Ok(GcStats {
5378                skipped: true,
5379                ..Default::default()
5380            });
5381        }
5382        self.evict_with(
5383            &crate::eviction::DuplicatePolicy,
5384            Some((
5385                size_before,
5386                crate::eviction::eviction_target(self.config.max_size),
5387            )),
5388            false,
5389            origin,
5390        )
5391    }
5392
5393    /// Reclaim orphaned blob files — content-addressed files on disk with no
5394    /// row in the `blobs` table. They accumulate when a crash interrupts a
5395    /// `put`/import between materialize (Phase 1) and the commit transaction
5396    /// (Phase 2), or when `remove_entry` runs against an entry whose
5397    /// `meta.json` is gone (so its blob hashes can't be decremented). Nothing
5398    /// else reclaims them: `evict*`/`remove_entry` only touch blobs reachable
5399    /// from an entry, and `total_size()` doesn't count them — so they leak
5400    /// invisibly to size-based eviction.
5401    ///
5402    /// Only blobs whose file mtime is older than `min_age` are swept, so a blob
5403    /// a concurrent `put` is materializing (it renames the file into place just
5404    /// before inserting its row) is never reclaimed out from under it. Unlinks
5405    /// run while holding the SQLite write lock (`BEGIN IMMEDIATE`), upholding
5406    /// the store invariant that a blob is only ever removed under that lock —
5407    /// so even if this races a `put` adopting a long-lived orphan, that put's
5408    /// Phase 2 re-materializes the blob before committing a reference to it.
5409    pub fn sweep_orphan_blobs(&self, min_age: Duration) -> Result<OrphanSweepStats> {
5410        self.refuse_write("sweep orphan blobs")?;
5411        let blobs_dir = self.config.store_dir().join("blobs");
5412        if !blobs_dir.exists() {
5413            return Ok(OrphanSweepStats::default());
5414        }
5415
5416        // Phase A (no lock): enumerate blob-shaped files old enough to sweep.
5417        // The directory walk is the slow part and holds no lock.
5418        let now = std::time::SystemTime::now();
5419        let mut candidates: Vec<(String, PathBuf, u64)> = Vec::new();
5420        let mut scanned = 0usize;
5421        for shard in fs::read_dir(&blobs_dir)?.flatten() {
5422            if !shard.path().is_dir() {
5423                continue;
5424            }
5425            let Ok(files) = fs::read_dir(shard.path()) else {
5426                continue;
5427            };
5428            for file in files.flatten() {
5429                let path = file.path();
5430                let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
5431                    continue;
5432                };
5433                if !is_blob_hash_name(name) {
5434                    continue;
5435                }
5436                let Ok(meta) = file.metadata() else { continue };
5437                if !meta.is_file() {
5438                    continue;
5439                }
5440                scanned += 1;
5441                let old_enough = meta
5442                    .modified()
5443                    .ok()
5444                    .and_then(|m| now.duration_since(m).ok())
5445                    .map(|age| age >= min_age)
5446                    .unwrap_or(false);
5447                if old_enough {
5448                    candidates.push((name.to_string(), path, meta.len()));
5449                }
5450            }
5451        }
5452
5453        let mut stats = OrphanSweepStats {
5454            scanned,
5455            ..Default::default()
5456        };
5457        if candidates.is_empty() {
5458            return Ok(stats);
5459        }
5460
5461        // Phase B (write lock held): re-check each candidate against the live
5462        // `blobs` table and unlink the unreferenced ones. `BEGIN IMMEDIATE`
5463        // takes the write lock up front so the unlinks serialize with any
5464        // `put`/`remove_entry` mutating the same blob.
5465        self.db.execute_batch("BEGIN IMMEDIATE")?;
5466        let result = (|| -> Result<()> {
5467            let referenced: std::collections::HashSet<String> = {
5468                let mut stmt = self.db.prepare("SELECT hash FROM blobs")?;
5469                stmt.query_map([], |row| row.get::<_, String>(0))?
5470                    .filter_map(|r| r.ok())
5471                    .collect()
5472            };
5473            for (hash, path, size) in &candidates {
5474                if referenced.contains(hash) {
5475                    continue;
5476                }
5477                unlink_blob(path);
5478                stats.removed += 1;
5479                stats.bytes_reclaimed += *size;
5480            }
5481            Ok(())
5482        })();
5483        match result {
5484            Ok(()) => {
5485                self.db.execute_batch("COMMIT")?;
5486                Ok(stats)
5487            }
5488            Err(e) => {
5489                let _ = self.db.execute_batch("ROLLBACK");
5490                Err(e)
5491            }
5492        }
5493    }
5494
5495    /// Backfill content_hash for entries that don't have one.
5496    /// Reads meta.json from each entry to get file hashes.
5497    /// Returns the number of entries updated.
5498    pub fn backfill_content_hashes(&self) -> Result<usize> {
5499        self.refuse_write("backfill content hashes")?;
5500        let keys: Vec<String> = {
5501            let mut stmt = self.db.prepare(
5502                "SELECT cache_key FROM entries WHERE content_hash IS NULL AND committed = 1",
5503            )?;
5504            stmt.query_map([], |row| row.get(0))?
5505                .collect::<Result<Vec<_>, _>>()?
5506        };
5507
5508        let mut updated = 0;
5509        for key in &keys {
5510            let meta_path = self.entry_dir(key).join("meta.json");
5511            if let Ok(content) = fs::read_to_string(&meta_path)
5512                && let Ok(meta) = serde_json::from_str::<EntryMeta>(&content)
5513            {
5514                let content_hash = compute_content_hash(&meta.files);
5515                self.db.execute(
5516                    "UPDATE entries SET content_hash = ?1 WHERE cache_key = ?2",
5517                    params![content_hash, key],
5518                )?;
5519                updated += 1;
5520            }
5521        }
5522        Ok(updated)
5523    }
5524
5525    /// Backfill `compile_time_ms` for entries written before it was indexed
5526    /// (kunobi-ninja/kache#594), reading each entry's `meta.json` — the same
5527    /// shape as [`Self::backfill_content_hashes`], and run from the same GC
5528    /// sweep.
5529    ///
5530    /// Only rows still at the `0` default are touched, so this converges: once
5531    /// an entry is backfilled it is never re-read. A genuinely zero-cost
5532    /// compile is indistinguishable from "not yet backfilled" here, which is
5533    /// harmless — it just gets re-read on the next sweep and stays 0.
5534    ///
5535    /// Bounded to 10,000 entries per call. Measured on
5536    /// a real 52k-entry store, an unbounded pass is ~6 s of `meta.json` reads —
5537    /// and this runs inside the daemon's GC sweep while the store mutex is
5538    /// held, so a first-GC-after-upgrade stall of that size is worth avoiding.
5539    /// Spreading it over successive sweeps costs nothing: eviction ranking
5540    /// treats a not-yet-backfilled entry exactly as it does today.
5541    pub fn backfill_compile_times(&self) -> Result<usize> {
5542        self.backfill_compile_times_limited(COMPILE_TIME_BACKFILL_BATCH)
5543    }
5544
5545    /// Backfill `entry_blobs` rows for entries written before the table
5546    /// existed (kunobi-ninja/kache#608), reading each entry's `meta.json` —
5547    /// the same shape and GC-sweep call site as
5548    /// [`Self::backfill_compile_times`], and bounded the same way so a
5549    /// first-GC-after-upgrade never stalls on a 50k-entry store.
5550    ///
5551    /// Converges: an entry gains rows once and is never re-read. Eviction
5552    /// ranks a not-yet-backfilled entry on its logical size, exactly as it
5553    /// did before the table existed. Entries whose meta.json is unreadable
5554    /// (or lists no files) can never gain rows and stay in the pre-#608
5555    /// ranking regime; they are the same entries `remove_entry` already
5556    /// refuses to touch (#276). Selection is randomized so a batch of such
5557    /// entries cannot permanently starve the valid keys behind it.
5558    pub fn backfill_entry_blobs(&self) -> Result<usize> {
5559        self.backfill_entry_blobs_limited(COMPILE_TIME_BACKFILL_BATCH)
5560    }
5561
5562    /// [`Self::backfill_entry_blobs`] with an explicit per-call bound.
5563    fn backfill_entry_blobs_limited(&self, limit: i64) -> Result<usize> {
5564        self.refuse_write("backfill blob references")?;
5565        let keys: Vec<String> = {
5566            let mut stmt = self.db.prepare(
5567                "SELECT cache_key FROM entries
5568                 WHERE committed = 1
5569                   AND cache_key NOT IN (SELECT cache_key FROM entry_blobs)
5570                 ORDER BY RANDOM()
5571                 LIMIT ?1",
5572            )?;
5573            stmt.query_map(params![limit], |row| row.get(0))?
5574                .collect::<Result<Vec<_>, _>>()?
5575        };
5576
5577        let mut updated = 0;
5578        for key in &keys {
5579            let meta_path = self.entry_dir(key).join("meta.json");
5580            if let Ok(content) = fs::read_to_string(&meta_path)
5581                && let Ok(meta) = serde_json::from_str::<EntryMeta>(&content)
5582                && !meta.files.is_empty()
5583            {
5584                let tx = self.db.unchecked_transaction()?;
5585                // Re-check under the write lock: a concurrent put/import may
5586                // have registered this entry's rows since the SELECT above —
5587                // and the entry row must still exist, or a concurrent removal
5588                // would leave a ghost mapping for a dead entry.
5589                let still_wanted: i64 = tx.query_row(
5590                    "SELECT EXISTS(SELECT 1 FROM entries WHERE cache_key = ?1)
5591                            AND NOT EXISTS(SELECT 1 FROM entry_blobs WHERE cache_key = ?1)",
5592                    params![key],
5593                    |row| row.get(0),
5594                )?;
5595                // An entry whose artifacts still sit beside meta.json has
5596                // references nobody counted yet, and a mapping would claim
5597                // they were. `migrate_entry_to_blobs` counts them and moves
5598                // the artifacts out; a later pass maps the entry.
5599                if still_wanted != 0 && !has_unmigrated_artifacts(&self.entry_dir(key), &meta) {
5600                    record_entry_blobs(&tx, key, &meta.files)?;
5601                    updated += 1;
5602                }
5603                tx.commit()?;
5604            }
5605        }
5606        Ok(updated)
5607    }
5608
5609    /// [`Self::backfill_compile_times`] with an explicit per-call bound, so the
5610    /// batching behavior can be tested without materializing a batch-sized
5611    /// store.
5612    fn backfill_compile_times_limited(&self, limit: i64) -> Result<usize> {
5613        self.refuse_write("backfill compile times")?;
5614        let keys: Vec<String> = {
5615            let mut stmt = self.db.prepare(
5616                "SELECT cache_key FROM entries WHERE compile_time_ms = 0 AND committed = 1
5617                 LIMIT ?1",
5618            )?;
5619            stmt.query_map(params![limit], |row| row.get(0))?
5620                .collect::<Result<Vec<_>, _>>()?
5621        };
5622
5623        let mut updated = 0;
5624        for key in &keys {
5625            let meta_path = self.entry_dir(key).join("meta.json");
5626            if let Ok(content) = fs::read_to_string(&meta_path)
5627                && let Ok(meta) = serde_json::from_str::<EntryMeta>(&content)
5628                && meta.compile_time_ms > 0
5629            {
5630                self.db.execute(
5631                    "UPDATE entries SET compile_time_ms = ?1 WHERE cache_key = ?2",
5632                    params![meta.compile_time_ms as i64, key],
5633                )?;
5634                updated += 1;
5635            }
5636        }
5637        Ok(updated)
5638    }
5639
5640    /// Record that an entry was evicted, with the features the decision was
5641    /// made on (kunobi-ninja/kache#594). `shadow` carries the shadow policy's
5642    /// verdict on the same entry — `(policy_name, it_would_evict_this_too)` —
5643    /// so later demand on the key splits by whether the candidate policy
5644    /// agreed with the live one.
5645    ///
5646    /// Best-effort: telemetry must never fail or slow an eviction, so errors
5647    /// are logged at debug and swallowed.
5648    fn record_tombstone(
5649        &self,
5650        features: &crate::eviction::EntryFeatures,
5651        policy: &str,
5652        shadow: Option<(&str, bool)>,
5653    ) {
5654        let result = self.db.execute(
5655            "INSERT OR REPLACE INTO eviction_tombstones
5656                (cache_key, evicted_at, policy, size, hit_count, idle_hours, compile_time_ms,
5657                 demanded_at, shadow_policy, shadow_would_evict)
5658             VALUES (?1, datetime('now'), ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8)",
5659            params![
5660                features.key,
5661                policy,
5662                features.size,
5663                features.hit_count,
5664                features.idle_hours,
5665                features.compile_time_ms,
5666                shadow.map(|(name, _)| name),
5667                shadow.map(|(_, would)| would),
5668            ],
5669        );
5670        if let Err(e) = result {
5671            tracing::debug!("gc: could not record tombstone: {e}");
5672        }
5673    }
5674
5675    /// Record the entries the shadow policy would have evicted in this sweep
5676    /// that the live one kept (kunobi-ninja/kache#594). A key already
5677    /// recorded keeps its first sweep. Best-effort, like
5678    /// [`Self::record_tombstone`].
5679    fn record_shadow_victims(
5680        &self,
5681        policy: &str,
5682        swept_at: &str,
5683        victims: &[&crate::eviction::EntryFeatures],
5684    ) {
5685        if victims.is_empty() {
5686            return;
5687        }
5688        let result = (|| -> rusqlite::Result<()> {
5689            let tx = self.db.unchecked_transaction()?;
5690            {
5691                let mut insert = tx.prepare(
5692                    "INSERT OR IGNORE INTO shadow_victims
5693                        (cache_key, swept_at, shadow_policy, size, hit_count, compile_time_ms)
5694                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
5695                )?;
5696                for victim in victims {
5697                    insert.execute(params![
5698                        victim.key,
5699                        swept_at,
5700                        policy,
5701                        victim.size,
5702                        victim.hit_count,
5703                        victim.compile_time_ms,
5704                    ])?;
5705                }
5706            }
5707            tx.commit()
5708        })();
5709        if let Err(e) = result {
5710            tracing::debug!("gc: could not record shadow victims: {e}");
5711        }
5712    }
5713
5714    /// What each policy's evictions cost in later recompilation, over the
5715    /// sweeps both sides were recorded for (kunobi-ninja/kache#594).
5716    ///
5717    /// The live side is every shadowed tombstone since the first recorded
5718    /// shadow victim. The shadow side is the tombstones it agreed with plus
5719    /// its own victims the live policy kept; a key it took earlier is not
5720    /// counted again when the live policy evicts it later. A shadow victim
5721    /// was demanded when its hit count rose after the sweep, on the entry or
5722    /// on its later tombstone, or when its tombstone saw a request. Rows of
5723    /// unknown cost are left out, as in [`Self::shadow_demand_split`].
5724    pub fn shadow_counterfactual(&self) -> Result<ShadowCounterfactual> {
5725        let row = self.db.query_row(
5726            "WITH since AS (SELECT MIN(swept_at) AS t FROM shadow_victims),
5727             live AS (
5728                SELECT * FROM eviction_tombstones
5729                 WHERE shadow_policy = 'value-density' AND compile_time_ms > 0
5730                   AND evicted_at >= (SELECT t FROM since)
5731             ),
5732             own AS (
5733                SELECT sv.size, sv.compile_time_ms,
5734                       (COALESCE(e.hit_count, 0) > sv.hit_count
5735                        OR COALESCE(t.hit_count, 0) > sv.hit_count
5736                        OR t.demanded_at IS NOT NULL) AS demanded
5737                  FROM shadow_victims sv
5738                  LEFT JOIN entries e ON e.cache_key = sv.cache_key
5739                  LEFT JOIN eviction_tombstones t
5740                         ON t.cache_key = sv.cache_key AND t.evicted_at >= sv.swept_at
5741                 WHERE sv.shadow_policy = 'value-density' AND sv.compile_time_ms > 0
5742             )
5743             SELECT
5744                (SELECT COUNT(*) FROM live),
5745                (SELECT COALESCE(SUM(size), 0) FROM live),
5746                (SELECT COALESCE(SUM(compile_time_ms), 0) FROM live WHERE demanded_at IS NOT NULL),
5747                (SELECT COUNT(*) FROM live
5748                  WHERE shadow_would_evict = 1
5749                    AND cache_key NOT IN (SELECT cache_key FROM shadow_victims))
5750                  + (SELECT COUNT(*) FROM own),
5751                (SELECT COALESCE(SUM(size), 0) FROM live
5752                  WHERE shadow_would_evict = 1
5753                    AND cache_key NOT IN (SELECT cache_key FROM shadow_victims))
5754                  + (SELECT COALESCE(SUM(size), 0) FROM own),
5755                (SELECT COALESCE(SUM(compile_time_ms), 0) FROM live
5756                  WHERE shadow_would_evict = 1 AND demanded_at IS NOT NULL
5757                    AND cache_key NOT IN (SELECT cache_key FROM shadow_victims))
5758                  + (SELECT COALESCE(SUM(compile_time_ms), 0) FROM own WHERE demanded)",
5759            [],
5760            |row| {
5761                let count = |i: usize| row.get::<_, i64>(i).map(|v| v.max(0) as u64);
5762                Ok(ShadowCounterfactual {
5763                    live: PolicyCost {
5764                        evicted: count(0)?,
5765                        bytes: count(1)?,
5766                        demanded_compile_time_ms: count(2)?,
5767                    },
5768                    shadow: PolicyCost {
5769                        evicted: count(3)?,
5770                        bytes: count(4)?,
5771                        demanded_compile_time_ms: count(5)?,
5772                    },
5773                })
5774            },
5775        )?;
5776        Ok(row)
5777    }
5778
5779    /// Fixed-horizon observations from the latest live tombstone and first
5780    /// shadow-only decision per key. Does not change eviction or access state.
5781    pub fn eviction_horizon_evidence(
5782        &self,
5783        as_of_unix_secs: i64,
5784        horizon_secs: u64,
5785    ) -> Result<crate::EvictionHorizonEvidence> {
5786        crate::eviction_evidence::read(&self.db, as_of_unix_secs, horizon_secs)
5787    }
5788
5789    /// Note that a key was requested after being evicted — the observation the
5790    /// live store cannot provide, since the entries it evicted are precisely
5791    /// the ones missing from it (kunobi-ninja/kache#594).
5792    ///
5793    /// Sits on the cache-miss path, so the common case (a key that was never
5794    /// cached at all) must stay read-only: the existence probe is a primary-key
5795    /// lookup, and only a hit on a not-yet-demanded tombstone takes the write.
5796    /// Only the *first* demand is recorded — that is the interval the reuse
5797    /// question is about.
5798    fn note_tombstone_demand(&self, cache_key: &str) {
5799        let pending: Result<i64, _> = self.db.query_row(
5800            "SELECT EXISTS(SELECT 1 FROM eviction_tombstones
5801                           WHERE cache_key = ?1 AND demanded_at IS NULL)",
5802            params![cache_key],
5803            |row| row.get(0),
5804        );
5805        if !matches!(pending, Ok(1)) {
5806            return;
5807        }
5808        let updated = self.db.execute(
5809            "UPDATE eviction_tombstones SET demanded_at = datetime('now')
5810             WHERE cache_key = ?1 AND demanded_at IS NULL",
5811            params![cache_key],
5812        );
5813        match updated {
5814            Ok(_) => tracing::debug!(
5815                cache_key = &cache_key[..16.min(cache_key.len())],
5816                "gc: evicted entry was demanded again"
5817            ),
5818            Err(e) => tracing::debug!("gc: could not record tombstone demand: {e}"),
5819        }
5820    }
5821
5822    /// Drop tombstones older than `keep_days`, bounding the table.
5823    ///
5824    /// Run from the GC sweep. A tombstone's value is the demand signal in the
5825    /// window after eviction; past that it is only taking up space.
5826    pub fn prune_tombstones(&self, keep_days: u64) -> Result<usize> {
5827        self.refuse_write("prune tombstones")?;
5828        let cutoff = format!("-{keep_days} days");
5829        let removed = self.db.execute(
5830            "DELETE FROM eviction_tombstones WHERE evicted_at < datetime('now', ?1)",
5831            params![cutoff],
5832        )?;
5833        self.db.execute(
5834            "DELETE FROM shadow_victims WHERE swept_at < datetime('now', ?1)",
5835            params![cutoff],
5836        )?;
5837        Ok(removed)
5838    }
5839
5840    /// `(tracked, demanded)` — how many evictions are being observed, and how
5841    /// many of those keys were later asked for again.
5842    ///
5843    /// The ratio is the headline number for #594: a high rate means eviction is
5844    /// discarding entries the build still wants.
5845    pub fn tombstone_stats(&self) -> Result<(usize, usize)> {
5846        let row = self.db.query_row(
5847            "SELECT COUNT(*), COUNT(demanded_at) FROM eviction_tombstones",
5848            [],
5849            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
5850        )?;
5851        Ok((row.0.max(0) as usize, row.1.max(0) as usize))
5852    }
5853
5854    /// Post-eviction demand split by the shadow policy's verdict
5855    /// (kunobi-ninja/kache#594): of the entries the live policy evicted, how
5856    /// often was each cohort — "shadow agreed" vs "shadow would have kept" —
5857    /// later asked for again? A markedly higher demand rate on the
5858    /// would-have-kept cohort flags live-policy mistakes the shadow avoids.
5859    /// Both cohorts come from the same evicted population, so the comparison
5860    /// avoids the inventory-value circularity the issue warns about.
5861    ///
5862    /// This is a **live-victim diagnostic**, not flip evidence on its own:
5863    /// the shadow's own victims that the live policy KEPT are invisible here
5864    /// (their reuse shows up only as ordinary hits), rates are right-censored
5865    /// by tombstone age, and a flip decision needs the cost-weighted
5866    /// objective, not raw demand counts. Rows whose `compile_time_ms` is
5867    /// still 0 are recorded but excluded from the headline numbers: the
5868    /// density shadow ranks unknown-cost entries as worthless by
5869    /// construction, and freshness correlates with not-yet-backfilled, so
5870    /// counting them would bias the kept cohort with young, high-demand
5871    /// keys.
5872    pub fn shadow_demand_split(&self) -> Result<ShadowDemandSplit> {
5873        let row = self.db.query_row(
5874            "SELECT
5875                COUNT(CASE WHEN shadow_would_evict = 1 THEN 1 END),
5876                COUNT(CASE WHEN shadow_would_evict = 1 AND demanded_at IS NOT NULL THEN 1 END),
5877                COUNT(CASE WHEN shadow_would_evict = 0 THEN 1 END),
5878                COUNT(CASE WHEN shadow_would_evict = 0 AND demanded_at IS NOT NULL THEN 1 END)
5879             FROM eviction_tombstones
5880             WHERE shadow_policy = 'value-density' AND compile_time_ms > 0",
5881            [],
5882            |row| {
5883                Ok(ShadowDemandSplit {
5884                    agreed: row.get::<_, i64>(0)?.max(0) as usize,
5885                    agreed_demanded: row.get::<_, i64>(1)?.max(0) as usize,
5886                    shadow_kept: row.get::<_, i64>(2)?.max(0) as usize,
5887                    shadow_kept_demanded: row.get::<_, i64>(3)?.max(0) as usize,
5888                })
5889            },
5890        )?;
5891        Ok(row)
5892    }
5893
5894    /// Remove a single cache entry (files + DB record).
5895    ///
5896    /// The entry row, its blob refcounts, **and** the unlink of any blob whose
5897    /// last reference is gone all happen inside one transaction. Because a blob
5898    /// file is only ever mutated while holding the SQLite write lock (here, and
5899    /// in `put`/`import`'s materialize step), the unlink can't race a concurrent
5900    /// adopter: either we run first (the adopter re-materializes the file under
5901    /// the same lock) or it runs first (our decrement won't reach zero).
5902    pub fn remove_entry(&self, cache_key: &str) -> Result<()> {
5903        self.remove_entry_guarded(cache_key, None).map(|_| ())
5904    }
5905
5906    /// Like [`remove_entry`](Self::remove_entry), but when `skip_if_idle_lt` is
5907    /// `Some(grace)` the removal is abandoned — returning `Ok(false)` without
5908    /// touching the DB or any blob — if the entry was last accessed within
5909    /// `grace` of now.
5910    ///
5911    /// This is the active-pin guard for eviction (kunobi-ninja/kache#326, #182):
5912    /// a cache hit bumps `last_accessed` (`get`, store.rs) right before the
5913    /// wrapper hardlinks the entry's blobs into the build, so a "recently
5914    /// accessed" entry is one a live build may be **mid-restore** on. The
5915    /// recency check runs INSIDE the same write-locked transaction that unlinks
5916    /// the blobs, so it serializes against that `last_accessed` bump: either the
5917    /// bump commits first (and we skip the eviction), or we delete first (and
5918    /// the racing restore reads a now-gone blob → ENOENT → clean recompile,
5919    /// never a false hit). Returns [`GuardedRemoval::Reclaimed`] when this
5920    /// call removed the entry, with the *physical* bytes and blob files
5921    /// actually reclaimed — zero when every blob is still referenced by
5922    /// another entry (#608).
5923    ///
5924    /// `None` (the plain `remove_entry` path) always removes — explicit purge /
5925    /// `doctor` must not be blocked by recency or by external clones.
5926    ///
5927    /// Concurrent same-key *publication* is guarded too (#670): the entry's
5928    /// references are decremented only if `meta.json` is byte-identical, under
5929    /// the write transaction, to what was read before it — a republication in
5930    /// between rolls the removal back — and a remover that deleted no row
5931    /// never touches the entry directory, since a fresh `meta.json` there may
5932    /// belong to a publisher whose row registration has not committed yet.
5933    fn remove_entry_guarded(
5934        &self,
5935        cache_key: &str,
5936        skip_if_idle_lt: Option<Duration>,
5937    ) -> Result<GuardedRemoval> {
5938        self.remove_entry_guarded_with_hook(cache_key, skip_if_idle_lt, || {})
5939    }
5940
5941    /// [`Self::remove_entry_guarded`] with a test seam: `after_meta_read` runs
5942    /// between the pre-transaction `meta.json` read and the write transaction,
5943    /// which is exactly the window the #670 republication guard defends.
5944    fn remove_entry_guarded_with_hook(
5945        &self,
5946        cache_key: &str,
5947        skip_if_idle_lt: Option<Duration>,
5948        after_meta_read: impl FnOnce(),
5949    ) -> Result<GuardedRemoval> {
5950        self.remove_entry_guarded_with_hooks(cache_key, skip_if_idle_lt, after_meta_read, || {})
5951    }
5952
5953    /// [`Self::remove_entry_guarded_with_hook`] with a second seam:
5954    /// `before_dir_cleanup` runs inside the cleanup transaction — after the
5955    /// logical removal has committed, holding the write lock, immediately
5956    /// before the republication check and directory removal. That is the
5957    /// residual #670 window where a publisher's fresh `meta.json` used to be
5958    /// deleted out from under its registration.
5959    fn remove_entry_guarded_with_hooks(
5960        &self,
5961        cache_key: &str,
5962        skip_if_idle_lt: Option<Duration>,
5963        after_meta_read: impl FnOnce(),
5964        before_dir_cleanup: impl FnOnce(),
5965    ) -> Result<GuardedRemoval> {
5966        self.refuse_write("remove an entry")?;
5967        // Boxed so the republication-retry loop below stays non-generic; the
5968        // production closures are zero-sized, so no allocation happens.
5969        let mut after_meta_read: Option<Box<dyn FnOnce() + '_>> = Some(Box::new(after_meta_read));
5970        let mut before_dir_cleanup: Option<Box<dyn FnOnce() + '_>> =
5971            Some(Box::new(before_dir_cleanup));
5972        loop {
5973            match self.remove_entry_attempt(
5974                cache_key,
5975                skip_if_idle_lt,
5976                after_meta_read.take(),
5977                before_dir_cleanup.take(),
5978            )? {
5979                RemovalAttempt::Done(Some(reclaim)) => {
5980                    return Ok(GuardedRemoval::Reclaimed(reclaim));
5981                }
5982                RemovalAttempt::Done(None) => return Ok(GuardedRemoval::Skipped),
5983                RemovalAttempt::Unreclaimable => return Ok(GuardedRemoval::Unreclaimable),
5984                // A republication landed while this attempt waited out a
5985                // concurrent removal: the row belongs to a fresh generation
5986                // whose meta is back. The caller asked to remove whatever is
5987                // currently published, so run again against the new
5988                // generation. Each pass requires another full republication
5989                // inside the window, so this cannot spin on its own.
5990                RemovalAttempt::Republished => {}
5991            }
5992        }
5993    }
5994
5995    fn remove_entry_attempt(
5996        &self,
5997        cache_key: &str,
5998        skip_if_idle_lt: Option<Duration>,
5999        after_meta_read: Option<Box<dyn FnOnce() + '_>>,
6000        before_dir_cleanup: Option<Box<dyn FnOnce() + '_>>,
6001    ) -> Result<RemovalAttempt> {
6002        let entry_dir = self.entry_dir(cache_key);
6003        let meta_path = entry_dir.join("meta.json");
6004
6005        // Load the blob hashes this entry references. If `meta.json` exists but
6006        // can't be read or parsed, we CANNOT know which blobs to decrement —
6007        // deleting the entry row anyway permanently orphans those refcounts (the
6008        // blobs keep their DB row and evade size-based eviction forever). Refuse
6009        // the removal so a corrupt entry never silently leaks (#276); callers
6010        // (GC / purge / `doctor --repair`) log and move on, and the entry stays
6011        // accounted-for until a fresh `put` (INSERT OR REPLACE) overwrites it.
6012        let meta_content: String;
6013        let hashes: Vec<String> = match fs::read_to_string(&meta_path) {
6014            Ok(content) => {
6015                let meta: EntryMeta = serde_json::from_str(&content).with_context(|| {
6016                    format!(
6017                        "entry {cache_key}: meta.json unparseable — refusing removal so blob \
6018                         refcounts are not leaked (#276)"
6019                    )
6020                })?;
6021                meta_content = content;
6022                meta.files.iter().map(|f| f.hash.clone()).collect()
6023            }
6024            Err(e)
6025                if e.kind() == std::io::ErrorKind::NotFound
6026                    || crate::atomic::is_transient_rename_error(&e) =>
6027            {
6028                // No readable meta.json. A same-key operation may be
6029                // mid-flight: a publisher materializes meta inside its
6030                // registration transaction, and a removal's cleanup pass runs
6031                // in its own locked transaction (#670) — so "meta missing, row
6032                // present" can be a healthy transient, not only the
6033                // stranded-entry shape. Bounce off the write lock — the no-op
6034                // write statement waits (busy_timeout) until any in-flight
6035                // writer commits or rolls back — then judge the settled state.
6036                // Both the row and the meta are checked while the lock is
6037                // still held: after dropping it another writer could move the
6038                // pairing again and a healthy already-absent state would
6039                // misreport as #276 corruption.
6040                //
6041                // A concurrent remover that already unlinked this meta.json
6042                // arrives here too. On Unix that read returns NotFound; on
6043                // Windows the name lingers delete-pending and the read fails
6044                // with ERROR_ACCESS_DENIED instead, which used to fall through
6045                // to the unreadable-meta arm and report #276 corruption for two
6046                // healthy removers. Both shapes mean the same thing — someone
6047                // else is mid-operation — so both settle on the write lock
6048                // rather than on a sleep.
6049                let tx = self.db.unchecked_transaction()?;
6050                tx.execute("UPDATE entries SET cache_key = cache_key WHERE 1 = 0", [])?;
6051                let row_exists: i64 = tx.query_row(
6052                    "SELECT EXISTS(SELECT 1 FROM entries WHERE cache_key = ?1)",
6053                    params![cache_key],
6054                    |row| row.get(0),
6055                )?;
6056                if row_exists == 0 {
6057                    // The concurrent removal won (or the key never existed);
6058                    // nothing left to remove, and the meta's state cannot
6059                    // change that — so decide before probing it, which on
6060                    // Windows may still be delete-pending and unstattable.
6061                    return Ok(RemovalAttempt::Done(None));
6062                }
6063                // fs::metadata, not Path::exists: exists() swallows every
6064                // error as false, and a permission failure must refuse like
6065                // the unreadable-meta arm below, not report already-absent.
6066                let meta_is_back = match fs::metadata(&meta_path) {
6067                    Ok(_) => true,
6068                    Err(e) => {
6069                        if e.kind() != std::io::ErrorKind::NotFound {
6070                            return Err(e).with_context(|| {
6071                                format!(
6072                                    "entry {cache_key}: checking republished meta.json — \
6073                                     refusing removal so blob refcounts are not leaked (#276)"
6074                                )
6075                            });
6076                        }
6077                        false
6078                    }
6079                };
6080                drop(tx);
6081                if meta_is_back {
6082                    // A republication landed while we waited: the row belongs
6083                    // to a fresh generation whose meta is back. The caller
6084                    // asked to remove whatever is currently published, so
6085                    // retry against the new generation. Each retry requires
6086                    // another full republication in the window, so this
6087                    // cannot spin on its own.
6088                    return Ok(RemovalAttempt::Republished);
6089                }
6090                // Settled: a row with no meta.json. Its blob list is unknown
6091                // and deleting the row would leak the refcounts — refuse, so
6092                // a corrupt entry never silently leaks (#276).
6093                anyhow::bail!(
6094                    "entry {cache_key}: meta.json missing but DB row present — refusing \
6095                     removal so blob refcounts are not leaked (#276)"
6096                );
6097            }
6098            Err(e) => {
6099                return Err(e).with_context(|| {
6100                    format!(
6101                        "entry {cache_key}: reading meta.json — refusing removal so blob \
6102                         refcounts are not leaked (#276)"
6103                    )
6104                });
6105            }
6106        };
6107
6108        if let Some(hook) = after_meta_read {
6109            hook();
6110        }
6111
6112        // Eviction only: refuse to drop an entry whose last-ref blobs are
6113        // still cloned into a worktree (kunobi-ninja/kache#725). Unlinking
6114        // those names frees no disk and destroys a still-usable hit.
6115        // Explicit `remove_entry` (purge / doctor) passes `skip_if_idle_lt =
6116        // None` and still unlinks. The filesystem probe runs before the write
6117        // lock is taken: the lock does not stop a restore from linking a
6118        // blob, so probing under it adds no safety and keeps builds waiting.
6119        let retained_blobs: Vec<(&str, i64)> =
6120            if skip_if_idle_lt.is_some() && !self.config.gc_evict_shared {
6121                let mut held_refs: std::collections::HashMap<&str, i64> =
6122                    std::collections::HashMap::new();
6123                for hash in &hashes {
6124                    *held_refs.entry(hash.as_str()).or_insert(0) += 1;
6125                }
6126                held_refs
6127                    .into_iter()
6128                    .filter(|(hash, _)| {
6129                        crate::filesystem::blob_has_external_retainer(&self.blob_path(hash))
6130                    })
6131                    .collect()
6132            } else {
6133                Vec::new()
6134            };
6135
6136        // IMMEDIATE takes the write lock before the first read. A DEFERRED
6137        // transaction would read first and upgrade to a writer at the DELETE,
6138        // and SQLite fails that upgrade at once with SQLITE_BUSY (or
6139        // SQLITE_BUSY_SNAPSHOT) without calling the busy handler. A build
6140        // writing to the index at that moment then made the sweep skip the
6141        // entry instead of waiting a few milliseconds for it.
6142        let tx = rusqlite::Transaction::new_unchecked(
6143            &self.db,
6144            rusqlite::TransactionBehavior::Immediate,
6145        )?;
6146
6147        // Active-pin guard (kunobi-ninja/kache#326, #182): bail out — under
6148        // the write lock, before any decrement or unlink — if the entry was
6149        // accessed within the grace window. Serializes against `get`'s
6150        // `last_accessed` bump so an in-flight restore is never deleted out
6151        // from under itself. Dropping `tx` here rolls back (nothing ran yet).
6152        if let Some(grace) = skip_if_idle_lt {
6153            let recently_accessed: i64 = tx.query_row(
6154                "SELECT EXISTS(SELECT 1 FROM entries \
6155                     WHERE cache_key = ?1 AND last_accessed >= datetime('now', ?2))",
6156                params![cache_key, format!("-{} seconds", grace.as_secs())],
6157                |row| row.get(0),
6158            )?;
6159            if recently_accessed != 0 {
6160                return Ok(RemovalAttempt::Done(None));
6161            }
6162        }
6163
6164        // A blob found retained above blocks the removal only while this
6165        // entry holds its last references, and that needs the lock.
6166        for (hash, held) in retained_blobs {
6167            let rc: i64 = tx.query_row(
6168                "SELECT refcount FROM blobs WHERE hash = ?1",
6169                params![hash],
6170                |row| row.get(0),
6171            )?;
6172            if holds_last_reference(rc, held) {
6173                return Ok(RemovalAttempt::Unreclaimable);
6174            }
6175        }
6176
6177        // Delete the entry row first. If rows_affected is 0, another remover
6178        // already released this entry's references; we skip the decrements so
6179        // two removers can never double-decrement a shared blob's refcount
6180        // and unlink a blob a live entry still points at (#510). This gate —
6181        // not `gc.lock` — is what makes concurrent removal safe; the lock is
6182        // defence in depth for bulk sweeps.
6183        let rows_affected = tx.execute(
6184            "DELETE FROM entries WHERE cache_key = ?1",
6185            params![cache_key],
6186        )?;
6187
6188        // A remover that deleted no row releases nothing and must not touch
6189        // the directory either (#670): a fresh `meta.json` there may belong
6190        // to a publisher whose registration has not committed yet. Reporting
6191        // `None` also keeps callers (eviction stats, tombstones) from
6192        // double-counting one entry as two removals (#510).
6193        if rows_affected == 0 {
6194            return Ok(RemovalAttempt::Done(None));
6195        }
6196
6197        // Republication guard (#670): the row just deleted may belong to a
6198        // NEWER publication than the meta.json this removal read its hash
6199        // list from — decrementing the old hashes against the new row's
6200        // refcounts corrupts the store. `put` materializes meta.json inside
6201        // its own registration transaction, so under the write lock this
6202        // transaction holds the pairing cannot move: any difference means a
6203        // republication won, and the removal rolls back untouched. A
6204        // meta.json that vanished or went corrupt in the window takes the
6205        // same rollback; the NEXT removal attempt reports it properly
6206        // through the #276 guards above.
6207        let still_ours = matches!(fs::read_to_string(&meta_path), Ok(now) if now == meta_content);
6208        if !still_ours {
6209            return Ok(RemovalAttempt::Done(None));
6210        }
6211        // A mapping nobody counted must not spend another entry's reference
6212        // and unlink a blob that entry still serves from.
6213        floor_blob_refs_at_mappings(&tx, cache_key)?;
6214        tx.execute(
6215            "DELETE FROM entry_blobs WHERE cache_key = ?1",
6216            params![cache_key],
6217        )?;
6218        // Decrement in the DB but defer every physical unlink to the cleanup
6219        // pass after commit: while this transaction can still roll back, the
6220        // blob files its refcounts describe must remain on disk.
6221        let mut reclaim = RemovalReclaim::default();
6222        let mut unlink = Vec::new();
6223        for hash in &hashes {
6224            tx.execute(
6225                "UPDATE blobs SET refcount = refcount - 1 WHERE hash = ?1",
6226                params![hash],
6227            )?;
6228            let row: Option<(i64, i64)> = tx
6229                .query_row(
6230                    "SELECT refcount, size FROM blobs WHERE hash = ?1",
6231                    params![hash],
6232                    |row| Ok((row.get(0)?, row.get(1)?)),
6233                )
6234                .ok();
6235            if let Some((rc, size)) = row
6236                && rc <= 0
6237            {
6238                tx.execute("DELETE FROM blobs WHERE hash = ?1", params![hash])?;
6239                unlink.push((hash.clone(), size));
6240            }
6241        }
6242
6243        // Commit the LOGICAL removal before touching the filesystem (#670).
6244        // SQLite can roll back SQL; it cannot restore a deleted meta.json or
6245        // an unlinked blob — so any structure that deletes files inside this
6246        // transaction turns a crash or commit failure after the deletions
6247        // into a committed row whose artifacts are gone, the exact phantom
6248        // this function exists to prevent. Committing first inverts every
6249        // crash window into the recoverable direction: a crash from here on
6250        // leaves at worst an unindexed directory (a later put or index
6251        // rebuild reclaims it) or orphaned blob files (the orphan sweep
6252        // reclaims those), never a live row without its files.
6253        tx.commit()?;
6254
6255        // Cleanup pass: a second short transaction whose only purpose is the
6256        // write lock. Serializing the filesystem deletions against same-key
6257        // writers is what closes the original #670 window — an unlocked
6258        // cleanup could delete a meta.json that a publisher materialized
6259        // (inside its own registration transaction) between our commit above
6260        // and this pass.
6261        let cleanup_tx = self.db.unchecked_transaction()?;
6262        cleanup_tx.execute("UPDATE entries SET cache_key = cache_key WHERE 1 = 0", [])?;
6263
6264        if let Some(hook) = before_dir_cleanup {
6265            hook();
6266        }
6267
6268        // A publisher may have republished this key between the commit above
6269        // and this lock. The directory then belongs to the new generation:
6270        // leave it untouched. Its blob adoption also re-inserted any of our
6271        // zero-ref rows it needed, which the per-blob guard below observes.
6272        let republished: i64 = cleanup_tx.query_row(
6273            "SELECT EXISTS(SELECT 1 FROM entries WHERE cache_key = ?1)",
6274            params![cache_key],
6275            |row| row.get(0),
6276        )?;
6277
6278        if republished == 0 {
6279            // Remove the entry directory (just meta.json in new format, may
6280            // have artifacts in legacy entries). Windows can surface external
6281            // interference as delete-pending errors (sharing violations from
6282            // readers mid-hardlink), so on any error re-check whether the
6283            // directory is actually gone, with a brief bounded retry (worst
6284            // case 50ms of extra lock hold). A directory that persists past
6285            // the retries is a real failure (permissions, open handles): it
6286            // propagates (#510). The logical removal is already committed, so
6287            // the failure leaves only an unindexed directory — recoverable —
6288            // never a live row whose files are gone.
6289            if let Ok(entries) = fs::read_dir(&entry_dir) {
6290                for entry in entries.flatten() {
6291                    let path = entry.path();
6292                    if let Ok(meta) = fs::metadata(&path) {
6293                        let mut perms = meta.permissions();
6294                        perms.set_readonly(false);
6295                        let _ = fs::set_permissions(&path, perms);
6296                    }
6297                }
6298            }
6299            let mut result = Ok(());
6300            for _ in 0..5 {
6301                result = match fs::remove_dir_all(&entry_dir) {
6302                    Ok(()) => Ok(()),
6303                    Err(e) => {
6304                        // Benign exactly when the directory is gone: NotFound
6305                        // is the Unix shape of losing the race, and Windows
6306                        // surfaces a competitor's in-flight delete as
6307                        // delete-pending errors instead.
6308                        if !entry_dir.exists() { Ok(()) } else { Err(e) }
6309                    }
6310                };
6311                if result.is_ok() {
6312                    break;
6313                }
6314                std::thread::sleep(Duration::from_millis(10));
6315            }
6316            result.with_context(|| format!("entry {cache_key}: removing entry directory"))?;
6317        }
6318
6319        // Unlink dead blobs under the write lock so a concurrent adopter
6320        // can't commit a reference to a file we're deleting — re-checked
6321        // per blob, because a publisher that won the lock between our two
6322        // transactions may have re-inserted some of the rows the first
6323        // transaction deleted. Only bytes whose last reference went away are
6324        // physically freed — that, not the entry's logical size, is what
6325        // eviction budgets on (#608).
6326        for (hash, size) in unlink {
6327            let readopted: i64 = cleanup_tx.query_row(
6328                "SELECT EXISTS(SELECT 1 FROM blobs WHERE hash = ?1)",
6329                params![hash],
6330                |row| row.get(0),
6331            )?;
6332            if readopted == 0 {
6333                let blob = self.blob_path(&hash);
6334                let disk =
6335                    crate::filesystem::blob_reclaimable_bytes(&blob).unwrap_or(size.max(0) as u64);
6336                unlink_blob(&blob);
6337                reclaim.freed_bytes += size.max(0) as u64;
6338                reclaim.disk_bytes_reclaimed += disk;
6339                reclaim.blobs_unlinked += 1;
6340            }
6341        }
6342        cleanup_tx.commit()?;
6343        Ok(RemovalAttempt::Done(Some(reclaim)))
6344    }
6345
6346    /// Test-only: insert a bare committed entry row, for tests that stage a
6347    /// synthetic `meta.json` and need removal to own the directory (#670
6348    /// made directory cleanup conditional on owning the row).
6349    #[cfg(any(test, feature = "test-support"))]
6350    #[doc(hidden)]
6351    pub fn insert_entry_row_for_test(&self, cache_key: &str) {
6352        self.db
6353            .execute(
6354                "INSERT OR REPLACE INTO entries (cache_key, crate_name, size, committed) \
6355                 VALUES (?1, 'test', 1, 1)",
6356                params![cache_key],
6357            )
6358            .expect("test entry row insert");
6359    }
6360
6361    /// Test-only: backdate an entry's `last_accessed` (via a SQLite datetime
6362    /// modifier like `"-1 hour"`) so eviction tests can move an entry past the
6363    /// active-pin grace without sleeping (kunobi-ninja/kache#326).
6364    #[cfg(any(test, feature = "test-support"))]
6365    #[doc(hidden)]
6366    pub fn set_last_accessed_for_test(&self, cache_key: &str, sql_modifier: &str) {
6367        self.db
6368            .execute(
6369                "UPDATE entries SET last_accessed = datetime('now', ?2) WHERE cache_key = ?1",
6370                params![cache_key, sql_modifier],
6371            )
6372            .unwrap();
6373    }
6374
6375    /// Test-only: delete a file that may share a blob's blocks (a `put`
6376    /// source, a stand-in target) and wait until the sweep's retainer check
6377    /// no longer sees it holding them.
6378    ///
6379    /// A reflinked ingest shares the source's blocks. XFS frees an unlinked
6380    /// inode's blocks in a background worker, so FIEMAP still reports the
6381    /// blob as a clone for a moment after the unlink, and a sweep in that
6382    /// window skips the entry as unreclaimable (kunobi-ninja/kache#1241).
6383    #[cfg(any(test, feature = "test-support"))]
6384    #[doc(hidden)]
6385    pub fn remove_clone_for_test(&self, path: &Path) {
6386        let blob = self.blob_path(&crate::file_hash::hash_file(path).unwrap());
6387        fs::remove_file(path).unwrap();
6388        for _ in 0..2000 {
6389            if !crate::filesystem::blob_has_external_retainer(&blob) {
6390                return;
6391            }
6392            std::thread::sleep(Duration::from_millis(5));
6393        }
6394        panic!(
6395            "{} still shares blocks 10s after {} was removed",
6396            blob.display(),
6397            path.display()
6398        );
6399    }
6400
6401    /// Clear the entire store, including retained corrupt index copies.
6402    /// Tracked target directories stay registered: they live outside the
6403    /// store, and `kache clean` finds them through the registry.
6404    ///
6405    /// Index rows drop first, in one transaction: once it commits no
6406    /// reader can begin a restore from a purged entry, and the
6407    /// filesystem wipe then runs against a store the index no longer
6408    /// references — a crash mid-wipe strands at worst orphan files for
6409    /// the sweep, never the pre-existing rows dangling over deleted
6410    /// blobs that the old wipe-then-delete order could leave.
6411    ///
6412    /// Publishers don't take `gc.lock`, so a put can still commit a
6413    /// fresh row while the wipe is deleting the files it just staged.
6414    /// The second row-deletion pass reduces that to the store's
6415    /// tolerated shapes: a row committed before the pass is dropped
6416    /// (its files become sweepable orphans), and one committed after it
6417    /// at worst lands stranded — the refuse-removal / miss / re-put
6418    /// path that already recovers it.
6419    pub fn clear(&self) -> Result<()> {
6420        self.refuse_write("clear the store")?;
6421        let drop_index_rows = || -> Result<()> {
6422            let tx = self.db.unchecked_transaction()?;
6423            tx.execute("DELETE FROM entries", [])?;
6424            tx.execute("DELETE FROM entry_blobs", [])?;
6425            tx.execute("DELETE FROM blobs", [])?;
6426            tx.execute("DELETE FROM incremental_dirs", [])?;
6427            tx.commit()?;
6428            Ok(())
6429        };
6430        drop_index_rows()?;
6431        let store_dir = self.config.store_dir();
6432        if store_dir.exists() {
6433            // Make everything writable recursively, then remove all subdirs
6434            for entry in fs::read_dir(&store_dir)?.flatten() {
6435                let path = entry.path();
6436                if path.is_dir() {
6437                    Self::make_writable_recursive(&path);
6438                    let _ = fs::remove_dir_all(&path);
6439                }
6440            }
6441        }
6442        drop_index_rows()?;
6443        remove_quarantined_index_files(&quarantined_index_files(&self.config.index_db_path())?)
6444    }
6445
6446    /// Recursively make all files in a directory writable so they can be deleted.
6447    fn make_writable_recursive(dir: &Path) {
6448        if let Ok(entries) = fs::read_dir(dir) {
6449            for entry in entries.flatten() {
6450                let path = entry.path();
6451                if path.is_dir() {
6452                    Self::make_writable_recursive(&path);
6453                } else if let Ok(meta) = fs::metadata(&path) {
6454                    let mut perms = meta.permissions();
6455                    perms.set_readonly(false);
6456                    let _ = fs::set_permissions(&path, perms);
6457                }
6458            }
6459        }
6460    }
6461
6462    /// List all entries for display.
6463    pub fn list_entries(&self, sort_by: &str) -> Result<Vec<EntryInfo>> {
6464        let order_clause = match sort_by {
6465            "size" => "size DESC",
6466            "hits" => "hit_count DESC",
6467            "age" => "created_at ASC",
6468            _ => "crate_name ASC",
6469        };
6470
6471        let mut stmt = self.db.prepare(&format!(
6472            "SELECT cache_key, crate_name, crate_type, profile, size, created_at, last_accessed, hit_count, content_hash FROM entries WHERE committed = 1 ORDER BY {order_clause}"
6473        ))?;
6474
6475        let entries = stmt
6476            .query_map([], |row| {
6477                Ok(EntryInfo {
6478                    cache_key: row.get(0)?,
6479                    crate_name: row.get(1)?,
6480                    crate_type: row.get(2)?,
6481                    profile: row.get(3)?,
6482                    size: row.get::<_, i64>(4)? as u64,
6483                    created_at: row.get(5)?,
6484                    last_accessed: row.get(6)?,
6485                    hit_count: row.get::<_, i64>(7)? as u64,
6486                    content_hash: row.get(8)?,
6487                })
6488            })?
6489            .collect::<Result<Vec<_>, _>>()?;
6490
6491        Ok(entries)
6492    }
6493
6494    /// Migrate a single legacy entry's artifacts into the blob store.
6495    /// Returns `false`, touching nothing, when no committed row owns the key.
6496    ///
6497    /// An artifact beside meta.json is not proof of a legacy entry: a remote
6498    /// download extracts into the same directory before its import, and a
6499    /// daemon killed in that window leaves the same shape. Counting
6500    /// references for it strands them, because no entry, and so no eviction,
6501    /// ever gives them back.
6502    ///
6503    /// A committed entry with a leftover import artifact still gains a
6504    /// reference it already held. That errs high only, and the daemon's
6505    /// blob-index reconcile repairs it.
6506    fn migrate_entry_to_blobs(&self, meta: &EntryMeta) -> Result<bool> {
6507        let committed: bool = self.db.query_row(
6508            "SELECT EXISTS(SELECT 1 FROM entries WHERE cache_key = ?1 AND committed = 1)",
6509            params![meta.cache_key],
6510            |row| row.get(0),
6511        )?;
6512        if !committed {
6513            return Ok(false);
6514        }
6515        let entry_dir = self.entry_dir(&meta.cache_key);
6516        for cached_file in &meta.files {
6517            let artifact_path = entry_dir.join(&cached_file.name);
6518            if !artifact_path.exists() {
6519                continue; // Already migrated
6520            }
6521            let blob = self.blob_path(&cached_file.hash);
6522            let blob_dir = blob.parent().unwrap();
6523            fs::create_dir_all(blob_dir)?;
6524
6525            // Check if blob already exists
6526            let existing: Option<i64> = self
6527                .db
6528                .query_row(
6529                    "SELECT refcount FROM blobs WHERE hash = ?1",
6530                    params![cached_file.hash],
6531                    |row| row.get(0),
6532                )
6533                .ok();
6534
6535            if existing.is_some() {
6536                // Blob exists — delete artifact, bump refcount
6537                if let Ok(m) = fs::metadata(&artifact_path) {
6538                    let mut perms = m.permissions();
6539                    perms.set_readonly(false);
6540                    let _ = fs::set_permissions(&artifact_path, perms);
6541                }
6542                fs::remove_file(&artifact_path)?;
6543                self.db.execute(
6544                    "UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
6545                    params![cached_file.hash],
6546                )?;
6547            } else {
6548                // New blob — rename artifact into blob store
6549                if let Ok(m) = fs::metadata(&artifact_path) {
6550                    let mut perms = m.permissions();
6551                    if !perms.readonly() {
6552                        perms.set_readonly(true);
6553                        fs::set_permissions(&artifact_path, perms)?;
6554                    }
6555                }
6556                fs::rename(&artifact_path, &blob)?;
6557                self.db.execute(
6558                    "INSERT OR IGNORE INTO blobs (hash, size, refcount) VALUES (?1, ?2, 1)",
6559                    params![cached_file.hash, cached_file.size as i64],
6560                )?;
6561                if self.db.changes() == 0 {
6562                    self.db.execute(
6563                        "UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
6564                        params![cached_file.hash],
6565                    )?;
6566                }
6567            }
6568        }
6569        Ok(true)
6570    }
6571
6572    /// Bulk-migrate all legacy entries' artifacts into the blob store.
6573    pub fn migrate_to_blobs(&self, progress: impl Fn(usize, usize)) -> Result<MigrationStats> {
6574        self.refuse_write("migrate entries")?;
6575        let store_dir = self.config.store_dir();
6576        let mut stats = MigrationStats::default();
6577
6578        let mut entry_dirs = Vec::new();
6579        if let Ok(entries) = fs::read_dir(&store_dir) {
6580            for entry in entries.flatten() {
6581                let path = entry.path();
6582                if path.is_dir() && path.file_name().is_some_and(|n| n != "blobs") {
6583                    let meta_path = path.join("meta.json");
6584                    if meta_path.exists() {
6585                        let has_artifacts = fs::read_dir(&path)
6586                            .into_iter()
6587                            .flatten()
6588                            .flatten()
6589                            .any(|e| e.file_name() != "meta.json");
6590                        if has_artifacts {
6591                            entry_dirs.push(path);
6592                        }
6593                    }
6594                }
6595            }
6596        }
6597
6598        let total = entry_dirs.len();
6599        for (i, entry_dir) in entry_dirs.iter().enumerate() {
6600            progress(i, total);
6601            stats.entries_scanned += 1;
6602
6603            let meta_path = entry_dir.join("meta.json");
6604            let content = match fs::read_to_string(&meta_path) {
6605                Ok(c) => c,
6606                Err(_) => {
6607                    stats.entries_skipped += 1;
6608                    continue;
6609                }
6610            };
6611            let meta: EntryMeta = match serde_json::from_str(&content) {
6612                Ok(m) => m,
6613                Err(_) => {
6614                    stats.entries_skipped += 1;
6615                    continue;
6616                }
6617            };
6618
6619            match self.migrate_entry_to_blobs(&meta) {
6620                Ok(true) => stats.entries_migrated += 1,
6621                Ok(false) | Err(_) => stats.entries_skipped += 1,
6622            }
6623        }
6624
6625        progress(total, total);
6626        Ok(stats)
6627    }
6628
6629    /// Return content-dedup statistics: unique blobs, physical vs logical size.
6630    pub fn blob_stats(&self) -> Result<BlobStats> {
6631        let total_blobs: i64 = self
6632            .db
6633            .query_row("SELECT COUNT(*) FROM blobs", [], |row| row.get(0))?;
6634        let total_blob_size: i64 =
6635            self.db
6636                .query_row("SELECT COALESCE(SUM(size), 0) FROM blobs", [], |row| {
6637                    row.get(0)
6638                })?;
6639        let total_logical_size: i64 =
6640            self.db
6641                .query_row("SELECT COALESCE(SUM(size), 0) FROM entries", [], |row| {
6642                    row.get(0)
6643                })?;
6644        Ok(BlobStats {
6645            total_blobs: total_blobs as usize,
6646            total_blob_size: total_blob_size as u64,
6647            total_logical_size: total_logical_size as u64,
6648            savings: (total_logical_size as u64).saturating_sub(total_blob_size as u64),
6649        })
6650    }
6651}
6652
6653/// Does an entry holding `held` references to a blob hold all of the blob's
6654/// `rc` remaining ones? An `rc` of zero or less means the index no longer
6655/// counts the blob at all.
6656fn holds_last_reference(rc: i64, held: i64) -> bool {
6657    rc > 0 && rc <= held
6658}
6659
6660/// Content-dedup statistics.
6661#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
6662pub struct BlobStats {
6663    pub total_blobs: usize,
6664    pub total_blob_size: u64,
6665    pub total_logical_size: u64,
6666    pub savings: u64,
6667}
6668
6669/// Statistics from a blob migration run.
6670#[derive(Debug, Default)]
6671pub struct MigrationStats {
6672    pub entries_scanned: usize,
6673    pub entries_migrated: usize,
6674    pub entries_skipped: usize,
6675    pub blobs_created: usize,
6676    pub blobs_reused: usize,
6677    pub bytes_saved: u64,
6678}
6679
6680#[derive(Debug, Clone)]
6681pub struct EntryInfo {
6682    pub cache_key: String,
6683    pub crate_name: String,
6684    pub crate_type: String,
6685    pub profile: String,
6686    pub size: u64,
6687    pub created_at: String,
6688    pub last_accessed: String,
6689    pub hit_count: u64,
6690    pub content_hash: Option<String>,
6691}
6692
6693/// Commit a transaction this code began, or roll it back. A failed `COMMIT`
6694/// can leave it open, so that is rolled back too.
6695fn finish_transaction(db: &rusqlite::Connection, succeeded: bool) -> Result<()> {
6696    if !succeeded {
6697        let _ = db.execute_batch("ROLLBACK");
6698        return Ok(());
6699    }
6700    if let Err(error) = db.execute_batch("COMMIT") {
6701        let _ = db.execute_batch("ROLLBACK");
6702        return Err(error.into());
6703    }
6704    Ok(())
6705}
6706#[cfg(test)]
6707mod tests;