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            || (self.ttl().is_some()
30                && !self.run_refs().is_empty()
31                && self.has_expired_run_rows().unwrap_or(false))
32    }
33
34    fn has_expired_run_rows(&self) -> Result<bool> {
35        let now_nanos = crate::engine::unix_nanos_now();
36        for run in self.run_refs() {
37            let mut reader = self.open_reader(run.run_id)?;
38            if reader
39                .all_rows()?
40                .iter()
41                .any(|row| self.row_expired_at(row, now_nanos))
42            {
43                return Ok(true);
44            }
45        }
46        Ok(false)
47    }
48
49    /// Compaction as a query optimization (§5.9): if [`should_compact`] reports
50    /// that runs have accumulated past the cost threshold, run [`compact`] and
51    /// return `true`; otherwise no-op and return `false`. Safe to call
52    /// periodically from a background task — [`compact`] is itself a no-op below
53    /// two runs and honors snapshot retention. Returns whether a compaction ran.
54    pub fn maybe_compact(&mut self) -> Result<bool> {
55        if !self.should_compact() {
56            return Ok(false);
57        }
58        self.compact()?;
59        Ok(true)
60    }
61
62    /// Merge all runs into a single level-1 run, dropping superseded versions
63    /// and tombstones — **but preserving** the version each pinned snapshot
64    /// still needs. No-op if there are fewer than two runs, unless a one-run
65    /// TTL table has expired payloads to reclaim.
66    pub fn compact(&mut self) -> Result<()> {
67        let reclaim_ttl = self.ttl().is_some() && self.has_expired_run_rows()?;
68        if self.run_refs().len() < 2 && !reclaim_ttl {
69            return Ok(());
70        }
71        let min_active = self.min_active_snapshot();
72        let old_refs: Vec<RunRef> = self.run_refs().to_vec();
73        let now_nanos = crate::engine::unix_nanos_now();
74
75        // Fold the mutable-run tier into the compaction input (Phase 11.1) so
76        // its rows are merged and rewritten to the output run. Draining here is
77        // safe: compaction does not rotate the WAL, so crash recovery replays
78        // those rows back into the memtable (the tier rebuilds from replay).
79        let mutable_rows = if self.mutable_run_len() > 0 {
80            self.drain_mutable_run()
81        } else {
82            Vec::new()
83        };
84
85        // Gather every version of every row across all runs.
86        let mut all: HashMap<u64, Vec<Row>> = HashMap::new();
87        for rr in &old_refs {
88            let mut reader = self.open_reader(rr.run_id)?;
89            for row in reader.all_rows()? {
90                all.entry(row.row_id.0).or_default().push(row);
91            }
92        }
93        // Merge the mutable-run tier's drained versions on top.
94        for row in mutable_rows {
95            all.entry(row.row_id.0).or_default().push(row);
96        }
97
98        let mut rows: Vec<Row> = Vec::new();
99        let mut expired_reclaimed = false;
100        let mut current_live_count = 0u64;
101        for (_, mut vers) in all {
102            vers.sort_by_key(|r| r.committed_epoch);
103            let newest = vers.last().expect("at least one version");
104            let newest_epoch = newest.committed_epoch;
105            if !newest.deleted && !self.row_expired_at(newest, now_nanos) {
106                current_live_count += 1;
107            }
108            for row in select_keep(&vers, min_active) {
109                if self.row_expired_at(&row, now_nanos) {
110                    expired_reclaimed = true;
111                    // If an older snapshot still needs a pre-expiry version,
112                    // keep a payload-free tombstone at the newest epoch. Merely
113                    // dropping the expired newest version could resurrect the
114                    // older row for current readers after the rewrite.
115                    if row.committed_epoch == newest_epoch
116                        && min_active.is_some_and(|epoch| newest_epoch > epoch)
117                    {
118                        let mut tombstone = row;
119                        tombstone.deleted = true;
120                        tombstone.columns.clear();
121                        rows.push(tombstone);
122                    }
123                } else {
124                    rows.push(row);
125                }
126            }
127        }
128        rows.sort_by_key(|r| (r.row_id, r.committed_epoch));
129
130        // Count logical rows at the current epoch, not retained historical
131        // versions (which may leave several non-deleted records per RowId).
132        self.live_count = current_live_count;
133
134        let retire_epoch = self.current_epoch().0;
135        if rows.is_empty() {
136            // Point the manifest at the empty run set and enqueue the superseded
137            // runs for retention-gated deletion (spec §6.4) — `gc()` deletes them
138            // once no pinned snapshot can still need them. Persisting before any
139            // unlink also keeps a concurrent `check`/`doctor` from ever seeing a
140            // RunRef whose file is already gone.
141            self.set_run_refs(Vec::new());
142            for rr in &old_refs {
143                self.retire_run(rr.run_id, retire_epoch);
144            }
145            self.persist_manifest(self.current_epoch())?;
146            // No live rows remain; the in-memory indexes are stale → drop the
147            // checkpoint so reopen rebuilds (empty) instead of loading it.
148            self.mark_indexes_incomplete();
149            self.bump_data_generation();
150            return Ok(());
151        }
152
153        let run_id = self.next_run_id();
154        self.bump_next_run_id();
155        let path = self.run_path(run_id);
156        let kek = self.kek();
157        let mut writer = RunWriter::new(self.schema(), run_id as u128, self.current_epoch(), 1)
158            .clean(min_active.is_none())
159            .with_zstd_level(self.compaction_zstd_level());
160        if let Some(k) = &kek {
161            writer = writer.with_encryption(k.as_ref(), self.indexable_column_specs());
162        }
163        let header = writer.write(&path, &rows)?;
164
165        // Point the manifest at the new run and enqueue the superseded runs for
166        // retention-gated deletion (spec §6.4): `gc()` deletes their files once
167        // `min_active_snapshot` passes this compaction epoch, so a reader pinned
168        // below it keeps a consistent on-disk view. Persisting the manifest
169        // (with both the new RunRef and the `retiring` queue) BEFORE any unlink
170        // also means a concurrent `check`/`doctor` never sees a RunRef whose file
171        // is gone, and the retired files are tracked (not orphans) across reopen.
172        self.set_run_refs(vec![RunRef {
173            run_id: run_id as u128,
174            level: 1,
175            epoch_created: header.epoch_created,
176            row_count: header.row_count,
177        }]);
178        for rr in &old_refs {
179            self.retire_run(rr.run_id, retire_epoch);
180        }
181        self.persist_manifest(self.current_epoch())?;
182        // Derived indexes must match the compacted live run. Otherwise removed
183        // tombstones remain in a newly stamped checkpoint and reappear on open.
184        self.rebuild_indexes_from_runs()?;
185        // Compaction yields exactly one run → (re)build the learned-range PGMs
186        // so the checkpoint captures them (otherwise reopen would load a
187        // checkpoint with empty learned_range and fall back to page-pruned scans).
188        self.build_learned_ranges()?;
189        self.clear_result_cache();
190        let _ = expired_reclaimed;
191        self.checkpoint_indexes(self.current_epoch());
192        self.bump_data_generation();
193        Ok(())
194    }
195}
196
197/// Versions to keep for one `RowId` given the oldest queryable snapshot. Every
198/// transition at or after the floor is retained, plus the boundary version
199/// visible at the floor. With no floor, only the current live version remains.
200fn select_keep(vers: &[Row], min_active: Option<Epoch>) -> Vec<Row> {
201    let newest = vers.last().expect("at least one version").clone();
202    match min_active {
203        None => {
204            if newest.deleted {
205                Vec::new()
206            } else {
207                vec![newest]
208            }
209        }
210        Some(min_e) => {
211            let recent_start = vers.partition_point(|row| row.committed_epoch < min_e);
212            let mut keep = vers[recent_start..].to_vec();
213            if recent_start > 0 && keep.first().map_or(true, |row| row.committed_epoch > min_e) {
214                let boundary = vers[recent_start - 1].clone();
215                if keep.is_empty() && boundary.deleted {
216                    return Vec::new();
217                }
218                keep.insert(0, boundary);
219            }
220            keep
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
229    use crate::{Snapshot, Value};
230    use tempfile::tempdir;
231
232    fn schema() -> Schema {
233        Schema {
234            schema_id: 1,
235            columns: vec![ColumnDef {
236                id: 1,
237                name: "v".into(),
238                ty: TypeId::Int64,
239                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
240                default_value: None,
241            }],
242            indexes: Vec::new(),
243            colocation: vec![],
244            constraints: Default::default(),
245            clustered: false,
246        }
247    }
248
249    #[test]
250    fn compaction_merges_runs_and_gcs_tombstoned_row() {
251        let dir = tempdir().unwrap();
252        let mut db = Table::create(dir.path(), schema(), 1).unwrap();
253        // Spill every flush to a run (this test exercises run-level merging).
254        db.set_mutable_run_spill_bytes(1);
255        let mut ids = Vec::new();
256        for i in 1..=5i64 {
257            ids.push(db.put(vec![(1, Value::Int64(i))]).unwrap());
258        }
259        db.flush().unwrap();
260        db.delete(ids[2]).unwrap();
261        db.flush().unwrap();
262        db.put(vec![(1, Value::Int64(60))]).unwrap();
263        db.flush().unwrap();
264        assert_eq!(db.run_count(), 3);
265
266        db.compact().unwrap();
267        assert_eq!(db.run_count(), 1);
268        let rows = db.visible_rows(db.snapshot()).unwrap();
269        let row_ids: Vec<u64> = rows.iter().map(|r| r.row_id.0).collect();
270        assert!(!row_ids.contains(&ids[2].0), "tombstoned row must be GC'd");
271        assert_eq!(rows.len(), 5);
272    }
273
274    #[test]
275    fn pinned_snapshot_survives_compaction() {
276        let dir = tempdir().unwrap();
277        let mut db = Table::create(dir.path(), schema(), 1).unwrap();
278        let r = db.put(vec![(1, Value::Int64(1))]).unwrap();
279        db.flush().unwrap(); // run 1: live version of r
280
281        // Pin a snapshot that sees the live version.
282        let pinned = db.pin_snapshot();
283        assert_eq!(
284            db.get(r, pinned)
285                .and_then(|row| row.columns.get(&1).cloned()),
286            Some(Value::Int64(1))
287        );
288
289        // Delete r, flush a second run, then compact with the pin still active.
290        db.delete(r).unwrap();
291        db.commit().unwrap();
292        db.flush().unwrap(); // run 2: tombstone for r
293        db.compact().unwrap(); // merges run1 + run2
294
295        // Pinned snapshot must still see the live version (retention kept it).
296        assert_eq!(
297            db.get(r, pinned)
298                .and_then(|row| row.columns.get(&1).cloned()),
299            Some(Value::Int64(1))
300        );
301        // Current snapshot sees the tombstone → row is gone.
302        assert_eq!(
303            db.get(r, db.snapshot())
304                .and_then(|row| row.columns.get(&1).cloned()),
305            None
306        );
307
308        // Release the pin; the next compaction may GC the live version.
309        db.unpin_snapshot(pinned);
310        db.compact().unwrap();
311        assert_eq!(
312            db.get(r, db.snapshot())
313                .and_then(|row| row.columns.get(&1).cloned()),
314            None
315        );
316    }
317
318    #[test]
319    fn _snapshot_import_used() {
320        let _ = Snapshot::at(Epoch(0));
321    }
322}