Skip to main content

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}