sui_cache/watch.rs
1//! L2 GC-survival: decide which store paths the warm cache must capture.
2//!
3//! ── WHAT THIS CLOSES ──────────────────────────────────────────────────────
4//! `theory/ATATAME.md` calls L2 *"the single highest-value gap in this
5//! doctrine"*. `attic watch-store` used to provide it; attic was retired
6//! 2026-07-31, and until something captures a newly-realized store path
7//! before `nix-collect-garbage` reaches it, the warm store is a
8//! within-session memo rather than a durable win.
9//!
10//! ── A RECONCILER, NOT AN EVENT STREAM ─────────────────────────────────────
11//! attic watched inotify events. This diffs *state* instead, and that is a
12//! deliberate upgrade rather than an implementation shortcut:
13//!
14//! * it converges after a restart, a missed event, or a crash — an event
15//! stream loses whatever happened while it was down;
16//! * it captures paths that arrived by SUBSTITUTION, which no build hook
17//! ever sees. rio's post-build hook only pushes what rio BUILDS, so a
18//! substituted path has never been covered by anything;
19//! * "what is missing" is computed from the cache itself, so a push that
20//! failed is simply still missing next pass — retry needs no bookkeeping.
21//!
22//! ── WHY A BASELINE, AND WHY IT IS THE DEFAULT ─────────────────────────────
23//! A pure reconciler would mirror the ENTIRE store on first run. Measured on
24//! rio 2026-08-08: 60,025 store paths against 6,929 cached, so the first pass
25//! would try to capture ~53,000 paths and grow a 12 GiB cache toward the size
26//! of the whole store. That is not what the doctrine asks for — L2 is
27//! *survival of newly-realized paths*, not a full mirror.
28//!
29//! So the watcher records a BASELINE at startup and captures only what
30//! appears after it. `--initial-reconcile` starts from an empty baseline for
31//! operators who do want the backfill, and `max_per_pass` bounds either mode
32//! so a large build cannot turn one tick into an unbounded upload.
33
34use std::collections::HashSet;
35
36/// What a single capture pass should do.
37#[derive(Debug, Clone, PartialEq, Eq, Default)]
38pub struct CapturePlan {
39 /// Store-path hashes to push this pass, bounded by `max_per_pass`.
40 pub to_capture: Vec<String>,
41 /// Candidates that did not fit this pass. They are NOT lost — the next
42 /// pass recomputes from live state and picks them up. Reported so a
43 /// persistently non-zero value tells the operator the interval is too
44 /// long or the bound too small for this machine's build rate.
45 pub deferred: usize,
46}
47
48/// Decide what to capture, from state alone.
49///
50/// `valid` are the hashes currently valid in the nix store, `cached` the
51/// hashes the cache already holds, `baseline` the hashes that existed when
52/// the watcher started (empty for a full backfill).
53///
54/// A candidate is a path that is valid, not cached, and not in the baseline.
55/// Order is deterministic (sorted) so a bounded pass is reproducible rather
56/// than dependent on hash-map iteration order — an operator re-running a pass
57/// against unchanged state gets the same answer.
58#[must_use]
59pub fn plan_capture(
60 valid: &[String],
61 cached: &HashSet<String>,
62 baseline: &HashSet<String>,
63 max_per_pass: usize,
64) -> CapturePlan {
65 let mut candidates: Vec<String> = valid
66 .iter()
67 .filter(|h| !cached.contains(*h) && !baseline.contains(*h))
68 .cloned()
69 .collect();
70 candidates.sort_unstable();
71 candidates.dedup();
72
73 let deferred = candidates.len().saturating_sub(max_per_pass);
74 candidates.truncate(max_per_pass);
75 CapturePlan {
76 to_capture: candidates,
77 deferred,
78 }
79}
80
81/// Outcome of a capture pass, for the operator-facing line.
82#[derive(Debug, Clone, PartialEq, Eq, Default)]
83pub struct WatchReport {
84 /// Valid store paths considered.
85 pub scanned: usize,
86 /// Paths successfully pushed into the warm cache.
87 pub captured: usize,
88 /// Paths that failed to push. Non-fatal — a single bad path must never
89 /// stop the watcher, or one unreadable path disables GC-survival for the
90 /// whole machine.
91 pub failed: usize,
92 /// Candidates that did not fit this pass.
93 pub deferred: usize,
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 fn set(items: &[&str]) -> HashSet<String> {
101 items.iter().map(|s| (*s).to_string()).collect()
102 }
103 fn v(items: &[&str]) -> Vec<String> {
104 items.iter().map(|s| (*s).to_string()).collect()
105 }
106
107 /// The default posture: a path present at startup is NOT captured. This
108 /// is what stops the first pass on a real machine from trying to mirror
109 /// 53,000 paths.
110 #[test]
111 fn baseline_paths_are_not_captured() {
112 let plan = plan_capture(&v(&["a", "b"]), &set(&[]), &set(&["a", "b"]), 100);
113 assert!(plan.to_capture.is_empty());
114 assert_eq!(plan.deferred, 0);
115 }
116
117 /// The whole point: something realized after startup gets captured.
118 #[test]
119 fn a_newly_realized_path_is_captured() {
120 let plan = plan_capture(&v(&["a", "b", "new"]), &set(&[]), &set(&["a", "b"]), 100);
121 assert_eq!(plan.to_capture, v(&["new"]));
122 }
123
124 /// Already-cached paths are never re-pushed — this is what makes the
125 /// watcher cheap to run on a short interval.
126 #[test]
127 fn cached_paths_are_skipped() {
128 let plan = plan_capture(&v(&["a", "new"]), &set(&["new"]), &set(&["a"]), 100);
129 assert!(plan.to_capture.is_empty());
130 }
131
132 /// A failed push leaves the path uncached, so the next pass retries it
133 /// with no retry bookkeeping at all. Modelled here as: still valid, still
134 /// not cached, still not in baseline => still a candidate.
135 #[test]
136 fn a_failed_capture_is_retried_next_pass() {
137 let baseline = set(&["a"]);
138 let first = plan_capture(&v(&["a", "new"]), &set(&[]), &baseline, 100);
139 assert_eq!(first.to_capture, v(&["new"]));
140 // push failed => cache still empty
141 let second = plan_capture(&v(&["a", "new"]), &set(&[]), &baseline, 100);
142 assert_eq!(second.to_capture, v(&["new"]), "must retry, not drop");
143 }
144
145 /// A big build must not turn one tick into an unbounded upload, and the
146 /// overflow must be REPORTED rather than silently dropped.
147 #[test]
148 fn max_per_pass_bounds_the_work_and_reports_the_remainder() {
149 let plan = plan_capture(&v(&["a", "b", "c", "d", "e"]), &set(&[]), &set(&[]), 2);
150 assert_eq!(plan.to_capture.len(), 2);
151 assert_eq!(plan.deferred, 3);
152 }
153
154 /// An empty baseline is the `--initial-reconcile` mode: everything
155 /// missing becomes a candidate.
156 #[test]
157 fn empty_baseline_backfills() {
158 let plan = plan_capture(&v(&["a", "b"]), &set(&["a"]), &set(&[]), 100);
159 assert_eq!(plan.to_capture, v(&["b"]));
160 }
161
162 /// Deterministic order — a bounded pass over unchanged state must pick
163 /// the same paths every time, not whatever the hash map yielded.
164 #[test]
165 fn selection_is_deterministic() {
166 let valid = v(&["z", "m", "a", "q"]);
167 let a = plan_capture(&valid, &set(&[]), &set(&[]), 2);
168 let b = plan_capture(&valid, &set(&[]), &set(&[]), 2);
169 assert_eq!(a, b);
170 assert_eq!(a.to_capture, v(&["a", "m"]));
171 }
172}