mongreldb_core/compaction.rs
1//! Tiered compaction (Phase 5) with snapshot retention.
2//!
3//! Merges all sorted runs into one, dropping superseded versions and tombstones
4//! — but preserving the version each pinned read snapshot still needs. Identical
5//! re-encoded pages reuse their content hash, so the page cache keeps hitting.
6
7use crate::engine::Table;
8use crate::epoch::Epoch;
9use crate::manifest::RunRef;
10use crate::memtable::Row;
11use crate::sorted_run::RunWriter;
12use crate::Result;
13use std::collections::HashMap;
14
15impl Table {
16 /// Background-compaction run-count threshold (§5.9). When a table accumulates
17 /// at least this many sorted runs, every multi-run query pays decode work
18 /// proportional to the run count; `maybe_compact` collapses them back to one.
19 /// Conservative so a steady write stream doesn't compact too eagerly.
20 pub const AUTO_COMPACT_RUN_THRESHOLD: usize = 8;
21
22 /// Whether this table would benefit from compaction right now — the
23 /// query-cost signal for §5.9. Pure run-count topology (no per-query
24 /// bookkeeping): once runs have accumulated past the threshold, scans and
25 /// pushdown queries are paying multi-run fallback cost, so compaction is
26 /// worthwhile. A daemon (or any long-lived holder) polls this.
27 pub fn should_compact(&self) -> bool {
28 self.run_refs().len() >= Self::AUTO_COMPACT_RUN_THRESHOLD
29 }
30
31 /// Compaction as a query optimization (§5.9): if [`should_compact`] reports
32 /// that runs have accumulated past the cost threshold, run [`compact`] and
33 /// return `true`; otherwise no-op and return `false`. Safe to call
34 /// periodically from a background task — [`compact`] is itself a no-op below
35 /// two runs and honors snapshot retention. Returns whether a compaction ran.
36 pub fn maybe_compact(&mut self) -> Result<bool> {
37 if !self.should_compact() {
38 return Ok(false);
39 }
40 self.compact()?;
41 Ok(true)
42 }
43
44 /// Merge all runs into a single level-1 run, dropping superseded versions
45 /// and tombstones — **but preserving** the version each pinned snapshot
46 /// still needs. No-op if there are fewer than two runs.
47 pub fn compact(&mut self) -> Result<()> {
48 if self.run_refs().len() < 2 {
49 return Ok(());
50 }
51 let min_active = self.min_active_snapshot();
52 let old_refs: Vec<RunRef> = self.run_refs().to_vec();
53
54 // Fold the mutable-run tier into the compaction input (Phase 11.1) so
55 // its rows are merged and rewritten to the output run. Draining here is
56 // safe: compaction does not rotate the WAL, so crash recovery replays
57 // those rows back into the memtable (the tier rebuilds from replay).
58 let mutable_rows = if self.mutable_run_len() > 0 {
59 self.drain_mutable_run()
60 } else {
61 Vec::new()
62 };
63
64 // Gather every version of every row across all runs.
65 let mut all: HashMap<u64, Vec<Row>> = HashMap::new();
66 for rr in &old_refs {
67 let mut reader = self.open_reader(rr.run_id)?;
68 for row in reader.all_rows()? {
69 all.entry(row.row_id.0).or_default().push(row);
70 }
71 }
72 // Merge the mutable-run tier's drained versions on top.
73 for row in mutable_rows {
74 all.entry(row.row_id.0).or_default().push(row);
75 }
76
77 let mut rows: Vec<Row> = Vec::new();
78 for (_, mut vers) in all {
79 vers.sort_by_key(|r| r.committed_epoch);
80 rows.extend(select_keep(&vers, min_active));
81 }
82 rows.sort_by_key(|r| (r.row_id, r.committed_epoch));
83
84 // Recompute the live-row counter from the merged survivors.
85 self.live_count = rows.iter().filter(|r| !r.deleted).count() as u64;
86
87 let retire_epoch = self.current_epoch().0;
88 if rows.is_empty() {
89 // Point the manifest at the empty run set and enqueue the superseded
90 // runs for retention-gated deletion (spec §6.4) — `gc()` deletes them
91 // once no pinned snapshot can still need them. Persisting before any
92 // unlink also keeps a concurrent `check`/`doctor` from ever seeing a
93 // RunRef whose file is already gone.
94 self.set_run_refs(Vec::new());
95 for rr in &old_refs {
96 self.retire_run(rr.run_id, retire_epoch);
97 }
98 self.persist_manifest(self.current_epoch())?;
99 // No live rows remain; the in-memory indexes are stale → drop the
100 // checkpoint so reopen rebuilds (empty) instead of loading it.
101 self.invalidate_index_checkpoint();
102 return Ok(());
103 }
104
105 let run_id = self.next_run_id();
106 self.bump_next_run_id();
107 let path = self.run_path(run_id);
108 let kek = self.kek();
109 let mut writer = RunWriter::new(self.schema(), run_id as u128, self.current_epoch(), 1)
110 .clean(min_active.is_none())
111 .with_zstd_level(self.compaction_zstd_level());
112 if let Some(k) = &kek {
113 writer = writer.with_encryption(k.as_ref(), self.indexable_column_specs());
114 }
115 let header = writer.write(&path, &rows)?;
116
117 // Point the manifest at the new run and enqueue the superseded runs for
118 // retention-gated deletion (spec §6.4): `gc()` deletes their files once
119 // `min_active_snapshot` passes this compaction epoch, so a reader pinned
120 // below it keeps a consistent on-disk view. Persisting the manifest
121 // (with both the new RunRef and the `retiring` queue) BEFORE any unlink
122 // also means a concurrent `check`/`doctor` never sees a RunRef whose file
123 // is gone, and the retired files are tracked (not orphans) across reopen.
124 self.set_run_refs(vec![RunRef {
125 run_id: run_id as u128,
126 level: 1,
127 epoch_created: header.epoch_created,
128 row_count: header.row_count,
129 }]);
130 for rr in &old_refs {
131 self.retire_run(rr.run_id, retire_epoch);
132 }
133 self.persist_manifest(self.current_epoch())?;
134 // Compaction yields exactly one run → (re)build the learned-range PGMs
135 // so the checkpoint captures them (otherwise reopen would load a
136 // checkpoint with empty learned_range and fall back to page-pruned scans).
137 self.build_learned_ranges()?;
138 self.clear_result_cache();
139 self.checkpoint_indexes(self.current_epoch());
140 Ok(())
141 }
142}
143
144/// Versions to keep for one `RowId` given the oldest pinned snapshot. Always
145/// keeps the newest version; if snapshots are active, also keeps the newest
146/// version `<= min_active` (the one the oldest snapshot sees). Tombstones are
147/// only dropped when no snapshot is active.
148fn select_keep(vers: &[Row], min_active: Option<Epoch>) -> Vec<Row> {
149 let newest = vers.last().expect("at least one version").clone();
150 match min_active {
151 None => {
152 if newest.deleted {
153 Vec::new()
154 } else {
155 vec![newest]
156 }
157 }
158 Some(min_e) => {
159 let mut keep = vec![newest.clone()];
160 // Newest version visible to the oldest snapshot.
161 if let Some(v) = vers.iter().rev().find(|v| v.committed_epoch <= min_e) {
162 if v.committed_epoch != newest.committed_epoch {
163 keep.push(v.clone());
164 }
165 }
166 keep
167 }
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
175 use crate::{Snapshot, Value};
176 use tempfile::tempdir;
177
178 fn schema() -> Schema {
179 Schema {
180 schema_id: 1,
181 columns: vec![ColumnDef {
182 id: 1,
183 name: "v".into(),
184 ty: TypeId::Int64,
185 flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
186 }],
187 indexes: Vec::new(),
188 colocation: vec![],
189 constraints: Default::default(),
190 clustered: false,
191 }
192 }
193
194 #[test]
195 fn compaction_merges_runs_and_gcs_tombstoned_row() {
196 let dir = tempdir().unwrap();
197 let mut db = Table::create(dir.path(), schema(), 1).unwrap();
198 // Spill every flush to a run (this test exercises run-level merging).
199 db.set_mutable_run_spill_bytes(1);
200 let mut ids = Vec::new();
201 for i in 1..=5i64 {
202 ids.push(db.put(vec![(1, Value::Int64(i))]).unwrap());
203 }
204 db.flush().unwrap();
205 db.delete(ids[2]).unwrap();
206 db.flush().unwrap();
207 db.put(vec![(1, Value::Int64(60))]).unwrap();
208 db.flush().unwrap();
209 assert_eq!(db.run_count(), 3);
210
211 db.compact().unwrap();
212 assert_eq!(db.run_count(), 1);
213 let rows = db.visible_rows(db.snapshot()).unwrap();
214 let row_ids: Vec<u64> = rows.iter().map(|r| r.row_id.0).collect();
215 assert!(!row_ids.contains(&ids[2].0), "tombstoned row must be GC'd");
216 assert_eq!(rows.len(), 5);
217 }
218
219 #[test]
220 fn pinned_snapshot_survives_compaction() {
221 let dir = tempdir().unwrap();
222 let mut db = Table::create(dir.path(), schema(), 1).unwrap();
223 let r = db.put(vec![(1, Value::Int64(1))]).unwrap();
224 db.flush().unwrap(); // run 1: live version of r
225
226 // Pin a snapshot that sees the live version.
227 let pinned = db.pin_snapshot();
228 assert_eq!(
229 db.get(r, pinned)
230 .and_then(|row| row.columns.get(&1).cloned()),
231 Some(Value::Int64(1))
232 );
233
234 // Delete r, flush a second run, then compact with the pin still active.
235 db.delete(r).unwrap();
236 db.commit().unwrap();
237 db.flush().unwrap(); // run 2: tombstone for r
238 db.compact().unwrap(); // merges run1 + run2
239
240 // Pinned snapshot must still see the live version (retention kept it).
241 assert_eq!(
242 db.get(r, pinned)
243 .and_then(|row| row.columns.get(&1).cloned()),
244 Some(Value::Int64(1))
245 );
246 // Current snapshot sees the tombstone → row is gone.
247 assert_eq!(
248 db.get(r, db.snapshot())
249 .and_then(|row| row.columns.get(&1).cloned()),
250 None
251 );
252
253 // Release the pin; the next compaction may GC the live version.
254 db.unpin_snapshot(pinned);
255 db.compact().unwrap();
256 assert_eq!(
257 db.get(r, db.snapshot())
258 .and_then(|row| row.columns.get(&1).cloned()),
259 None
260 );
261 }
262
263 #[test]
264 fn _snapshot_import_used() {
265 let _ = Snapshot::at(Epoch(0));
266 }
267}