Skip to main content

velesdb_core/column_store/
mod.rs

1//! Column-oriented storage for high-performance metadata filtering.
2//!
3//! This module provides a columnar storage format for frequently filtered fields,
4//! avoiding the overhead of JSON parsing during filter operations.
5//!
6//! # Performance Goals
7//!
8//! - Maintain 50M+ items/sec filter throughput at 100k items (vs 19M/s with
9//!   JSON) — measured by the `column_filter_benchmark` micro-benchmark of
10//!   this module's filtering API. The `SELECT ... WHERE` query path invokes
11//!   these typed filters through the per-collection payload mirror
12//!   (`collection::payload_mirror`), built adaptively for scan-heavy
13//!   workloads; the `ColumnStore` also backs JOIN execution.
14//! - Cache-friendly sequential memory access
15//! - Support for common filter operations: Eq, Gt, Lt, In, Range
16//!
17//! # Architecture
18//!
19//! ```text
20//! ColumnStore
21//! ├── columns: HashMap<field_name, TypedColumn>
22//! │   ├── "category" -> StringColumn(Vec<Option<StringId>>)
23//! │   ├── "price"    -> IntColumn(Vec<Option<i64>>)
24//! │   └── "rating"   -> FloatColumn(Vec<Option<f64>>)
25//! ```
26
27// Reason: Numeric casts in column store are intentional:
28// - All casts are for columnar data processing and statistics
29// - u64/usize conversions for row indices and bitmap operations
30// - Values bounded by column cardinality and row count
31// - Precision loss acceptable for column statistics
32#![allow(clippy::cast_precision_loss)]
33#![allow(clippy::cast_possible_truncation)]
34#![allow(clippy::doc_markdown)] // Column-store docs include many storage type identifiers.
35
36mod batch;
37#[cfg(test)]
38mod batch_tests;
39mod filter;
40mod filter_array;
41mod filter_geo;
42#[cfg(test)]
43mod filter_tests;
44pub(crate) mod haversine;
45#[cfg(test)]
46mod haversine_tests;
47mod primary_key_ops;
48mod string_table;
49mod types;
50mod vacuum;
51#[cfg(test)]
52mod vacuum_tests;
53
54use roaring::RoaringBitmap;
55use rustc_hash::FxHashMap;
56use std::collections::HashMap;
57
58pub use filter_geo::{CompareOp, GeoBboxParams, GeoDistanceParams};
59pub use string_table::StringTable;
60pub use types::{
61    AutoVacuumConfig, BatchUpdate, BatchUpdateResult, BatchUpsertResult, ColumnStoreError,
62    ColumnType, ColumnValue, ExpireResult, StringId, TypedColumn, UpsertResult, VacuumConfig,
63    VacuumStats,
64};
65
66/// Column store for high-performance filtering.
67#[derive(Debug, Default)]
68pub struct ColumnStore {
69    /// Columns indexed by field name
70    pub(crate) columns: HashMap<String, TypedColumn>,
71    /// String interning table
72    pub(crate) string_table: StringTable,
73    /// Number of rows
74    pub(crate) row_count: usize,
75    /// Primary key column name (if any)
76    pub(crate) primary_key_column: Option<String>,
77    /// Primary key index: pk_value → row_idx (O(1) lookup)
78    pub(crate) primary_index: HashMap<i64, usize>,
79    /// Reverse index: row_idx → pk_value (O(1) reverse lookup for expire_rows)
80    pub(crate) row_idx_to_pk: HashMap<usize, i64>,
81    /// Deleted row indices (tombstones). Single source of truth for deletion
82    /// state (EPIC-043 US-002 / audit F-2.11): a `RoaringBitmap` for O(1)
83    /// `contains`. Rows with index >= `u32::MAX` cannot be tombstoned.
84    pub(crate) deletion_bitmap: RoaringBitmap,
85    /// Row expiry timestamps: row_idx → expiry_timestamp (US-004 TTL)
86    pub(crate) row_expiry: HashMap<usize, u64>,
87}
88
89impl ColumnStore {
90    /// Creates a new empty column store.
91    #[must_use]
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Creates a column store with pre-defined indexed fields.
97    #[must_use]
98    pub fn with_schema(fields: &[(&str, ColumnType)]) -> Self {
99        let mut store = Self::new();
100        for (name, col_type) in fields {
101            store.add_column(name, col_type);
102        }
103        store
104    }
105
106    /// Creates a column store with validated schema (rejects nested arrays).
107    ///
108    /// # Errors
109    ///
110    /// Returns `ColumnStoreError::TypeMismatch` if any column uses nested arrays.
111    pub fn with_schema_validated(fields: &[(&str, ColumnType)]) -> Result<Self, ColumnStoreError> {
112        for (name, col_type) in fields {
113            Self::reject_nested_array(name, col_type)?;
114        }
115        Ok(Self::with_schema(fields))
116    }
117
118    /// Creates a column store with a primary key for O(1) lookups.
119    ///
120    /// # Errors
121    ///
122    /// Returns `Error::ColumnStoreError` if `pk_column` is not found in `fields`
123    /// or is not of type `Int`.
124    pub fn with_primary_key(
125        fields: &[(&str, ColumnType)],
126        pk_column: &str,
127    ) -> crate::error::Result<Self> {
128        let pk_field = fields
129            .iter()
130            .find(|(name, _)| *name == pk_column)
131            .ok_or_else(|| {
132                crate::error::Error::ColumnStoreError(format!(
133                    "Primary key column '{}' not found in fields: {:?}",
134                    pk_column,
135                    fields.iter().map(|(n, _)| *n).collect::<Vec<_>>()
136                ))
137            })?;
138        if !matches!(pk_field.1, ColumnType::Int) {
139            return Err(crate::error::Error::ColumnStoreError(format!(
140                "Primary key column '{}' must be Int type, got {:?}",
141                pk_column, pk_field.1
142            )));
143        }
144
145        let mut store = Self::with_schema(fields);
146        store.primary_key_column = Some(pk_column.to_string());
147        store.primary_index = HashMap::new();
148        Ok(store)
149    }
150
151    /// Returns the primary key column name if set.
152    #[must_use]
153    pub fn primary_key_column(&self) -> Option<&str> {
154        self.primary_key_column.as_deref()
155    }
156
157    /// Adds a new column to the store.
158    ///
159    /// **Warning**: this does **not** backfill nulls for rows that already
160    /// exist, so the new column would be shorter than `row_count` and
161    /// desynchronized from every other column. It is only sound to call this
162    /// while the store is empty (`row_count == 0`, i.e. schema-definition
163    /// time). Use [`ColumnStore::add_column_backfilled`] to add a column once
164    /// rows exist.
165    ///
166    /// Nested arrays are silently treated as scalar arrays; use
167    /// `with_schema_validated` for strict schema validation.
168    ///
169    /// # Panics
170    ///
171    /// In debug builds, panics if the store already contains rows
172    /// (`row_count > 0`). Release builds skip the check for performance, but
173    /// such a call remains a logic error.
174    pub fn add_column(&mut self, name: &str, col_type: &ColumnType) {
175        debug_assert!(
176            self.row_count == 0,
177            "ColumnStore::add_column called with {} existing row(s): the new column '{name}' \
178             would not be backfilled and would desynchronize the store. \
179             Use add_column_backfilled instead.",
180            self.row_count
181        );
182        self.insert_empty_column(name, col_type);
183    }
184
185    /// Adds a column after rows already exist, backfilling nulls so the new
186    /// column stays aligned with `row_count` (required by the per-collection
187    /// payload mirror, whose schema evolves as new fields appear).
188    pub(crate) fn add_column_backfilled(&mut self, name: &str, col_type: &ColumnType) {
189        self.insert_empty_column(name, col_type);
190        if let Some(column) = self.columns.get_mut(name) {
191            for _ in 0..self.row_count {
192                column.push_null();
193            }
194        }
195    }
196
197    /// Inserts a fresh, empty column of the requested type (no backfill,
198    /// no emptiness guard). Shared by `add_column` (empty-store path) and
199    /// `add_column_backfilled` (non-empty path, which backfills right after).
200    fn insert_empty_column(&mut self, name: &str, col_type: &ColumnType) {
201        let column = match col_type {
202            ColumnType::Int => TypedColumn::new_int(0),
203            ColumnType::Float => TypedColumn::new_float(0),
204            ColumnType::String => TypedColumn::new_string(0),
205            ColumnType::Bool => TypedColumn::new_bool(0),
206            ColumnType::Array(inner) => TypedColumn::new_array((**inner).clone(), 0),
207            ColumnType::GeoPoint => TypedColumn::new_geopoint(0),
208        };
209        self.columns.insert(name.to_string(), column);
210    }
211
212    /// Tombstones a row by index without requiring the primary-key machinery.
213    ///
214    /// Used by the payload mirror, which maps point ids to row indices
215    /// externally (`u64` ids do not fit the `i64` primary-key API).
216    pub(crate) fn tombstone_row(&mut self, row_idx: usize) {
217        if row_idx >= self.row_count {
218            return;
219        }
220        if let Ok(idx) = u32::try_from(row_idx) {
221            self.deletion_bitmap.insert(idx);
222        }
223    }
224
225    /// Returns the total number of rows in the store (including deleted/tombstoned rows).
226    #[must_use]
227    pub fn row_count(&self) -> usize {
228        self.row_count
229    }
230
231    /// Returns the number of active (non-deleted) rows in the store.
232    #[must_use]
233    pub fn active_row_count(&self) -> usize {
234        self.row_count
235            .saturating_sub(self.deletion_bitmap.len() as usize)
236    }
237
238    /// Returns the number of deleted (tombstoned) rows.
239    #[must_use]
240    pub fn deleted_row_count(&self) -> usize {
241        self.deletion_bitmap.len() as usize
242    }
243
244    /// Returns the string table for string interning.
245    #[must_use]
246    pub fn string_table(&self) -> &StringTable {
247        &self.string_table
248    }
249
250    /// Returns a mutable reference to the string table.
251    pub fn string_table_mut(&mut self) -> &mut StringTable {
252        &mut self.string_table
253    }
254
255    /// Pushes values for a new row (low-level, no validation).
256    pub fn push_row_unchecked(&mut self, values: &[(&str, ColumnValue)]) {
257        let value_map: FxHashMap<&str, &ColumnValue> =
258            values.iter().map(|(k, v)| (*k, v)).collect();
259
260        for (name, column) in &mut self.columns {
261            if let Some(value) = value_map.get(name.as_str()) {
262                column.push_typed(value);
263            } else {
264                column.push_null();
265            }
266        }
267        self.row_count += 1;
268    }
269
270    /// Convenience alias for [`push_row_unchecked()`](Self::push_row_unchecked).
271    #[inline]
272    pub fn push_row(&mut self, values: &[(&str, ColumnValue)]) {
273        self.push_row_unchecked(values);
274    }
275
276    /// Gets a column by name.
277    #[must_use]
278    pub fn get_column(&self, name: &str) -> Option<&TypedColumn> {
279        self.columns.get(name)
280    }
281
282    /// Rejects nested array types (`Array(Array(...))`) at schema creation time.
283    fn reject_nested_array(name: &str, col_type: &ColumnType) -> Result<(), ColumnStoreError> {
284        if let ColumnType::Array(inner) = col_type {
285            if matches!(inner.as_ref(), ColumnType::Array(_)) {
286                return Err(ColumnStoreError::TypeMismatch {
287                    expected: "scalar element type (Int, Float, String, Bool)".to_string(),
288                    actual: format!("nested Array in column '{name}'"),
289                });
290            }
291        }
292        Ok(())
293    }
294
295    /// Returns an iterator over column names.
296    pub fn column_names(&self) -> impl Iterator<Item = &str> {
297        self.columns.keys().map(String::as_str)
298    }
299
300    /// Gets a value from a column at a specific row index as JSON.
301    #[must_use]
302    pub fn get_value_as_json(&self, column: &str, row_idx: usize) -> Option<serde_json::Value> {
303        if self.is_row_deleted_bitmap(row_idx) {
304            return None;
305        }
306
307        let col = self.columns.get(column)?;
308        // String columns need special handling for intern-table resolution.
309        if let TypedColumn::String(v) = col {
310            return v.get(row_idx).and_then(|opt| {
311                opt.and_then(|id| self.string_table.get(id).map(|s| serde_json::json!(s)))
312            });
313        }
314        // Array columns need special handling for string element resolution.
315        if let TypedColumn::Array { data, .. } = col {
316            return self.get_array_as_json(data, row_idx);
317        }
318        // GeoPoint columns return {"lat": f64, "lng": f64}.
319        if let TypedColumn::GeoPoint(v) = col {
320            return v
321                .get(row_idx)
322                .and_then(|opt| opt.map(|(lat, lng)| serde_json::json!({"lat": lat, "lng": lng})));
323        }
324        col.get_as_json_non_string(row_idx)
325    }
326
327    /// Converts an array column cell to a JSON array, resolving string IDs.
328    fn get_array_as_json(
329        &self,
330        data: &[Option<smallvec::SmallVec<[ColumnValue; 8]>>],
331        row_idx: usize,
332    ) -> Option<serde_json::Value> {
333        let arr = data.get(row_idx)?.as_ref()?;
334        let json_arr: Vec<serde_json::Value> =
335            arr.iter().map(|v| self.column_value_to_json(v)).collect();
336        Some(serde_json::Value::Array(json_arr))
337    }
338
339    /// Converts a single `ColumnValue` to its JSON representation.
340    fn column_value_to_json(&self, value: &ColumnValue) -> serde_json::Value {
341        match value {
342            ColumnValue::Int(v) => serde_json::json!(v),
343            ColumnValue::Float(v) => serde_json::json!(v),
344            ColumnValue::Bool(v) => serde_json::json!(v),
345            ColumnValue::String(id) => self
346                .string_table
347                .get(*id)
348                .map_or(serde_json::Value::Null, |s| serde_json::json!(s)),
349            ColumnValue::Null => serde_json::Value::Null,
350            ColumnValue::Array(inner) => {
351                let arr: Vec<serde_json::Value> =
352                    inner.iter().map(|v| self.column_value_to_json(v)).collect();
353                serde_json::Value::Array(arr)
354            }
355            ColumnValue::GeoPoint(lat, lng) => {
356                serde_json::json!({"lat": lat, "lng": lng})
357            }
358        }
359    }
360}