Skip to main content

nodedb_columnar/mutation/
engine.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! MutationEngine struct, constructor, accessors, and shared helpers.
4
5use std::collections::HashMap;
6
7use nodedb_types::columnar::ColumnarSchema;
8use nodedb_types::surrogate::Surrogate;
9use nodedb_types::value::Value;
10
11use crate::delete_bitmap::DeleteBitmap;
12use crate::error::ColumnarError;
13use crate::memtable::ColumnarMemtable;
14use crate::pk_index::{PkIndex, encode_pk};
15use crate::wal_record::ColumnarWalRecord;
16
17/// Coordinates all columnar mutations for a single collection.
18///
19/// Owns the memtable, PK index, and per-segment delete bitmaps.
20/// Produces WAL records for each mutation that the caller must persist.
21pub struct MutationEngine {
22    pub(super) collection: String,
23    pub(super) schema: ColumnarSchema,
24    pub(super) memtable: ColumnarMemtable,
25    pub(super) pk_index: PkIndex,
26    /// Per-segment delete bitmaps. Key = segment_id.
27    pub(super) delete_bitmaps: HashMap<u64, DeleteBitmap>,
28    /// PK column indices in the schema.
29    pub(super) pk_col_indices: Vec<usize>,
30    /// Counter for assigning segment IDs.
31    pub(super) next_segment_id: u64,
32    /// Current "memtable segment ID" — a virtual segment ID for rows
33    /// that are still in the memtable (not yet flushed).
34    pub(super) memtable_segment_id: u64,
35    /// Row counter within the current memtable (resets on flush).
36    pub(super) memtable_row_counter: u32,
37    /// Per-row surrogate identities, parallel to the memtable rows.
38    ///
39    /// Entry `i` is the surrogate assigned to memtable row `i` at insert
40    /// time, or `None` if no surrogate was supplied (test fixtures, legacy
41    /// inserts). The vec is drained alongside the memtable on flush.
42    pub(super) memtable_surrogates: Vec<Option<Surrogate>>,
43}
44
45/// Result of a mutation operation, including the WAL record to persist.
46#[derive(Debug)]
47pub struct MutationResult {
48    /// WAL record(s) to persist before the mutation is considered durable.
49    pub wal_records: Vec<ColumnarWalRecord>,
50}
51
52impl MutationEngine {
53    /// Create a new mutation engine for a collection with the default flush threshold.
54    pub fn new(collection: String, schema: ColumnarSchema) -> Self {
55        Self::with_flush_threshold(collection, schema, crate::memtable::DEFAULT_FLUSH_THRESHOLD)
56    }
57
58    /// Create a new mutation engine with a custom memtable flush threshold.
59    ///
60    /// The threshold is clamped to a minimum of 1 to prevent a zero-threshold
61    /// from causing unbounded flush loops. Use this when the node-level
62    /// `QueryTuning::columnar_flush_threshold` config overrides the default.
63    pub fn with_flush_threshold(
64        collection: String,
65        schema: ColumnarSchema,
66        flush_threshold: usize,
67    ) -> Self {
68        let pk_col_indices: Vec<usize> = schema
69            .columns
70            .iter()
71            .enumerate()
72            .filter(|(_, c)| c.primary_key)
73            .map(|(i, _)| i)
74            .collect();
75
76        let memtable = ColumnarMemtable::with_threshold(&schema, flush_threshold.max(1));
77        // Reserve segment_id 0 for the first memtable. Real segments start at 1.
78        let memtable_segment_id = 0;
79
80        Self {
81            collection,
82            schema,
83            memtable,
84            pk_index: PkIndex::new(),
85            delete_bitmaps: HashMap::new(),
86            pk_col_indices,
87            next_segment_id: 1,
88            memtable_segment_id,
89            memtable_row_counter: 0,
90            memtable_surrogates: Vec::new(),
91        }
92    }
93
94    // -- Accessors --
95
96    /// Access the memtable.
97    pub fn memtable(&self) -> &ColumnarMemtable {
98        &self.memtable
99    }
100
101    /// Mutable access to the memtable (for drain on flush).
102    pub fn memtable_mut(&mut self) -> &mut ColumnarMemtable {
103        &mut self.memtable
104    }
105
106    /// Access the PK index.
107    pub fn pk_index(&self) -> &PkIndex {
108        &self.pk_index
109    }
110
111    /// Mutable access to the PK index (for cold-start rebuild).
112    pub fn pk_index_mut(&mut self) -> &mut PkIndex {
113        &mut self.pk_index
114    }
115
116    /// Access a segment's delete bitmap.
117    pub fn delete_bitmap(&self, segment_id: u64) -> Option<&DeleteBitmap> {
118        self.delete_bitmaps.get(&segment_id)
119    }
120
121    /// Mutable access to a segment's delete bitmap. Creates an empty one
122    /// on first access so callers can `mark_deleted_batch` unconditionally.
123    /// Used by temporal-purge paths that tombstone superseded row positions
124    /// without going through the single-row `insert` / `delete` paths.
125    pub fn delete_bitmap_mut(&mut self, segment_id: u64) -> &mut DeleteBitmap {
126        self.delete_bitmaps.entry(segment_id).or_default()
127    }
128
129    /// The virtual segment id used for rows still in the memtable.
130    pub fn memtable_segment_id(&self) -> u64 {
131        self.memtable_segment_id
132    }
133
134    /// The schema's primary-key column indices, in schema order.
135    pub fn pk_col_indices(&self) -> &[usize] {
136        &self.pk_col_indices
137    }
138
139    /// Access all delete bitmaps.
140    pub fn delete_bitmaps(&self) -> &HashMap<u64, DeleteBitmap> {
141        &self.delete_bitmaps
142    }
143
144    /// The collection name.
145    pub fn collection(&self) -> &str {
146        &self.collection
147    }
148
149    /// The schema.
150    pub fn schema(&self) -> &ColumnarSchema {
151        &self.schema
152    }
153
154    /// Whether the memtable should be flushed.
155    pub fn should_flush(&self) -> bool {
156        self.memtable.should_flush()
157    }
158
159    /// Access the per-row surrogate table for the memtable.
160    ///
161    /// Index matches memtable row order; `None` entries indicate rows
162    /// inserted without a surrogate (test fixtures, legacy paths).
163    pub fn memtable_surrogates(&self) -> &[Option<Surrogate>] {
164        &self.memtable_surrogates
165    }
166
167    /// Iterate non-deleted rows in the memtable as `Vec<Value>`.
168    ///
169    /// Skips rows marked as deleted in the memtable's virtual segment
170    /// delete bitmap. For rows in flushed segments, use `SegmentReader`.
171    /// Yields `Err` for a live row with a corrupt cell.
172    pub fn scan_memtable_rows(
173        &self,
174    ) -> impl Iterator<Item = Result<Vec<Value>, ColumnarError>> + '_ {
175        let deletes = self.delete_bitmaps.get(&self.memtable_segment_id);
176        self.memtable
177            .iter_rows()
178            .enumerate()
179            .filter_map(move |(row_idx, row)| {
180                if deletes.is_some_and(|bm| bm.is_deleted(row_idx as u32)) {
181                    None
182                } else {
183                    Some(row.map_err(|e| self.memtable_read_fault(e)))
184                }
185            })
186    }
187
188    /// Iterate non-deleted rows paired with their surrogate identity.
189    ///
190    /// Yields `(Option<Surrogate>, Vec<Value>)`. The surrogate is `None`
191    /// for rows inserted without one (test fixtures, legacy paths). Deleted
192    /// rows are filtered out exactly as in [`Self::scan_memtable_rows`].
193    /// Yields `Err` for a live row with a corrupt cell.
194    pub fn scan_memtable_rows_with_surrogates(
195        &self,
196    ) -> impl Iterator<Item = Result<(Option<Surrogate>, Vec<Value>), ColumnarError>> + '_ {
197        let deletes = self.delete_bitmaps.get(&self.memtable_segment_id);
198        let surrogates = &self.memtable_surrogates;
199        self.memtable
200            .iter_rows()
201            .enumerate()
202            .filter_map(move |(row_idx, row)| {
203                if deletes.is_some_and(|bm| bm.is_deleted(row_idx as u32)) {
204                    return None;
205                }
206                let surrogate = surrogates.get(row_idx).copied().flatten();
207                Some(
208                    row.map(|row| (surrogate, row))
209                        .map_err(|e| self.memtable_read_fault(e)),
210                )
211            })
212    }
213
214    /// Get a single row from the memtable by index.
215    ///
216    /// `Ok(None)` if the row is deleted or past the end. `Err` if a cell of
217    /// the row is corrupt.
218    pub fn get_memtable_row(&self, row_idx: usize) -> Result<Option<Vec<Value>>, ColumnarError> {
219        if self
220            .delete_bitmaps
221            .get(&self.memtable_segment_id)
222            .is_some_and(|bm| bm.is_deleted(row_idx as u32))
223        {
224            return Ok(None);
225        }
226        self.memtable
227            .get_row(row_idx)
228            .map_err(|e| self.memtable_read_fault(e))
229    }
230
231    /// Record a corrupt memtable cell and hand the error back.
232    ///
233    /// Every memtable row read of this engine routes its error through here,
234    /// so the corruption is reported once, where it is detected.
235    pub(super) fn memtable_read_fault(&self, err: ColumnarError) -> ColumnarError {
236        crate::diag::memtable_cell_corrupt(&err, &self.collection);
237        err
238    }
239
240    /// Roll back in-memory inserts to `row_count_before`.
241    ///
242    /// Undoes the effect of one or more inserts that appended rows starting
243    /// at `row_count_before`. For each inserted row:
244    /// - The corresponding PK entry is removed from the PK index.
245    /// - If the insert displaced a prior row (upsert tombstone), that prior
246    ///   row's PK index entry is restored and its tombstone bit cleared.
247    ///
248    /// The memtable is then truncated to `row_count_before`. Used exclusively
249    /// by the transaction undo log; never called on the normal write path.
250    pub fn rollback_memtable_inserts(
251        &mut self,
252        row_count_before: usize,
253        inserted_pks: &[Vec<u8>],
254        displaced: &[(Vec<u8>, crate::pk_index::RowLocation)],
255    ) {
256        // 1. Remove newly inserted PK entries.
257        for pk in inserted_pks {
258            self.pk_index.remove(pk);
259        }
260        // 2. Restore displaced prior-row PK entries and clear tombstones.
261        for (pk, prior_location) in displaced {
262            self.pk_index.upsert(pk.clone(), *prior_location);
263            // Clear the tombstone bit that the insert set on the prior row.
264            if let Some(bm) = self.delete_bitmaps.get_mut(&prior_location.segment_id) {
265                bm.unmark_deleted(prior_location.row_index);
266            }
267        }
268        // 3. Truncate memtable and surrogate list.
269        self.cut_memtable_to(row_count_before);
270    }
271
272    /// Cut the memtable, its surrogate table and its row counter back to
273    /// `row_count` rows.
274    ///
275    /// A cut row can carry a tombstone: a later row of the same write
276    /// replaced it. Those tombstones are cleared too, so a row appended
277    /// later at that index starts live.
278    pub(super) fn cut_memtable_to(&mut self, row_count: usize) {
279        if let Some(bm) = self.delete_bitmaps.get_mut(&self.memtable_segment_id) {
280            bm.unmark_from(row_count as u32);
281        }
282        self.memtable.truncate_to(row_count);
283        self.memtable_surrogates.truncate(row_count);
284        self.memtable_row_counter = row_count as u32;
285    }
286
287    /// Reverse one or more positional deletes: for each `(pk_bytes, location)`
288    /// clear the row's delete-bitmap bit and re-bind the PK index to that
289    /// location.
290    ///
291    /// Undoes the effect of [`Self::delete`] (and the delete-half of
292    /// [`Self::update`]) so a rolled-back mutation leaves the affected rows
293    /// present with their original values. Used exclusively by the transaction
294    /// undo log; never called on the normal write path. Mirrors the
295    /// displaced-row restore in [`Self::rollback_memtable_inserts`].
296    pub fn restore_deleted_rows(&mut self, rows: &[(Vec<u8>, crate::pk_index::RowLocation)]) {
297        for (pk_bytes, location) in rows {
298            if let Some(bm) = self.delete_bitmaps.get_mut(&location.segment_id) {
299                bm.unmark_deleted(location.row_index);
300            }
301            self.pk_index.upsert(pk_bytes.clone(), *location);
302        }
303    }
304
305    /// The segment ID that will be assigned to the next flushed segment.
306    ///
307    /// Use this to obtain the ID to pass to `on_memtable_flushed`.
308    pub fn next_segment_id(&self) -> u64 {
309        self.next_segment_id
310    }
311
312    /// Encode a PK value as index bytes. Exposed for callers that need
313    /// to probe the PK index (e.g. `ON CONFLICT DO UPDATE` routing).
314    pub fn encode_pk_from_row(&self, values: &[Value]) -> Result<Vec<u8>, ColumnarError> {
315        self.extract_pk_bytes(values)
316    }
317
318    // -- Internal helpers --
319
320    /// Extract PK bytes from a row of values.
321    pub(super) fn extract_pk_bytes(&self, values: &[Value]) -> Result<Vec<u8>, ColumnarError> {
322        if values.len() != self.schema.columns.len() {
323            return Err(ColumnarError::SchemaMismatch {
324                expected: self.schema.columns.len(),
325                got: values.len(),
326            });
327        }
328
329        if self.pk_col_indices.len() == 1 {
330            Ok(encode_pk(&values[self.pk_col_indices[0]]))
331        } else {
332            let pk_values: Vec<&Value> = self.pk_col_indices.iter().map(|&i| &values[i]).collect();
333            Ok(crate::pk_index::encode_composite_pk(&pk_values))
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use nodedb_types::columnar::{ColumnDef, ColumnType, ColumnarSchema};
341    use nodedb_types::surrogate_bitmap::SurrogateBitmap;
342
343    use super::*;
344
345    fn minimal_schema() -> ColumnarSchema {
346        ColumnarSchema {
347            columns: vec![ColumnDef::required("id", ColumnType::Int64).with_primary_key()],
348            version: 1,
349        }
350    }
351
352    #[test]
353    fn segment_id_allocator_returns_err_at_u64_max() {
354        let mut engine = MutationEngine::new("test".to_string(), minimal_schema());
355        engine.next_segment_id = u64::MAX;
356        let result = engine.on_memtable_flushed(u64::MAX - 1);
357        assert!(
358            matches!(result, Err(ColumnarError::SegmentIdExhausted)),
359            "expected SegmentIdExhausted, got: {result:?}"
360        );
361    }
362
363    #[test]
364    fn with_flush_threshold_triggers_should_flush_at_threshold() {
365        use nodedb_types::value::Value;
366
367        let schema = minimal_schema();
368        // Threshold of 2: after 2 inserts the engine must report should_flush.
369        let mut engine = MutationEngine::with_flush_threshold("col".to_string(), schema, 2);
370        assert!(!engine.should_flush(), "empty engine should not need flush");
371
372        engine.insert(&[Value::Integer(1)]).expect("insert row 1");
373        assert!(!engine.should_flush(), "1 row below threshold of 2");
374
375        engine.insert(&[Value::Integer(2)]).expect("insert row 2");
376        assert!(
377            engine.should_flush(),
378            "2 rows at threshold of 2 must trigger flush"
379        );
380    }
381
382    #[test]
383    fn with_flush_threshold_zero_is_clamped_to_one() {
384        use nodedb_types::value::Value;
385
386        let schema = minimal_schema();
387        let mut engine = MutationEngine::with_flush_threshold("col".to_string(), schema, 0);
388        engine.insert(&[Value::Integer(1)]).expect("insert");
389        // A zero threshold would be pathological; clamped to 1, so 1 row = flush.
390        assert!(
391            engine.should_flush(),
392            "clamped-to-1 threshold: 1 row must flush"
393        );
394    }
395
396    fn test_schema() -> ColumnarSchema {
397        ColumnarSchema::new(vec![
398            ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
399            ColumnDef::required("name", ColumnType::String),
400            ColumnDef::nullable("score", ColumnType::Float64),
401        ])
402        .expect("valid")
403    }
404
405    #[test]
406    fn insert_and_pk_check() {
407        let mut engine = MutationEngine::new("test".into(), test_schema());
408
409        let result = engine
410            .insert(&[
411                Value::Integer(1),
412                Value::String("Alice".into()),
413                Value::Float(0.75),
414            ])
415            .expect("insert");
416
417        assert_eq!(result.wal_records.len(), 1);
418        assert!(matches!(
419            &result.wal_records[0],
420            ColumnarWalRecord::InsertRow { .. }
421        ));
422
423        assert_eq!(engine.pk_index().len(), 1);
424        assert_eq!(engine.memtable().row_count(), 1);
425    }
426
427    #[test]
428    fn delete_by_pk() {
429        let mut engine = MutationEngine::new("test".into(), test_schema());
430
431        engine
432            .insert(&[
433                Value::Integer(1),
434                Value::String("Alice".into()),
435                Value::Null,
436            ])
437            .expect("insert");
438
439        let result = engine.delete(&Value::Integer(1)).expect("delete");
440        assert_eq!(result.wal_records.len(), 1);
441        assert!(matches!(
442            &result.wal_records[0],
443            ColumnarWalRecord::DeleteRows { .. }
444        ));
445
446        // PK should be removed from index.
447        assert!(engine.pk_index().is_empty());
448    }
449
450    #[test]
451    fn delete_nonexistent_pk() {
452        let mut engine = MutationEngine::new("test".into(), test_schema());
453
454        let err = engine.delete(&Value::Integer(999));
455        assert!(matches!(err, Err(ColumnarError::PrimaryKeyNotFound)));
456    }
457
458    #[test]
459    fn update_row() {
460        let mut engine = MutationEngine::new("test".into(), test_schema());
461
462        engine
463            .insert(&[
464                Value::Integer(1),
465                Value::String("Alice".into()),
466                Value::Float(0.5),
467            ])
468            .expect("insert");
469
470        // Update: change name and score, keep same PK.
471        let result = engine
472            .update(
473                &Value::Integer(1),
474                &[
475                    Value::Integer(1),
476                    Value::String("Alice Updated".into()),
477                    Value::Float(0.75),
478                ],
479                None,
480            )
481            .expect("update");
482
483        // Should produce 2 WAL records: delete + insert.
484        assert_eq!(result.wal_records.len(), 2);
485        assert!(matches!(
486            &result.wal_records[0],
487            ColumnarWalRecord::DeleteRows { .. }
488        ));
489        assert!(matches!(
490            &result.wal_records[1],
491            ColumnarWalRecord::InsertRow { .. }
492        ));
493
494        // PK index should still have 1 entry.
495        assert_eq!(engine.pk_index().len(), 1);
496        // Memtable should have 2 rows (original + updated).
497        assert_eq!(engine.memtable().row_count(), 2);
498    }
499
500    #[test]
501    fn memtable_flush_remaps_pk() {
502        let mut engine = MutationEngine::new("test".into(), test_schema());
503
504        for i in 0..5 {
505            engine
506                .insert(&[
507                    Value::Integer(i),
508                    Value::String(format!("u{i}")),
509                    Value::Null,
510                ])
511                .expect("insert");
512        }
513
514        // Simulate flush: memtable becomes segment 1.
515        let result = engine.on_memtable_flushed(1).expect("flush");
516        assert_eq!(result.wal_records.len(), 1);
517        assert!(matches!(
518            &result.wal_records[0],
519            ColumnarWalRecord::MemtableFlushed {
520                segment_id: 1,
521                row_count: 5,
522                ..
523            }
524        ));
525
526        // PK index entries should now point to segment 1.
527        let pk = encode_pk(&Value::Integer(3));
528        let loc = engine.pk_index().get(&pk).expect("pk exists");
529        assert_eq!(loc.segment_id, 1);
530        assert_eq!(loc.row_index, 3);
531    }
532
533    #[test]
534    fn multiple_inserts_and_deletes() {
535        let mut engine = MutationEngine::new("test".into(), test_schema());
536
537        for i in 0..10 {
538            engine
539                .insert(&[
540                    Value::Integer(i),
541                    Value::String(format!("u{i}")),
542                    Value::Null,
543                ])
544                .expect("insert");
545        }
546
547        // Delete odd-numbered rows.
548        for i in (1..10).step_by(2) {
549            engine.delete(&Value::Integer(i)).expect("delete");
550        }
551
552        assert_eq!(engine.pk_index().len(), 5); // 0, 2, 4, 6, 8.
553    }
554
555    #[test]
556    fn prefilter_row_boundary_membership() {
557        // Insert 5 rows with surrogates 10, 20, 30, 40, 50.
558        // A prefilter bitmap containing only {20, 40} should yield exactly
559        // the two matching rows and skip the other three.
560        let mut engine = MutationEngine::new("col".into(), test_schema());
561
562        let surrogates = [10u32, 20, 30, 40, 50];
563        for (i, &s) in surrogates.iter().enumerate() {
564            engine
565                .insert_with_surrogate(
566                    &[
567                        Value::Integer(i as i64),
568                        Value::String(format!("r{i}")),
569                        Value::Null,
570                    ],
571                    Surrogate(s),
572                )
573                .expect("insert_with_surrogate");
574        }
575
576        assert_eq!(engine.memtable_surrogates().len(), 5);
577
578        let bitmap = SurrogateBitmap::from_iter([Surrogate(20), Surrogate(40)]);
579
580        // Simulate block-boundary check: memtable surrogate range is [10, 50];
581        // bitmap range is [20, 40] — they intersect, so block is NOT skipped.
582        let surrogates_slice = engine.memtable_surrogates();
583        let (mt_min, mt_max) = surrogates_slice
584            .iter()
585            .flatten()
586            .fold((u32::MAX, u32::MIN), |(lo, hi), s| {
587                (lo.min(s.0), hi.max(s.0))
588            });
589        assert_eq!(mt_min, 10);
590        assert_eq!(mt_max, 50);
591        let bm_min = bitmap.0.min().unwrap();
592        let bm_max = bitmap.0.max().unwrap();
593        // Ranges overlap — block must NOT be skipped.
594        assert!(!(bm_max < mt_min || bm_min > mt_max));
595
596        // Row-boundary: count rows that pass the bitmap membership check.
597        let passing: Vec<_> = engine
598            .scan_memtable_rows_with_surrogates()
599            .map(|r| r.expect("read"))
600            .filter(|(sur, _)| sur.is_some_and(|s| bitmap.contains(s)))
601            .collect();
602        assert_eq!(passing.len(), 2);
603        // The two matched rows should be at surrogate positions 20 and 40 (indices 1 and 3).
604        assert!(passing[0].0 == Some(Surrogate(20)));
605        assert!(passing[1].0 == Some(Surrogate(40)));
606    }
607
608    #[test]
609    fn prefilter_block_boundary_skip() {
610        // Insert rows with surrogates 100, 200, 300.
611        // A prefilter bitmap containing only {10, 20} (below the memtable range)
612        // should cause the block-boundary check to flag a skip.
613        let mut engine = MutationEngine::new("col".into(), test_schema());
614
615        for (i, s) in [100u32, 200, 300].iter().enumerate() {
616            engine
617                .insert_with_surrogate(
618                    &[
619                        Value::Integer(i as i64),
620                        Value::String(format!("r{i}")),
621                        Value::Null,
622                    ],
623                    Surrogate(*s),
624                )
625                .expect("insert_with_surrogate");
626        }
627
628        let surrogates_slice = engine.memtable_surrogates();
629        let (mt_min, mt_max) = surrogates_slice
630            .iter()
631            .flatten()
632            .fold((u32::MAX, u32::MIN), |(lo, hi), s| {
633                (lo.min(s.0), hi.max(s.0))
634            });
635        assert_eq!(mt_min, 100);
636        assert_eq!(mt_max, 300);
637
638        let bitmap = SurrogateBitmap::from_iter([Surrogate(10), Surrogate(20)]);
639        let bm_min = bitmap.0.min().unwrap();
640        let bm_max = bitmap.0.max().unwrap();
641
642        // bm_max (20) < mt_min (100) → disjoint ranges → block MUST be skipped.
643        assert!(bm_max < mt_min || bm_min > mt_max);
644    }
645
646    #[test]
647    fn insert_without_surrogate_stores_none() {
648        let mut engine = MutationEngine::new("col".into(), test_schema());
649        engine
650            .insert(&[Value::Integer(1), Value::String("x".into()), Value::Null])
651            .expect("insert");
652        assert_eq!(engine.memtable_surrogates(), &[None]);
653    }
654
655    #[test]
656    fn an_update_keeps_the_old_rows_surrogate() {
657        let mut engine = MutationEngine::new("col".into(), test_schema());
658        engine
659            .insert_with_surrogate(
660                &[Value::Integer(1), Value::String("x".into()), Value::Null],
661                Surrogate(42),
662            )
663            .expect("insert");
664        engine
665            .update(
666                &Value::Integer(1),
667                &[Value::Integer(2), Value::String("y".into()), Value::Null],
668                Some(Surrogate(7)),
669            )
670            .expect("update");
671        let live: Vec<Option<Surrogate>> = engine
672            .scan_memtable_rows_with_surrogates()
673            .map(|r| r.expect("read").0)
674            .collect();
675        assert_eq!(
676            live,
677            vec![Some(Surrogate(42))],
678            "the replacement row carries the identity the memtable row had"
679        );
680    }
681
682    #[test]
683    fn an_update_of_a_flushed_row_carries_the_segment_surrogate() {
684        let mut engine = MutationEngine::new("col".into(), test_schema());
685        engine
686            .insert_with_surrogate(
687                &[Value::Integer(1), Value::String("x".into()), Value::Null],
688                Surrogate(42),
689            )
690            .expect("insert");
691        let segment_id = engine.next_segment_id();
692        let _drained = engine.memtable_mut().drain_optimized();
693        engine.on_memtable_flushed(segment_id).expect("flush");
694        engine
695            .update(
696                &Value::Integer(1),
697                &[Value::Integer(1), Value::String("y".into()), Value::Null],
698                Some(Surrogate(42)),
699            )
700            .expect("update");
701        let live: Vec<Option<Surrogate>> = engine
702            .scan_memtable_rows_with_surrogates()
703            .map(|r| r.expect("read").0)
704            .collect();
705        assert_eq!(
706            live,
707            vec![Some(Surrogate(42))],
708            "the replacement row carries the identity the flushed row had"
709        );
710    }
711
712    #[test]
713    fn flush_clears_surrogate_table() {
714        let mut engine = MutationEngine::new("col".into(), test_schema());
715        engine
716            .insert_with_surrogate(
717                &[Value::Integer(1), Value::String("x".into()), Value::Null],
718                Surrogate(42),
719            )
720            .expect("insert");
721        assert_eq!(engine.memtable_surrogates().len(), 1);
722        engine.on_memtable_flushed(1).expect("flush");
723        assert!(engine.memtable_surrogates().is_empty());
724    }
725
726    /// Every WAL record a mutation can produce must be one the replay path can
727    /// act on. A segment-rewrite ("compaction commit") record has no producer and
728    /// no replay handler, so the mutation engine must never emit one.
729    #[test]
730    fn mutations_only_emit_replayable_wal_records() {
731        let mut engine = MutationEngine::new("test".into(), test_schema());
732
733        let mut records = Vec::new();
734        for i in 0..10 {
735            records.extend(
736                engine
737                    .insert(&[
738                        Value::Integer(i),
739                        Value::String(format!("u{i}")),
740                        Value::Null,
741                    ])
742                    .expect("insert")
743                    .wal_records,
744            );
745        }
746        records.extend(engine.on_memtable_flushed(1).expect("flush").wal_records);
747        for i in 0..3 {
748            records.extend(
749                engine
750                    .delete(&Value::Integer(i))
751                    .expect("delete")
752                    .wal_records,
753            );
754        }
755
756        assert!(!records.is_empty());
757        for rec in &records {
758            assert!(
759                matches!(
760                    rec,
761                    ColumnarWalRecord::InsertRow { .. }
762                        | ColumnarWalRecord::DeleteRows { .. }
763                        | ColumnarWalRecord::MemtableFlushed { .. }
764                ),
765                "unexpected WAL record shape: {rec:?}"
766            );
767        }
768    }
769
770    /// Both segment counters at `u64::MAX` must fail the flush, not wrap.
771    #[test]
772    fn segment_id_allocator_returns_err_when_both_counters_are_exhausted() {
773        let mut engine = MutationEngine::new("test".into(), test_schema());
774
775        engine.next_segment_id = u64::MAX;
776        engine.memtable_segment_id = u64::MAX;
777
778        let err = engine
779            .on_memtable_flushed(u64::MAX)
780            .expect_err("should be exhausted");
781        assert!(
782            matches!(err, ColumnarError::SegmentIdExhausted),
783            "expected SegmentIdExhausted, got {err:?}"
784        );
785    }
786}