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//!
7//! # HLC visibility (P0.5-T3)
8//!
9//! When versions carry `commit_ts`, newest-version selection uses
10//! [`crate::epoch::Snapshot::version_is_newer`] so HLC order wins over local
11//! epoch order. Physical reclamation still consults the epoch GC floor
12//! ([`Table::min_active_snapshot`]) until every pin source projects a durable
13//! HLC (`GcFloor` is diagnostic until that cutover). Sorted-run materialisation
14//! currently drops `commit_ts` (no system column yet), so run-resident versions
15//! are retained under epoch rules only after spill.
16
17use crate::engine::Table;
18use crate::epoch::{Epoch, MaintenanceReceipt, Snapshot};
19use crate::manifest::RunRef;
20use crate::memtable::Row;
21use crate::sorted_run::RunWriter;
22use crate::{ExecutionControl, MongrelError, Result};
23use std::collections::HashMap;
24use std::path::Path;
25
26impl Table {
27    /// Background-compaction run-count threshold (§5.9). When a table accumulates
28    /// at least this many sorted runs, every multi-run query pays decode work
29    /// proportional to the run count; `maybe_compact` collapses them back to one.
30    /// Conservative so a steady write stream doesn't compact too eagerly.
31    pub const AUTO_COMPACT_RUN_THRESHOLD: usize = 8;
32
33    /// Maximum number of L0 runs permitted to overlap one another before
34    /// compaction is forced (TODO §1.2 closure gate). L0 is the mutable-run
35    /// spill tier — every flush that exceeds the mutable-run spill threshold
36    /// produces a fresh L0 run, and once their row-id ranges start to pile up
37    /// the read path pays decode work proportional to the overlap. Compaction
38    /// collapses every L0 (and any L1+ disjoint from the result) into a single
39    /// L1 run with a disjoint range.
40    pub const MAX_L0_OVERLAPPING_RUNS: usize = 64;
41
42    /// Whether this table would benefit from compaction right now — the
43    /// query-cost signal for §5.9. Pure run-count topology (no per-query
44    /// bookkeeping): once runs have accumulated past the threshold, scans and
45    /// pushdown queries are paying multi-run fallback cost, so compaction is
46    /// worthwhile. A daemon (or any long-lived holder) polls this.
47    pub fn should_compact(&self) -> bool {
48        if self.run_refs().len() >= Self::AUTO_COMPACT_RUN_THRESHOLD {
49            return true;
50        }
51        // Force-compact before the L0 overlap cap is exceeded. Reading the
52        // count is O(runs) and cheaper than a TTL scan, so it can run on every
53        // `should_compact` poll without bookkeeping.
54        if self.l0_run_count() >= Self::MAX_L0_OVERLAPPING_RUNS {
55            return true;
56        }
57        self.ttl().is_some()
58            && !self.run_refs().is_empty()
59            && self.has_expired_run_rows().unwrap_or(false)
60    }
61
62    /// Number of run-refs currently at L0 (mutable-run spill tier).
63    pub fn l0_run_count(&self) -> usize {
64        self.run_refs().iter().filter(|rr| rr.level == 0).count()
65    }
66
67    /// `true` when `candidate` (min_row_id, max_row_id) overlaps any L1+ run
68    /// still alive after `retire` is removed. L1+ runs must stay disjoint so
69    /// the read path can binary-search the level and only open the single
70    /// covering run. `retire` is the set of run-ids this compaction is about
71    /// to supersede — the freshly merged L1 will replace them, so a
72    /// self-overlap against them is expected and ignored.
73    fn l1_range_overlaps(
74        &self,
75        min_row_id: u64,
76        max_row_id: u64,
77        retire: &std::collections::HashSet<u128>,
78    ) -> bool {
79        if min_row_id > max_row_id {
80            return false;
81        }
82        for rr in self.run_refs() {
83            if rr.level < 1 || retire.contains(&rr.run_id) {
84                continue;
85            }
86            if let Some((lo, hi)) = self.run_row_id_range(rr.run_id) {
87                // Two closed intervals [a, b] and [c, d] overlap iff a <= d
88                // and c <= b. Empty existing ranges (row_count == 0) trivially
89                // do not overlap.
90                if rr.row_count == 0 {
91                    continue;
92                }
93                if min_row_id <= hi && lo <= max_row_id {
94                    return true;
95                }
96            }
97        }
98        false
99    }
100
101    fn has_expired_run_rows(&self) -> Result<bool> {
102        self.has_expired_run_rows_inner(None)
103    }
104
105    fn has_expired_run_rows_inner(&self, control: Option<&ExecutionControl>) -> Result<bool> {
106        let now_nanos = crate::engine::unix_nanos_now();
107        for (run_index, run) in self.run_refs().iter().enumerate() {
108            if run_index % 256 == 0 {
109                if let Some(control) = control {
110                    control.checkpoint()?;
111                }
112            }
113            let mut reader = self.open_reader(run.run_id)?;
114            for (row_index, row) in reader.all_rows()?.iter().enumerate() {
115                if row_index % 256 == 0 {
116                    if let Some(control) = control {
117                        control.checkpoint()?;
118                    }
119                }
120                if self.row_expired_at(row, now_nanos) {
121                    return Ok(true);
122                }
123            }
124        }
125        Ok(false)
126    }
127
128    /// Compaction as a query optimization (§5.9): if [`should_compact`] reports
129    /// that runs have accumulated past the cost threshold, run [`compact`] and
130    /// return `true`; otherwise no-op and return `false`. Safe to call
131    /// periodically from a background task — [`compact`] is itself a no-op below
132    /// two runs and honors snapshot retention. Returns whether a compaction ran.
133    pub fn maybe_compact(&mut self) -> Result<bool> {
134        if !self.should_compact() {
135            return Ok(false);
136        }
137        self.compact()?;
138        Ok(true)
139    }
140
141    /// Merge all runs into a single level-1 run, dropping superseded versions
142    /// and tombstones — **but preserving** the version each pinned snapshot
143    /// still needs. No-op if there are fewer than two runs, unless a one-run
144    /// TTL table has expired payloads to reclaim.
145    pub fn compact(&mut self) -> Result<()> {
146        let control = ExecutionControl::new(None);
147        self.compact_controlled(&control, || true).map(|_| ())
148    }
149
150    /// Build replacement output cooperatively. Live state is unchanged until
151    /// `before_publish` succeeds. No cancellation checkpoint runs after that
152    /// manifest-publication boundary.
153    #[doc(hidden)]
154    pub fn compact_controlled<F>(
155        &mut self,
156        control: &ExecutionControl,
157        before_publish: F,
158    ) -> Result<bool>
159    where
160        F: FnOnce() -> bool,
161    {
162        self.compact_controlled_with_receipt(control, before_publish)
163            .map(|(changed, _)| changed)
164    }
165
166    /// Build replacement output cooperatively and return the exact table
167    /// snapshot used by the compaction. No receipt is returned for a no-op.
168    #[doc(hidden)]
169    pub fn compact_controlled_with_receipt<F>(
170        &mut self,
171        control: &ExecutionControl,
172        before_publish: F,
173    ) -> Result<(bool, Option<MaintenanceReceipt>)>
174    where
175        F: FnOnce() -> bool,
176    {
177        control.checkpoint()?;
178        let maintenance_epoch = self.current_epoch();
179        let reclaim_ttl = self.ttl().is_some() && self.has_expired_run_rows_inner(Some(control))?;
180        if self.run_refs().len() < 2 && !reclaim_ttl {
181            return Ok((false, None));
182        }
183        let min_active = self.min_active_snapshot();
184        let old_refs: Vec<RunRef> = self.run_refs().to_vec();
185        let now_nanos = crate::engine::unix_nanos_now();
186        let mutable_rows = if self.mutable_run_len() > 0 {
187            self.snapshot_mutable_run()
188        } else {
189            Vec::new()
190        };
191
192        let mut all: HashMap<u64, Vec<Row>> = HashMap::new();
193        let mut scanned = 0_usize;
194        for rr in &old_refs {
195            control.checkpoint()?;
196            let mut reader = self.open_reader(rr.run_id)?;
197            for row in reader.all_rows()? {
198                if scanned.is_multiple_of(256) {
199                    control.checkpoint()?;
200                }
201                scanned += 1;
202                all.entry(row.row_id.0).or_default().push(row);
203            }
204        }
205        for row in mutable_rows {
206            if scanned.is_multiple_of(256) {
207                control.checkpoint()?;
208            }
209            scanned += 1;
210            all.entry(row.row_id.0).or_default().push(row);
211        }
212
213        let mut rows = Vec::new();
214        let mut current_live_count = 0u64;
215        for (row_index, (_, mut versions)) in all.into_iter().enumerate() {
216            if row_index % 256 == 0 {
217                control.checkpoint()?;
218            }
219            // Epoch-ascending for the floor partition in select_keep (physical
220            // reclamation is still epoch-gated). Newest-live selection uses HLC
221            // via version_is_newer when stamps are present (P0.5-T3).
222            versions.sort_by_key(|row| row.committed_epoch);
223            let Some(newest) = versions
224                .iter()
225                .max_by(|a, b| cmp_version_order(a, b))
226                .cloned()
227            else {
228                continue;
229            };
230            let newest_epoch = newest.committed_epoch;
231            if !newest.deleted && !self.row_expired_at(&newest, now_nanos) {
232                current_live_count += 1;
233            }
234            for row in select_keep(&versions, min_active) {
235                if self.row_expired_at(&row, now_nanos) {
236                    if is_same_version(&row, &newest)
237                        && min_active.is_some_and(|epoch| newest_epoch > epoch)
238                    {
239                        let mut tombstone = row;
240                        tombstone.deleted = true;
241                        tombstone.columns.clear();
242                        rows.push(tombstone);
243                    }
244                } else {
245                    rows.push(row);
246                }
247            }
248        }
249        rows.sort_by_key(|row| (row.row_id, row.committed_epoch));
250
251        let mut staged_run = None;
252        if !rows.is_empty() {
253            // L1+ disjointness gate (TODO §1.2): the merged run is destined
254            // for L1, and L1+ runs must cover disjoint row-id ranges so the
255            // read path can binary-search the level. Runs being retired by
256            // this compaction (every L0 plus any L1 being replaced) are
257            // excluded from the check — the new L1 will atomically swap
258            // them out on publish. A self-overlap against a run we own is
259            // expected; a collision with a run we DON'T own means a prior
260            // compaction is still settling, so refuse and let the caller
261            // retry once the topology has calmed.
262            let retire: std::collections::HashSet<u128> =
263                old_refs.iter().map(|rr| rr.run_id).collect();
264            let new_min = rows.first().map(|r| r.row_id.0).unwrap_or(0);
265            let new_max = rows.last().map(|r| r.row_id.0).unwrap_or(0);
266            if self.l1_range_overlaps(new_min, new_max, &retire) {
267                return Err(MongrelError::InvalidArgument(format!(
268                    "compaction would publish an L1 run overlapping an existing L1+ \
269                     range [{new_min}, {new_max}]; wait for prior compactions to retire"
270                )));
271            }
272
273            let run_id = self.alloc_run_id()?;
274            let final_name = format!("r-{run_id}.sr");
275            let stage_name = format!(
276                "{final_name}.compact-stage-{}-{}",
277                std::process::id(),
278                std::time::SystemTime::now()
279                    .duration_since(std::time::UNIX_EPOCH)
280                    .unwrap_or_default()
281                    .as_nanos()
282            );
283            let kek = self.kek();
284            let mut writer = RunWriter::new(self.schema(), run_id as u128, maintenance_epoch, 1)
285                .clean(min_active.is_none())
286                .with_zstd_level(self.compaction_zstd_level());
287            if let Some(k) = &kek {
288                writer = writer.with_encryption(k.as_ref(), self.indexable_column_specs());
289            }
290            let header = match self.create_run_entry(Path::new(&stage_name))? {
291                Some(file) => writer.write_file(file, &rows),
292                None => writer.write(self.runs_dir().join(&stage_name), &rows),
293            };
294            let header = match header {
295                Ok(header) => header,
296                Err(error) => {
297                    let _ = self.remove_run_entry(Path::new(&stage_name));
298                    return Err(error);
299                }
300            };
301            staged_run = Some((
302                stage_name,
303                final_name,
304                RunRef {
305                    run_id: run_id as u128,
306                    level: 1,
307                    epoch_created: header.epoch_created,
308                    row_count: header.row_count,
309                },
310            ));
311        }
312
313        if let Err(error) = control.checkpoint() {
314            if let Some((stage_name, _, _)) = staged_run {
315                let _ = self.remove_run_entry(Path::new(&stage_name));
316            }
317            return Err(error);
318        }
319        if !before_publish() {
320            if let Some((stage_name, _, _)) = staged_run {
321                let _ = self.remove_run_entry(Path::new(&stage_name));
322            }
323            return Err(MongrelError::Cancelled);
324        }
325
326        // Publish the staged file before changing any live topology. A failed
327        // rename or directory sync can leave only an unreferenced run file;
328        // the old manifest, mutable run, and in-memory refs remain intact.
329        let replacement_ref = if let Some((stage_name, final_name, staged_ref)) = staged_run {
330            self.publish_run_entry(Path::new(&stage_name), Path::new(&final_name))?;
331            Some(staged_ref)
332        } else {
333            None
334        };
335
336        if self.mutable_run_len() > 0 {
337            self.drain_mutable_run();
338        }
339        self.live_count = current_live_count;
340        let retire_epoch = maintenance_epoch.0;
341        if let Some(replacement_ref) = replacement_ref {
342            self.set_run_refs(vec![replacement_ref]);
343            for run in &old_refs {
344                self.retire_run(run.run_id, retire_epoch);
345            }
346        } else {
347            self.set_run_refs(Vec::new());
348            for run in &old_refs {
349                self.retire_run(run.run_id, retire_epoch);
350            }
351        }
352
353        // The new manifest must explicitly reject the old global-index
354        // checkpoint.  Compaction does not advance the data epoch, so leaving
355        // the old epoch stamped here could make reopen accept indexes for the
356        // superseded runs.
357        self.prepare_indexes_for_run_replacement();
358        if let Err(error) = self.persist_manifest(maintenance_epoch) {
359            self.poison_after_maintenance_publish_failure();
360            return Err(MongrelError::CommitOutcomeUnknown {
361                epoch: maintenance_epoch.0,
362                message: format!("compaction manifest publication failed: {error}"),
363            });
364        }
365        self.clear_result_cache();
366        self.bump_data_generation();
367
368        // The replacement topology is durable.  Index rebuild failures do not
369        // make row data uncertain: keep the indexes marked incomplete so the
370        // next indexed operation or reopen rebuilds them from the new runs.
371        if let Err(error) = self
372            .rebuild_indexes_from_runs()
373            .and_then(|_| self.build_learned_ranges())
374        {
375            return Err(MongrelError::DurableCommit {
376                epoch: maintenance_epoch.0,
377                message: format!("compaction committed but index rebuild failed: {error}"),
378            });
379        }
380        self.finish_indexes_for_run_replacement();
381        self.checkpoint_indexes(maintenance_epoch);
382        // Issue 4 / spec §8.4 step 5: republish the run-lookup directory so
383        // the next open fast-paths point lookups against the post-compaction
384        // run set. A directory publish failure is logged but does not poison
385        // the compaction — the manifest is already durable, and the next
386        // flush will rebuild the directory lazily.
387        let _ = mongreldb_fault::inject("compaction.replacement_publication");
388        if let Err(error) = self.publish_run_lookup_directory() {
389            // Mark the directory incomplete so the read path falls back to
390            // range scans until a future publish succeeds.
391            self.lookup_metrics
392                .directory_incomplete
393                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
394            self.lookup_metrics
395                .directory_lookup_fallback
396                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
397            // Best-effort log via the trace shape; tests that arm the fault
398            // hook will assert the metric moved.
399            let _ = error;
400        }
401        Ok((
402            true,
403            Some(MaintenanceReceipt {
404                epoch: maintenance_epoch,
405            }),
406        ))
407    }
408}
409
410/// Compare two versions for recency: HLC when both stamped, else local epoch.
411fn cmp_version_order(a: &Row, b: &Row) -> std::cmp::Ordering {
412    if Snapshot::version_is_newer(
413        a.committed_epoch,
414        a.commit_ts,
415        b.committed_epoch,
416        b.commit_ts,
417    ) {
418        std::cmp::Ordering::Greater
419    } else if Snapshot::version_is_newer(
420        b.committed_epoch,
421        b.commit_ts,
422        a.committed_epoch,
423        a.commit_ts,
424    ) {
425        std::cmp::Ordering::Less
426    } else {
427        a.committed_epoch
428            .cmp(&b.committed_epoch)
429            .then_with(|| a.commit_ts.cmp(&b.commit_ts))
430    }
431}
432
433fn is_same_version(a: &Row, b: &Row) -> bool {
434    a.committed_epoch == b.committed_epoch && a.commit_ts == b.commit_ts
435}
436
437/// Versions to keep for one `RowId` given the oldest queryable snapshot. Every
438/// transition at or after the floor is retained, plus the boundary version
439/// visible at the floor. With no floor, only the current live version remains
440/// (chosen by HLC when stamps are present — P0.5-T3 / P0.5-X8).
441///
442/// `vers` must be sorted ascending by `committed_epoch` (the floor partition
443/// is still epoch-based; see module residual). HLC-only pins without epoch
444/// projection still report ZERO on [`crate::epoch::GcFloor`] and do not lower
445/// this floor.
446fn select_keep(vers: &[Row], min_active: Option<Epoch>) -> Vec<Row> {
447    let Some(newest) = vers.iter().max_by(|a, b| cmp_version_order(a, b)).cloned() else {
448        return Vec::new();
449    };
450    match min_active {
451        None => {
452            if newest.deleted {
453                Vec::new()
454            } else {
455                vec![newest]
456            }
457        }
458        Some(min_e) => {
459            let recent_start = vers.partition_point(|row| row.committed_epoch < min_e);
460            let mut keep = vers[recent_start..].to_vec();
461            if recent_start > 0 && keep.first().is_none_or(|row| row.committed_epoch > min_e) {
462                let boundary = vers[recent_start - 1].clone();
463                if keep.is_empty() && boundary.deleted {
464                    return Vec::new();
465                }
466                keep.insert(0, boundary);
467            }
468            // Rid-reuse guard: when a rid is tombstoned and then re-allocated to
469            // a new pk at the same committed_epoch, both versions sit at that
470            // epoch with no way for the sort key to break the tie. If the
471            // version chosen as the "boundary" above is a live row while a
472            // tombstone sits at the same epoch, the live row would survive into
473            // the new run even though the tombstone was the last write to the
474            // old pk. Drop the whole rid so the live row cannot leak into a
475            // range/hit set — pinned snapshots still see the tombstone as
476            // `deleted=true` (the run reader's MVCC pass would too, but only
477            // for the older "deleted" column; a rid reuse leaves the same
478            // physical rid serving a different row, which `visible_rows` and
479            // range scans must reject).
480            if keep.len() == 1
481                && !keep[0].deleted
482                && vers
483                    .iter()
484                    .any(|r| r.deleted && r.committed_epoch == keep[0].committed_epoch)
485            {
486                return Vec::new();
487            }
488            keep
489        }
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
497    use crate::{Database, Snapshot, Value};
498    use tempfile::tempdir;
499
500    fn schema() -> Schema {
501        Schema {
502            schema_id: 1,
503            columns: vec![ColumnDef {
504                id: 1,
505                name: "v".into(),
506                ty: TypeId::Int64,
507                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
508                default_value: None,
509                embedding_source: None,
510            }],
511            indexes: Vec::new(),
512            colocation: vec![],
513            constraints: Default::default(),
514            clustered: false,
515        }
516    }
517
518    #[test]
519    fn compaction_merges_runs_and_gcs_tombstoned_row() {
520        let dir = tempdir().unwrap();
521        let mut db = Table::create(dir.path(), schema(), 1).unwrap();
522        // Spill every flush to a run (this test exercises run-level merging).
523        db.set_mutable_run_spill_bytes(1);
524        let mut ids = Vec::new();
525        for i in 1..=5i64 {
526            ids.push(db.put(vec![(1, Value::Int64(i))]).unwrap());
527        }
528        db.flush().unwrap();
529        db.delete(ids[2]).unwrap();
530        db.flush().unwrap();
531        db.put(vec![(1, Value::Int64(60))]).unwrap();
532        db.flush().unwrap();
533        assert_eq!(db.run_count(), 3);
534
535        db.compact().unwrap();
536        assert_eq!(db.run_count(), 1);
537        let rows = db.visible_rows(db.snapshot()).unwrap();
538        let row_ids: Vec<u64> = rows.iter().map(|r| r.row_id.0).collect();
539        assert!(!row_ids.contains(&ids[2].0), "tombstoned row must be GC'd");
540        assert_eq!(rows.len(), 5);
541    }
542
543    #[test]
544    fn pinned_snapshot_survives_compaction() {
545        let dir = tempdir().unwrap();
546        let mut db = Table::create(dir.path(), schema(), 1).unwrap();
547        let r = db.put(vec![(1, Value::Int64(1))]).unwrap();
548        db.flush().unwrap(); // run 1: live version of r
549
550        // Pin a snapshot that sees the live version.
551        let pinned = db.pin_snapshot();
552        assert_eq!(
553            db.get(r, pinned)
554                .and_then(|row| row.columns.get(&1).cloned()),
555            Some(Value::Int64(1))
556        );
557
558        // Delete r, flush a second run, then compact with the pin still active.
559        db.delete(r).unwrap();
560        db.commit().unwrap();
561        db.flush().unwrap(); // run 2: tombstone for r
562        db.compact().unwrap(); // merges run1 + run2
563
564        // Pinned snapshot must still see the live version (retention kept it).
565        assert_eq!(
566            db.get(r, pinned)
567                .and_then(|row| row.columns.get(&1).cloned()),
568            Some(Value::Int64(1))
569        );
570        // Current snapshot sees the tombstone → row is gone.
571        assert_eq!(
572            db.get(r, db.snapshot())
573                .and_then(|row| row.columns.get(&1).cloned()),
574            None
575        );
576
577        // Release the pin; the next compaction may GC the live version.
578        db.unpin_snapshot(pinned);
579        db.compact().unwrap();
580        assert_eq!(
581            db.get(r, db.snapshot())
582                .and_then(|row| row.columns.get(&1).cloned()),
583            None
584        );
585    }
586
587    #[test]
588    fn controlled_compaction_cancel_before_publish_preserves_live_state() {
589        let dir = tempdir().unwrap();
590        let mut table = Table::create(dir.path(), schema(), 1).unwrap();
591        table.set_mutable_run_spill_bytes(1);
592        table.put(vec![(1, Value::Int64(1))]).unwrap();
593        table.flush().unwrap();
594        table.put(vec![(1, Value::Int64(2))]).unwrap();
595        table.flush().unwrap();
596        let before_refs: Vec<_> = table
597            .run_refs()
598            .iter()
599            .map(|run| (run.run_id, run.level, run.epoch_created, run.row_count))
600            .collect();
601
602        let error = table
603            .compact_controlled(&ExecutionControl::new(None), || false)
604            .unwrap_err();
605        assert!(matches!(error, MongrelError::Cancelled));
606        let after_refs: Vec<_> = table
607            .run_refs()
608            .iter()
609            .map(|run| (run.run_id, run.level, run.epoch_created, run.row_count))
610            .collect();
611        assert_eq!(after_refs, before_refs);
612        assert_eq!(table.visible_rows(table.snapshot()).unwrap().len(), 2);
613        assert!(std::fs::read_dir(table.runs_dir())
614            .unwrap()
615            .all(|entry| !entry
616                .unwrap()
617                .file_name()
618                .to_string_lossy()
619                .contains("compact-stage")));
620    }
621
622    #[test]
623    fn compaction_manifest_failure_poisons_standalone_table_until_reopen() {
624        let dir = tempdir().unwrap();
625        let mut table = Table::create(dir.path(), schema(), 1).unwrap();
626        table.set_mutable_run_spill_bytes(1);
627        table.put(vec![(1, Value::Int64(1))]).unwrap();
628        table.flush().unwrap();
629        table.put(vec![(1, Value::Int64(2))]).unwrap();
630        table.flush().unwrap();
631        let manifest = dir.path().join(crate::manifest::MANIFEST_FILENAME);
632        let saved_manifest = dir.path().join("_mf.saved");
633        std::fs::rename(&manifest, &saved_manifest).unwrap();
634        std::fs::create_dir(&manifest).unwrap();
635
636        let error = table.compact().unwrap_err();
637        assert!(matches!(error, MongrelError::CommitOutcomeUnknown { .. }));
638        assert_eq!(table.visible_rows(table.snapshot()).unwrap().len(), 2);
639        assert!(table
640            .put(vec![(1, Value::Int64(3))])
641            .unwrap_err()
642            .to_string()
643            .contains("reopen required"));
644
645        drop(table);
646        std::fs::remove_dir(&manifest).unwrap();
647        std::fs::rename(saved_manifest, manifest).unwrap();
648        let reopened = Table::open(dir.path()).unwrap();
649        assert_eq!(reopened.visible_rows(reopened.snapshot()).unwrap().len(), 2);
650    }
651
652    #[test]
653    fn compaction_manifest_failure_poisons_mounted_database() {
654        let dir = tempdir().unwrap();
655        let db = Database::create(dir.path()).unwrap();
656        let table_id = db.create_table("items", schema()).unwrap();
657        let handle = db.table("items").unwrap();
658        {
659            let mut table = handle.lock();
660            table.set_mutable_run_spill_bytes(1);
661            table.put(vec![(1, Value::Int64(1))]).unwrap();
662            table.flush().unwrap();
663            table.put(vec![(1, Value::Int64(2))]).unwrap();
664            table.flush().unwrap();
665        }
666        let manifest = dir
667            .path()
668            .join("tables")
669            .join(table_id.to_string())
670            .join(crate::manifest::MANIFEST_FILENAME);
671        let saved_manifest = manifest.with_extension("saved");
672        std::fs::rename(&manifest, &saved_manifest).unwrap();
673        std::fs::create_dir(&manifest).unwrap();
674
675        let error = handle.lock().compact().unwrap_err();
676        assert!(matches!(error, MongrelError::CommitOutcomeUnknown { .. }));
677        assert!(db
678            .create_table("blocked", schema())
679            .unwrap_err()
680            .to_string()
681            .contains("database poisoned"));
682    }
683
684    #[test]
685    fn _snapshot_import_used() {
686        let _ = Snapshot::at(Epoch(0));
687    }
688
689    fn hlc(physical_micros: u64) -> mongreldb_types::hlc::HlcTimestamp {
690        mongreldb_types::hlc::HlcTimestamp {
691            physical_micros,
692            logical: 0,
693            node_tiebreaker: 1,
694        }
695    }
696
697    fn stamped(id: u64, epoch: u64, ts: mongreldb_types::hlc::HlcTimestamp, v: i64) -> Row {
698        crate::memtable::Row::new_with_hlc(crate::rowid::RowId(id), Epoch(epoch), ts)
699            .with_column(1, Value::Int64(v))
700    }
701
702    /// P0.5-X8 (HLC): when versions carry commit_ts, select_keep retains the
703    /// HLC-newest live version under no pin — even if that version has a lower
704    /// local epoch than an older-HLC rewrite.
705    #[test]
706    fn select_keep_preserves_hlc_visibility_under_inverted_epoch_order() {
707        let early = hlc(100);
708        let mid = hlc(150);
709        let late = hlc(200);
710        // Must be epoch-ascending for the floor partition.
711        let mut versions = vec![
712            stamped(1, 1, late, 99),  // high HLC, low epoch
713            stamped(1, 2, mid, 50),   // mid HLC
714            stamped(1, 50, early, 1), // low HLC, high epoch
715        ];
716        versions.sort_by_key(|row| row.committed_epoch);
717        // No pin: only the HLC-newest live version remains (late, epoch 1).
718        let kept = select_keep(&versions, None);
719        assert_eq!(kept.len(), 1);
720        assert_eq!(kept[0].commit_ts, Some(late));
721        assert_eq!(kept[0].committed_epoch, Epoch(1));
722        assert_eq!(kept[0].columns.get(&1), Some(&Value::Int64(99)));
723
724        // Epoch floor at 2: retain epoch >= 2. Physical floor is still
725        // epoch-gated (P0.5-T6 residual); HLC-newest at epoch 1 is not kept
726        // when a version exists exactly at the floor.
727        let kept = select_keep(&versions, Some(Epoch(2)));
728        assert!(kept.iter().any(|r| r.commit_ts == Some(mid)));
729        assert!(kept.iter().any(|r| r.commit_ts == Some(early)));
730        assert!(!kept.iter().any(|r| r.commit_ts == Some(late)));
731
732        // Floor between versions (epoch 10): recent starts at epoch 50, so the
733        // boundary version just below the floor (epoch 2, mid) is retained.
734        let kept = select_keep(&versions, Some(Epoch(10)));
735        assert!(kept.iter().any(|r| r.commit_ts == Some(early)));
736        assert!(
737            kept.iter().any(|r| r.commit_ts == Some(mid)),
738            "boundary version below floor must be retained"
739        );
740        assert!(!kept.iter().any(|r| r.commit_ts == Some(late)));
741    }
742
743    #[test]
744    fn cmp_version_order_prefers_hlc_when_both_stamped() {
745        let early = stamped(1, 99, hlc(100), 1);
746        let late = stamped(1, 1, hlc(200), 2);
747        assert_eq!(cmp_version_order(&early, &late), std::cmp::Ordering::Less);
748        assert_eq!(
749            cmp_version_order(&late, &early),
750            std::cmp::Ordering::Greater
751        );
752        // Unstamped falls back to epoch.
753        let a = crate::memtable::Row::new(crate::rowid::RowId(1), Epoch(1));
754        let b = crate::memtable::Row::new(crate::rowid::RowId(1), Epoch(3));
755        assert_eq!(cmp_version_order(&a, &b), std::cmp::Ordering::Less);
756    }
757}