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}