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                embedding_source: None,
345            }],
346            indexes: Vec::new(),
347            colocation: vec![],
348            constraints: Default::default(),
349            clustered: false,
350        }
351    }
352
353    #[test]
354    fn compaction_merges_runs_and_gcs_tombstoned_row() {
355        let dir = tempdir().unwrap();
356        let mut db = Table::create(dir.path(), schema(), 1).unwrap();
357        // Spill every flush to a run (this test exercises run-level merging).
358        db.set_mutable_run_spill_bytes(1);
359        let mut ids = Vec::new();
360        for i in 1..=5i64 {
361            ids.push(db.put(vec![(1, Value::Int64(i))]).unwrap());
362        }
363        db.flush().unwrap();
364        db.delete(ids[2]).unwrap();
365        db.flush().unwrap();
366        db.put(vec![(1, Value::Int64(60))]).unwrap();
367        db.flush().unwrap();
368        assert_eq!(db.run_count(), 3);
369
370        db.compact().unwrap();
371        assert_eq!(db.run_count(), 1);
372        let rows = db.visible_rows(db.snapshot()).unwrap();
373        let row_ids: Vec<u64> = rows.iter().map(|r| r.row_id.0).collect();
374        assert!(!row_ids.contains(&ids[2].0), "tombstoned row must be GC'd");
375        assert_eq!(rows.len(), 5);
376    }
377
378    #[test]
379    fn pinned_snapshot_survives_compaction() {
380        let dir = tempdir().unwrap();
381        let mut db = Table::create(dir.path(), schema(), 1).unwrap();
382        let r = db.put(vec![(1, Value::Int64(1))]).unwrap();
383        db.flush().unwrap(); // run 1: live version of r
384
385        // Pin a snapshot that sees the live version.
386        let pinned = db.pin_snapshot();
387        assert_eq!(
388            db.get(r, pinned)
389                .and_then(|row| row.columns.get(&1).cloned()),
390            Some(Value::Int64(1))
391        );
392
393        // Delete r, flush a second run, then compact with the pin still active.
394        db.delete(r).unwrap();
395        db.commit().unwrap();
396        db.flush().unwrap(); // run 2: tombstone for r
397        db.compact().unwrap(); // merges run1 + run2
398
399        // Pinned snapshot must still see the live version (retention kept it).
400        assert_eq!(
401            db.get(r, pinned)
402                .and_then(|row| row.columns.get(&1).cloned()),
403            Some(Value::Int64(1))
404        );
405        // Current snapshot sees the tombstone → row is gone.
406        assert_eq!(
407            db.get(r, db.snapshot())
408                .and_then(|row| row.columns.get(&1).cloned()),
409            None
410        );
411
412        // Release the pin; the next compaction may GC the live version.
413        db.unpin_snapshot(pinned);
414        db.compact().unwrap();
415        assert_eq!(
416            db.get(r, db.snapshot())
417                .and_then(|row| row.columns.get(&1).cloned()),
418            None
419        );
420    }
421
422    #[test]
423    fn controlled_compaction_cancel_before_publish_preserves_live_state() {
424        let dir = tempdir().unwrap();
425        let mut table = Table::create(dir.path(), schema(), 1).unwrap();
426        table.set_mutable_run_spill_bytes(1);
427        table.put(vec![(1, Value::Int64(1))]).unwrap();
428        table.flush().unwrap();
429        table.put(vec![(1, Value::Int64(2))]).unwrap();
430        table.flush().unwrap();
431        let before_refs: Vec<_> = table
432            .run_refs()
433            .iter()
434            .map(|run| (run.run_id, run.level, run.epoch_created, run.row_count))
435            .collect();
436
437        let error = table
438            .compact_controlled(&ExecutionControl::new(None), || false)
439            .unwrap_err();
440        assert!(matches!(error, MongrelError::Cancelled));
441        let after_refs: Vec<_> = table
442            .run_refs()
443            .iter()
444            .map(|run| (run.run_id, run.level, run.epoch_created, run.row_count))
445            .collect();
446        assert_eq!(after_refs, before_refs);
447        assert_eq!(table.visible_rows(table.snapshot()).unwrap().len(), 2);
448        assert!(std::fs::read_dir(table.runs_dir())
449            .unwrap()
450            .all(|entry| !entry
451                .unwrap()
452                .file_name()
453                .to_string_lossy()
454                .contains("compact-stage")));
455    }
456
457    #[test]
458    fn compaction_manifest_failure_poisons_standalone_table_until_reopen() {
459        let dir = tempdir().unwrap();
460        let mut table = Table::create(dir.path(), schema(), 1).unwrap();
461        table.set_mutable_run_spill_bytes(1);
462        table.put(vec![(1, Value::Int64(1))]).unwrap();
463        table.flush().unwrap();
464        table.put(vec![(1, Value::Int64(2))]).unwrap();
465        table.flush().unwrap();
466        let manifest = dir.path().join(crate::manifest::MANIFEST_FILENAME);
467        let saved_manifest = dir.path().join("_mf.saved");
468        std::fs::rename(&manifest, &saved_manifest).unwrap();
469        std::fs::create_dir(&manifest).unwrap();
470
471        let error = table.compact().unwrap_err();
472        assert!(matches!(error, MongrelError::CommitOutcomeUnknown { .. }));
473        assert_eq!(table.visible_rows(table.snapshot()).unwrap().len(), 2);
474        assert!(table
475            .put(vec![(1, Value::Int64(3))])
476            .unwrap_err()
477            .to_string()
478            .contains("reopen required"));
479
480        drop(table);
481        std::fs::remove_dir(&manifest).unwrap();
482        std::fs::rename(saved_manifest, manifest).unwrap();
483        let reopened = Table::open(dir.path()).unwrap();
484        assert_eq!(reopened.visible_rows(reopened.snapshot()).unwrap().len(), 2);
485    }
486
487    #[test]
488    fn compaction_manifest_failure_poisons_mounted_database() {
489        let dir = tempdir().unwrap();
490        let db = Database::create(dir.path()).unwrap();
491        let table_id = db.create_table("items", schema()).unwrap();
492        let handle = db.table("items").unwrap();
493        {
494            let mut table = handle.lock();
495            table.set_mutable_run_spill_bytes(1);
496            table.put(vec![(1, Value::Int64(1))]).unwrap();
497            table.flush().unwrap();
498            table.put(vec![(1, Value::Int64(2))]).unwrap();
499            table.flush().unwrap();
500        }
501        let manifest = dir
502            .path()
503            .join("tables")
504            .join(table_id.to_string())
505            .join(crate::manifest::MANIFEST_FILENAME);
506        let saved_manifest = manifest.with_extension("saved");
507        std::fs::rename(&manifest, &saved_manifest).unwrap();
508        std::fs::create_dir(&manifest).unwrap();
509
510        let error = handle.lock().compact().unwrap_err();
511        assert!(matches!(error, MongrelError::CommitOutcomeUnknown { .. }));
512        assert!(db
513            .create_table("blocked", schema())
514            .unwrap_err()
515            .to_string()
516            .contains("database poisoned"));
517    }
518
519    #[test]
520    fn _snapshot_import_used() {
521        let _ = Snapshot::at(Epoch(0));
522    }
523}