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    /// Add a new column to the schema, backfilling existing rows with nulls/defaults.
201    pub fn add_column(&mut self, name: String, column_type: ColumnType, nullable: bool) {
202        if self.schema.columns.iter().any(|c| c.name == name) {
203            return;
204        }
205
206        let existing_rows = self.row_count;
207        let mut col = ColumnData::new(&column_type, nullable);
208        if existing_rows > 0 {
209            col.backfill_nulls(existing_rows);
210        }
211
212        self.columns.push(col);
213        let col_def = if nullable {
214            ColumnDef::nullable(name, column_type)
215        } else {
216            ColumnDef::required(name, column_type)
217        };
218        self.schema.columns.push(col_def);
219    }
220}
221
222/// Borrowed value for zero-copy ingest into the columnar memtable.
223#[derive(Debug, Clone, Copy)]
224#[non_exhaustive]
225pub enum IngestValue<'a> {
226    Null,
227    Int64(i64),
228    Float64(f64),
229    Bool(bool),
230    Timestamp(i64),
231    /// Borrowed string — for `String` or `DictEncoded` columns.
232    Str(&'a str),
233}
234
235/// Row iterator over a columnar memtable.
236pub struct MemtableRowIter<'a> {
237    columns: &'a [ColumnData],
238    row_count: usize,
239    current: usize,
240}
241
242impl Iterator for MemtableRowIter<'_> {
243    type Item = Vec<Value>;
244
245    fn next(&mut self) -> Option<Self::Item> {
246        if self.current >= self.row_count {
247            return None;
248        }
249        let mut row = Vec::with_capacity(self.columns.len());
250        for col in self.columns {
251            row.push(col.get_value(self.current));
252        }
253        self.current += 1;
254        Some(row)
255    }
256
257    fn size_hint(&self) -> (usize, Option<usize>) {
258        let remaining = self.row_count - self.current;
259        (remaining, Some(remaining))
260    }
261}
262
263impl ExactSizeIterator for MemtableRowIter<'_> {}
264
265#[cfg(test)]
266mod tests {
267    use nodedb_types::columnar::{ColumnDef, ColumnType, ColumnarSchema};
268
269    use super::*;
270
271    fn test_schema() -> ColumnarSchema {
272        ColumnarSchema::new(vec![
273            ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
274            ColumnDef::required("name", ColumnType::String),
275            ColumnDef::nullable("score", ColumnType::Float64),
276        ])
277        .expect("valid schema")
278    }
279
280    #[test]
281    fn append_and_count() {
282        let schema = test_schema();
283        let mut mt = ColumnarMemtable::new(&schema);
284
285        mt.append_row(&[
286            Value::Integer(1),
287            Value::String("Alice".into()),
288            Value::Float(0.75),
289        ])
290        .expect("append");
291
292        mt.append_row(&[Value::Integer(2), Value::String("Bob".into()), Value::Null])
293            .expect("append");
294
295        assert_eq!(mt.row_count(), 2);
296        assert!(!mt.is_empty());
297    }
298
299    #[test]
300    fn null_violation_rejected() {
301        let schema = test_schema();
302        let mut mt = ColumnarMemtable::new(&schema);
303
304        let err = mt
305            .append_row(&[Value::Null, Value::String("x".into()), Value::Null])
306            .unwrap_err();
307        assert!(matches!(err, ColumnarError::NullViolation(ref s) if s == "id"));
308    }
309
310    #[test]
311    fn schema_mismatch_rejected() {
312        let schema = test_schema();
313        let mut mt = ColumnarMemtable::new(&schema);
314
315        let err = mt.append_row(&[Value::Integer(1)]).unwrap_err();
316        assert!(matches!(err, ColumnarError::SchemaMismatch { .. }));
317    }
318
319    #[test]
320    fn flush_threshold() {
321        let schema = test_schema();
322        let mut mt = ColumnarMemtable::with_threshold(&schema, 3);
323
324        for i in 0..2 {
325            mt.append_row(&[
326                Value::Integer(i),
327                Value::String(format!("u{i}")),
328                Value::Null,
329            ])
330            .expect("append");
331        }
332        assert!(!mt.should_flush());
333
334        mt.append_row(&[Value::Integer(2), Value::String("u2".into()), Value::Null])
335            .expect("append");
336        assert!(mt.should_flush());
337    }
338
339    #[test]
340    fn drain_resets() {
341        let schema = test_schema();
342        let mut mt = ColumnarMemtable::new(&schema);
343
344        mt.append_row(&[
345            Value::Integer(1),
346            Value::String("x".into()),
347            Value::Float(0.5),
348        ])
349        .expect("append");
350
351        let (_schema, columns, row_count) = mt.drain();
352        assert_eq!(row_count, 1);
353        assert_eq!(columns.len(), 3);
354        assert_eq!(mt.row_count(), 0);
355        assert!(mt.is_empty());
356
357        match &columns[0] {
358            ColumnData::Int64 { values, valid } => {
359                assert_eq!(values, &[1]);
360                assert!(valid.is_none());
361            }
362            _ => panic!("expected Int64"),
363        }
364        match &columns[1] {
365            ColumnData::String {
366                data,
367                offsets,
368                valid,
369            } => {
370                assert_eq!(std::str::from_utf8(data).unwrap(), "x");
371                assert_eq!(offsets, &[0, 1]);
372                assert!(valid.is_none());
373            }
374            _ => panic!("expected String"),
375        }
376    }
377
378    #[test]
379    fn all_types() {
380        let schema = ColumnarSchema::new(vec![
381            ColumnDef::required("i", ColumnType::Int64),
382            ColumnDef::required("f", ColumnType::Float64),
383            ColumnDef::required("b", ColumnType::Bool),
384            ColumnDef::required("ts", ColumnType::Timestamp),
385            ColumnDef::required("s", ColumnType::String),
386            ColumnDef::required("raw", ColumnType::Bytes),
387            ColumnDef::required("vec", ColumnType::Vector(3)),
388        ])
389        .expect("valid");
390
391        let mut mt = ColumnarMemtable::new(&schema);
392        mt.append_row(&[
393            Value::Integer(42),
394            Value::Float(0.25),
395            Value::Bool(true),
396            Value::Integer(1_700_000_000),
397            Value::String("hello".into()),
398            Value::Bytes(vec![0xDE, 0xAD]),
399            Value::Array(vec![
400                Value::Float(1.0),
401                Value::Float(2.0),
402                Value::Float(3.0),
403            ]),
404        ])
405        .expect("append all types");
406
407        assert_eq!(mt.row_count(), 1);
408    }
409
410    #[test]
411    fn dict_encode_low_cardinality() {
412        let schema = ColumnarSchema::new(vec![ColumnDef::required("qtype", ColumnType::String)])
413            .expect("valid");
414
415        let mut mt = ColumnarMemtable::new(&schema);
416        let qtypes = ["A", "B", "AAAA", "NS", "MX", "SOA", "CNAME", "PTR"];
417        for _ in 0..10 {
418            for &q in &qtypes {
419                mt.append_row(&[Value::String(q.into())]).expect("append");
420            }
421        }
422        assert_eq!(mt.row_count(), 80);
423
424        mt.try_dict_encode_columns(DICT_ENCODE_MAX_CARDINALITY);
425
426        let (_schema, columns, _row_count) = mt.drain();
427        match &columns[0] {
428            ColumnData::DictEncoded {
429                ids,
430                dictionary,
431                valid,
432                ..
433            } => {
434                assert_eq!(ids.len(), 80);
435                assert!(valid.is_none());
436                assert_eq!(dictionary.len(), 8);
437                for &id in ids {
438                    assert!((id as usize) < dictionary.len());
439                }
440                for (i, &q) in qtypes.iter().enumerate().take(8) {
441                    let expected_id = dictionary.iter().position(|s| s == q).expect("in dict");
442                    assert_eq!(ids[i], expected_id as u32);
443                }
444            }
445            _ => panic!("expected DictEncoded after try_dict_encode_columns"),
446        }
447    }
448
449    #[test]
450    fn dict_encode_exceeds_cardinality_stays_string() {
451        let schema = ColumnarSchema::new(vec![ColumnDef::required("name", ColumnType::String)])
452            .expect("valid");
453
454        let mut mt = ColumnarMemtable::new(&schema);
455        let max: u32 = 4;
456        for i in 0..=max {
457            mt.append_row(&[Value::String(format!("val_{i}"))])
458                .expect("append");
459        }
460
461        mt.try_dict_encode_columns(max);
462
463        let (_schema, columns, _row_count) = mt.drain();
464        assert!(matches!(columns[0], ColumnData::String { .. }));
465    }
466
467    #[test]
468    fn dict_encode_with_nulls() {
469        let schema = ColumnarSchema::new(vec![ColumnDef::nullable("tag", ColumnType::String)])
470            .expect("valid");
471
472        let mut mt = ColumnarMemtable::new(&schema);
473        mt.append_row(&[Value::String("foo".into())])
474            .expect("append");
475        mt.append_row(&[Value::Null]).expect("append null");
476        mt.append_row(&[Value::String("bar".into())])
477            .expect("append");
478        mt.append_row(&[Value::Null]).expect("append null");
479
480        mt.try_dict_encode_columns(DICT_ENCODE_MAX_CARDINALITY);
481
482        let (_schema, columns, _row_count) = mt.drain();
483        match &columns[0] {
484            ColumnData::DictEncoded {
485                ids,
486                valid,
487                dictionary,
488                ..
489            } => {
490                assert_eq!(ids.len(), 4);
491                let v = valid.as_ref().expect("nullable column has validity bitmap");
492                assert_eq!(v.len(), 4);
493                assert!(v[0]);
494                assert!(!v[1]);
495                assert!(v[2]);
496                assert!(!v[3]);
497                assert_eq!(dictionary.len(), 2);
498            }
499            _ => panic!("expected DictEncoded"),
500        }
501    }
502}