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