Skip to main content

nodedb_columnar/memtable/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Columnar memtable: in-memory row buffer with typed column vectors.
4//!
5//! Each column is stored as a typed vector (Vec<i64>, Vec<f64>, etc.) rather
6//! than Vec<Value> to avoid enum overhead and enable SIMD-friendly memory layout.
7//! The memtable accumulates INSERTs and flushes to a segment when the row count
8//! reaches the configured threshold.
9//!
10//! NOT thread-safe — lives on a single Data Plane core (!Send by design in Origin,
11//! Mutex-wrapped in Lite).
12
13mod column_data;
14
15pub use column_data::{ColumnData, DICT_ENCODE_MAX_CARDINALITY};
16
17use nodedb_types::columnar::{ColumnDef, ColumnType, ColumnarSchema};
18use nodedb_types::value::Value;
19
20use crate::error::ColumnarError;
21
22/// Default flush threshold: 64K rows per memtable.
23pub const DEFAULT_FLUSH_THRESHOLD: usize = 65_536;
24
25/// In-memory columnar buffer that accumulates INSERTs.
26///
27/// Each column is stored as a typed vector. The memtable flushes to a
28/// compressed segment when the row count reaches the threshold.
29pub struct ColumnarMemtable {
30    schema: ColumnarSchema,
31    columns: Vec<ColumnData>,
32    row_count: usize,
33    flush_threshold: usize,
34}
35
36impl ColumnarMemtable {
37    /// Create a new empty memtable for the given schema.
38    pub fn new(schema: &ColumnarSchema) -> Self {
39        Self::with_threshold(schema, DEFAULT_FLUSH_THRESHOLD)
40    }
41
42    /// Create with a custom flush threshold.
43    pub fn with_threshold(schema: &ColumnarSchema, flush_threshold: usize) -> Self {
44        let columns = schema
45            .columns
46            .iter()
47            .map(|col| ColumnData::new(&col.column_type, col.nullable))
48            .collect();
49        Self {
50            schema: schema.clone(),
51            columns,
52            row_count: 0,
53            flush_threshold,
54        }
55    }
56
57    /// Append a row of values. Validates types and nullability.
58    pub fn append_row(&mut self, values: &[Value]) -> Result<(), ColumnarError> {
59        if values.len() != self.schema.columns.len() {
60            return Err(ColumnarError::SchemaMismatch {
61                expected: self.schema.columns.len(),
62                got: values.len(),
63            });
64        }
65
66        for (i, (col_def, value)) in self.schema.columns.iter().zip(values.iter()).enumerate() {
67            if matches!(value, Value::Null) && !col_def.nullable {
68                return Err(ColumnarError::NullViolation(col_def.name.clone()));
69            }
70            self.columns[i].push(value, &col_def.name, &col_def.column_type)?;
71        }
72
73        self.row_count += 1;
74        debug_assert!(
75            self.columns.iter().all(|c| c.len() == self.row_count),
76            "column lengths must stay aligned with row_count"
77        );
78        Ok(())
79    }
80
81    /// Number of rows currently buffered.
82    pub fn row_count(&self) -> usize {
83        self.row_count
84    }
85
86    /// Whether the memtable has reached its flush threshold.
87    pub fn should_flush(&self) -> bool {
88        self.row_count >= self.flush_threshold
89    }
90
91    /// Whether the memtable is empty.
92    pub fn is_empty(&self) -> bool {
93        self.row_count == 0
94    }
95
96    /// Access the schema.
97    pub fn schema(&self) -> &ColumnarSchema {
98        &self.schema
99    }
100
101    /// Access the raw column data (for the segment writer).
102    pub fn columns(&self) -> &[ColumnData] {
103        &self.columns
104    }
105
106    /// Convert low-cardinality `String` columns to `DictEncoded` in-place.
107    pub fn try_dict_encode_columns(&mut self, max_cardinality: u32) {
108        for col in &mut self.columns {
109            if let ColumnData::String { .. } = col
110                && let Some(encoded) = ColumnData::try_dict_encode(col, max_cardinality)
111            {
112                *col = encoded;
113            }
114        }
115    }
116
117    /// Iterate rows as `Vec<Value>`. For scan/read operations.
118    pub fn iter_rows(&self) -> MemtableRowIter<'_> {
119        MemtableRowIter {
120            columns: &self.columns,
121            row_count: self.row_count,
122            current: 0,
123        }
124    }
125
126    /// Get a single row by index as `Vec<Value>`.
127    pub fn get_row(&self, row_idx: usize) -> Option<Vec<Value>> {
128        if row_idx >= self.row_count {
129            return None;
130        }
131        let mut row = Vec::with_capacity(self.columns.len());
132        for col in &self.columns {
133            row.push(col.get_value(row_idx));
134        }
135        Some(row)
136    }
137
138    /// Truncate the memtable to `n` rows, discarding all rows beyond that point.
139    ///
140    /// Used by transaction rollback to restore the memtable to its pre-write state.
141    /// No-op if `n >= self.row_count`.
142    pub fn truncate_to(&mut self, n: usize) {
143        if n >= self.row_count {
144            return;
145        }
146        for col in &mut self.columns {
147            col.truncate(n);
148        }
149        self.row_count = n;
150        debug_assert!(
151            self.columns.iter().all(|c| c.len() == self.row_count),
152            "column lengths misaligned after truncate_to"
153        );
154    }
155
156    /// Drain the memtable: return all column data and reset to empty.
157    pub fn drain(&mut self) -> (ColumnarSchema, Vec<ColumnData>, usize) {
158        let columns = std::mem::replace(
159            &mut self.columns,
160            self.schema
161                .columns
162                .iter()
163                .map(|col| ColumnData::new(&col.column_type, col.nullable))
164                .collect(),
165        );
166        let row_count = self.row_count;
167        self.row_count = 0;
168        (self.schema.clone(), columns, row_count)
169    }
170
171    /// Drain with automatic dictionary encoding for low-cardinality String columns.
172    pub fn drain_optimized(&mut self) -> (ColumnarSchema, Vec<ColumnData>, usize) {
173        self.try_dict_encode_columns(DICT_ENCODE_MAX_CARDINALITY);
174        self.drain()
175    }
176
177    /// Zero-copy row ingest for timeseries and high-throughput paths.
178    ///
179    /// Accepts borrowed values via `IngestValue<'_>`, avoiding string cloning
180    /// for tag columns that are already interned in the `DictEncoded` dictionary.
181    pub fn ingest_row_refs(&mut self, values: &[IngestValue<'_>]) -> Result<(), ColumnarError> {
182        if values.len() != self.schema.columns.len() {
183            return Err(ColumnarError::SchemaMismatch {
184                expected: self.schema.columns.len(),
185                got: values.len(),
186            });
187        }
188
189        for (i, (col_def, value)) in self.schema.columns.iter().zip(values.iter()).enumerate() {
190            if matches!(value, IngestValue::Null) && !col_def.nullable {
191                return Err(ColumnarError::NullViolation(col_def.name.clone()));
192            }
193            self.columns[i].push_ref(value, &col_def.name)?;
194        }
195
196        self.row_count += 1;
197        Ok(())
198    }
199
200    /// Reconstruct a memtable directly from already-validated column data.
201    ///
202    /// Used by `MutationEngine::from_snapshot` to restore a memtable from a
203    /// backup without re-running per-row validation. The caller must guarantee
204    /// that `columns` is parallel to `schema.columns` and that every column
205    /// contains exactly `row_count` rows. The flush threshold is set to the
206    /// default value.
207    pub(crate) fn from_raw_columns(
208        schema: &ColumnarSchema,
209        columns: Vec<ColumnData>,
210        row_count: usize,
211    ) -> Self {
212        Self {
213            schema: schema.clone(),
214            columns,
215            row_count,
216            flush_threshold: DEFAULT_FLUSH_THRESHOLD,
217        }
218    }
219
220    /// Add a new column to the schema, backfilling existing rows with nulls/defaults.
221    pub fn add_column(&mut self, name: String, column_type: ColumnType, nullable: bool) {
222        if self.schema.columns.iter().any(|c| c.name == name) {
223            return;
224        }
225
226        let existing_rows = self.row_count;
227        let mut col = ColumnData::new(&column_type, nullable);
228        if existing_rows > 0 {
229            col.backfill_nulls(existing_rows);
230        }
231
232        self.columns.push(col);
233        let col_def = if nullable {
234            ColumnDef::nullable(name, column_type)
235        } else {
236            ColumnDef::required(name, column_type)
237        };
238        self.schema.columns.push(col_def);
239    }
240}
241
242/// Borrowed value for zero-copy ingest into the columnar memtable.
243#[derive(Debug, Clone, Copy)]
244#[non_exhaustive]
245pub enum IngestValue<'a> {
246    Null,
247    Int64(i64),
248    Float64(f64),
249    Bool(bool),
250    Timestamp(i64),
251    /// Borrowed string — for `String` or `DictEncoded` columns.
252    Str(&'a str),
253}
254
255/// Row iterator over a columnar memtable.
256pub struct MemtableRowIter<'a> {
257    columns: &'a [ColumnData],
258    row_count: usize,
259    current: usize,
260}
261
262impl Iterator for MemtableRowIter<'_> {
263    type Item = Vec<Value>;
264
265    fn next(&mut self) -> Option<Self::Item> {
266        if self.current >= self.row_count {
267            return None;
268        }
269        let mut row = Vec::with_capacity(self.columns.len());
270        for col in self.columns {
271            row.push(col.get_value(self.current));
272        }
273        self.current += 1;
274        Some(row)
275    }
276
277    fn size_hint(&self) -> (usize, Option<usize>) {
278        let remaining = self.row_count - self.current;
279        (remaining, Some(remaining))
280    }
281}
282
283impl ExactSizeIterator for MemtableRowIter<'_> {}
284
285#[cfg(test)]
286mod tests {
287    use nodedb_types::columnar::{ColumnDef, ColumnType, ColumnarSchema};
288
289    use super::*;
290
291    fn test_schema() -> ColumnarSchema {
292        ColumnarSchema::new(vec![
293            ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
294            ColumnDef::required("name", ColumnType::String),
295            ColumnDef::nullable("score", ColumnType::Float64),
296        ])
297        .expect("valid schema")
298    }
299
300    #[test]
301    fn append_and_count() {
302        let schema = test_schema();
303        let mut mt = ColumnarMemtable::new(&schema);
304
305        mt.append_row(&[
306            Value::Integer(1),
307            Value::String("Alice".into()),
308            Value::Float(0.75),
309        ])
310        .expect("append");
311
312        mt.append_row(&[Value::Integer(2), Value::String("Bob".into()), Value::Null])
313            .expect("append");
314
315        assert_eq!(mt.row_count(), 2);
316        assert!(!mt.is_empty());
317    }
318
319    #[test]
320    fn null_violation_rejected() {
321        let schema = test_schema();
322        let mut mt = ColumnarMemtable::new(&schema);
323
324        let err = mt
325            .append_row(&[Value::Null, Value::String("x".into()), Value::Null])
326            .unwrap_err();
327        assert!(matches!(err, ColumnarError::NullViolation(ref s) if s == "id"));
328    }
329
330    #[test]
331    fn schema_mismatch_rejected() {
332        let schema = test_schema();
333        let mut mt = ColumnarMemtable::new(&schema);
334
335        let err = mt.append_row(&[Value::Integer(1)]).unwrap_err();
336        assert!(matches!(err, ColumnarError::SchemaMismatch { .. }));
337    }
338
339    #[test]
340    fn flush_threshold() {
341        let schema = test_schema();
342        let mut mt = ColumnarMemtable::with_threshold(&schema, 3);
343
344        for i in 0..2 {
345            mt.append_row(&[
346                Value::Integer(i),
347                Value::String(format!("u{i}")),
348                Value::Null,
349            ])
350            .expect("append");
351        }
352        assert!(!mt.should_flush());
353
354        mt.append_row(&[Value::Integer(2), Value::String("u2".into()), Value::Null])
355            .expect("append");
356        assert!(mt.should_flush());
357    }
358
359    #[test]
360    fn drain_resets() {
361        let schema = test_schema();
362        let mut mt = ColumnarMemtable::new(&schema);
363
364        mt.append_row(&[
365            Value::Integer(1),
366            Value::String("x".into()),
367            Value::Float(0.5),
368        ])
369        .expect("append");
370
371        let (_schema, columns, row_count) = mt.drain();
372        assert_eq!(row_count, 1);
373        assert_eq!(columns.len(), 3);
374        assert_eq!(mt.row_count(), 0);
375        assert!(mt.is_empty());
376
377        match &columns[0] {
378            ColumnData::Int64 { values, valid } => {
379                assert_eq!(values, &[1]);
380                assert!(valid.is_none());
381            }
382            _ => panic!("expected Int64"),
383        }
384        match &columns[1] {
385            ColumnData::String {
386                data,
387                offsets,
388                valid,
389            } => {
390                assert_eq!(std::str::from_utf8(data).unwrap(), "x");
391                assert_eq!(offsets, &[0, 1]);
392                assert!(valid.is_none());
393            }
394            _ => panic!("expected String"),
395        }
396    }
397
398    #[test]
399    fn all_types() {
400        let schema = ColumnarSchema::new(vec![
401            ColumnDef::required("i", ColumnType::Int64),
402            ColumnDef::required("f", ColumnType::Float64),
403            ColumnDef::required("b", ColumnType::Bool),
404            ColumnDef::required("ts", ColumnType::Timestamp),
405            ColumnDef::required("s", ColumnType::String),
406            ColumnDef::required("raw", ColumnType::Bytes),
407            ColumnDef::required("vec", ColumnType::Vector(3)),
408        ])
409        .expect("valid");
410
411        let mut mt = ColumnarMemtable::new(&schema);
412        mt.append_row(&[
413            Value::Integer(42),
414            Value::Float(0.25),
415            Value::Bool(true),
416            Value::Integer(1_700_000_000),
417            Value::String("hello".into()),
418            Value::Bytes(vec![0xDE, 0xAD]),
419            Value::Array(vec![
420                Value::Float(1.0),
421                Value::Float(2.0),
422                Value::Float(3.0),
423            ]),
424        ])
425        .expect("append all types");
426
427        assert_eq!(mt.row_count(), 1);
428    }
429
430    #[test]
431    fn dict_encode_low_cardinality() {
432        let schema = ColumnarSchema::new(vec![ColumnDef::required("qtype", ColumnType::String)])
433            .expect("valid");
434
435        let mut mt = ColumnarMemtable::new(&schema);
436        let qtypes = ["A", "B", "AAAA", "NS", "MX", "SOA", "CNAME", "PTR"];
437        for _ in 0..10 {
438            for &q in &qtypes {
439                mt.append_row(&[Value::String(q.into())]).expect("append");
440            }
441        }
442        assert_eq!(mt.row_count(), 80);
443
444        mt.try_dict_encode_columns(DICT_ENCODE_MAX_CARDINALITY);
445
446        let (_schema, columns, _row_count) = mt.drain();
447        match &columns[0] {
448            ColumnData::DictEncoded {
449                ids,
450                dictionary,
451                valid,
452                ..
453            } => {
454                assert_eq!(ids.len(), 80);
455                assert!(valid.is_none());
456                assert_eq!(dictionary.len(), 8);
457                for &id in ids {
458                    assert!((id as usize) < dictionary.len());
459                }
460                for (i, &q) in qtypes.iter().enumerate().take(8) {
461                    let expected_id = dictionary.iter().position(|s| s == q).expect("in dict");
462                    assert_eq!(ids[i], expected_id as u32);
463                }
464            }
465            _ => panic!("expected DictEncoded after try_dict_encode_columns"),
466        }
467    }
468
469    #[test]
470    fn dict_encode_exceeds_cardinality_stays_string() {
471        let schema = ColumnarSchema::new(vec![ColumnDef::required("name", ColumnType::String)])
472            .expect("valid");
473
474        let mut mt = ColumnarMemtable::new(&schema);
475        let max: u32 = 4;
476        for i in 0..=max {
477            mt.append_row(&[Value::String(format!("val_{i}"))])
478                .expect("append");
479        }
480
481        mt.try_dict_encode_columns(max);
482
483        let (_schema, columns, _row_count) = mt.drain();
484        assert!(matches!(columns[0], ColumnData::String { .. }));
485    }
486
487    #[test]
488    fn dict_encode_with_nulls() {
489        let schema = ColumnarSchema::new(vec![ColumnDef::nullable("tag", ColumnType::String)])
490            .expect("valid");
491
492        let mut mt = ColumnarMemtable::new(&schema);
493        mt.append_row(&[Value::String("foo".into())])
494            .expect("append");
495        mt.append_row(&[Value::Null]).expect("append null");
496        mt.append_row(&[Value::String("bar".into())])
497            .expect("append");
498        mt.append_row(&[Value::Null]).expect("append null");
499
500        mt.try_dict_encode_columns(DICT_ENCODE_MAX_CARDINALITY);
501
502        let (_schema, columns, _row_count) = mt.drain();
503        match &columns[0] {
504            ColumnData::DictEncoded {
505                ids,
506                valid,
507                dictionary,
508                ..
509            } => {
510                assert_eq!(ids.len(), 4);
511                let v = valid.as_ref().expect("nullable column has validity bitmap");
512                assert_eq!(v.len(), 4);
513                assert!(v[0]);
514                assert!(!v[1]);
515                assert!(v[2]);
516                assert!(!v[3]);
517                assert_eq!(dictionary.len(), 2);
518            }
519            _ => panic!("expected DictEncoded"),
520        }
521    }
522}