1use crate::engine::Table;
8use crate::sorted_run::read_header;
9use crate::Result;
10use std::collections::HashSet;
11
12#[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#[derive(Debug, Clone)]
22pub struct GcVersionsReport {
23 pub floor: crate::epoch::Epoch,
29 pub runs_reaped: usize,
32}
33
34#[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#[derive(Debug, Default, Clone)]
46pub struct DoctorReport {
47 pub runs_dropped: Vec<u128>,
48}
49
50impl Table {
51 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 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 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 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 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 !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); db.put(vec![(1, Value::Int64(1))]).unwrap();
208 db.flush().unwrap(); 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 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); db.put(vec![(1, Value::Int64(1))]).unwrap();
228 db.flush().unwrap();
229 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 #[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(); db.put(vec![(1, Value::Int64(2))]).unwrap();
253 db.flush().unwrap(); db.compact().unwrap(); 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 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 #[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}