Skip to main content

mongreldb_core/
gc.rs

1//! Operational tools: garbage collection and integrity check.
2//!
3//! `gc` removes run files not referenced by the manifest (orphans from aborted
4//! flushes / old compaction inputs) and stale WAL segments (all but the active
5//! one). `check` re-verifies every referenced run's footer checksum.
6
7use crate::engine::Table;
8use crate::sorted_run::read_header;
9use crate::Result;
10use std::collections::HashSet;
11
12/// Outcome of a [`Table::gc`] pass.
13#[derive(Debug, Default, Clone)]
14pub struct GcReport {
15    pub runs_removed: usize,
16    pub wal_segments_removed: usize,
17    pub bytes_freed: u64,
18}
19
20/// Outcome of a [`Table::gc_versions`] pass (S1C-004).
21#[derive(Debug, Clone)]
22pub struct GcVersionsReport {
23    /// The unified version-retention floor the pass consulted: the oldest
24    /// epoch still held by ANY pin source (oldest active transaction
25    /// snapshot, configured history retention, and every registered
26    /// backup/PITR, replication, cursor/read-generation, and
27    /// online-index-build pin).
28    pub floor: crate::epoch::Epoch,
29    /// Retiring runs physically reaped because their `retire_epoch` was at or
30    /// below `floor`.
31    pub runs_reaped: usize,
32}
33
34/// Outcome of a [`Table::check`] pass.
35#[derive(Debug, Default, Clone)]
36pub struct CheckReport {
37    pub runs_checked: usize,
38    pub runs_ok: usize,
39    pub issues: Vec<String>,
40}
41
42/// Outcome of a [`Table::doctor`] pass (best-effort repair): corrupt runs are
43/// dropped from the manifest so the table is usable again, at the cost of the
44/// data they held.
45#[derive(Debug, Default, Clone)]
46pub struct DoctorReport {
47    pub runs_dropped: Vec<u128>,
48}
49
50impl Table {
51    /// Remove orphan run files (not in the manifest) and all but the active WAL
52    /// segment. Safe to run any time; readers pin snapshots, not files.
53    pub fn gc(&self) -> Result<GcReport> {
54        let mut report = GcReport::default();
55        let live: HashSet<u128> = self
56            .run_refs()
57            .iter()
58            .map(|r| r.run_id)
59            .chain(self.retiring_run_ids())
60            .collect();
61
62        for entry in std::fs::read_dir(self.runs_dir())? {
63            let entry = entry?;
64            let path = entry.path();
65            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
66                continue;
67            };
68            let Some(id) = name
69                .strip_prefix("r-")
70                .and_then(|s| s.strip_suffix(".sr"))
71                .and_then(|s| s.parse::<u128>().ok())
72            else {
73                continue;
74            };
75            if !live.contains(&id) {
76                let bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
77                if std::fs::remove_file(&path).is_ok() {
78                    report.runs_removed += 1;
79                    report.bytes_freed += bytes;
80                }
81            }
82        }
83
84        // WAL: keep only the highest-numbered segment.
85        let mut segs: Vec<(u32, std::path::PathBuf)> = Vec::new();
86        for entry in std::fs::read_dir(self.wal_dir())? {
87            let entry = entry?;
88            let path = entry.path();
89            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
90                continue;
91            };
92            let Some(n) = name
93                .strip_prefix("seg-")
94                .and_then(|s| s.strip_suffix(".wal"))
95                .and_then(|s| s.parse::<u32>().ok())
96            else {
97                continue;
98            };
99            segs.push((n, path));
100        }
101        segs.sort_by_key(|(n, _)| *n);
102        if let Some((_, active)) = segs.last() {
103            let active = active.clone();
104            for (_, path) in segs {
105                if path != active {
106                    let bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
107                    if std::fs::remove_file(&path).is_ok() {
108                        report.wal_segments_removed += 1;
109                        report.bytes_freed += bytes;
110                    }
111                }
112            }
113        }
114        Ok(report)
115    }
116
117    /// Reclaim retiring runs gated on the unified version-retention floor
118    /// (S1C-004). The floor — [`Table::version_gc_floor`] — is the oldest
119    /// epoch held by ANY pin source: the oldest active transaction snapshot,
120    /// the configured history-retention window, and every pin registered in
121    /// the table's [`crate::retention::PinRegistry`] (backup/PITR,
122    /// replication, cursor/read-generation, online-index-build). A retiring
123    /// run is reaped only when its `retire_epoch` is at or below that floor,
124    /// so no pin source can lose versions it still reads.
125    ///
126    /// The `Database`-level backup run-file pins (`backup_pins`) are still
127    /// consulted by the `Database` GC/checkpoint paths; merging that
128    /// mechanism into the [`crate::retention::PinRegistry`] is a documented
129    /// follow-up.
130    pub fn gc_versions(&mut self) -> Result<GcVersionsReport> {
131        let floor = self.version_gc_floor();
132        let runs_reaped = self.reap_retiring(floor, &std::collections::HashSet::new())?;
133        Ok(GcVersionsReport { floor, runs_reaped })
134    }
135
136    /// Verify every manifest-referenced run's footer checksum.
137    pub fn check(&self) -> Result<CheckReport> {
138        let mut report = CheckReport::default();
139        for rr in self.run_refs() {
140            report.runs_checked += 1;
141            let path = self.run_path(rr.run_id as u64);
142            match read_header(&path) {
143                Ok(_) => report.runs_ok += 1,
144                Err(e) => report.issues.push(format!("run {}: {e}", rr.run_id)),
145            }
146        }
147        Ok(report)
148    }
149
150    /// Best-effort repair: drop any manifest-referenced run that fails its
151    /// checksum, so the table reopens in a consistent (if lossy) state.
152    pub fn doctor(&mut self) -> Result<DoctorReport> {
153        let mut report = DoctorReport::default();
154        let live: Vec<u128> = self.run_refs().iter().map(|r| r.run_id).collect();
155        let mut kept: Vec<crate::manifest::RunRef> = Vec::new();
156        for id in live {
157            let path = self.run_path(id as u64);
158            if read_header(&path).is_ok() {
159                if let Some(rr) = self.run_refs().iter().find(|r| r.run_id == id).cloned() {
160                    kept.push(rr);
161                }
162            } else {
163                report.runs_dropped.push(id);
164            }
165        }
166        self.set_run_refs(kept);
167        self.persist_manifest(self.current_epoch())?;
168        // If any run was dropped, the checkpoint now references row-ids whose
169        // data is gone → invalidate it so reopen rebuilds from survivors.
170        if !report.runs_dropped.is_empty() {
171            self.invalidate_index_checkpoint();
172        }
173        Ok(report)
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
181    use crate::Value;
182    use tempfile::tempdir;
183
184    fn schema() -> Schema {
185        Schema {
186            schema_id: 1,
187            columns: vec![ColumnDef {
188                id: 1,
189                name: "v".into(),
190                ty: TypeId::Int64,
191                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
192                default_value: None,
193                embedding_source: None,
194            }],
195            indexes: Vec::new(),
196            colocation: vec![],
197            constraints: Default::default(),
198            clustered: false,
199        }
200    }
201
202    #[test]
203    fn gc_removes_orphan_runs_and_old_wal_segments() {
204        let dir = tempdir().unwrap();
205        let mut db = Table::create(dir.path(), schema(), 1).unwrap();
206        db.set_mutable_run_spill_bytes(1); // spill every flush to a run
207        db.put(vec![(1, Value::Int64(1))]).unwrap();
208        db.flush().unwrap(); // run 1 + rotated WAL
209                             // Orphan run file.
210        std::fs::write(dir.path().join("_runs").join("r-777.sr"), b"junk").unwrap();
211
212        let report = db.gc().unwrap();
213        assert!(report.runs_removed >= 1, "orphan run removed");
214        assert!(!dir.path().join("_runs").join("r-777.sr").exists());
215
216        // check should still pass for the live run.
217        let check = db.check().unwrap();
218        assert_eq!(check.runs_checked, 1);
219        assert!(check.issues.is_empty(), "{:?}", check.issues);
220    }
221
222    #[test]
223    fn check_flags_a_corrupt_run() {
224        let dir = tempdir().unwrap();
225        let mut db = Table::create(dir.path(), schema(), 1).unwrap();
226        db.set_mutable_run_spill_bytes(1); // spill every flush to a run
227        db.put(vec![(1, Value::Int64(1))]).unwrap();
228        db.flush().unwrap();
229        // Corrupt the live run.
230        let live = dir.path().join("_runs").join("r-1.sr");
231        let mut bytes = std::fs::read(&live).unwrap();
232        bytes[300] ^= 0xFF;
233        std::fs::write(&live, bytes).unwrap();
234
235        let check = db.check().unwrap();
236        assert_eq!(check.runs_checked, 1);
237        assert!(!check.issues.is_empty(), "corruption must be flagged");
238    }
239
240    /// S1C-004: every pin source blocks version GC while held; reclamation
241    /// proceeds once the pin is released.
242    #[test]
243    fn gc_versions_blocked_by_each_pin_source_and_reclaims_after_release() {
244        use crate::epoch::Epoch;
245        use crate::retention::PinSource;
246
247        let dir = tempdir().unwrap();
248        let mut db = Table::create(dir.path(), schema(), 1).unwrap();
249        db.set_mutable_run_spill_bytes(1);
250        db.put(vec![(1, Value::Int64(1))]).unwrap();
251        db.flush().unwrap(); // run 1
252        db.put(vec![(1, Value::Int64(2))]).unwrap();
253        db.flush().unwrap(); // run 2
254        db.compact().unwrap(); // merged run 3; runs 1+2 retiring at epoch 2
255        assert_eq!(db.run_count(), 1);
256        assert_eq!(db.current_epoch(), Epoch(2));
257
258        for source in PinSource::ALL {
259            let guard = db.pin_registry().pin(source, Epoch(1));
260            assert_eq!(
261                db.version_gc_floor(),
262                Epoch(1),
263                "{source:?} pin must lower the reclamation floor"
264            );
265            let report = db.gc_versions().unwrap();
266            assert_eq!(report.floor, Epoch(1));
267            assert_eq!(
268                report.runs_reaped, 0,
269                "{source:?} pin must block retiring-run reclamation"
270            );
271            drop(guard);
272        }
273
274        // No pins left: the floor returns to the visible epoch (2), which is
275        // at the retire epoch — both superseded runs are reclaimed.
276        assert_eq!(db.version_gc_floor(), Epoch(2));
277        let report = db.gc_versions().unwrap();
278        assert_eq!(report.runs_reaped, 2, "all retiring runs reaped");
279    }
280
281    /// S1C-004 diagnostics: local snapshot pins project as the transaction
282    /// source; registered pins appear with their own source and epoch.
283    #[test]
284    fn version_pins_report_lists_registered_and_projected_sources() {
285        use crate::epoch::Epoch;
286        use crate::retention::PinSource;
287
288        let dir = tempdir().unwrap();
289        let mut db = Table::create(dir.path(), schema(), 1).unwrap();
290        db.put(vec![(1, Value::Int64(1))]).unwrap();
291        db.commit().unwrap();
292        assert!(db.version_pins_report().is_empty());
293
294        let snapshot = db.pin_snapshot();
295        let backup = db.pin_registry().pin(PinSource::BackupPitr, Epoch(0));
296        let report = db.version_pins_report();
297        assert_eq!(report.len(), 2);
298        let transaction = report.get(PinSource::TransactionSnapshot).unwrap();
299        assert_eq!(transaction.oldest_epoch, snapshot.epoch);
300        assert_eq!(transaction.pin_count, 0, "projection carries no guard");
301        let backup_info = report.get(PinSource::BackupPitr).unwrap();
302        assert_eq!(backup_info.oldest_epoch, Epoch(0));
303        assert_eq!(backup_info.pin_count, 1);
304        assert_eq!(report.oldest_epoch(), Some(Epoch(0)));
305
306        drop(backup);
307        db.unpin_snapshot(snapshot);
308        assert!(db.version_pins_report().is_empty());
309    }
310}