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 // Derived indexes must match the compacted live run. Otherwise removed
182 // tombstones remain in a newly stamped checkpoint and reappear on open.
183 self.rebuild_indexes_from_runs()?;
184 // Compaction yields exactly one run → (re)build the learned-range PGMs
185 // so the checkpoint captures them (otherwise reopen would load a
186 // checkpoint with empty learned_range and fall back to page-pruned scans).
187 self.build_learned_ranges()?;
188 self.clear_result_cache();
189 let _ = expired_reclaimed;
190 self.checkpoint_indexes(self.current_epoch());
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}