Skip to main content

mongreldb_core/
memtable.rs

1//! In-memory write buffer (the "memtable").
2//!
3//! Phase 11.2 wires the buffered [`crate::be_tree::BeTree`] (a Bε-tree over the
4//! composite `(RowId, Epoch)` version key) in as the live memtable, replacing
5//! the prototype skip list. A Bε-tree buffers many pending mutations per
6//! internal node and flushes them to one child in bulk, so write amplification
7//! approaches O(1) — the update-amplification win the design calls for. The
8//! composite key keeps multiple versions of a logical row coexisting. Product
9//! visibility prefers HLC via [`crate::epoch::Snapshot::observes_row`] /
10//! [`crate::epoch::Snapshot::version_is_newer`] when versions carry `commit_ts`
11//! (P0.5-T3); epoch-only APIs remain for dual-model legacy call sites.
12
13use crate::be_tree::BeTree;
14use crate::epoch::Epoch;
15use crate::rowid::RowId;
16use serde::{Deserialize, Serialize};
17use std::collections::{BTreeMap, HashMap};
18use std::sync::Arc;
19
20/// A cell value in the in-memory path. The flush path re-encodes these into
21/// columnar pages; it is intentionally simple for the prototype.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub enum Value {
24    Null,
25    Bool(bool),
26    Int64(i64),
27    Float64(f64),
28    Bytes(Vec<u8>),
29    Embedding(Vec<f32>),
30    /// Unscaled decimal value (i128). The column's `TypeId::Decimal128`
31    /// carries the precision/scale for formatting.
32    Decimal(i128),
33    /// SQL INTERVAL value: months, days, nanoseconds.
34    Interval {
35        months: i64,
36        days: i32,
37        nanos: i64,
38    },
39    /// RFC 4122 UUID (16 bytes, big-endian for sort order).
40    Uuid([u8; 16]),
41    /// JSON value stored as a UTF-8 byte sequence.
42    Json(Vec<u8>),
43    /// Generated embedding with durable source and model provenance.
44    ///
45    /// Kept last so existing bincode enum discriminants remain stable.
46    GeneratedEmbedding(Box<crate::embedding::GeneratedEmbeddingValue>),
47}
48
49impl Value {
50    pub fn as_embedding(&self) -> Option<&[f32]> {
51        match self {
52            Self::Embedding(values) => Some(values),
53            Self::GeneratedEmbedding(value) => Some(&value.vector),
54            _ => None,
55        }
56    }
57
58    pub fn generated_embedding_metadata(
59        &self,
60    ) -> Option<&crate::embedding::GeneratedEmbeddingMetadata> {
61        match self {
62            Self::GeneratedEmbedding(value) => Some(&value.metadata),
63            _ => None,
64        }
65    }
66
67    /// Lexicographically-comparable byte encoding for index keys (PK HOT,
68    /// bitmaps). Big-endian for integers so byte order matches value order.
69    pub fn encode_key(&self) -> Vec<u8> {
70        match self {
71            Value::Null => Vec::new(),
72            Value::Bool(b) => vec![*b as u8],
73            Value::Int64(n) => n.to_be_bytes().to_vec(),
74            Value::Float64(f) => f.to_bits().to_be_bytes().to_vec(),
75            Value::Bytes(b) => b.clone(),
76            Value::Embedding(v) => {
77                let mut out = Vec::with_capacity(v.len() * 4);
78                for x in v {
79                    out.extend_from_slice(&x.to_bits().to_be_bytes());
80                }
81                out
82            }
83            Value::GeneratedEmbedding(value) => {
84                let mut out = Vec::with_capacity(value.vector.len() * 4);
85                for x in &value.vector {
86                    out.extend_from_slice(&x.to_bits().to_be_bytes());
87                }
88                out
89            }
90            Value::Decimal(d) => d.to_be_bytes().to_vec(),
91            Value::Interval {
92                months,
93                days,
94                nanos,
95            } => {
96                let mut out = Vec::with_capacity(20);
97                out.extend_from_slice(&months.to_be_bytes());
98                out.extend_from_slice(&days.to_be_bytes());
99                out.extend_from_slice(&nanos.to_be_bytes());
100                out
101            }
102            Value::Uuid(b) => b.to_vec(),
103            Value::Json(b) => b.clone(),
104        }
105    }
106
107    pub(crate) fn estimated_bytes(&self) -> u64 {
108        match self {
109            Value::Null => 1,
110            Value::Bool(_) => 1,
111            Value::Int64(_) | Value::Float64(_) => 8,
112            Value::Bytes(bytes) | Value::Json(bytes) => 16 + bytes.len() as u64,
113            Value::Embedding(values) => 16 + (values.len() as u64) * 4,
114            Value::GeneratedEmbedding(value) => {
115                16 + (value.vector.len() as u64) * 4
116                    + value.metadata.provider_id.len() as u64
117                    + value.metadata.model_id.len() as u64
118                    + value.metadata.model_version.len() as u64
119                    + value.metadata.preprocessing_version.len() as u64
120                    + 48
121            }
122            Value::Decimal(_) | Value::Uuid(_) => 16,
123            Value::Interval { .. } => 20,
124        }
125    }
126}
127
128/// One logical row held in the memtable. A `deleted` row is a tombstone.
129///
130/// Field order of the **bincode WAL `Put` payload** is fixed as
131/// `(row_id, committed_epoch, columns, deleted)` — the 0.63.1 layout.
132/// [`Self::commit_ts`] is in-memory only (`#[serde(skip)]`); durable HLC for
133/// WAL recovery is `Op::CommitTimestamp`, and sorted runs use the
134/// `SYS_COMMIT_TS` system column (with its own legacy-compatible path).
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct Row {
137    pub row_id: RowId,
138    pub committed_epoch: Epoch,
139    pub columns: HashMap<u16, Value>,
140    pub deleted: bool,
141    /// Optional HLC stamp (P0.5 dual-model). Not encoded in WAL `Put` bincode
142    /// payloads — see struct-level docs. Kept last so call sites and future
143    /// wire evolution treat the 0.63.1 fields as the stable prefix.
144    #[serde(skip)]
145    pub commit_ts: Option<mongreldb_types::hlc::HlcTimestamp>,
146}
147
148impl Row {
149    pub fn new(row_id: RowId, committed_epoch: Epoch) -> Self {
150        Self {
151            row_id,
152            committed_epoch,
153            columns: HashMap::new(),
154            deleted: false,
155            commit_ts: None,
156        }
157    }
158
159    pub fn new_with_hlc(
160        row_id: RowId,
161        committed_epoch: Epoch,
162        commit_ts: mongreldb_types::hlc::HlcTimestamp,
163    ) -> Self {
164        Self {
165            row_id,
166            committed_epoch,
167            columns: HashMap::new(),
168            deleted: false,
169            commit_ts: Some(commit_ts),
170        }
171    }
172
173    pub fn with_column(mut self, column_id: u16, value: Value) -> Self {
174        self.columns.insert(column_id, value);
175        self
176    }
177
178    /// Rough byte estimate for flush-threshold decisions.
179    pub fn estimated_bytes(&self) -> u64 {
180        self.columns
181            .values()
182            .fold(32, |bytes, value| bytes + value.estimated_bytes())
183    }
184}
185
186/// Bε-tree-backed memtable, ordered by `(RowId, Epoch)`. A drop-in replacement
187/// for the prototype skip list: the same MVCC semantics with lower write
188/// amplification (buffered messages flush to children in bulk).
189#[derive(Clone)]
190struct MemtableSegment {
191    tree: BeTree,
192    byte_size: u64,
193}
194
195/// Structurally shared committed overlays plus one small mutable write delta.
196#[derive(Clone)]
197pub struct Memtable {
198    frozen: Arc<Vec<Arc<MemtableSegment>>>,
199    active: MemtableSegment,
200    byte_size: u64,
201}
202
203impl Default for Memtable {
204    fn default() -> Self {
205        Self::new()
206    }
207}
208
209impl Memtable {
210    pub fn new() -> Self {
211        Self {
212            frozen: Arc::new(Vec::new()),
213            active: MemtableSegment {
214                tree: BeTree::new(),
215                byte_size: 0,
216            },
217            byte_size: 0,
218        }
219    }
220
221    /// Append a row version (keyed by `(row_id, committed_epoch)`). Versions are
222    /// never overwritten; the newest visible one wins at read time.
223    pub fn upsert(&mut self, row: Row) {
224        let bytes = row.estimated_bytes();
225        self.byte_size = self.byte_size.saturating_add(bytes);
226        self.active.byte_size = self.active.byte_size.saturating_add(bytes);
227        self.active.tree.insert_row(row);
228    }
229
230    /// Append a tombstone version for `row_id` at `epoch`. The tombstone copies
231    /// the columns from the newest live version so that engine-level HOT cleanup
232    /// can recover the primary-key value during WAL replay.
233    pub fn tombstone(&mut self, row_id: RowId, epoch: Epoch) {
234        let mut columns = HashMap::new();
235        if let Some(live) = self.get(row_id, Epoch(epoch.0.saturating_sub(1))) {
236            columns = live.columns;
237        }
238        let row = Row {
239            row_id,
240            committed_epoch: epoch,
241            columns,
242            deleted: true,
243            commit_ts: None,
244        };
245        self.upsert(row);
246    }
247
248    /// Read the row at `row_id` visible to `snapshot`: the newest version with
249    /// `epoch <= snapshot`. Returns `None` if that version is a tombstone (or no
250    /// such version exists).
251    pub fn get(&self, row_id: RowId, snapshot_epoch: Epoch) -> Option<Row> {
252        self.get_version(row_id, snapshot_epoch)
253            .and_then(|(_, row)| (!row.deleted).then_some(row))
254    }
255
256    /// Newest version of `row_id` with `epoch <= snapshot`, **including
257    /// tombstones** (as a `Row` with `deleted=true`). Legacy epoch-only entry
258    /// point; prefer [`Self::get_version_at`] when the caller holds a full
259    /// [`crate::epoch::Snapshot`].
260    pub fn get_version(&self, row_id: RowId, snapshot_epoch: Epoch) -> Option<(Epoch, Row)> {
261        self.get_version_at(row_id, crate::epoch::Snapshot::at(snapshot_epoch))
262    }
263
264    /// Newest version of `row_id` visible under `snapshot` (including
265    /// tombstones). Uses HLC authority when stamps are present (P0.5-T3).
266    ///
267    /// Seeks each segment's composite-key range for `row_id` so dual-model
268    /// mixes (stamped + unstamped) and HLC/epoch order inversions stay correct
269    /// without materializing every version in the memtable.
270    pub fn get_version_at(
271        &self,
272        row_id: RowId,
273        snapshot: crate::epoch::Snapshot,
274    ) -> Option<(Epoch, Row)> {
275        if !snapshot.uses_hlc_authority() {
276            let mut best = self.active.tree.get_version(row_id, snapshot.epoch);
277            for segment in self.frozen.iter().rev() {
278                let Some(candidate) = segment.tree.get_version(row_id, snapshot.epoch) else {
279                    continue;
280                };
281                if best.as_ref().is_none_or(|(epoch, _)| candidate.0 > *epoch) {
282                    best = Some(candidate);
283                }
284            }
285            return best;
286        }
287
288        let mut best: Option<Row> = None;
289        for segment in self
290            .frozen
291            .iter()
292            .map(|segment| &segment.tree)
293            .chain(std::iter::once(&self.active.tree))
294        {
295            segment.visit_versions(row_id, |row| {
296                if !snapshot.observes_row(row.committed_epoch, row.commit_ts) {
297                    return;
298                }
299                if best.as_ref().is_none_or(|current| {
300                    crate::epoch::Snapshot::version_is_newer(
301                        row.committed_epoch,
302                        row.commit_ts,
303                        current.committed_epoch,
304                        current.commit_ts,
305                    )
306                }) {
307                    best = Some(row);
308                }
309            });
310        }
311        best.map(|row| (row.committed_epoch, row))
312    }
313
314    /// Number of stored versions.
315    pub fn len(&self) -> usize {
316        self.active.tree.mutations()
317            + self
318                .frozen
319                .iter()
320                .map(|segment| segment.tree.mutations())
321                .sum::<usize>()
322    }
323
324    pub fn is_empty(&self) -> bool {
325        self.active.tree.is_empty() && self.frozen.is_empty()
326    }
327
328    pub fn approx_bytes(&self) -> u64 {
329        self.byte_size
330    }
331
332    /// Visible rows at `snapshot`, deduplicated to the newest version per
333    /// `RowId` (tombstones drop their row). Returned in ascending `RowId` order.
334    pub fn visible_rows(&self, snapshot_epoch: Epoch) -> Vec<Row> {
335        self.visible_versions(snapshot_epoch)
336            .into_iter()
337            .filter(|r| !r.deleted)
338            .collect()
339    }
340
341    /// Newest visible version per `RowId` at `snapshot`, **including
342    /// tombstones** (as `Row`s with `deleted=true`). Used by the engine to merge
343    /// versions across the memtable and sorted runs.
344    pub fn visible_versions(&self, snapshot_epoch: Epoch) -> Vec<Row> {
345        self.visible_versions_at(crate::epoch::Snapshot::at(snapshot_epoch))
346    }
347
348    pub fn visible_versions_at(&self, snapshot: crate::epoch::Snapshot) -> Vec<Row> {
349        self.newest_visible_map(snapshot).into_values().collect()
350    }
351
352    /// Newest visible version per `RowId` as an ordered map (ascending RowId).
353    /// Callers that need to stream into a controlled merge without a second
354    /// full `Vec` should drain this map in batches rather than collecting.
355    pub(crate) fn newest_visible_map(
356        &self,
357        snapshot: crate::epoch::Snapshot,
358    ) -> BTreeMap<RowId, Row> {
359        let mut by_row: BTreeMap<RowId, Row> = BTreeMap::new();
360        for segment in self
361            .frozen
362            .iter()
363            .map(|segment| &segment.tree)
364            .chain(std::iter::once(&self.active.tree))
365        {
366            for row in segment.versions() {
367                if !snapshot.observes_version(row.committed_epoch, row.commit_ts) {
368                    continue;
369                }
370                by_row
371                    .entry(row.row_id)
372                    .and_modify(|existing| {
373                        if crate::epoch::Snapshot::version_is_newer(
374                            row.committed_epoch,
375                            row.commit_ts,
376                            existing.committed_epoch,
377                            existing.commit_ts,
378                        ) {
379                            *existing = row.clone();
380                        }
381                    })
382                    .or_insert(row);
383            }
384        }
385        by_row
386    }
387
388    /// Freeze the current write delta so future clones share it by `Arc`.
389    pub(crate) fn seal(&mut self) {
390        if self.active.tree.is_empty() {
391            return;
392        }
393        let active = std::mem::replace(
394            &mut self.active,
395            MemtableSegment {
396                tree: BeTree::new(),
397                byte_size: 0,
398            },
399        );
400        Arc::make_mut(&mut self.frozen).push(Arc::new(active));
401        if self.frozen.len() >= crate::MAX_READ_GENERATION_LAYERS {
402            self.consolidate();
403        }
404    }
405
406    fn consolidate(&mut self) {
407        let mut tree = BeTree::new();
408        for row in self
409            .frozen
410            .iter()
411            .flat_map(|segment| segment.tree.versions())
412        {
413            tree.insert_row(row);
414        }
415        self.frozen = Arc::new(vec![Arc::new(MemtableSegment {
416            tree,
417            byte_size: self.byte_size,
418        })]);
419    }
420
421    #[cfg(test)]
422    pub(crate) fn frozen_layer_count(&self) -> usize {
423        self.frozen.len()
424    }
425
426    /// Drain all versions (for a memtable-to-run flush). Returns them in
427    /// ascending `(RowId, Epoch)` order.
428    pub fn drain_sorted(&mut self) -> Vec<Row> {
429        let mut out = self
430            .frozen
431            .iter()
432            .flat_map(|segment| segment.tree.versions())
433            .chain(self.active.tree.versions())
434            .collect::<Vec<_>>();
435        out.sort_by_key(|row| (row.row_id, row.committed_epoch));
436        self.frozen = Arc::new(Vec::new());
437        self.active = MemtableSegment {
438            tree: BeTree::new(),
439            byte_size: 0,
440        };
441        self.byte_size = 0;
442        out
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    fn row(id: u64, epoch: u64) -> Row {
451        Row::new(RowId(id), Epoch(epoch)).with_column(1, Value::Int64(id as i64 * 10))
452    }
453
454    #[test]
455    fn upsert_get_and_visibility() {
456        let mut m = Memtable::new();
457        m.upsert(row(1, 5));
458        assert_eq!(m.len(), 1);
459        assert!(m.get(RowId(1), Epoch(5)).is_some());
460        assert!(m.get(RowId(1), Epoch(4)).is_none()); // not yet visible
461        assert!(m.get(RowId(2), Epoch(9)).is_none()); // missing
462    }
463
464    #[test]
465    fn tombstone_supersedes_at_its_epoch() {
466        let mut m = Memtable::new();
467        m.upsert(row(1, 1));
468        // Before the tombstone: the live version is visible.
469        assert!(m.get(RowId(1), Epoch(1)).is_some());
470        m.tombstone(RowId(1), Epoch(2));
471        // At/after the tombstone: hidden.
472        assert!(m.get(RowId(1), Epoch(2)).is_none());
473        assert!(m.get(RowId(1), Epoch(9)).is_none());
474        // A snapshot before the tombstone still sees the live version.
475        assert!(m.get(RowId(1), Epoch(1)).is_some());
476    }
477
478    #[test]
479    fn sealed_generations_share_rows_and_consolidate() {
480        let mut writer = Memtable::new();
481        for id in 0..crate::MAX_READ_GENERATION_LAYERS as u64 + 2 {
482            writer.upsert(row(id, id + 1));
483            writer.seal();
484        }
485        assert!(writer.frozen_layer_count() < crate::MAX_READ_GENERATION_LAYERS);
486        let generation = writer.clone();
487        writer.upsert(row(99, 99));
488        assert!(generation.get(RowId(99), Epoch(99)).is_none());
489        assert!(writer.get(RowId(99), Epoch(99)).is_some());
490    }
491
492    #[test]
493    fn hlc_visibility_is_authoritative_when_stamped() {
494        use mongreldb_types::hlc::HlcTimestamp;
495        let mut m = Memtable::new();
496        let early = HlcTimestamp {
497            physical_micros: 100,
498            logical: 0,
499            node_tiebreaker: 1,
500        };
501        let late = HlcTimestamp {
502            physical_micros: 200,
503            logical: 0,
504            node_tiebreaker: 1,
505        };
506        let mut r1 = Row::new_with_hlc(RowId(1), Epoch(1), early);
507        r1.columns.insert(1, Value::Int64(1));
508        let mut r2 = Row::new_with_hlc(RowId(1), Epoch(2), late);
509        r2.columns.insert(1, Value::Int64(2));
510        m.upsert(r1);
511        m.upsert(r2);
512        let snap = crate::epoch::Snapshot::at_hlc(Epoch(99), early);
513        let versions = m.visible_versions_at(snap);
514        assert_eq!(versions.len(), 1);
515        assert_eq!(versions[0].columns.get(&1), Some(&Value::Int64(1)));
516        let snap2 = crate::epoch::Snapshot::at_hlc(Epoch(1), late);
517        assert_eq!(
518            m.visible_versions_at(snap2)[0].columns.get(&1),
519            Some(&Value::Int64(2))
520        );
521    }
522
523    #[test]
524    fn snapshot_hlc_hides_later_commit_ts_even_if_epoch_higher() {
525        use mongreldb_types::hlc::HlcTimestamp;
526        let mut m = Memtable::new();
527        let early = HlcTimestamp {
528            physical_micros: 100,
529            logical: 0,
530            node_tiebreaker: 1,
531        };
532        let late = HlcTimestamp {
533            physical_micros: 200,
534            logical: 0,
535            node_tiebreaker: 1,
536        };
537        // Epoch(1) with late HLC would win under epoch-only rules when snap
538        // epoch is 99 — HLC authority must hide it under an early pin.
539        let mut late_row = Row::new_with_hlc(RowId(1), Epoch(1), late);
540        late_row.columns.insert(1, Value::Int64(99));
541        let mut early_row = Row::new_with_hlc(RowId(1), Epoch(50), early);
542        early_row.columns.insert(1, Value::Int64(1));
543        m.upsert(late_row);
544        m.upsert(early_row);
545        let snap = crate::epoch::Snapshot::at_hlc(Epoch(99), early);
546        let versions = m.visible_versions_at(snap);
547        assert_eq!(versions.len(), 1);
548        assert_eq!(versions[0].columns.get(&1), Some(&Value::Int64(1)));
549        assert_eq!(versions[0].commit_ts, Some(early));
550    }
551
552    #[test]
553    fn epoch_only_snapshot_sees_hlc_stamped_rows_by_epoch() {
554        use mongreldb_types::hlc::HlcTimestamp;
555        let mut m = Memtable::new();
556        let ts = HlcTimestamp {
557            physical_micros: 50,
558            logical: 0,
559            node_tiebreaker: 1,
560        };
561        m.upsert(Row::new_with_hlc(RowId(1), Epoch(1), ts).with_column(1, Value::Int64(1)));
562        m.upsert(Row::new(RowId(2), Epoch(1)).with_column(1, Value::Int64(2)));
563        let legacy = crate::epoch::Snapshot::at(Epoch(99));
564        let versions = m.visible_versions_at(legacy);
565        assert_eq!(
566            versions.len(),
567            2,
568            "dual-model: epoch pin sees HLC rows by epoch"
569        );
570        assert!(m.get_version_at(RowId(1), legacy).is_some());
571        assert!(m.get_version_at(RowId(2), legacy).is_some());
572        let future = crate::epoch::Snapshot::at(Epoch(0));
573        assert!(m.get_version_at(RowId(1), future).is_none());
574    }
575
576    #[test]
577    fn get_version_at_prefers_hlc_over_epoch_order() {
578        use mongreldb_types::hlc::HlcTimestamp;
579        let mut m = Memtable::new();
580        let early = HlcTimestamp {
581            physical_micros: 100,
582            logical: 0,
583            node_tiebreaker: 1,
584        };
585        let late = HlcTimestamp {
586            physical_micros: 200,
587            logical: 0,
588            node_tiebreaker: 1,
589        };
590        m.upsert(Row::new_with_hlc(RowId(1), Epoch(1), late).with_column(1, Value::Int64(99)));
591        m.upsert(Row::new_with_hlc(RowId(1), Epoch(50), early).with_column(1, Value::Int64(1)));
592        let snap = crate::epoch::Snapshot::at_hlc(Epoch(99), early);
593        let (_, row) = m.get_version_at(RowId(1), snap).expect("visible");
594        assert_eq!(row.columns.get(&1), Some(&Value::Int64(1)));
595        assert_eq!(row.commit_ts, Some(early));
596    }
597
598    /// WAL `Put` payloads must keep the 0.63.1 bincode layout
599    /// `(row_id, committed_epoch, columns, deleted)`. `commit_ts` is
600    /// in-memory only (`#[serde(skip)]`) so a 0.63.1-shaped blob still opens.
601    #[test]
602    fn wal_put_row_bincode_matches_0_63_1_layout() {
603        use mongreldb_types::hlc::HlcTimestamp;
604        use serde::{Deserialize, Serialize};
605
606        #[derive(Serialize, Deserialize)]
607        struct LegacyRow {
608            row_id: RowId,
609            committed_epoch: Epoch,
610            columns: HashMap<u16, Value>,
611            deleted: bool,
612        }
613
614        let legacy = LegacyRow {
615            row_id: RowId(7),
616            committed_epoch: Epoch(3),
617            columns: [(1, Value::Int64(42))].into_iter().collect(),
618            deleted: false,
619        };
620        let bytes = bincode::serialize(&legacy).expect("legacy encode");
621
622        let decoded: Row = bincode::deserialize(&bytes).expect("0.63.1 payload must decode");
623        assert_eq!(decoded.row_id, RowId(7));
624        assert_eq!(decoded.committed_epoch, Epoch(3));
625        assert_eq!(decoded.columns.get(&1), Some(&Value::Int64(42)));
626        assert!(!decoded.deleted);
627        assert!(decoded.commit_ts.is_none());
628
629        // Round-trip through Row: commit_ts is not on the wire.
630        let stamped = HlcTimestamp {
631            physical_micros: 1_700_000_000_000,
632            logical: 2,
633            node_tiebreaker: 9,
634        };
635        let mut live = Row::new_with_hlc(RowId(7), Epoch(3), stamped);
636        live.columns.insert(1, Value::Int64(42));
637        let wire = bincode::serialize(&live).expect("row encode");
638        assert_eq!(
639            wire, bytes,
640            "WAL Put encoding must match the 0.63.1 four-field layout"
641        );
642        let again: Row = bincode::deserialize(&wire).expect("row decode");
643        assert!(
644            again.commit_ts.is_none(),
645            "commit_ts is restored from Op::CommitTimestamp, not the Put blob"
646        );
647    }
648
649    #[test]
650    fn drain_sorted_is_ascending_and_empties() {
651        let mut m = Memtable::new();
652        m.upsert(row(3, 1));
653        m.upsert(row(1, 1));
654        m.upsert(row(2, 1));
655        let out = m.drain_sorted();
656        let ids: Vec<u64> = out.iter().map(|r| r.row_id.0).collect();
657        assert_eq!(ids, vec![1, 2, 3]);
658        assert!(m.is_empty());
659        assert_eq!(m.approx_bytes(), 0);
660    }
661
662    #[test]
663    fn visible_rows_dedups_to_newest_version() {
664        let mut m = Memtable::new();
665        m.upsert(row(1, 1));
666        m.upsert(row(2, 9)); // future relative to snapshot 5
667        m.upsert(row(3, 1));
668        m.upsert(row(1, 3)); // newer version of row 1
669        let ids: Vec<u64> = m
670            .visible_rows(Epoch(5))
671            .iter()
672            .map(|r| r.row_id.0)
673            .collect();
674        assert_eq!(ids, vec![1, 3]);
675    }
676}