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(¤t) {
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;