Skip to main content

rch_common/
disk_reclaim.rs

1//! Safe disk-reclaim receipts with active-build protection
2//! (bd-session-history-remediation-ocv9i.11.3).
3//!
4//! Under disk pressure RCH may reclaim space, but session history showed the
5//! danger of an over-eager reaper: clipping a live build's target dir, or
6//! deleting something outside RCH's own roots. This module makes reclaim
7//! **receipt-driven** — every candidate gets an explicit, auditable decision
8//! before anything is deleted — and bakes in two hard safety invariants:
9//!
10//! 1. **Managed-root bounding.** Reclaim is only ever planned inside an
11//!    RCH-managed root, and only for paths that pass the shared safe-path guard
12//!    ([`crate::stale_target_reap::is_safe_reap_path`]). An unmanaged or unsafe
13//!    path is recorded as skipped, never reclaimed.
14//! 2. **Active-build protection.** A path covered by an active build/heartbeat
15//!    is protected and never reclaimed — even under critical pressure.
16//!
17//! [`plan_reclaim`] is a **pure** function over candidate facts and a
18//! [`ReclaimPolicy`]; it produces a [`ReclaimReceipt`] listing every per-path
19//! decision, the planned bytes/inodes, and the protected/unmanaged sets. The
20//! daemon performs the actual deletion and calls [`ReclaimReceipt::finalize`]
21//! with the measured bytes/inodes, then journals the receipt as an
22//! [`IncidentEvent`] via [`ReclaimReceipt::to_incident_event`]. The safety
23//! invariants are provable here without touching the filesystem.
24
25use serde::{Deserialize, Serialize};
26
27use crate::incident::{
28    ControlState, IncidentEvent, IncidentEventType, IncidentReasonCode, IncidentSource,
29    SelectedMode,
30};
31use crate::stale_target_reap::{idle_minutes_from_hours, is_safe_reap_path};
32
33/// What kind of reclaimable thing a candidate is. Distinguishing these lets the
34/// receipt explain *what* was freed and keep operator-only cleanup separate from
35/// automatic reclaim.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ReclaimCategory {
39    /// A stale per-job/pooled Rust target dir.
40    StaleTargetDir,
41    /// A stale cargo home (registry/cache) RCH provisioned.
42    StaleCargoHome,
43    /// A rotatable RCH log file/dir.
44    LogRotation,
45    /// Cleanup that requires an operator decision; never auto-reclaimed.
46    OperatorRequiredManual,
47}
48
49impl ReclaimCategory {
50    /// Stable lowercase token.
51    #[must_use]
52    pub fn as_str(self) -> &'static str {
53        match self {
54            ReclaimCategory::StaleTargetDir => "stale_target_dir",
55            ReclaimCategory::StaleCargoHome => "stale_cargo_home",
56            ReclaimCategory::LogRotation => "log_rotation",
57            ReclaimCategory::OperatorRequiredManual => "operator_required_manual",
58        }
59    }
60}
61
62/// Why a candidate was NOT reclaimed.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum ReclaimSkipReason {
66    /// Protected: an active build/heartbeat covers this path.
67    ActiveBuild,
68    /// Outside every RCH-managed root — must never be touched.
69    UnmanagedRoot,
70    /// Failed the safe-path guard (root, `..`, shell metacharacters, …).
71    UnsafePath,
72    /// Within the conservative idle window — a warm cache to preserve.
73    NotStale,
74    /// Requires manual operator cleanup; surfaced but not auto-reclaimed.
75    OperatorRequired,
76}
77
78impl ReclaimSkipReason {
79    /// Stable lowercase token.
80    #[must_use]
81    pub fn as_str(self) -> &'static str {
82        match self {
83            ReclaimSkipReason::ActiveBuild => "active_build",
84            ReclaimSkipReason::UnmanagedRoot => "unmanaged_root",
85            ReclaimSkipReason::UnsafePath => "unsafe_path",
86            ReclaimSkipReason::NotStale => "not_stale",
87            ReclaimSkipReason::OperatorRequired => "operator_required",
88        }
89    }
90}
91
92/// The decision for one candidate path.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case", tag = "decision")]
95pub enum ReclaimDecision {
96    /// Reclaim this path (counts toward planned bytes/inodes).
97    Reclaim,
98    /// Skip this path, with the reason.
99    Skip { reason: ReclaimSkipReason },
100}
101
102impl ReclaimDecision {
103    /// Whether this decision reclaims the path.
104    #[must_use]
105    pub fn is_reclaim(self) -> bool {
106        matches!(self, ReclaimDecision::Reclaim)
107    }
108}
109
110/// A reclaim candidate: facts about one path the caller is considering. Plain
111/// data so the plan is pure and unit-testable.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct ReclaimCandidate {
114    /// Absolute path.
115    pub path: String,
116    /// What kind of thing it is.
117    pub category: ReclaimCategory,
118    /// Size on disk (bytes).
119    pub bytes: u64,
120    /// Inode count.
121    pub inodes: u64,
122    /// Minutes since the path last saw activity.
123    pub idle_minutes: u64,
124    /// An active build/heartbeat currently covers this path.
125    pub active_build: bool,
126}
127
128/// Policy bounding what may be reclaimed.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct ReclaimPolicy {
131    /// RCH-managed roots; reclaim is only ever planned inside one of these.
132    pub managed_roots: Vec<String>,
133    /// Conservative idle window — paths idle for less are preserved.
134    pub min_idle_minutes: u64,
135}
136
137impl ReclaimPolicy {
138    /// Build a policy from managed roots and an `idle_hours` setting, applying
139    /// the shared 1h floor so a misconfiguration can never reclaim a live cache.
140    #[must_use]
141    pub fn new(managed_roots: Vec<String>, idle_hours: u32) -> Self {
142        Self {
143            managed_roots,
144            min_idle_minutes: idle_minutes_from_hours(idle_hours),
145        }
146    }
147
148    /// Whether `path` lies within a managed root (exact root or a descendant).
149    #[must_use]
150    pub fn is_within_managed_root(&self, path: &str) -> bool {
151        self.managed_roots.iter().any(|root| {
152            let root = root.trim_end_matches('/');
153            !root.is_empty() && (path == root || path.starts_with(&format!("{root}/")))
154        })
155    }
156}
157
158/// Decide a single candidate against the policy. Pure and total.
159///
160/// Precedence enforces safety outermost-first: the safe-path guard, then
161/// managed-root bounding, then active-build protection (paramount — even under
162/// critical pressure), then operator-required (reported, never auto-reclaimed),
163/// then the conservative staleness window.
164#[must_use]
165pub fn decide_candidate(candidate: &ReclaimCandidate, policy: &ReclaimPolicy) -> ReclaimDecision {
166    let skip = |reason| ReclaimDecision::Skip { reason };
167    if !is_safe_reap_path(&candidate.path) {
168        return skip(ReclaimSkipReason::UnsafePath);
169    }
170    if !policy.is_within_managed_root(&candidate.path) {
171        return skip(ReclaimSkipReason::UnmanagedRoot);
172    }
173    if candidate.active_build {
174        return skip(ReclaimSkipReason::ActiveBuild);
175    }
176    if candidate.category == ReclaimCategory::OperatorRequiredManual {
177        return skip(ReclaimSkipReason::OperatorRequired);
178    }
179    if candidate.idle_minutes < policy.min_idle_minutes {
180        return skip(ReclaimSkipReason::NotStale);
181    }
182    ReclaimDecision::Reclaim
183}
184
185/// The auditable decision for one path.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct PathReceipt {
188    pub path: String,
189    pub category: ReclaimCategory,
190    #[serde(flatten)]
191    pub decision: ReclaimDecision,
192    pub bytes: u64,
193    pub inodes: u64,
194}
195
196/// The receipt for a planned (and later performed) reclaim pass.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct ReclaimReceipt {
199    /// Managed roots reclaim was bounded to.
200    pub managed_roots: Vec<String>,
201    /// Conservative idle window used.
202    pub min_idle_minutes: u64,
203    /// Per-path decisions, in candidate order.
204    pub paths: Vec<PathReceipt>,
205    /// Sum of bytes for paths decided `Reclaim`.
206    pub planned_bytes: u64,
207    /// Sum of inodes for paths decided `Reclaim`.
208    pub planned_inodes: u64,
209    /// Bytes actually freed (filled by the daemon after deletion).
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub actual_bytes: Option<u64>,
212    /// Inodes actually freed (filled by the daemon after deletion).
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub actual_inodes: Option<u64>,
215}
216
217impl ReclaimReceipt {
218    /// Paths decided `Reclaim` — exactly what the daemon may delete.
219    #[must_use]
220    pub fn reclaimable_paths(&self) -> Vec<&str> {
221        self.paths
222            .iter()
223            .filter(|p| p.decision.is_reclaim())
224            .map(|p| p.path.as_str())
225            .collect()
226    }
227
228    /// Paths skipped for a given reason (e.g. active build, unmanaged root).
229    #[must_use]
230    pub fn skipped_for(&self, reason: ReclaimSkipReason) -> Vec<&str> {
231        self.paths
232            .iter()
233            .filter(|p| p.decision == ReclaimDecision::Skip { reason })
234            .map(|p| p.path.as_str())
235            .collect()
236    }
237
238    /// Paths protected because an active build covers them.
239    #[must_use]
240    pub fn protected_active(&self) -> Vec<&str> {
241        self.skipped_for(ReclaimSkipReason::ActiveBuild)
242    }
243
244    /// Record the bytes/inodes actually freed after the daemon performed the
245    /// reclaim.
246    pub fn finalize(&mut self, actual_bytes: u64, actual_inodes: u64) {
247        self.actual_bytes = Some(actual_bytes);
248        self.actual_inodes = Some(actual_inodes);
249    }
250
251    /// A journal/incident record for this receipt, keyed `DiskFull`. Carries the
252    /// salient accounting in `details` (no raw paths in the wire schema's
253    /// fingerprint field — the command fingerprint identifies the pass).
254    #[must_use]
255    pub fn to_incident_event(
256        &self,
257        project_id: impl Into<String>,
258        occurred_at_unix_ms: u64,
259    ) -> IncidentEvent {
260        let reclaimable = self.reclaimable_paths().len();
261        let protected = self.protected_active().len();
262        let unmanaged = self.skipped_for(ReclaimSkipReason::UnmanagedRoot).len();
263        let mut event = IncidentEvent::new(
264            IncidentEventType::WorkerLifecycle,
265            IncidentReasonCode::DiskFull,
266            IncidentSource::Daemon,
267            project_id,
268            "disk_reclaim",
269            SelectedMode::Local,
270            true,
271            occurred_at_unix_ms,
272        )
273        .with_control(ControlState {
274            target_dir_policy: Some("reclaim".to_string()),
275            ..ControlState::default()
276        })
277        .with_detail("planned_bytes", self.planned_bytes.to_string())
278        .with_detail("planned_inodes", self.planned_inodes.to_string())
279        .with_detail("reclaimable", reclaimable.to_string())
280        .with_detail("protected_active", protected.to_string())
281        .with_detail("skipped_unmanaged", unmanaged.to_string());
282        if let Some(actual) = self.actual_bytes {
283            event = event.with_detail("actual_bytes", actual.to_string());
284        }
285        if let Some(actual) = self.actual_inodes {
286            event = event.with_detail("actual_inodes", actual.to_string());
287        }
288        event
289    }
290
291    /// Human-readable summary line.
292    #[must_use]
293    pub fn render(&self) -> String {
294        format!(
295            "reclaim: {} path(s) reclaimable, {} planned bytes, {} protected (active), {} unmanaged-skipped",
296            self.reclaimable_paths().len(),
297            self.planned_bytes,
298            self.protected_active().len(),
299            self.skipped_for(ReclaimSkipReason::UnmanagedRoot).len(),
300        )
301    }
302}
303
304/// Plan a reclaim pass: decide every candidate and total the planned savings.
305/// Pure — performs no I/O and deletes nothing.
306#[must_use]
307pub fn plan_reclaim(candidates: &[ReclaimCandidate], policy: &ReclaimPolicy) -> ReclaimReceipt {
308    let mut paths = Vec::with_capacity(candidates.len());
309    let mut planned_bytes = 0u64;
310    let mut planned_inodes = 0u64;
311    for candidate in candidates {
312        let decision = decide_candidate(candidate, policy);
313        if decision.is_reclaim() {
314            planned_bytes = planned_bytes.saturating_add(candidate.bytes);
315            planned_inodes = planned_inodes.saturating_add(candidate.inodes);
316        }
317        paths.push(PathReceipt {
318            path: candidate.path.clone(),
319            category: candidate.category,
320            decision,
321            bytes: candidate.bytes,
322            inodes: candidate.inodes,
323        });
324    }
325    ReclaimReceipt {
326        managed_roots: policy.managed_roots.clone(),
327        min_idle_minutes: policy.min_idle_minutes,
328        paths,
329        planned_bytes,
330        planned_inodes,
331        actual_bytes: None,
332        actual_inodes: None,
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    fn policy() -> ReclaimPolicy {
341        // 12h idle window, managed roots under /tmp/rch and /data/rch.
342        ReclaimPolicy::new(
343            vec!["/tmp/rch".to_string(), "/data/rch/pool".to_string()],
344            12,
345        )
346    }
347
348    fn candidate(path: &str, category: ReclaimCategory) -> ReclaimCandidate {
349        ReclaimCandidate {
350            path: path.to_string(),
351            category,
352            bytes: 1_000,
353            inodes: 10,
354            idle_minutes: 10_000, // very stale by default
355            active_build: false,
356        }
357    }
358
359    // --- The two safety invariants the bead requires proven -----------------
360
361    #[test]
362    fn no_active_build_path_is_reclaimed_even_under_critical_pressure() {
363        // Aggressive policy (zero idle window = "critical pressure"): an active
364        // build path is STILL protected.
365        let aggressive = ReclaimPolicy {
366            managed_roots: vec!["/tmp/rch".to_string()],
367            min_idle_minutes: 0,
368        };
369        let mut c = candidate(
370            "/tmp/rch/proj/.rch-target-w-job-1-2-0",
371            ReclaimCategory::StaleTargetDir,
372        );
373        c.active_build = true;
374        let receipt = plan_reclaim(&[c], &aggressive);
375        assert!(
376            receipt.reclaimable_paths().is_empty(),
377            "active build must not be reclaimed"
378        );
379        assert_eq!(receipt.protected_active().len(), 1);
380        assert_eq!(receipt.planned_bytes, 0);
381    }
382
383    #[test]
384    fn no_unmanaged_path_is_touched() {
385        // A stale, non-active path OUTSIDE managed roots is never reclaimed.
386        let c = candidate("/home/user/project/target", ReclaimCategory::StaleTargetDir);
387        let receipt = plan_reclaim(&[c], &policy());
388        assert!(receipt.reclaimable_paths().is_empty());
389        assert_eq!(
390            receipt.skipped_for(ReclaimSkipReason::UnmanagedRoot).len(),
391            1
392        );
393    }
394
395    #[test]
396    fn unsafe_paths_are_rejected_before_anything_else() {
397        for bad in [
398            "/tmp/rch/../etc/passwd",
399            "/tmp/rch/$(whoami)",
400            "/", // root
401            "relative/path",
402        ] {
403            let c = candidate(bad, ReclaimCategory::StaleTargetDir);
404            let receipt = plan_reclaim(&[c], &policy());
405            assert!(
406                receipt.reclaimable_paths().is_empty(),
407                "unsafe path reclaimed: {bad}"
408            );
409            assert_eq!(
410                receipt.skipped_for(ReclaimSkipReason::UnsafePath).len(),
411                1,
412                "expected unsafe-path skip for {bad}"
413            );
414        }
415    }
416
417    // --- Category distinction + conservative staleness ----------------------
418
419    #[test]
420    fn distinguishes_reclaimable_categories_from_operator_required() {
421        let candidates = vec![
422            candidate(
423                "/tmp/rch/p/.rch-target-w-job-1-2-0",
424                ReclaimCategory::StaleTargetDir,
425            ),
426            candidate("/tmp/rch/cargo-home-stale", ReclaimCategory::StaleCargoHome),
427            candidate("/tmp/rch/logs/old", ReclaimCategory::LogRotation),
428            candidate(
429                "/tmp/rch/mystery-bigdir",
430                ReclaimCategory::OperatorRequiredManual,
431            ),
432        ];
433        let receipt = plan_reclaim(&candidates, &policy());
434        // Three auto-reclaimable categories are planned; operator-required is not.
435        assert_eq!(receipt.reclaimable_paths().len(), 3);
436        assert_eq!(
437            receipt
438                .skipped_for(ReclaimSkipReason::OperatorRequired)
439                .len(),
440            1
441        );
442        assert_eq!(receipt.planned_bytes, 3_000);
443        assert_eq!(receipt.planned_inodes, 30);
444    }
445
446    #[test]
447    fn conservative_staleness_preserves_warm_caches() {
448        // Within the idle window (managed, not active) => preserved as NotStale.
449        let mut c = candidate(
450            "/tmp/rch/p/.rch-target-w-job-1-2-0",
451            ReclaimCategory::StaleTargetDir,
452        );
453        c.idle_minutes = 5; // far below 12h floor
454        let receipt = plan_reclaim(&[c], &policy());
455        assert!(receipt.reclaimable_paths().is_empty());
456        assert_eq!(receipt.skipped_for(ReclaimSkipReason::NotStale).len(), 1);
457    }
458
459    #[test]
460    fn idle_floor_is_applied_even_with_zero_hours_config() {
461        // ReclaimPolicy::new applies the shared 1h floor, so a 0-hour config
462        // still preserves a 30-min-idle cache.
463        let p = ReclaimPolicy::new(vec!["/tmp/rch".to_string()], 0);
464        assert_eq!(p.min_idle_minutes, 60);
465        let mut c = candidate(
466            "/tmp/rch/p/.rch-target-w-job-1-2-0",
467            ReclaimCategory::StaleTargetDir,
468        );
469        c.idle_minutes = 30;
470        let receipt = plan_reclaim(&[c], &p);
471        assert!(receipt.reclaimable_paths().is_empty());
472    }
473
474    // --- Accounting, finalize, journal --------------------------------------
475
476    #[test]
477    fn planned_totals_count_only_reclaimable() {
478        let candidates = vec![
479            candidate(
480                "/tmp/rch/p/.rch-target-w-job-1-2-0",
481                ReclaimCategory::StaleTargetDir,
482            ), // reclaim
483            {
484                let mut a = candidate(
485                    "/tmp/rch/active/.rch-target-w-job-9-9-0",
486                    ReclaimCategory::StaleTargetDir,
487                );
488                a.active_build = true; // skipped
489                a.bytes = 9_999;
490                a
491            },
492            candidate("/elsewhere/junk", ReclaimCategory::StaleTargetDir), // unmanaged
493        ];
494        let receipt = plan_reclaim(&candidates, &policy());
495        assert_eq!(
496            receipt.planned_bytes, 1_000,
497            "only the one reclaimable path counts"
498        );
499    }
500
501    #[test]
502    fn finalize_records_actual_freed() {
503        let c = candidate(
504            "/tmp/rch/p/.rch-target-w-job-1-2-0",
505            ReclaimCategory::StaleTargetDir,
506        );
507        let mut receipt = plan_reclaim(&[c], &policy());
508        assert_eq!(receipt.actual_bytes, None);
509        receipt.finalize(950, 9);
510        assert_eq!(receipt.actual_bytes, Some(950));
511        assert_eq!(receipt.actual_inodes, Some(9));
512    }
513
514    #[test]
515    fn journal_event_is_diskfull_with_accounting() {
516        let candidates = vec![
517            candidate(
518                "/tmp/rch/p/.rch-target-w-job-1-2-0",
519                ReclaimCategory::StaleTargetDir,
520            ),
521            {
522                let mut a = candidate(
523                    "/tmp/rch/active/.rch-target-w-job-9-9-0",
524                    ReclaimCategory::StaleTargetDir,
525                );
526                a.active_build = true;
527                a
528            },
529        ];
530        let mut receipt = plan_reclaim(&candidates, &policy());
531        receipt.finalize(1_000, 10);
532        let event = receipt.to_incident_event("proj-1", 1_700_000_000_000);
533        assert_eq!(event.reason_code, IncidentReasonCode::DiskFull);
534        assert_eq!(event.event_type, IncidentEventType::WorkerLifecycle);
535        assert_eq!(
536            event.details.get("planned_bytes").map(String::as_str),
537            Some("1000")
538        );
539        assert_eq!(
540            event.details.get("protected_active").map(String::as_str),
541            Some("1")
542        );
543        assert_eq!(
544            event.details.get("actual_bytes").map(String::as_str),
545            Some("1000")
546        );
547    }
548
549    // --- Serde / contract ---------------------------------------------------
550
551    #[test]
552    fn receipt_serializes_with_stable_decision_tokens() {
553        let candidates = vec![
554            candidate(
555                "/tmp/rch/p/.rch-target-w-job-1-2-0",
556                ReclaimCategory::StaleTargetDir,
557            ),
558            candidate("/elsewhere/x", ReclaimCategory::LogRotation),
559        ];
560        let receipt = plan_reclaim(&candidates, &policy());
561        let value = serde_json::to_value(&receipt).unwrap();
562        assert_eq!(value["paths"][0]["decision"], "reclaim");
563        assert_eq!(value["paths"][0]["category"], "stale_target_dir");
564        assert_eq!(value["paths"][1]["decision"], "skip");
565        assert_eq!(value["paths"][1]["reason"], "unmanaged_root");
566        // actual_* omitted before finalize.
567        assert!(value.get("actual_bytes").is_none());
568        // Round-trips losslessly.
569        let back: ReclaimReceipt = serde_json::from_value(value).unwrap();
570        assert_eq!(back, receipt);
571    }
572
573    #[test]
574    fn managed_root_membership_is_boundary_safe() {
575        let p = policy();
576        assert!(p.is_within_managed_root("/tmp/rch"));
577        assert!(p.is_within_managed_root("/tmp/rch/sub/dir"));
578        // A sibling that merely shares a prefix string is NOT inside the root.
579        assert!(!p.is_within_managed_root("/tmp/rch-evil/x"));
580        assert!(!p.is_within_managed_root("/tmp/rchx"));
581        assert!(!p.is_within_managed_root("/data/rch/poolside")); // not /data/rch/pool[/...]
582        assert!(p.is_within_managed_root("/data/rch/pool/abc"));
583    }
584}