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