lex_store/gc.rs
1//! Predicate-driven garbage collection of the op log (#261 slice 2).
2//!
3//! Three retention rules combine to form the surviving set:
4//!
5//! 1. **Branch reachability** — every op reachable from any branch
6//! head's `head_op` is retained. Always on; not configurable.
7//! The branch DAG is the source of truth; deleting an op
8//! referenced by a branch head would corrupt history.
9//! 2. **Predicate match** — `policy.gc_retention.retain` lists
10//! [`lex_vcs::Predicate`]s; ops matching any one are retained.
11//! Useful for "keep every op produced under session X" or
12//! "keep all `EffectAudit`-tagged ops" (when those predicates
13//! land).
14//! 3. **Parent-of-retained closure** — if op X is retained, every
15//! parent of X is retained too. Walks transitively up the DAG.
16//! This honors the acceptance criterion "Refuse to delete an op
17//! that's still a parent of a retained op."
18//!
19//! Apply is idempotent: re-running on a store that's already been
20//! GC'd has no further effect because the surviving set is stable.
21
22use crate::policy::PolicyFile;
23use crate::store::{Store, StoreError};
24use lex_vcs::{OpId, OpLog, Predicate};
25use serde::{Deserialize, Serialize};
26use std::collections::{BTreeMap, BTreeSet};
27
28/// Why an op survived a GC plan. Serialized as JSON in the
29/// `lex op gc --dry-run` envelope so reviewers can see the
30/// reasoning per op.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum RetentionReason {
34 /// Reachable via DAG walk from at least one branch head.
35 ReachableFromBranch,
36 /// Matched at least one `policy.gc_retention.retain` predicate
37 /// (the index is into that list, not into the merged input —
38 /// CLI overrides land before policy entries).
39 MatchedPredicate(usize),
40 /// Ancestor of an op retained by one of the above rules.
41 /// Closure rule preserving DAG integrity.
42 ParentOfRetained,
43}
44
45/// The plan for a single GC pass: which ops survive (with the
46/// reason) and which are slated for deletion. `apply_gc(plan)`
47/// turns this into actual filesystem changes.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct GcPlan {
50 pub retained: BTreeMap<OpId, RetentionReason>,
51 pub to_delete: Vec<OpId>,
52}
53
54impl GcPlan {
55 /// True when there's nothing to delete — the common case for a
56 /// fresh store or a re-run after a previous GC pass.
57 pub fn is_empty(&self) -> bool {
58 self.to_delete.is_empty()
59 }
60}
61
62impl Store {
63 /// Build a [`GcPlan`] from the store's current state plus an
64 /// optional list of additional retention predicates from the
65 /// CLI (`lex op gc --retain ...`). The policy file's
66 /// `gc_retention.retain` entries are appended to those.
67 ///
68 /// Returns `StoreError::Io(InvalidData, ...)` if a predicate
69 /// in `policy.json` fails to parse.
70 pub fn plan_gc(
71 &self,
72 cli_retain: &[Predicate],
73 ) -> Result<GcPlan, StoreError> {
74 let log = OpLog::open(self.root())?;
75 // 1. Collect every op currently in the log. This is the
76 // universe we'll partition into retained vs to_delete.
77 let universe: BTreeSet<OpId> = log
78 .list_all()?
79 .into_iter()
80 .map(|r| r.op_id)
81 .collect();
82
83 let mut retained: BTreeMap<OpId, RetentionReason> = BTreeMap::new();
84
85 // 2. Branch reachability. Walk every branch head; mark
86 // every op in any walk-back as ReachableFromBranch.
87 for branch_name in self.list_branches()? {
88 let Some(branch) = self.get_branch(&branch_name)? else { continue };
89 let Some(head) = branch.head_op else { continue };
90 for rec in log.walk_back(&head, None)? {
91 retained
92 .entry(rec.op_id)
93 .or_insert(RetentionReason::ReachableFromBranch);
94 }
95 }
96
97 // 3. Predicate-based retention. CLI retain predicates first
98 // (their indices start at 0), then policy.json entries
99 // (their indices continue).
100 let mut all_retain: Vec<Predicate> = cli_retain.to_vec();
101 let policy = PolicyFile::load_optional(self.root())?;
102 for (i, raw) in policy.gc_retention.retain.iter().enumerate() {
103 let pred = Predicate::from_value(raw)
104 .map_err(|e| StoreError::Io(std::io::Error::new(
105 std::io::ErrorKind::InvalidData,
106 format!("policy.gc_retention.retain[{i}]: {e}"),
107 )))?;
108 all_retain.push(pred);
109 }
110 for (i, predicate) in all_retain.iter().enumerate() {
111 for rec in lex_vcs::evaluate(&log, predicate)? {
112 retained
113 .entry(rec.op_id)
114 .or_insert(RetentionReason::MatchedPredicate(i));
115 }
116 }
117
118 // 4. Parent-of-retained closure. Walk every retained op's
119 // parents transitively; any not yet retained gets the
120 // ParentOfRetained reason.
121 let frontier: Vec<OpId> = retained.keys().cloned().collect();
122 for op_id in frontier {
123 for rec in log.walk_back(&op_id, None)? {
124 retained
125 .entry(rec.op_id)
126 .or_insert(RetentionReason::ParentOfRetained);
127 }
128 }
129
130 // 5. The deletion set is the universe minus the retained.
131 let to_delete: Vec<OpId> = universe
132 .iter()
133 .filter(|id| !retained.contains_key(*id))
134 .cloned()
135 .collect();
136
137 Ok(GcPlan { retained, to_delete })
138 }
139
140 /// Apply a [`GcPlan`] — actually delete every op in
141 /// `plan.to_delete`. Idempotent: running again on the same
142 /// store after a successful apply yields a plan with an empty
143 /// deletion set.
144 ///
145 /// Returns the number of op records actually removed (loose
146 /// files deleted + packed ops dropped during pack rewrites).
147 pub fn apply_gc(&self, plan: &GcPlan) -> Result<usize, StoreError> {
148 if plan.to_delete.is_empty() {
149 return Ok(0);
150 }
151 let log = OpLog::open(self.root())?;
152 let victims: BTreeSet<OpId> = plan.to_delete.iter().cloned().collect();
153 Ok(log.evict(&victims)?)
154 }
155}
156
157impl PolicyFile {
158 /// Convenience: load policy.json or return the default. Used
159 /// by [`Store::plan_gc`] which doesn't care whether the file
160 /// exists — absent file ↔ empty policy ↔ no retention rules.
161 fn load_optional(root: &std::path::Path) -> std::io::Result<Self> {
162 Ok(crate::policy::load(root)?.unwrap_or_default())
163 }
164}