Skip to main content

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}