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            return Ok(());
150        }
151
152        let run_id = self.next_run_id();
153        self.bump_next_run_id();
154        let path = self.run_path(run_id);
155        let kek = self.kek();
156        let mut writer = RunWriter::new(self.schema(), run_id as u128, self.current_epoch(), 1)
157            .clean(min_active.is_none())
158            .with_zstd_level(self.compaction_zstd_level());
159        if let Some(k) = &kek {
160            writer = writer.with_encryption(k.as_ref(), self.indexable_column_specs());
161        }
162        let header = writer.write(&path, &rows)?;
163
164        // Point the manifest at the new run and enqueue the superseded runs for
165        // retention-gated deletion (spec §6.4): `gc()` deletes their files once
166        // `min_active_snapshot` passes this compaction epoch, so a reader pinned
167        // below it keeps a consistent on-disk view. Persisting the manifest
168        // (with both the new RunRef and the `retiring` queue) BEFORE any unlink
169        // also means a concurrent `check`/`doctor` never sees a RunRef whose file
170        // is gone, and the retired files are tracked (not orphans) across reopen.
171        self.set_run_refs(vec![RunRef {
172            run_id: run_id as u128,
173            level: 1,
174            epoch_created: header.epoch_created,
175            row_count: header.row_count,
176        }]);
177        for rr in &old_refs {
178            self.retire_run(rr.run_id, retire_epoch);
179        }
180        self.persist_manifest(self.current_epoch())?;
181        // Compaction yields exactly one run → (re)build the learned-range PGMs
182        // so the checkpoint captures them (otherwise reopen would load a
183        // checkpoint with empty learned_range and fall back to page-pruned scans).
184        self.build_learned_ranges()?;
185        self.clear_result_cache();
186        if expired_reclaimed {
187            self.mark_indexes_incomplete();
188        } else {
189            self.checkpoint_indexes(self.current_epoch());
190        }
191        Ok(())
192    }
193}
194
195/// Versions to keep for one `RowId` given the oldest queryable snapshot. Every
196/// transition at or after the floor is retained, plus the boundary version
197/// visible at the floor. With no floor, only the current live version remains.
198fn select_keep(vers: &[Row], min_active: Option<Epoch>) -> Vec<Row> {
199    let newest = vers.last().expect("at least one version").clone();
200    match min_active {
201        None => {
202            if newest.deleted {
203                Vec::new()
204            } else {
205                vec![newest]
206            }
207        }
208        Some(min_e) => {
209            let recent_start = vers.partition_point(|row| row.committed_epoch < min_e);
210            let mut keep = vers[recent_start..].to_vec();
211            if recent_start > 0 && keep.first().map_or(true, |row| row.committed_epoch > min_e) {
212                let boundary = vers[recent_start - 1].clone();
213                if keep.is_empty() && boundary.deleted {
214                    return Vec::new();
215                }
216                keep.insert(0, boundary);
217            }
218            keep
219        }
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
227    use crate::{Snapshot, Value};
228    use tempfile::tempdir;
229
230    fn schema() -> Schema {
231        Schema {
232            schema_id: 1,
233            columns: vec![ColumnDef {
234                id: 1,
235                name: "v".into(),
236                ty: TypeId::Int64,
237                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
238                default_value: None,
239            }],
240            indexes: Vec::new(),
241            colocation: vec![],
242            constraints: Default::default(),
243            clustered: false,
244        }
245    }
246
247    #[test]
248    fn compaction_merges_runs_and_gcs_tombstoned_row() {
249        let dir = tempdir().unwrap();
250        let mut db = Table::create(dir.path(), schema(), 1).unwrap();
251        // Spill every flush to a run (this test exercises run-level merging).
252        db.set_mutable_run_spill_bytes(1);
253        let mut ids = Vec::new();
254        for i in 1..=5i64 {
255            ids.push(db.put(vec![(1, Value::Int64(i))]).unwrap());
256        }
257        db.flush().unwrap();
258        db.delete(ids[2]).unwrap();
259        db.flush().unwrap();
260        db.put(vec![(1, Value::Int64(60))]).unwrap();
261        db.flush().unwrap();
262        assert_eq!(db.run_count(), 3);
263
264        db.compact().unwrap();
265        assert_eq!(db.run_count(), 1);
266        let rows = db.visible_rows(db.snapshot()).unwrap();
267        let row_ids: Vec<u64> = rows.iter().map(|r| r.row_id.0).collect();
268        assert!(!row_ids.contains(&ids[2].0), "tombstoned row must be GC'd");
269        assert_eq!(rows.len(), 5);
270    }
271
272    #[test]
273    fn pinned_snapshot_survives_compaction() {
274        let dir = tempdir().unwrap();
275        let mut db = Table::create(dir.path(), schema(), 1).unwrap();
276        let r = db.put(vec![(1, Value::Int64(1))]).unwrap();
277        db.flush().unwrap(); // run 1: live version of r
278
279        // Pin a snapshot that sees the live version.
280        let pinned = db.pin_snapshot();
281        assert_eq!(
282            db.get(r, pinned)
283                .and_then(|row| row.columns.get(&1).cloned()),
284            Some(Value::Int64(1))
285        );
286
287        // Delete r, flush a second run, then compact with the pin still active.
288        db.delete(r).unwrap();
289        db.commit().unwrap();
290        db.flush().unwrap(); // run 2: tombstone for r
291        db.compact().unwrap(); // merges run1 + run2
292
293        // Pinned snapshot must still see the live version (retention kept it).
294        assert_eq!(
295            db.get(r, pinned)
296                .and_then(|row| row.columns.get(&1).cloned()),
297            Some(Value::Int64(1))
298        );
299        // Current snapshot sees the tombstone → row is gone.
300        assert_eq!(
301            db.get(r, db.snapshot())
302                .and_then(|row| row.columns.get(&1).cloned()),
303            None
304        );
305
306        // Release the pin; the next compaction may GC the live version.
307        db.unpin_snapshot(pinned);
308        db.compact().unwrap();
309        assert_eq!(
310            db.get(r, db.snapshot())
311                .and_then(|row| row.columns.get(&1).cloned()),
312            None
313        );
314    }
315
316    #[test]
317    fn _snapshot_import_used() {
318        let _ = Snapshot::at(Epoch(0));
319    }
320}