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    pub fn scan_memtable_rows(&self) -> impl Iterator<Item = Vec<Value>> + '_ {
172        let deletes = self.delete_bitmaps.get(&self.memtable_segment_id);
173        self.memtable
174            .iter_rows()
175            .enumerate()
176            .filter_map(move |(row_idx, row)| {
177                if deletes.is_some_and(|bm| bm.is_deleted(row_idx as u32)) {
178                    None
179                } else {
180                    Some(row)
181                }
182            })
183    }
184
185    /// Iterate non-deleted rows paired with their surrogate identity.
186    ///
187    /// Yields `(Option<Surrogate>, Vec<Value>)`. The surrogate is `None`
188    /// for rows inserted without one (test fixtures, legacy paths). Deleted
189    /// rows are filtered out exactly as in [`Self::scan_memtable_rows`].
190    pub fn scan_memtable_rows_with_surrogates(
191        &self,
192    ) -> impl Iterator<Item = (Option<Surrogate>, Vec<Value>)> + '_ {
193        let deletes = self.delete_bitmaps.get(&self.memtable_segment_id);
194        let surrogates = &self.memtable_surrogates;
195        self.memtable
196            .iter_rows()
197            .enumerate()
198            .filter_map(move |(row_idx, row)| {
199                if deletes.is_some_and(|bm| bm.is_deleted(row_idx as u32)) {
200                    return None;
201                }
202                let surrogate = surrogates.get(row_idx).copied().flatten();
203                Some((surrogate, row))
204            })
205    }
206
207    /// Get a single row from the memtable by index (None if deleted).
208    pub fn get_memtable_row(&self, row_idx: usize) -> Option<Vec<Value>> {
209        if self
210            .delete_bitmaps
211            .get(&self.memtable_segment_id)
212            .is_some_and(|bm| bm.is_deleted(row_idx as u32))
213        {
214            return None;
215        }
216        self.memtable.get_row(row_idx)
217    }
218
219    /// Roll back in-memory inserts to `row_count_before`.
220    ///
221    /// Undoes the effect of one or more inserts that appended rows starting
222    /// at `row_count_before`. For each inserted row:
223    /// - The corresponding PK entry is removed from the PK index.
224    /// - If the insert displaced a prior row (upsert tombstone), that prior
225    ///   row's PK index entry is restored and its tombstone bit cleared.
226    ///
227    /// The memtable is then truncated to `row_count_before`. Used exclusively
228    /// by the transaction undo log; never called on the normal write path.
229    pub fn rollback_memtable_inserts(
230        &mut self,
231        row_count_before: usize,
232        inserted_pks: &[Vec<u8>],
233        displaced: &[(Vec<u8>, crate::pk_index::RowLocation)],
234    ) {
235        // 1. Remove newly inserted PK entries.
236        for pk in inserted_pks {
237            self.pk_index.remove(pk);
238        }
239        // 2. Restore displaced prior-row PK entries and clear tombstones.
240        for (pk, prior_location) in displaced {
241            self.pk_index.upsert(pk.clone(), *prior_location);
242            // Clear the tombstone bit that the insert set on the prior row.
243            if let Some(bm) = self.delete_bitmaps.get_mut(&prior_location.segment_id) {
244                bm.unmark_deleted(prior_location.row_index);
245            }
246        }
247        // 3. Truncate memtable and surrogate list.
248        self.memtable.truncate_to(row_count_before);
249        self.memtable_surrogates.truncate(row_count_before);
250        self.memtable_row_counter = row_count_before as u32;
251    }
252
253    /// Reverse one or more positional deletes: for each `(pk_bytes, location)`
254    /// clear the row's delete-bitmap bit and re-bind the PK index to that
255    /// location.
256    ///
257    /// Undoes the effect of [`Self::delete`] (and the delete-half of
258    /// [`Self::update`]) so a rolled-back mutation leaves the affected rows
259    /// present with their original values. Used exclusively by the transaction
260    /// undo log; never called on the normal write path. Mirrors the
261    /// displaced-row restore in [`Self::rollback_memtable_inserts`].
262    pub fn restore_deleted_rows(&mut self, rows: &[(Vec<u8>, crate::pk_index::RowLocation)]) {
263        for (pk_bytes, location) in rows {
264            if let Some(bm) = self.delete_bitmaps.get_mut(&location.segment_id) {
265                bm.unmark_deleted(location.row_index);
266            }
267            self.pk_index.upsert(pk_bytes.clone(), *location);
268        }
269    }
270
271    /// The segment ID that will be assigned to the next flushed segment.
272    ///
273    /// Use this to obtain the ID to pass to `on_memtable_flushed`.
274    pub fn next_segment_id(&self) -> u64 {
275        self.next_segment_id
276    }
277
278    /// Whether a segment should be compacted based on its delete ratio.
279    pub fn should_compact(&self, segment_id: u64, total_rows: u64) -> bool {
280        self.delete_bitmaps
281            .get(&segment_id)
282            .is_some_and(|bm| bm.should_compact(total_rows, 0.2))
283    }
284
285    /// Encode a PK value as index bytes. Exposed for callers that need
286    /// to probe the PK index (e.g. `ON CONFLICT DO UPDATE` routing).
287    pub fn encode_pk_from_row(&self, values: &[Value]) -> Result<Vec<u8>, ColumnarError> {
288        self.extract_pk_bytes(values)
289    }
290
291    // -- Internal helpers --
292
293    /// Extract PK bytes from a row of values.
294    pub(super) fn extract_pk_bytes(&self, values: &[Value]) -> Result<Vec<u8>, ColumnarError> {
295        if values.len() != self.schema.columns.len() {
296            return Err(ColumnarError::SchemaMismatch {
297                expected: self.schema.columns.len(),
298                got: values.len(),
299            });
300        }
301
302        if self.pk_col_indices.len() == 1 {
303            Ok(encode_pk(&values[self.pk_col_indices[0]]))
304        } else {
305            let pk_values: Vec<&Value> = self.pk_col_indices.iter().map(|&i| &values[i]).collect();
306            Ok(crate::pk_index::encode_composite_pk(&pk_values))
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use nodedb_types::columnar::{ColumnDef, ColumnType, ColumnarSchema};
314
315    use super::*;
316
317    fn minimal_schema() -> ColumnarSchema {
318        ColumnarSchema {
319            columns: vec![ColumnDef::required("id", ColumnType::Int64).with_primary_key()],
320            version: 1,
321        }
322    }
323
324    #[test]
325    fn segment_id_allocator_returns_err_at_u64_max() {
326        let mut engine = MutationEngine::new("test".to_string(), minimal_schema());
327        engine.next_segment_id = u64::MAX;
328        let result = engine.on_memtable_flushed(u64::MAX - 1);
329        assert!(
330            matches!(result, Err(ColumnarError::SegmentIdExhausted)),
331            "expected SegmentIdExhausted, got: {result:?}"
332        );
333    }
334
335    #[test]
336    fn with_flush_threshold_triggers_should_flush_at_threshold() {
337        use nodedb_types::value::Value;
338
339        let schema = minimal_schema();
340        // Threshold of 2: after 2 inserts the engine must report should_flush.
341        let mut engine = MutationEngine::with_flush_threshold("col".to_string(), schema, 2);
342        assert!(!engine.should_flush(), "empty engine should not need flush");
343
344        engine.insert(&[Value::Integer(1)]).expect("insert row 1");
345        assert!(!engine.should_flush(), "1 row below threshold of 2");
346
347        engine.insert(&[Value::Integer(2)]).expect("insert row 2");
348        assert!(
349            engine.should_flush(),
350            "2 rows at threshold of 2 must trigger flush"
351        );
352    }
353
354    #[test]
355    fn with_flush_threshold_zero_is_clamped_to_one() {
356        use nodedb_types::value::Value;
357
358        let schema = minimal_schema();
359        let mut engine = MutationEngine::with_flush_threshold("col".to_string(), schema, 0);
360        engine.insert(&[Value::Integer(1)]).expect("insert");
361        // A zero threshold would be pathological; clamped to 1, so 1 row = flush.
362        assert!(
363            engine.should_flush(),
364            "clamped-to-1 threshold: 1 row must flush"
365        );
366    }
367}