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, MaintenanceReceipt};
9use crate::manifest::RunRef;
10use crate::memtable::Row;
11use crate::sorted_run::RunWriter;
12use crate::{ExecutionControl, MongrelError, Result};
13use std::collections::HashMap;
14use std::path::Path;
15
16impl Table {
17    /// Background-compaction run-count threshold (§5.9). When a table accumulates
18    /// at least this many sorted runs, every multi-run query pays decode work
19    /// proportional to the run count; `maybe_compact` collapses them back to one.
20    /// Conservative so a steady write stream doesn't compact too eagerly.
21    pub const AUTO_COMPACT_RUN_THRESHOLD: usize = 8;
22
23    /// Whether this table would benefit from compaction right now — the
24    /// query-cost signal for §5.9. Pure run-count topology (no per-query
25    /// bookkeeping): once runs have accumulated past the threshold, scans and
26    /// pushdown queries are paying multi-run fallback cost, so compaction is
27    /// worthwhile. A daemon (or any long-lived holder) polls this.
28    pub fn should_compact(&self) -> bool {
29        self.run_refs().len() >= Self::AUTO_COMPACT_RUN_THRESHOLD
30            || (self.ttl().is_some()
31                && !self.run_refs().is_empty()
32                && self.has_expired_run_rows().unwrap_or(false))
33    }
34
35    fn has_expired_run_rows(&self) -> Result<bool> {
36        self.has_expired_run_rows_inner(None)
37    }
38
39    fn has_expired_run_rows_inner(&self, control: Option<&ExecutionControl>) -> Result<bool> {
40        let now_nanos = crate::engine::unix_nanos_now();
41        for (run_index, run) in self.run_refs().iter().enumerate() {
42            if run_index % 256 == 0 {
43                if let Some(control) = control {
44                    control.checkpoint()?;
45                }
46            }
47            let mut reader = self.open_reader(run.run_id)?;
48            for (row_index, row) in reader.all_rows()?.iter().enumerate() {
49                if row_index % 256 == 0 {
50                    if let Some(control) = control {
51                        control.checkpoint()?;
52                    }
53                }
54                if self.row_expired_at(row, now_nanos) {
55                    return Ok(true);
56                }
57            }
58        }
59        Ok(false)
60    }
61
62    /// Compaction as a query optimization (§5.9): if [`should_compact`] reports
63    /// that runs have accumulated past the cost threshold, run [`compact`] and
64    /// return `true`; otherwise no-op and return `false`. Safe to call
65    /// periodically from a background task — [`compact`] is itself a no-op below
66    /// two runs and honors snapshot retention. Returns whether a compaction ran.
67    pub fn maybe_compact(&mut self) -> Result<bool> {
68        if !self.should_compact() {
69            return Ok(false);
70        }
71        self.compact()?;
72        Ok(true)
73    }
74
75    /// Merge all runs into a single level-1 run, dropping superseded versions
76    /// and tombstones — **but preserving** the version each pinned snapshot
77    /// still needs. No-op if there are fewer than two runs, unless a one-run
78    /// TTL table has expired payloads to reclaim.
79    pub fn compact(&mut self) -> Result<()> {
80        let control = ExecutionControl::new(None);
81        self.compact_controlled(&control, || true).map(|_| ())
82    }
83
84    /// Build replacement output cooperatively. Live state is unchanged until
85    /// `before_publish` succeeds. No cancellation checkpoint runs after that
86    /// manifest-publication boundary.
87    #[doc(hidden)]
88    pub fn compact_controlled<F>(
89        &mut self,
90        control: &ExecutionControl,
91        before_publish: F,
92    ) -> Result<bool>
93    where
94        F: FnOnce() -> bool,
95    {
96        self.compact_controlled_with_receipt(control, before_publish)
97            .map(|(changed, _)| changed)
98    }
99
100    /// Build replacement output cooperatively and return the exact table
101    /// snapshot used by the compaction. No receipt is returned for a no-op.
102    #[doc(hidden)]
103    pub fn compact_controlled_with_receipt<F>(
104        &mut self,
105        control: &ExecutionControl,
106        before_publish: F,
107    ) -> Result<(bool, Option<MaintenanceReceipt>)>
108    where
109        F: FnOnce() -> bool,
110    {
111        control.checkpoint()?;
112        let maintenance_epoch = self.current_epoch();
113        let reclaim_ttl = self.ttl().is_some() && self.has_expired_run_rows_inner(Some(control))?;
114        if self.run_refs().len() < 2 && !reclaim_ttl {
115            return Ok((false, None));
116        }
117        let min_active = self.min_active_snapshot();
118        let old_refs: Vec<RunRef> = self.run_refs().to_vec();
119        let now_nanos = crate::engine::unix_nanos_now();
120        let mutable_rows = if self.mutable_run_len() > 0 {
121            self.snapshot_mutable_run()
122        } else {
123            Vec::new()
124        };
125
126        let mut all: HashMap<u64, Vec<Row>> = HashMap::new();
127        let mut scanned = 0_usize;
128        for rr in &old_refs {
129            control.checkpoint()?;
130            let mut reader = self.open_reader(rr.run_id)?;
131            for row in reader.all_rows()? {
132                if scanned.is_multiple_of(256) {
133                    control.checkpoint()?;
134                }
135                scanned += 1;
136                all.entry(row.row_id.0).or_default().push(row);
137            }
138        }
139        for row in mutable_rows {
140            if scanned.is_multiple_of(256) {
141                control.checkpoint()?;
142            }
143            scanned += 1;
144            all.entry(row.row_id.0).or_default().push(row);
145        }
146
147        let mut rows = Vec::new();
148        let mut current_live_count = 0u64;
149        for (row_index, (_, mut versions)) in all.into_iter().enumerate() {
150            if row_index % 256 == 0 {
151                control.checkpoint()?;
152            }
153            versions.sort_by_key(|row| row.committed_epoch);
154            let Some(newest) = versions.last() else {
155                continue;
156            };
157            let newest_epoch = newest.committed_epoch;
158            if !newest.deleted && !self.row_expired_at(newest, now_nanos) {
159                current_live_count += 1;
160            }
161            for row in select_keep(&versions, min_active) {
162                if self.row_expired_at(&row, now_nanos) {
163                    if row.committed_epoch == newest_epoch
164                        && min_active.is_some_and(|epoch| newest_epoch > epoch)
165                    {
166                        let mut tombstone = row;
167                        tombstone.deleted = true;
168                        tombstone.columns.clear();
169                        rows.push(tombstone);
170                    }
171                } else {
172                    rows.push(row);
173                }
174            }
175        }
176        rows.sort_by_key(|row| (row.row_id, row.committed_epoch));
177
178        let mut staged_run = None;
179        if !rows.is_empty() {
180            let run_id = self.alloc_run_id()?;
181            let final_name = format!("r-{run_id}.sr");
182            let stage_name = format!(
183                "{final_name}.compact-stage-{}-{}",
184                std::process::id(),
185                std::time::SystemTime::now()
186                    .duration_since(std::time::UNIX_EPOCH)
187                    .unwrap_or_default()
188                    .as_nanos()
189            );
190            let kek = self.kek();
191            let mut writer = RunWriter::new(self.schema(), run_id as u128, maintenance_epoch, 1)
192                .clean(min_active.is_none())
193                .with_zstd_level(self.compaction_zstd_level());
194            if let Some(k) = &kek {
195                writer = writer.with_encryption(k.as_ref(), self.indexable_column_specs());
196            }
197            let header = match self.create_run_entry(Path::new(&stage_name))? {
198                Some(file) => writer.write_file(file, &rows),
199                None => writer.write(self.runs_dir().join(&stage_name), &rows),
200            };
201            let header = match header {
202                Ok(header) => header,
203                Err(error) => {
204                    let _ = self.remove_run_entry(Path::new(&stage_name));
205                    return Err(error);
206                }
207            };
208            staged_run = Some((
209                stage_name,
210                final_name,
211                RunRef {
212                    run_id: run_id as u128,
213                    level: 1,
214                    epoch_created: header.epoch_created,
215                    row_count: header.row_count,
216                },
217            ));
218        }
219
220        if let Err(error) = control.checkpoint() {
221            if let Some((stage_name, _, _)) = staged_run {
222                let _ = self.remove_run_entry(Path::new(&stage_name));
223            }
224            return Err(error);
225        }
226        if !before_publish() {
227            if let Some((stage_name, _, _)) = staged_run {
228                let _ = self.remove_run_entry(Path::new(&stage_name));
229            }
230            return Err(MongrelError::Cancelled);
231        }
232
233        // Publish the staged file before changing any live topology. A failed
234        // rename or directory sync can leave only an unreferenced run file;
235        // the old manifest, mutable run, and in-memory refs remain intact.
236        let replacement_ref = if let Some((stage_name, final_name, staged_ref)) = staged_run {
237            self.publish_run_entry(Path::new(&stage_name), Path::new(&final_name))?;
238            Some(staged_ref)
239        } else {
240            None
241        };
242
243        if self.mutable_run_len() > 0 {
244            self.drain_mutable_run();
245        }
246        self.live_count = current_live_count;
247        let retire_epoch = maintenance_epoch.0;
248        if let Some(replacement_ref) = replacement_ref {
249            self.set_run_refs(vec![replacement_ref]);
250            for run in &old_refs {
251                self.retire_run(run.run_id, retire_epoch);
252            }
253        } else {
254            self.set_run_refs(Vec::new());
255            for run in &old_refs {
256                self.retire_run(run.run_id, retire_epoch);
257            }
258        }
259
260        // The new manifest must explicitly reject the old global-index
261        // checkpoint.  Compaction does not advance the data epoch, so leaving
262        // the old epoch stamped here could make reopen accept indexes for the
263        // superseded runs.
264        self.prepare_indexes_for_run_replacement();
265        if let Err(error) = self.persist_manifest(maintenance_epoch) {
266            self.poison_after_maintenance_publish_failure();
267            return Err(MongrelError::CommitOutcomeUnknown {
268                epoch: maintenance_epoch.0,
269                message: format!("compaction manifest publication failed: {error}"),
270            });
271        }
272        self.clear_result_cache();
273        self.bump_data_generation();
274
275        // The replacement topology is durable.  Index rebuild failures do not
276        // make row data uncertain: keep the indexes marked incomplete so the
277        // next indexed operation or reopen rebuilds them from the new runs.
278        if let Err(error) = self
279            .rebuild_indexes_from_runs()
280            .and_then(|_| self.build_learned_ranges())
281        {
282            return Err(MongrelError::DurableCommit {
283                epoch: maintenance_epoch.0,
284                message: format!("compaction committed but index rebuild failed: {error}"),
285            });
286        }
287        self.finish_indexes_for_run_replacement();
288        self.checkpoint_indexes(maintenance_epoch);
289        Ok((
290            true,
291            Some(MaintenanceReceipt {
292                epoch: maintenance_epoch,
293            }),
294        ))
295    }
296}
297
298/// Versions to keep for one `RowId` given the oldest queryable snapshot. Every
299/// transition at or after the floor is retained, plus the boundary version
300/// visible at the floor. With no floor, only the current live version remains.
301fn select_keep(vers: &[Row], min_active: Option<Epoch>) -> Vec<Row> {
302    let Some(newest) = vers.last().cloned() else {
303        return Vec::new();
304    };
305    match min_active {
306        None => {
307            if newest.deleted {
308                Vec::new()
309            } else {
310                vec![newest]
311            }
312        }
313        Some(min_e) => {
314            let recent_start = vers.partition_point(|row| row.committed_epoch < min_e);
315            let mut keep = vers[recent_start..].to_vec();
316            if recent_start > 0 && keep.first().is_none_or(|row| row.committed_epoch > min_e) {
317                let boundary = vers[recent_start - 1].clone();
318                if keep.is_empty() && boundary.deleted {
319                    return Vec::new();
320                }
321                keep.insert(0, boundary);
322            }
323            keep
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
332    use crate::{Database, Snapshot, Value};
333    use tempfile::tempdir;
334
335    fn schema() -> Schema {
336        Schema {
337            schema_id: 1,
338            columns: vec![ColumnDef {
339                id: 1,
340                name: "v".into(),
341                ty: TypeId::Int64,
342                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
343                default_value: None,
344            }],
345            indexes: Vec::new(),
346            colocation: vec![],
347            constraints: Default::default(),
348            clustered: false,
349        }
350    }
351
352    #[test]
353    fn compaction_merges_runs_and_gcs_tombstoned_row() {
354        let dir = tempdir().unwrap();
355        let mut db = Table::create(dir.path(), schema(), 1).unwrap();
356        // Spill every flush to a run (this test exercises run-level merging).
357        db.set_mutable_run_spill_bytes(1);
358        let mut ids = Vec::new();
359        for i in 1..=5i64 {
360            ids.push(db.put(vec![(1, Value::Int64(i))]).unwrap());
361        }
362        db.flush().unwrap();
363        db.delete(ids[2]).unwrap();
364        db.flush().unwrap();
365        db.put(vec![(1, Value::Int64(60))]).unwrap();
366        db.flush().unwrap();
367        assert_eq!(db.run_count(), 3);
368
369        db.compact().unwrap();
370        assert_eq!(db.run_count(), 1);
371        let rows = db.visible_rows(db.snapshot()).unwrap();
372        let row_ids: Vec<u64> = rows.iter().map(|r| r.row_id.0).collect();
373        assert!(!row_ids.contains(&ids[2].0), "tombstoned row must be GC'd");
374        assert_eq!(rows.len(), 5);
375    }
376
377    #[test]
378    fn pinned_snapshot_survives_compaction() {
379        let dir = tempdir().unwrap();
380        let mut db = Table::create(dir.path(), schema(), 1).unwrap();
381        let r = db.put(vec![(1, Value::Int64(1))]).unwrap();
382        db.flush().unwrap(); // run 1: live version of r
383
384        // Pin a snapshot that sees the live version.
385        let pinned = db.pin_snapshot();
386        assert_eq!(
387            db.get(r, pinned)
388                .and_then(|row| row.columns.get(&1).cloned()),
389            Some(Value::Int64(1))
390        );
391
392        // Delete r, flush a second run, then compact with the pin still active.
393        db.delete(r).unwrap();
394        db.commit().unwrap();
395        db.flush().unwrap(); // run 2: tombstone for r
396        db.compact().unwrap(); // merges run1 + run2
397
398        // Pinned snapshot must still see the live version (retention kept it).
399        assert_eq!(
400            db.get(r, pinned)
401                .and_then(|row| row.columns.get(&1).cloned()),
402            Some(Value::Int64(1))
403        );
404        // Current snapshot sees the tombstone → row is gone.
405        assert_eq!(
406            db.get(r, db.snapshot())
407                .and_then(|row| row.columns.get(&1).cloned()),
408            None
409        );
410
411        // Release the pin; the next compaction may GC the live version.
412        db.unpin_snapshot(pinned);
413        db.compact().unwrap();
414        assert_eq!(
415            db.get(r, db.snapshot())
416                .and_then(|row| row.columns.get(&1).cloned()),
417            None
418        );
419    }
420
421    #[test]
422    fn controlled_compaction_cancel_before_publish_preserves_live_state() {
423        let dir = tempdir().unwrap();
424        let mut table = Table::create(dir.path(), schema(), 1).unwrap();
425        table.set_mutable_run_spill_bytes(1);
426        table.put(vec![(1, Value::Int64(1))]).unwrap();
427        table.flush().unwrap();
428        table.put(vec![(1, Value::Int64(2))]).unwrap();
429        table.flush().unwrap();
430        let before_refs: Vec<_> = table
431            .run_refs()
432            .iter()
433            .map(|run| (run.run_id, run.level, run.epoch_created, run.row_count))
434            .collect();
435
436        let error = table
437            .compact_controlled(&ExecutionControl::new(None), || false)
438            .unwrap_err();
439        assert!(matches!(error, MongrelError::Cancelled));
440        let after_refs: Vec<_> = table
441            .run_refs()
442            .iter()
443            .map(|run| (run.run_id, run.level, run.epoch_created, run.row_count))
444            .collect();
445        assert_eq!(after_refs, before_refs);
446        assert_eq!(table.visible_rows(table.snapshot()).unwrap().len(), 2);
447        assert!(std::fs::read_dir(table.runs_dir())
448            .unwrap()
449            .all(|entry| !entry
450                .unwrap()
451                .file_name()
452                .to_string_lossy()
453                .contains("compact-stage")));
454    }
455
456    #[test]
457    fn compaction_manifest_failure_poisons_standalone_table_until_reopen() {
458        let dir = tempdir().unwrap();
459        let mut table = Table::create(dir.path(), schema(), 1).unwrap();
460        table.set_mutable_run_spill_bytes(1);
461        table.put(vec![(1, Value::Int64(1))]).unwrap();
462        table.flush().unwrap();
463        table.put(vec![(1, Value::Int64(2))]).unwrap();
464        table.flush().unwrap();
465        let manifest = dir.path().join(crate::manifest::MANIFEST_FILENAME);
466        let saved_manifest = dir.path().join("_mf.saved");
467        std::fs::rename(&manifest, &saved_manifest).unwrap();
468        std::fs::create_dir(&manifest).unwrap();
469
470        let error = table.compact().unwrap_err();
471        assert!(matches!(error, MongrelError::CommitOutcomeUnknown { .. }));
472        assert_eq!(table.visible_rows(table.snapshot()).unwrap().len(), 2);
473        assert!(table
474            .put(vec![(1, Value::Int64(3))])
475            .unwrap_err()
476            .to_string()
477            .contains("reopen required"));
478
479        drop(table);
480        std::fs::remove_dir(&manifest).unwrap();
481        std::fs::rename(saved_manifest, manifest).unwrap();
482        let reopened = Table::open(dir.path()).unwrap();
483        assert_eq!(reopened.visible_rows(reopened.snapshot()).unwrap().len(), 2);
484    }
485
486    #[test]
487    fn compaction_manifest_failure_poisons_mounted_database() {
488        let dir = tempdir().unwrap();
489        let db = Database::create(dir.path()).unwrap();
490        let table_id = db.create_table("items", schema()).unwrap();
491        let handle = db.table("items").unwrap();
492        {
493            let mut table = handle.lock();
494            table.set_mutable_run_spill_bytes(1);
495            table.put(vec![(1, Value::Int64(1))]).unwrap();
496            table.flush().unwrap();
497            table.put(vec![(1, Value::Int64(2))]).unwrap();
498            table.flush().unwrap();
499        }
500        let manifest = dir
501            .path()
502            .join("tables")
503            .join(table_id.to_string())
504            .join(crate::manifest::MANIFEST_FILENAME);
505        let saved_manifest = manifest.with_extension("saved");
506        std::fs::rename(&manifest, &saved_manifest).unwrap();
507        std::fs::create_dir(&manifest).unwrap();
508
509        let error = handle.lock().compact().unwrap_err();
510        assert!(matches!(error, MongrelError::CommitOutcomeUnknown { .. }));
511        assert!(db
512            .create_table("blocked", schema())
513            .unwrap_err()
514            .to_string()
515            .contains("database poisoned"));
516    }
517
518    #[test]
519    fn _snapshot_import_used() {
520        let _ = Snapshot::at(Epoch(0));
521    }
522}