Skip to main content

doover_core/
maintenance.rs

1//! Maintenance / garbage collection (step 7).
2//!
3//! Retention contract:
4//! - the cutoff derives from MAX(started_at_ms) in the journal, NEVER the
5//!   wall clock — a backward NTP jump must not make recent snapshots look
6//!   collectable (carried-forward rule from the audit rounds);
7//! - pinned actions and anything a live undo references survive regardless
8//!   of age; the journal's newest action is always "recent" by definition;
9//! - journal rows are pruned along with store objects (old raw_command
10//!   strings may embed secrets), preserving chain references and pins;
11//! - stale store/tmp entries (crash leftovers) are removed once they are
12//!   comfortably older than any plausible in-flight ingestion.
13
14use crate::journal::{Journal, JournalError};
15use crate::snapshot::{SnapshotError, Store};
16use std::path::Path;
17
18const DAY_MS: i64 = 24 * 60 * 60 * 1000;
19/// Tmp entries older than this are crash leftovers, not in-flight ingests.
20const TMP_MAX_AGE_MS: u64 = 60 * 60 * 1000;
21/// Size/free eviction never touches rows within this window of the journal's
22/// newest action: they may belong to an in-flight session (round-12 FK
23/// guard), their objects are grace-protected anyway (round 14), and the
24/// just-ran action is the one a user most plausibly wants to undo. Aligned
25/// with the object grace window on purpose.
26const HOT_WINDOW_MS: i64 = TMP_MAX_AGE_MS as i64;
27/// Rows evicted per size-cap iteration before re-measuring pressure.
28const EVICTION_BATCH: u32 = 64;
29
30#[derive(Debug, thiserror::Error)]
31pub enum MaintenanceError {
32    #[error(transparent)]
33    Journal(#[from] JournalError),
34    #[error(transparent)]
35    Snapshot(#[from] SnapshotError),
36}
37
38pub struct GcOptions {
39    /// Keep everything newer than this many days before the journal's newest
40    /// action (journal-relative, not wall-clock).
41    pub keep_days: i64,
42    pub dry_run: bool,
43    /// Store size ceiling (apparent bytes). When exceeded, the oldest
44    /// evictable actions are removed — rows AND objects — until within the
45    /// cap. Pins and the hot window are absolute floors. `None` = no cap.
46    pub cap_bytes: Option<u64>,
47    /// Minimum free space on the store's filesystem. Below it, eviction runs
48    /// exactly like the cap. `None` = no floor. NOTE: the automatic post-hook
49    /// trigger deliberately passes `None` here — deficit-driven eviction
50    /// destroys history over pressure that is usually NOT doover's fault (and
51    /// frees ~0 physical bytes on CoW filesystems), so it only ever runs from
52    /// an explicit `doover gc`, where the report is in front of the user.
53    pub min_free_bytes: Option<u64>,
54    /// Wall-clock ceiling for the EVICTION loop (D1 discipline). On expiry
55    /// the pass stops cleanly with `still_over_budget = true`; a later pass
56    /// resumes where the row order left off. `None` = unbounded (manual gc).
57    pub time_budget: Option<std::time::Duration>,
58}
59
60/// Env-driven maintenance settings shared by `doover gc` and the post-hook
61/// trigger. Parsing is fail-safe (garbage → default); an explicit `0` is the
62/// documented opt-out for each knob.
63#[derive(Debug, Clone, Copy)]
64pub struct MaintenanceBudget {
65    /// DOOVER_MAX_STORE_BYTES (default 5 GiB; 0 = uncapped).
66    pub cap_bytes: Option<u64>,
67    /// DOOVER_MIN_FREE_BYTES (default 1 GiB; 0 = no floor). In the automatic
68    /// path a breach triggers a retention+cap pass and a loud warning — it
69    /// never drives eviction (that requires an explicit `doover gc`).
70    pub min_free_bytes: Option<u64>,
71    /// DOOVER_GC_EVERY: run gc from the post hook every N completed actions
72    /// (default 50; 0 = NO automatic gc at all, including the free-space
73    /// trigger — breaches then only warn).
74    pub gc_every: u64,
75    /// DOOVER_KEEP_DAYS (default 7; 0 = retention opt-out, keep forever) —
76    /// the retention window the trigger uses.
77    pub keep_days: i64,
78}
79
80impl MaintenanceBudget {
81    pub fn from_env() -> Self {
82        let get = |k: &str| std::env::var(k).ok();
83        Self {
84            cap_bytes: parse_opt_bytes(get("DOOVER_MAX_STORE_BYTES").as_deref(), 5 << 30),
85            min_free_bytes: parse_opt_bytes(get("DOOVER_MIN_FREE_BYTES").as_deref(), 1 << 30),
86            // clamped so the `as i64` in the cadence check can never wrap
87            // negative and accidentally fire on every action
88            gc_every: parse_u64_or(get("DOOVER_GC_EVERY").as_deref(), 50).min(i64::MAX as u64),
89            // 0 follows the knob convention: opt OUT of retention (keep
90            // forever). Without the special case, 0 would mean "prune
91            // everything older than the newest action" — the exact opposite,
92            // run silently from the trigger (D2 review).
93            keep_days: match parse_u64_or(get("DOOVER_KEEP_DAYS").as_deref(), 7) {
94                0 => i64::MAX,
95                d => d.min(i64::MAX as u64) as i64,
96            },
97        }
98    }
99
100    /// No budgets and no trigger — the config for tests and for callers that
101    /// only want explicit, manual gc.
102    pub fn disabled() -> Self {
103        Self {
104            cap_bytes: None,
105            min_free_bytes: None,
106            gc_every: 0,
107            keep_days: 7,
108        }
109    }
110}
111
112/// The exact GcOptions the AUTOMATIC (post-hook) trigger runs with. A pure
113/// function so its load-bearing fields are pinned by unit test (round 18):
114/// never dry-run, floor NEVER drives automatic eviction, and the pass always
115/// carries the 3s time budget (D1 discipline on the hook path).
116pub fn auto_gc_options(b: &MaintenanceBudget) -> GcOptions {
117    GcOptions {
118        keep_days: b.keep_days,
119        dry_run: false,
120        cap_bytes: b.cap_bytes,
121        // deficit-driven eviction is a manual `doover gc` decision only
122        min_free_bytes: None,
123        time_budget: Some(std::time::Duration::from_secs(3)),
124    }
125}
126
127/// Fail-safe byte-budget parse: unset/garbage → default, explicit 0 → None
128/// (the opt-out). Config can never silently zero a protection budget.
129fn parse_opt_bytes(v: Option<&str>, default: u64) -> Option<u64> {
130    match v {
131        None => Some(default),
132        Some(s) => match s.trim().parse::<u64>() {
133            Ok(0) => None,
134            Ok(n) => Some(n),
135            Err(_) => Some(default),
136        },
137    }
138}
139
140fn parse_u64_or(v: Option<&str>, default: u64) -> u64 {
141    v.and_then(|s| s.trim().parse().ok()).unwrap_or(default)
142}
143
144#[derive(Debug, Default)]
145pub struct GcReport {
146    pub dry_run: bool,
147    /// The journal-relative cutoff used (None = empty journal, no-op).
148    pub cutoff_ms: Option<i64>,
149    pub objects_removed: u64,
150    pub bytes_freed: u64,
151    pub actions_pruned: u64,
152    pub sessions_pruned: u64,
153    pub tmp_removed: u64,
154    /// Bytes over `cap_bytes` measured BEFORE eviction — identical in dry-run
155    /// and real mode (round-12 honesty: dry-run measures what real acts on).
156    pub over_cap_bytes_before: u64,
157    /// Free-space shortfall below `min_free_bytes` measured before eviction.
158    pub free_deficit_bytes_before: u64,
159    /// Actions evicted by the size/free pass (always 0 in dry-run — eviction
160    /// is iterative and only measured, never simulated).
161    pub cap_evicted_actions: u64,
162    /// Real runs only: budgets still unmet after evicting everything
163    /// evictable (pins + hot window are absolute floors). Surfaced so
164    /// "bounded by pins" is a visible state, not silent failure.
165    pub still_over_budget: bool,
166}
167
168pub fn gc(
169    journal: &Journal,
170    store: &Store,
171    doover_home: &Path,
172    opts: &GcOptions,
173) -> Result<GcReport, MaintenanceError> {
174    let mut report = GcReport {
175        dry_run: opts.dry_run,
176        ..Default::default()
177    };
178
179    // crash leftovers are collectable even on an empty journal
180    report.tmp_removed = store.clean_tmp(TMP_MAX_AGE_MS, opts.dry_run)?;
181
182    let Some(recorded_newest) = journal.max_started_at()? else {
183        return Ok(report); // nothing journaled: nothing else to judge
184    };
185    // Clamp the anchor to now: one row with a forward-skewed timestamp (a
186    // brief clock jump) must not become a phantom "newest" that makes every
187    // REAL action look ancient — collapsing the hot window and dragging the
188    // retention cutoff into the present. The clamp only engages when the
189    // journal claims the future, so the round-7 rule (a BACKWARD jump must
190    // not make recent snapshots collectable) is untouched: in that case
191    // `recorded_newest` is in now's past and min() keeps it.
192    let newest = recorded_newest.min(crate::journal::now_ms());
193    // saturating: a pathological --keep-days must not overflow the i64 window
194    // (panic in debug, wrap in release). Saturation lands at i64::MIN — an
195    // infinite retention window that keeps everything, the safe direction.
196    let cutoff = newest.saturating_sub(opts.keep_days.max(0).saturating_mul(DAY_MS));
197    report.cutoff_ms = Some(cutoff);
198
199    // store objects: everything the journal still vouches for stays. The
200    // grace window keeps just-promoted objects a concurrent hook has not yet
201    // journaled (GC-vs-writer race), so gc is safe to run while an agent works
202    let live = journal.live_hashes(cutoff)?;
203    let (objects_removed, bytes_freed) = store.prune(&live, TMP_MAX_AGE_MS, opts.dry_run)?;
204    report.objects_removed = objects_removed;
205    report.bytes_freed = bytes_freed;
206
207    // journal rows: prune AFTER computing the live set so this pass's object
208    // decisions were made against the journal state the user could inspect
209    let (actions_pruned, sessions_pruned) = journal.prune_before(cutoff, opts.dry_run)?;
210    report.actions_pruned = actions_pruned;
211    report.sessions_pruned = sessions_pruned;
212
213    // ---- size cap + free-space floor (D2): oldest-first eviction ----------
214    // Pressure is measured in BOTH modes so dry-run reports what a real run
215    // faces. The cap arm compares apparent bytes to apparent bytes, so
216    // dry-run credits the bytes its simulated retention prune would have
217    // freed. The DEFICIT arm is physical (statvfs) — apparent bytes must
218    // never be subtracted from it (on CoW filesystems deleting a clone frees
219    // ~0 physical blocks; the units simply differ), so no credit applies and
220    // the dry-run deficit is an honest upper bound.
221    let apparent = store.total_bytes()?;
222    let cap_credit = if opts.dry_run { report.bytes_freed } else { 0 };
223    let over_cap = match opts.cap_bytes {
224        Some(cap) => apparent.saturating_sub(cap_credit).saturating_sub(cap),
225        None => 0,
226    };
227    let deficit = free_deficit(doover_home, opts);
228    report.over_cap_bytes_before = over_cap;
229    report.free_deficit_bytes_before = deficit;
230    if opts.dry_run || (over_cap == 0 && deficit == 0) {
231        return Ok(report);
232    }
233
234    // The budgets outrank the retention window (disk-fill is the hard
235    // failure), but never pins, never pending/chain-referenced rows, and
236    // never the hot window. Eviction reuses the SAME audited pipeline as
237    // retention gc — a cutoff advanced past the oldest evictable rows — so it
238    // inherits the round-12/14 concurrency guarantees instead of new deletion
239    // code. Apparent size is tracked incrementally (one walk, not one per
240    // batch); the optional deadline keeps a triggered gc off the hook's
241    // critical path (D1 discipline: bounded time, honest partial result).
242    let deadline = opts.time_budget.map(|d| std::time::Instant::now() + d);
243    let ceiling = newest.saturating_sub(HOT_WINDOW_MS);
244    let mut apparent_now = apparent;
245    loop {
246        if deadline.is_some_and(|dl| std::time::Instant::now() >= dl) {
247            report.still_over_budget = true;
248            break;
249        }
250        let Some(batch_end) = journal.oldest_evictable_batch_end(EVICTION_BATCH, ceiling)? else {
251            // nothing evictable is left below the hot window: budgets are
252            // bounded by pins/hot rows. Report it — never force past floors.
253            report.still_over_budget = true;
254            break;
255        };
256        let evict_cutoff = batch_end.saturating_add(1).min(ceiling);
257        let live = journal.live_hashes(evict_cutoff)?;
258        let (objs, bytes) = store.prune(&live, TMP_MAX_AGE_MS, false)?;
259        let (actions, sessions) = journal.prune_before(evict_cutoff, false)?;
260        report.objects_removed += objs;
261        report.bytes_freed += bytes;
262        report.cap_evicted_actions += actions;
263        report.sessions_pruned += sessions;
264
265        apparent_now = apparent_now.saturating_sub(bytes);
266        let over_cap = match opts.cap_bytes {
267            Some(cap) => apparent_now.saturating_sub(cap),
268            None => 0,
269        };
270        if over_cap == 0 && free_deficit(doover_home, opts) == 0 {
271            break;
272        }
273        if actions == 0 && objs == 0 {
274            // no forward progress (e.g. remaining old objects still inside
275            // the mtime grace window): stop and say so rather than spin
276            report.still_over_budget = true;
277            break;
278        }
279    }
280    Ok(report)
281}
282
283/// Bytes short of the free-space floor — zero when within budget or when the
284/// signal is unavailable/degenerate (an unreadable filesystem must not
285/// trigger history-destroying eviction over a phantom).
286fn free_deficit(doover_home: &Path, opts: &GcOptions) -> u64 {
287    match opts.min_free_bytes {
288        Some(floor) => match crate::snapshot::free_bytes(doover_home) {
289            Some(free) => floor.saturating_sub(free),
290            None => 0,
291        },
292        None => 0,
293    }
294}
295
296#[cfg(test)]
297mod budget_parse_tests {
298    use super::{parse_opt_bytes, parse_u64_or};
299
300    #[test]
301    fn byte_budget_parse_is_fail_safe() {
302        // unset/garbage -> default: config can never silently zero a budget
303        assert_eq!(parse_opt_bytes(None, 5 << 30), Some(5 << 30));
304        assert_eq!(parse_opt_bytes(Some("garbage"), 5 << 30), Some(5 << 30));
305        assert_eq!(parse_opt_bytes(Some("-1"), 5 << 30), Some(5 << 30));
306        assert_eq!(parse_opt_bytes(Some(""), 5 << 30), Some(5 << 30));
307        // explicit 0 is the documented opt-out
308        assert_eq!(parse_opt_bytes(Some("0"), 5 << 30), None);
309        // real values honored (whitespace tolerated)
310        assert_eq!(parse_opt_bytes(Some(" 1048576 "), 5 << 30), Some(1_048_576));
311        assert_eq!(parse_u64_or(Some("25"), 50), 25);
312        assert_eq!(parse_u64_or(Some("junk"), 50), 50);
313        assert_eq!(parse_u64_or(None, 50), 50);
314    }
315}
316
317#[cfg(test)]
318mod auto_gc_options_tests {
319    use super::{MaintenanceBudget, auto_gc_options};
320
321    /// Round 18 (mutation-confirmed gaps): these fields are load-bearing D2
322    /// review constraints; a drive-by edit must fail HERE, not ship silently.
323    #[test]
324    fn auto_gc_options_pins_the_trigger_contract() {
325        let b = MaintenanceBudget {
326            cap_bytes: Some(123),
327            min_free_bytes: Some(999), // set — and must NOT reach the options
328            gc_every: 50,
329            keep_days: 7,
330        };
331        let o = auto_gc_options(&b);
332        assert!(!o.dry_run);
333        assert_eq!(o.cap_bytes, Some(123), "cap passes through");
334        assert_eq!(o.keep_days, 7, "retention passes through");
335        assert_eq!(
336            o.min_free_bytes, None,
337            "floor NEVER drives automatic eviction (manual gc only)"
338        );
339        assert_eq!(
340            o.time_budget,
341            Some(std::time::Duration::from_secs(3)),
342            "the triggered pass always carries the 3s budget (D1 discipline)"
343        );
344    }
345}