From 64f7ad79b47dbb3035bc49a2143bf80fecffd489 Mon Sep 17 00:00:00 2001 From: meh Date: Thu, 6 Aug 2026 05:11:48 +0700 Subject: [PATCH 1/2] feat: add columnar fields and clustered indexes --- codegen/src/common/model/column.rs | 11 +- codegen/src/common/model/columnar.rs | 47 +++ codegen/src/common/model/mod.rs | 2 + codegen/src/common/parser/columnar.rs | 308 ++++++++++++++ codegen/src/common/parser/columns.rs | 15 + codegen/src/common/parser/mod.rs | 1 + codegen/src/generators/columnar.rs | 392 ++++++++++++++++++ codegen/src/generators/in_memory/index/mod.rs | 8 +- .../src/generators/in_memory/index/usual.rs | 10 + .../generators/in_memory/queries/in_place.rs | 2 + .../generators/in_memory/queries/update.rs | 9 + codegen/src/generators/in_memory/table/mod.rs | 5 + codegen/src/generators/mod.rs | 1 + codegen/src/generators/persist/index/cdc.rs | 10 + codegen/src/generators/persist/index/mod.rs | 8 +- codegen/src/generators/persist/index/usual.rs | 10 + .../generators/persist/queries/in_place.rs | 2 + .../src/generators/persist/queries/update.rs | 8 + codegen/src/generators/persist/table/mod.rs | 5 + codegen/src/generators/read_only/index/mod.rs | 8 +- .../src/generators/read_only/index/usual.rs | 6 + codegen/src/generators/read_only/table/mod.rs | 5 + codegen/src/persist_index/generator.rs | 32 +- codegen/src/persist_index/mod.rs | 22 + codegen/src/worktable/mod.rs | 103 +++++ docs/columnar-index-plan.md | 164 ++++++++ src/columnar.rs | 226 ++++++++++ src/lib.rs | 14 +- src/mem_stat/mod.rs | 63 ++- tests/worktable/columnar.rs | 159 +++++++ tests/worktable/mod.rs | 1 + 31 files changed, 1645 insertions(+), 12 deletions(-) create mode 100644 codegen/src/common/model/columnar.rs create mode 100644 codegen/src/common/parser/columnar.rs create mode 100644 codegen/src/generators/columnar.rs create mode 100644 docs/columnar-index-plan.md create mode 100644 src/columnar.rs create mode 100644 tests/worktable/columnar.rs diff --git a/codegen/src/common/model/column.rs b/codegen/src/common/model/column.rs index 611c9d73..8047d4ae 100644 --- a/codegen/src/common/model/column.rs +++ b/codegen/src/common/model/column.rs @@ -2,7 +2,7 @@ use indexmap::IndexMap; use std::collections::HashMap; use crate::common::model::index::Index; -use crate::common::model::{GeneratorType, IndexBackend}; +use crate::common::model::{ColumnarFieldConfig, ColumnarIndex, GeneratorType, IndexBackend}; use proc_macro2::{Ident, TokenStream}; use quote::quote; use syn::spanned::Spanned; @@ -17,6 +17,8 @@ pub struct Columns { pub columns_map: HashMap, pub field_positions: HashMap, pub indexes: IndexMap, + pub columnar_fields: IndexMap, + pub columnar_indexes: IndexMap, pub primary_keys: Vec, pub primary_index_backend: IndexBackend, pub generator_type: GeneratorType, @@ -30,6 +32,7 @@ pub struct Row { pub gen_type: GeneratorType, pub optional: bool, pub index_backend: Option, + pub columnar: Option, } impl Columns { @@ -40,6 +43,7 @@ impl Columns { let mut pk = vec![]; let mut gen_type = None; let mut primary_index_backend = None; + let mut columnar_fields = IndexMap::new(); for (pos, row) in rows.into_iter().enumerate() { let type_ = &row.type_; @@ -53,6 +57,9 @@ impl Columns { }; columns_map.insert(row.name.clone(), type_); field_positions.insert(row.name.clone(), pos); + if let Some(config) = row.columnar { + columnar_fields.insert(row.name.clone(), config); + } if row.is_primary_key { if let Some(t) = gen_type { @@ -90,6 +97,8 @@ impl Columns { is_sized: sized, columns_map, indexes: Default::default(), + columnar_fields, + columnar_indexes: Default::default(), primary_keys: pk, primary_index_backend: primary_index_backend.unwrap_or_default(), generator_type: gen_type.expect("set"), diff --git a/codegen/src/common/model/columnar.rs b/codegen/src/common/model/columnar.rs new file mode 100644 index 00000000..d6fb4bc7 --- /dev/null +++ b/codegen/src/common/model/columnar.rs @@ -0,0 +1,47 @@ +use proc_macro2::Ident; + +pub const DEFAULT_COLUMNAR_CHUNK_ROWS: usize = 65_536; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ColumnCompression { + None, + #[default] + Auto, + Delta, + Rle, + Dictionary, +} + +impl ColumnCompression { + pub(crate) fn name(self) -> &'static str { + match self { + Self::None => "none", + Self::Auto => "auto", + Self::Delta => "delta", + Self::Rle => "rle", + Self::Dictionary => "dictionary", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ColumnarFieldConfig { + pub chunk_rows: usize, + pub compression: ColumnCompression, +} + +impl Default for ColumnarFieldConfig { + fn default() -> Self { + Self { + chunk_rows: DEFAULT_COLUMNAR_CHUNK_ROWS, + compression: ColumnCompression::Auto, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ColumnarIndex { + pub name: Ident, + pub columns: Vec, + pub cluster_by: Vec, +} diff --git a/codegen/src/common/model/mod.rs b/codegen/src/common/model/mod.rs index 93604295..0be5541a 100644 --- a/codegen/src/common/model/mod.rs +++ b/codegen/src/common/model/mod.rs @@ -1,4 +1,5 @@ mod column; +mod columnar; mod config; mod index; pub mod operation; @@ -7,6 +8,7 @@ mod primary_key; mod queries; pub use column::{Columns, Row}; +pub use columnar::{ColumnCompression, ColumnarFieldConfig, ColumnarIndex}; pub use config::Config; pub use index::{Index, IndexBackend}; pub use operation::Operation; diff --git a/codegen/src/common/parser/columnar.rs b/codegen/src/common/parser/columnar.rs new file mode 100644 index 00000000..e133cde8 --- /dev/null +++ b/codegen/src/common/parser/columnar.rs @@ -0,0 +1,308 @@ +use std::collections::HashSet; + +use indexmap::IndexMap; +use proc_macro2::{Delimiter, Ident, TokenTree}; +use syn::spanned::Spanned as _; + +use crate::common::Parser; +use crate::common::model::{ColumnCompression, ColumnarFieldConfig, ColumnarIndex}; + +impl Parser { + pub(super) fn try_parse_columnar_field(&mut self) -> syn::Result> { + let Some(TokenTree::Ident(attribute)) = self.input_iter.peek() else { + return Ok(None); + }; + if attribute != "columnar" { + return Ok(None); + } + + let attribute_span = attribute.span(); + self.input_iter.next(); + let Some(TokenTree::Group(group)) = self.input_iter.next() else { + return Err(syn::Error::new( + attribute_span, + "expected `columnar(...)` after the field type", + )); + }; + if group.delimiter() != Delimiter::Parenthesis { + return Err(syn::Error::new(group.span(), "expected `columnar(...)`")); + } + + let mut config = ColumnarFieldConfig::default(); + let mut saw_chunk_rows = false; + let mut saw_compression = false; + let mut parser = Parser::new(group.stream()); + + while parser.has_next() { + let option = parser + .input_iter + .next() + .ok_or_else(|| syn::Error::new(attribute_span, "expected a columnar field option"))?; + let TokenTree::Ident(option) = option else { + return Err(syn::Error::new(option.span(), "expected a columnar option name")); + }; + let value = parser + .input_iter + .next() + .ok_or_else(|| syn::Error::new(option.span(), format!("expected `{option}(...)`")))?; + let TokenTree::Group(value) = value else { + return Err(syn::Error::new(value.span(), format!("expected `{option}(...)`"))); + }; + if value.delimiter() != Delimiter::Parenthesis { + return Err(syn::Error::new(value.span(), format!("expected `{option}(...)`"))); + } + + match option.to_string().as_str() { + "chunk_rows" => { + if saw_chunk_rows { + return Err(syn::Error::new(option.span(), "duplicate `chunk_rows` option")); + } + saw_chunk_rows = true; + let mut values = value.stream().into_iter(); + let Some(TokenTree::Literal(rows)) = values.next() else { + return Err(syn::Error::new(value.span(), "`chunk_rows` expects an integer")); + }; + if values.next().is_some() { + return Err(syn::Error::new(value.span(), "`chunk_rows` expects one integer")); + } + let parsed = rows + .to_string() + .replace('_', "") + .parse::() + .map_err(|_| syn::Error::new(rows.span(), "invalid `chunk_rows` integer"))?; + if parsed == 0 { + return Err(syn::Error::new(rows.span(), "`chunk_rows` must be greater than zero")); + } + config.chunk_rows = parsed; + } + "compression" => { + if saw_compression { + return Err(syn::Error::new(option.span(), "duplicate `compression` option")); + } + saw_compression = true; + let mut values = value.stream().into_iter(); + let Some(TokenTree::Ident(compression)) = values.next() else { + return Err(syn::Error::new(value.span(), "`compression` expects a policy name")); + }; + if values.next().is_some() { + return Err(syn::Error::new(value.span(), "`compression` expects one policy")); + } + config.compression = match compression.to_string().as_str() { + "none" => ColumnCompression::None, + "auto" => ColumnCompression::Auto, + "delta" => ColumnCompression::Delta, + "rle" => ColumnCompression::Rle, + "dictionary" => ColumnCompression::Dictionary, + _ => { + return Err(syn::Error::new( + compression.span(), + "unknown compression; expected `none`, `auto`, `delta`, `rle`, or `dictionary`", + )); + } + }; + } + _ => { + return Err(syn::Error::new( + option.span(), + "unknown columnar option; expected `chunk_rows` or `compression`", + )); + } + } + parser.try_parse_comma()?; + } + + Ok(Some(config)) + } + + pub fn parse_columnar_indexes(&mut self) -> syn::Result> { + let section = self + .input_iter + .next() + .ok_or_else(|| syn::Error::new(self.input.span(), "expected `columnar_indexes` section"))?; + let TokenTree::Ident(section) = section else { + return Err(syn::Error::new(section.span(), "expected `columnar_indexes`")); + }; + if section != "columnar_indexes" { + return Err(syn::Error::new(section.span(), "expected `columnar_indexes`")); + } + self.parse_colon()?; + + let body = self + .input_iter + .next() + .ok_or_else(|| syn::Error::new(section.span(), "expected `columnar_indexes: { ... }`"))?; + let TokenTree::Group(body) = body else { + return Err(syn::Error::new(body.span(), "expected `columnar_indexes: { ... }`")); + }; + if body.delimiter() != Delimiter::Brace { + return Err(syn::Error::new(body.span(), "expected braces around columnar indexes")); + } + + let mut parser = Parser::new(body.stream()); + let mut indexes = IndexMap::new(); + while parser.has_next() { + let name = parser + .input_iter + .next() + .ok_or_else(|| syn::Error::new(body.span(), "expected a columnar index name"))?; + let TokenTree::Ident(name) = name else { + return Err(syn::Error::new(name.span(), "expected a columnar index name")); + }; + parser.parse_colon()?; + let definition = parser + .input_iter + .next() + .ok_or_else(|| syn::Error::new(name.span(), "expected a columnar index definition"))?; + let TokenTree::Group(definition) = definition else { + return Err(syn::Error::new(definition.span(), "expected `{ ... }`")); + }; + if definition.delimiter() != Delimiter::Brace { + return Err(syn::Error::new(definition.span(), "expected `{ ... }`")); + } + + let mut definition_parser = Parser::new(definition.stream()); + let mut columns = None; + let mut cluster_by = None; + while definition_parser.has_next() { + let property = definition_parser + .input_iter + .next() + .ok_or_else(|| syn::Error::new(definition.span(), "expected a columnar index property"))?; + let TokenTree::Ident(property) = property else { + return Err(syn::Error::new(property.span(), "expected `columns` or `cluster_by`")); + }; + definition_parser.parse_colon()?; + let values = parse_ident_list(&mut definition_parser, property.span())?; + match property.to_string().as_str() { + "columns" if columns.is_none() => columns = Some(values), + "cluster_by" if cluster_by.is_none() => cluster_by = Some(values), + "columns" | "cluster_by" => { + return Err(syn::Error::new(property.span(), "duplicate columnar index property")); + } + _ => { + return Err(syn::Error::new( + property.span(), + "unknown columnar index property; expected `columns` or `cluster_by`", + )); + } + } + definition_parser.try_parse_comma()?; + } + + let columns = + columns.ok_or_else(|| syn::Error::new(name.span(), "columnar index requires `columns: [...]`"))?; + if columns.is_empty() { + return Err(syn::Error::new(name.span(), "columnar index `columns` cannot be empty")); + } + let cluster_by = cluster_by.unwrap_or_else(|| columns.clone()); + if cluster_by.is_empty() { + return Err(syn::Error::new( + name.span(), + "columnar index `cluster_by` cannot be empty", + )); + } + ensure_unique(&columns, "columnar index `columns` contains a duplicate")?; + ensure_unique(&cluster_by, "columnar index `cluster_by` contains a duplicate")?; + + if indexes.contains_key(&name) { + return Err(syn::Error::new(name.span(), "duplicate columnar index name")); + } + indexes.insert( + name.clone(), + ColumnarIndex { + name, + columns, + cluster_by, + }, + ); + parser.try_parse_comma()?; + } + self.try_parse_comma()?; + Ok(indexes) + } +} + +fn parse_ident_list(parser: &mut Parser, span: proc_macro2::Span) -> syn::Result> { + let list = parser + .input_iter + .next() + .ok_or_else(|| syn::Error::new(span, "expected `[field, ...]`"))?; + let TokenTree::Group(list) = list else { + return Err(syn::Error::new(list.span(), "expected `[field, ...]`")); + }; + if list.delimiter() != Delimiter::Bracket { + return Err(syn::Error::new(list.span(), "expected `[field, ...]`")); + } + let mut values = Parser::new(list.stream()); + let mut result = Vec::new(); + while values.has_next() { + let field = values + .input_iter + .next() + .ok_or_else(|| syn::Error::new(list.span(), "expected a field identifier"))?; + let TokenTree::Ident(field) = field else { + return Err(syn::Error::new(field.span(), "expected a field identifier")); + }; + result.push(field); + values.try_parse_comma()?; + } + Ok(result) +} + +fn ensure_unique(values: &[Ident], message: &str) -> syn::Result<()> { + let mut seen = HashSet::new(); + for value in values { + if !seen.insert(value.to_string()) { + return Err(syn::Error::new(value.span(), message)); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use quote::quote; + + use crate::common::Parser; + use crate::common::model::{ColumnCompression, ColumnarFieldConfig}; + + #[test] + fn parses_columnar_field_options() { + let mut parser = Parser::new(quote! { + columnar(chunk_rows(65_536), compression(delta)) + }); + let config = parser.try_parse_columnar_field().unwrap().unwrap(); + assert_eq!(config.chunk_rows, 65_536); + assert_eq!(config.compression, ColumnCompression::Delta); + } + + #[test] + fn empty_columnar_field_uses_defaults() { + let mut parser = Parser::new(quote! { columnar() }); + let config = parser.try_parse_columnar_field().unwrap().unwrap(); + assert_eq!(config.chunk_rows, ColumnarFieldConfig::default().chunk_rows); + assert_eq!(config.compression, ColumnCompression::Auto); + } + + #[test] + fn parses_columnar_indexes() { + let mut parser = Parser::new(quote! { + columnar_indexes: { + host_time: { + columns: [host_id, timestamp], + cluster_by: [host_id, timestamp], + }, + }, + }); + let indexes = parser.parse_columnar_indexes().unwrap(); + let index = indexes.values().next().unwrap(); + assert_eq!( + index.columns.iter().map(ToString::to_string).collect::>(), + ["host_id", "timestamp"] + ); + assert_eq!( + index.cluster_by.iter().map(ToString::to_string).collect::>(), + ["host_id", "timestamp"] + ); + } +} diff --git a/codegen/src/common/parser/columns.rs b/codegen/src/common/parser/columns.rs index 65126f64..ede52016 100644 --- a/codegen/src/common/parser/columns.rs +++ b/codegen/src/common/parser/columns.rs @@ -110,6 +110,8 @@ impl Parser { let index_backend = self.try_parse_index_backend()?; + let columnar = self.try_parse_columnar_field()?; + self.try_parse_comma()?; Ok(Row { @@ -119,6 +121,7 @@ impl Parser { gen_type, optional, index_backend, + columnar, }) } } @@ -324,6 +327,18 @@ mod tests { assert_eq!(row.index_backend, Some(crate::common::model::IndexBackend::Congee)); } + #[test] + fn test_columnar_field_parse() { + let row_tokens = quote! { + host_id: u64 columnar(chunk_rows(65_536), compression(auto)), + }; + let mut parser = Parser::new(row_tokens); + let row = parser.parse_row().unwrap(); + let config = row.columnar.unwrap(); + assert_eq!(config.chunk_rows, 65_536); + assert_eq!(config.compression, crate::common::model::ColumnCompression::Auto); + } + #[test] fn test_using_rejected_on_plain_column() { let tokens = quote! {columns: { diff --git a/codegen/src/common/parser/mod.rs b/codegen/src/common/parser/mod.rs index e2571e63..5dfa20a3 100644 --- a/codegen/src/common/parser/mod.rs +++ b/codegen/src/common/parser/mod.rs @@ -1,4 +1,5 @@ mod attribute; +mod columnar; mod columns; mod config; mod index; diff --git a/codegen/src/generators/columnar.rs b/codegen/src/generators/columnar.rs new file mode 100644 index 00000000..6a741607 --- /dev/null +++ b/codegen/src/generators/columnar.rs @@ -0,0 +1,392 @@ +use convert_case::{Case, Casing}; +use proc_macro2::{Ident, Literal, Span, TokenStream}; +use quote::{format_ident, quote}; + +use crate::common::model::{ColumnCompression, Columns}; +use crate::common::name_generator::{WorktableNameGenerator, is_float}; + +fn data_ident(table: &Ident) -> Ident { + format_ident!("{}ColumnarData", table) +} + +fn column_field(field: &Ident) -> Ident { + format_ident!("column_{}", field) +} + +fn index_field(index: &Ident) -> Ident { + format_ident!("columnar_index_{}", index) +} + +fn compression_variant(compression: ColumnCompression) -> Ident { + Ident::new( + &compression.name().from_case(Case::Snake).to_case(Case::Pascal), + Span::mixed_site(), + ) +} + +fn key_type(columns: &Columns, fields: &[Ident]) -> TokenStream { + let fields = fields.iter().map(|field| { + let ty = columns.columns_map.get(field).expect("validated columnar index field"); + if is_float(&ty.to_string()) { + quote! { OrderedFloat<#ty> } + } else { + quote! { #ty } + } + }); + quote! { (#(#fields,)*) } +} + +fn row_key(columns: &Columns, fields: &[Ident], row: TokenStream) -> TokenStream { + let fields = fields.iter().map(|field| { + let ty = columns.columns_map.get(field).expect("validated columnar index field"); + if is_float(&ty.to_string()) { + quote! { OrderedFloat(#row.#field) } + } else { + quote! { #row.#field.clone() } + } + }); + quote! { (#(#fields,)*) } +} + +pub(crate) fn index_struct_field(table: &Ident, columns: &Columns, persisted: bool) -> TokenStream { + if columns.columnar_fields.is_empty() { + return quote! {}; + } + let data = data_ident(table); + let skip = persisted.then(|| quote! { #[index(skip)] }); + quote! { + #skip + columnar: ParkingRwLock<#data> + } +} + +pub(crate) fn index_default_field(columns: &Columns) -> TokenStream { + if columns.columnar_fields.is_empty() { + quote! {} + } else { + quote! { columnar: ParkingRwLock::new(Default::default()), } + } +} + +pub(crate) fn save_row(columns: &Columns) -> TokenStream { + if columns.columnar_fields.is_empty() { + quote! {} + } else { + quote! { self.columnar.write().save_row(&row); } + } +} + +pub(crate) fn reinsert_row(columns: &Columns) -> TokenStream { + if columns.columnar_fields.is_empty() { + quote! {} + } else { + quote! { self.columnar.write().replace_row(&row_old, &row_new); } + } +} + +pub(crate) fn delete_row(columns: &Columns) -> TokenStream { + if columns.columnar_fields.is_empty() { + quote! {} + } else { + quote! { self.columnar.write().delete_row(&row); } + } +} + +pub(crate) fn mark_dirty(columns: &Columns) -> TokenStream { + if columns.columnar_fields.is_empty() { + quote! {} + } else { + quote! { self.columnar.write().mark_dirty(); } + } +} + +pub(crate) fn table_mark_dirty(columns: &Columns) -> TokenStream { + if columns.columnar_fields.is_empty() { + quote! {} + } else { + quote! { self.0.indexes.columnar.write().mark_dirty(); } + } +} + +pub(crate) fn definitions(table: &Ident, columns: &Columns) -> TokenStream { + if columns.columnar_fields.is_empty() { + return quote! {}; + } + + let names = WorktableNameGenerator::from_table_name(table.to_string()); + let row = names.get_row_type_ident(); + let pk = names.get_primary_key_type_ident(); + let data = data_ident(table); + + let column_fields = columns.columnar_fields.iter().map(|(field, _)| { + let storage = column_field(field); + let ty = columns.columns_map.get(field).expect("columnar field exists"); + quote! { #storage: ColumnarColumn<#ty>, } + }); + let column_defaults = columns.columnar_fields.iter().map(|(field, config)| { + let storage = column_field(field); + let chunk_rows = Literal::usize_unsuffixed(config.chunk_rows); + let compression = compression_variant(config.compression); + quote! { #storage: ColumnarColumn::new(#chunk_rows, ColumnCompression::#compression), } + }); + let index_fields = columns.columnar_indexes.values().map(|index| { + let field = index_field(&index.name); + let ty = key_type(columns, &index.cluster_by); + quote! { #field: ClusteredColumnarIndex<#ty>, } + }); + let index_defaults = columns.columnar_indexes.values().map(|index| { + let field = index_field(&index.name); + quote! { #field: Default::default(), } + }); + + let set_columns = columns.columnar_fields.keys().map(|field| { + let storage = column_field(field); + quote! { self.#storage.set(row_id, row.#field.clone()); } + }); + let remove_columns = columns.columnar_fields.keys().map(|field| { + let storage = column_field(field); + quote! { self.#storage.remove(row_id); } + }); + let insert_indexes = columns.columnar_indexes.values().map(|index| { + let field = index_field(&index.name); + let key = row_key(columns, &index.cluster_by, quote! { row }); + quote! { self.#field.insert(#key, row_id); } + }); + let delete_indexes = columns.columnar_indexes.values().map(|index| { + let field = index_field(&index.name); + let key = row_key(columns, &index.cluster_by, quote! { row }); + quote! { self.#field.remove(&#key, row_id); } + }); + let replace_remove_indexes = columns.columnar_indexes.values().map(|index| { + let field = index_field(&index.name); + let key = row_key(columns, &index.cluster_by, quote! { row_old }); + quote! { self.#field.remove(&#key, row_id); } + }); + let replace_insert_indexes = columns.columnar_indexes.values().map(|index| { + let field = index_field(&index.name); + let key = row_key(columns, &index.cluster_by, quote! { row_new }); + quote! { self.#field.insert(#key, row_id); } + }); + let replace_columns = columns.columnar_fields.keys().map(|field| { + let storage = column_field(field); + quote! { self.#storage.set(row_id, row_new.#field.clone()); } + }); + + quote! { + #[derive(Debug, MemStat)] + struct #data { + next_row_id: u64, + dirty: bool, + row_ids: std::collections::BTreeMap<#pk, ColumnRowId>, + primary_keys: ColumnarColumn<#pk>, + #(#column_fields)* + #(#index_fields)* + } + + impl Default for #data { + fn default() -> Self { + Self { + next_row_id: 0, + // Persisted and read-only tables reconstruct this derived + // replica from authoritative rows on first access. + dirty: true, + row_ids: Default::default(), + primary_keys: ColumnarColumn::new(65_536, ColumnCompression::None), + #(#column_defaults)* + #(#index_defaults)* + } + } + } + + impl #data { + fn save_row(&mut self, row: &#row) { + let primary_key = row.get_primary_key(); + let row_id = if let Some(row_id) = self.row_ids.get(&primary_key).copied() { + row_id + } else { + let row_id = ColumnRowId::new(self.next_row_id); + self.next_row_id = self.next_row_id.saturating_add(1); + self.row_ids.insert(primary_key.clone(), row_id); + self.primary_keys.set(row_id, primary_key); + row_id + }; + #(#set_columns)* + #(#insert_indexes)* + } + + fn delete_row(&mut self, row: &#row) { + let primary_key = row.get_primary_key(); + let Some(row_id) = self.row_ids.remove(&primary_key) else { + return; + }; + #(#delete_indexes)* + #(#remove_columns)* + self.primary_keys.remove(row_id); + } + + fn replace_row(&mut self, row_old: &#row, row_new: &#row) { + let old_primary_key = row_old.get_primary_key(); + let new_primary_key = row_new.get_primary_key(); + if old_primary_key != new_primary_key { + self.delete_row(row_old); + self.save_row(row_new); + return; + } + let Some(row_id) = self.row_ids.get(&old_primary_key).copied() else { + self.save_row(row_new); + return; + }; + #(#replace_remove_indexes)* + #(#replace_columns)* + #(#replace_insert_indexes)* + } + + fn mark_dirty(&mut self) { + self.dirty = true; + } + } + } +} + +pub(crate) fn table_methods(table: &Ident, columns: &Columns) -> TokenStream { + if columns.columnar_fields.is_empty() { + return quote! {}; + } + + let names = WorktableNameGenerator::from_table_name(table.to_string()); + let row = names.get_row_type_ident(); + let pk = names.get_primary_key_type_ident(); + let data = data_ident(table); + + let field_methods = columns.columnar_fields.iter().map(|(field, _)| { + let storage = column_field(field); + let scan = format_ident!("columnar_scan_{}", field); + let project = format_ident!("columnar_project_{}", field); + let ty = columns.columns_map.get(field).expect("columnar field exists"); + quote! { + pub fn #scan(&self) -> Vec<(ColumnRowId, #ty)> { + loop { + self.ensure_columnar_current(); + let columnar = self.0.indexes.columnar.read(); + if !columnar.dirty { + return columnar.#storage.iter() + .map(|(row_id, value)| (row_id, value.clone())) + .collect(); + } + } + } + + pub fn #project(&self, row_ids: &[ColumnRowId]) -> Vec<(ColumnRowId, #ty)> { + loop { + self.ensure_columnar_current(); + let columnar = self.0.indexes.columnar.read(); + if !columnar.dirty { + return row_ids.iter().filter_map(|row_id| { + columnar.#storage.get(*row_id).cloned().map(|value| (*row_id, value)) + }).collect(); + } + } + } + } + }); + + let index_methods = columns.columnar_indexes.values().map(|index| { + let storage = index_field(&index.name); + let select = format_ident!("columnar_select_{}", index.name); + let scan = format_ident!("columnar_scan_{}", index.name); + let args = index.cluster_by.iter().map(|field| { + let ty = columns.columns_map.get(field).expect("columnar index field exists"); + quote! { #field: #ty } + }); + let key_fields = index.cluster_by.iter().map(|field| { + let ty = columns.columns_map.get(field).expect("columnar index field exists"); + if is_float(&ty.to_string()) { + quote! { OrderedFloat(#field) } + } else { + quote! { #field } + } + }); + quote! { + pub fn #select(&self, #(#args),*) -> Vec { + let key = (#(#key_fields,)*); + loop { + self.ensure_columnar_current(); + let columnar = self.0.indexes.columnar.read(); + if !columnar.dirty { + return columnar.#storage.exact(&key); + } + } + } + + pub fn #scan(&self) -> Vec { + loop { + self.ensure_columnar_current(); + let columnar = self.0.indexes.columnar.read(); + if !columnar.dirty { + return columnar.#storage.ordered_row_ids(); + } + } + } + } + }); + + quote! { + fn ensure_columnar_current(&self) { + // Take the writer lock before reading authoritative rows. A row + // mutation publishes to this same lock after changing row storage, + // so it either lands in this rebuild or dirties/updates the replica + // after the rebuild. Scanning first and locking later would allow a + // stale rebuild to overwrite a concurrent mutation. + let mut columnar = self.0.indexes.columnar.write(); + if !columnar.dirty { + return; + } + let rows: Vec<#row> = { + let read_guard = self.0.data.read_guard(); + self.0.primary_index.pk_map.iter_values().filter_map(|(_, link)| { + let _read_guard = &read_guard; + self.0.data.select_non_ghosted(link.0).ok() + }).collect() + }; + // Rebuild derived vectors and clustered metadata while retaining + // every assigned row id. A concurrent reinsert may temporarily + // publish a ghost link in the primary index while waiting for this + // columnar lock; dropping a key that is absent from this scan would + // then change its stable row id. Delete paths remove their mapping + // explicitly, so a dirty refresh never infers deletion from an + // absent/transient row. + let mut rebuilt: #data = Default::default(); + rebuilt.next_row_id = columnar.next_row_id; + rebuilt.row_ids = std::mem::take(&mut columnar.row_ids); + rebuilt.primary_keys = std::mem::replace( + &mut columnar.primary_keys, + ColumnarColumn::new(65_536, ColumnCompression::None), + ); + for row in &rows { + rebuilt.save_row(row); + } + rebuilt.dirty = false; + *columnar = rebuilt; + } + + /// Resolves logical columnar row ids back to authoritative WorkTable + /// primary keys without exposing physical data-page links. + pub fn columnar_resolve_primary_keys( + &self, + row_ids: &[ColumnRowId], + ) -> Vec<(ColumnRowId, #pk)> { + loop { + self.ensure_columnar_current(); + let columnar = self.0.indexes.columnar.read(); + if !columnar.dirty { + return row_ids.iter().filter_map(|row_id| { + columnar.primary_keys.get(*row_id).cloned().map(|key| (*row_id, key)) + }).collect(); + } + } + } + + #(#field_methods)* + #(#index_methods)* + } +} diff --git a/codegen/src/generators/in_memory/index/mod.rs b/codegen/src/generators/in_memory/index/mod.rs index d912985f..d2ca8b31 100644 --- a/codegen/src/generators/in_memory/index/mod.rs +++ b/codegen/src/generators/in_memory/index/mod.rs @@ -12,6 +12,7 @@ use quote::quote; impl InMemoryGenerator { /// Generates index type and it's impls. pub fn gen_index_def(&mut self) -> syn::Result { + let columnar_def = crate::generators::columnar::definitions(&self.name, &self.columns); let type_def = self.gen_type_def()?; let impl_def = self.gen_secondary_index_impl_def(); let info_def = self.gen_secondary_index_info_impl_def(); @@ -24,6 +25,7 @@ impl InMemoryGenerator { let available_indexes = self.gen_available_indexes(); Ok(quote! { + #columnar_def #type_def #impl_def #info_def @@ -94,11 +96,13 @@ impl InMemoryGenerator { #[derive(Debug, MemStat)] } }; + let columnar_field = crate::generators::columnar::index_struct_field(&self.name, &self.columns, false); Ok(quote! { #derive pub struct #ident { - #(#index_rows),* + #(#index_rows,)* + #columnar_field } }) } @@ -161,12 +165,14 @@ impl InMemoryGenerator { Ok::<_, syn::Error>(res) }) .collect::, syn::Error>>()?; + let columnar_field = crate::generators::columnar::index_default_field(&self.columns); Ok(quote! { impl Default for #index_type_ident { fn default() -> Self { Self { #(#index_rows)* + #columnar_field } } } diff --git a/codegen/src/generators/in_memory/index/usual.rs b/codegen/src/generators/in_memory/index/usual.rs index 2c90abc7..4b4b06ea 100644 --- a/codegen/src/generators/in_memory/index/usual.rs +++ b/codegen/src/generators/in_memory/index/usual.rs @@ -73,11 +73,13 @@ impl InMemoryGenerator { } }) .collect::>(); + let columnar_save = crate::generators::columnar::save_row(&self.columns); quote! { fn save_row(&self, row: #row_type_ident, link: Link) -> core::result::Result<(), IndexError<#available_index_ident>> { let mut inserted_indexes: Vec<#available_index_ident> = vec![]; #(#save_rows)* + #columnar_save core::result::Result::Ok(()) } } @@ -151,6 +153,7 @@ impl InMemoryGenerator { (insert, remove) }) .unzip(); + let columnar_reinsert = crate::generators::columnar::reinsert_row(&self.columns); quote! { fn reinsert_row(&self, @@ -163,6 +166,7 @@ impl InMemoryGenerator { let mut inserted_indexes: Vec<#available_index_ident> = vec![]; #(#insert_rows)* #(#remove_rows)* + #columnar_reinsert core::result::Result::Ok(()) } } @@ -196,10 +200,12 @@ impl InMemoryGenerator { } }) .collect::>(); + let columnar_delete = crate::generators::columnar::delete_row(&self.columns); quote! { fn delete_row(&self, row: #row_type_ident, link: Link) -> core::result::Result<(), IndexError<#available_index_ident>> { #(#delete_rows)* + #columnar_delete core::result::Result::Ok(()) } } @@ -240,6 +246,7 @@ impl InMemoryGenerator { quote! {} } }); + let columnar_dirty = crate::generators::columnar::mark_dirty(&self.columns); quote! { fn process_difference_remove( @@ -248,6 +255,7 @@ impl InMemoryGenerator { difference: std::collections::HashMap<&str, Difference<#avt_type_ident>> ) -> core::result::Result<(), IndexError<#avt_index_ident>> { #(#process_difference_remove_rows)* + #columnar_dirty core::result::Result::Ok(()) } } @@ -299,6 +307,7 @@ impl InMemoryGenerator { quote! {} } }); + let columnar_dirty = crate::generators::columnar::mark_dirty(&self.columns); quote! { fn process_difference_insert( @@ -308,6 +317,7 @@ impl InMemoryGenerator { ) -> core::result::Result<(), IndexError<#avt_index_ident>> { let mut inserted_indexes: Vec<#avt_index_ident> = vec![]; #(#process_difference_insert_rows)* + #columnar_dirty core::result::Result::Ok(()) } } diff --git a/codegen/src/generators/in_memory/queries/in_place.rs b/codegen/src/generators/in_memory/queries/in_place.rs index c0c38720..a450992a 100644 --- a/codegen/src/generators/in_memory/queries/in_place.rs +++ b/codegen/src/generators/in_memory/queries/in_place.rs @@ -95,6 +95,7 @@ impl InMemoryGenerator { } }; let custom_lock = self.gen_custom_lock_for_update(lock_ident); + let columnar_dirty = crate::generators::columnar::table_mark_dirty(&self.columns); quote! { pub async fn #method_ident( @@ -124,6 +125,7 @@ impl InMemoryGenerator { .map_err(WorkTableError::PagesError)? }; + #columnar_dirty Ok(()) } } diff --git a/codegen/src/generators/in_memory/queries/update.rs b/codegen/src/generators/in_memory/queries/update.rs index 745ab635..bfb30e9b 100644 --- a/codegen/src/generators/in_memory/queries/update.rs +++ b/codegen/src/generators/in_memory/queries/update.rs @@ -55,6 +55,7 @@ impl InMemoryGenerator { let persist_call = self.gen_persist_call(); let persist_op = self.gen_persist_op(); let full_row_lock = self.gen_full_lock_for_update(); + let columnar_dirty = crate::generators::columnar::table_mark_dirty(&self.columns); // A full-row `update(row)` replaces EVERY column, so it inherently // rewrites every secondary index. The in-place fast path only applies // when no updated field is indexed (it emits no index diff), so a @@ -88,6 +89,7 @@ impl InMemoryGenerator { }; #diff_process_remove + #columnar_dirty self.0.update_state.remove(&pk); @@ -104,6 +106,7 @@ impl InMemoryGenerator { self.0.data.update_in_place::<{ #const_name }>(row.clone(), link).is_ok() }; if in_place_ok { + #columnar_dirty self.0.update_state.remove(&pk); return core::result::Result::Ok(()); } @@ -616,6 +619,7 @@ impl InMemoryGenerator { let persist_call = self.gen_persist_call(); let persist_op = self.gen_persist_op(); let custom_lock = self.gen_custom_lock_for_update(lock_ident); + let columnar_dirty = crate::generators::columnar::table_mark_dirty(&self.columns); let finish_update = if archived_swap_is_safe { quote! { @@ -627,6 +631,7 @@ impl InMemoryGenerator { }).map_err(WorkTableError::PagesError)? }; #diff_process_remove + #columnar_dirty #persist_call @@ -752,6 +757,7 @@ impl InMemoryGenerator { } }; let custom_lock = self.gen_custom_lock_for_update(lock_ident); + let columnar_dirty = crate::generators::columnar::table_mark_dirty(&self.columns); quote! { pub async fn #method_ident(&self, row: #query_ident, by: #by_ident) -> core::result::Result<(), WorkTableError> { @@ -828,6 +834,7 @@ impl InMemoryGenerator { guards.remove(&pk); } + #columnar_dirty core::result::Result::Ok(()) } } @@ -871,6 +878,7 @@ impl InMemoryGenerator { } }; let custom_lock = self.gen_custom_lock_for_update(lock_ident); + let columnar_dirty = crate::generators::columnar::table_mark_dirty(&self.columns); let finish_update = if self.columns.is_sized { quote! { @@ -884,6 +892,7 @@ impl InMemoryGenerator { } #diff_process_remove + #columnar_dirty #persist_call diff --git a/codegen/src/generators/in_memory/table/mod.rs b/codegen/src/generators/in_memory/table/mod.rs index ee1765ab..458efd40 100644 --- a/codegen/src/generators/in_memory/table/mod.rs +++ b/codegen/src/generators/in_memory/table/mod.rs @@ -18,6 +18,8 @@ impl InMemoryGenerator { let index_fns = self.gen_table_index_fns()?; let select_query_executor_impl = self.gen_table_select_query_executor_impl(); let column_range_type = self.gen_table_column_range_type(); + let columnar_methods = crate::generators::columnar::table_methods(&self.name, &self.columns); + let table_ident = WorktableNameGenerator::from_table_name(self.name.to_string()).get_work_table_ident(); Ok(quote! { #page_size_consts @@ -27,6 +29,9 @@ impl InMemoryGenerator { #index_fns #select_query_executor_impl #column_range_type + impl #table_ident { + #columnar_methods + } }) } diff --git a/codegen/src/generators/mod.rs b/codegen/src/generators/mod.rs index 83bc1ecb..3f376633 100644 --- a/codegen/src/generators/mod.rs +++ b/codegen/src/generators/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod columnar; pub mod in_memory; pub(crate) mod index_backend; pub mod persist; diff --git a/codegen/src/generators/persist/index/cdc.rs b/codegen/src/generators/persist/index/cdc.rs index 86573cfd..550560f4 100644 --- a/codegen/src/generators/persist/index/cdc.rs +++ b/codegen/src/generators/persist/index/cdc.rs @@ -68,6 +68,7 @@ impl PersistGenerator { }) .collect::>(); let idents = self.columns.indexes.values().map(|idx| &idx.name).collect::>(); + let columnar_save = crate::generators::columnar::save_row(&self.columns); quote! { fn save_row_cdc(&self, row: #row_type_ident, link: Link) -> (#events_ident, Result<(), IndexError<#available_index_ident>>) { @@ -75,6 +76,7 @@ impl PersistGenerator { let mut partial_events = #events_ident::default(); #(#save_rows)* + #columnar_save (#events_ident { #(#idents,)* }, Ok(())) @@ -148,6 +150,7 @@ impl PersistGenerator { }) .unzip(); let idents = self.columns.indexes.values().map(|idx| &idx.name).collect::>(); + let columnar_reinsert = crate::generators::columnar::reinsert_row(&self.columns); quote! { fn reinsert_row_cdc( @@ -162,6 +165,7 @@ impl PersistGenerator { #(#insert_rows)* #(#remove_rows)* + #columnar_reinsert (#events_ident { #(#idents,)* }, Ok(())) @@ -188,10 +192,12 @@ impl PersistGenerator { }) .collect::>(); let idents = self.columns.indexes.values().map(|idx| &idx.name).collect::>(); + let columnar_delete = crate::generators::columnar::delete_row(&self.columns); quote! { fn delete_row_cdc(&self, row: #row_type_ident, link: Link) -> (#events_ident, Result<(), IndexError<#available_index_ident>>) { #(#delete_rows)* + #columnar_delete (#events_ident { #(#idents,)* }, Ok(())) @@ -306,6 +312,7 @@ impl PersistGenerator { } }); let idents = self.columns.indexes.values().map(|idx| &idx.name).collect::>(); + let columnar_dirty = crate::generators::columnar::mark_dirty(&self.columns); quote! { fn process_difference_remove_cdc( @@ -314,6 +321,7 @@ impl PersistGenerator { difference: std::collections::HashMap<&str, Difference<#avt_type_ident>> ) -> (#events_ident, Result<(), IndexError<#available_index_ident>>) { #(#process_difference_rows)* + #columnar_dirty (#events_ident { #(#idents,)* }, Ok(())) @@ -376,6 +384,7 @@ impl PersistGenerator { } }); let idents = self.columns.indexes.values().map(|idx| &idx.name).collect::>(); + let columnar_dirty = crate::generators::columnar::mark_dirty(&self.columns); quote! { fn process_difference_insert_cdc( @@ -387,6 +396,7 @@ impl PersistGenerator { let mut partial_events = #events_ident::default(); #(#process_difference_insert_rows)* + #columnar_dirty (#events_ident { #(#idents,)* }, Ok(())) diff --git a/codegen/src/generators/persist/index/mod.rs b/codegen/src/generators/persist/index/mod.rs index 476fe724..1633c908 100644 --- a/codegen/src/generators/persist/index/mod.rs +++ b/codegen/src/generators/persist/index/mod.rs @@ -11,6 +11,7 @@ use quote::quote; impl PersistGenerator { pub fn gen_index_def(&mut self) -> syn::Result { + let columnar_def = crate::generators::columnar::definitions(&self.name, &self.columns); let type_def = self.gen_type_def()?; let impl_def = self.gen_secondary_index_impl_def(); let info_def = self.gen_secondary_index_info_impl_def(); @@ -19,6 +20,7 @@ impl PersistGenerator { let available_indexes = self.gen_available_indexes(); Ok(quote! { + #columnar_def #type_def #impl_def #info_def @@ -73,11 +75,13 @@ impl PersistGenerator { let derive = quote! { #[derive(Debug, MemStat, PersistIndex)] }; + let columnar_field = crate::generators::columnar::index_struct_field(&self.name, &self.columns, true); Ok(quote! { #derive pub struct #ident { - #(#index_rows),* + #(#index_rows,)* + #columnar_field } }) } @@ -145,12 +149,14 @@ impl PersistGenerator { Ok::<_, syn::Error>(res) }) .collect::, syn::Error>>()?; + let columnar_field = crate::generators::columnar::index_default_field(&self.columns); Ok(quote! { impl Default for #index_type_ident { fn default() -> Self { Self { #(#index_rows)* + #columnar_field } } } diff --git a/codegen/src/generators/persist/index/usual.rs b/codegen/src/generators/persist/index/usual.rs index e8629cc8..23e0929b 100644 --- a/codegen/src/generators/persist/index/usual.rs +++ b/codegen/src/generators/persist/index/usual.rs @@ -69,11 +69,13 @@ impl PersistGenerator { } }) .collect::>(); + let columnar_save = crate::generators::columnar::save_row(&self.columns); quote! { fn save_row(&self, row: #row_type_ident, link: Link) -> core::result::Result<(), IndexError<#available_index_ident>> { let mut inserted_indexes: Vec<#available_index_ident> = vec![]; #(#save_rows)* + #columnar_save core::result::Result::Ok(()) } } @@ -147,6 +149,7 @@ impl PersistGenerator { (insert, remove) }) .unzip(); + let columnar_reinsert = crate::generators::columnar::reinsert_row(&self.columns); quote! { fn reinsert_row(&self, @@ -159,6 +162,7 @@ impl PersistGenerator { let mut inserted_indexes: Vec<#available_index_ident> = vec![]; #(#insert_rows)* #(#remove_rows)* + #columnar_reinsert core::result::Result::Ok(()) } } @@ -189,10 +193,12 @@ impl PersistGenerator { } }) .collect::>(); + let columnar_delete = crate::generators::columnar::delete_row(&self.columns); quote! { fn delete_row(&self, row: #row_type_ident, link: Link) -> core::result::Result<(), IndexError<#available_index_ident>> { #(#delete_rows)* + #columnar_delete core::result::Result::Ok(()) } } @@ -231,6 +237,7 @@ impl PersistGenerator { quote! {} } }); + let columnar_dirty = crate::generators::columnar::mark_dirty(&self.columns); quote! { fn process_difference_remove( @@ -239,6 +246,7 @@ impl PersistGenerator { difference: std::collections::HashMap<&str, Difference<#avt_type_ident>> ) -> core::result::Result<(), IndexError<#avt_index_ident>> { #(#process_difference_remove_rows)* + #columnar_dirty core::result::Result::Ok(()) } } @@ -288,6 +296,7 @@ impl PersistGenerator { quote! {} } }); + let columnar_dirty = crate::generators::columnar::mark_dirty(&self.columns); quote! { fn process_difference_insert( @@ -297,6 +306,7 @@ impl PersistGenerator { ) -> core::result::Result<(), IndexError<#avt_index_ident>> { let mut inserted_indexes: Vec<#avt_index_ident> = vec![]; #(#process_difference_insert_rows)* + #columnar_dirty core::result::Result::Ok(()) } } diff --git a/codegen/src/generators/persist/queries/in_place.rs b/codegen/src/generators/persist/queries/in_place.rs index fcc066e8..0dd5a280 100644 --- a/codegen/src/generators/persist/queries/in_place.rs +++ b/codegen/src/generators/persist/queries/in_place.rs @@ -97,6 +97,7 @@ impl PersistGenerator { } }; let custom_lock = self.gen_custom_lock_for_update(lock_ident); + let columnar_dirty = crate::generators::columnar::table_mark_dirty(&self.columns); quote! { pub async fn #method_ident( @@ -126,6 +127,7 @@ impl PersistGenerator { .map_err(WorkTableError::PagesError)? }; + #columnar_dirty Ok(()) } } diff --git a/codegen/src/generators/persist/queries/update.rs b/codegen/src/generators/persist/queries/update.rs index dbe0dfcb..9b72a58d 100644 --- a/codegen/src/generators/persist/queries/update.rs +++ b/codegen/src/generators/persist/queries/update.rs @@ -55,6 +55,7 @@ impl PersistGenerator { let persist_call = self.gen_persist_call(); let persist_op = self.gen_persist_op(); let full_row_lock = self.gen_full_lock_for_update(); + let columnar_dirty = crate::generators::columnar::table_mark_dirty(&self.columns); let size_check = if self.columns.is_sized { quote! {} } else { @@ -126,6 +127,7 @@ impl PersistGenerator { }).map_err(WorkTableError::PagesError)? }; #diff_process_remove + #columnar_dirty self.0.update_state.remove(&pk); @@ -415,6 +417,7 @@ impl PersistGenerator { let persist_call = self.gen_persist_call(); let persist_op = self.gen_persist_op(); let custom_lock = self.gen_custom_lock_for_update(lock_ident); + let columnar_dirty = crate::generators::columnar::table_mark_dirty(&self.columns); quote! { pub async fn #method_ident(&self, row: #query_ident, pk: Pk) -> core::result::Result<(), WorkTableError> @@ -448,6 +451,7 @@ impl PersistGenerator { }).map_err(WorkTableError::PagesError)? }; #diff_process_remove + #columnar_dirty #persist_call @@ -544,6 +548,7 @@ impl PersistGenerator { } }; let custom_lock = self.gen_custom_lock_for_update(lock_ident); + let columnar_dirty = crate::generators::columnar::table_mark_dirty(&self.columns); quote! { pub async fn #method_ident(&self, row: #query_ident, by: #by_ident) -> core::result::Result<(), WorkTableError> { @@ -621,6 +626,7 @@ impl PersistGenerator { guards.remove(&pk); } + #columnar_dirty core::result::Result::Ok(()) } } @@ -664,6 +670,7 @@ impl PersistGenerator { } }; let custom_lock = self.gen_custom_lock_for_update(lock_ident); + let columnar_dirty = crate::generators::columnar::table_mark_dirty(&self.columns); quote! { pub async fn #method_ident(&self, row: #query_ident, by: #by_ident) -> core::result::Result<(), WorkTableError> { @@ -718,6 +725,7 @@ impl PersistGenerator { } #diff_process_remove + #columnar_dirty #persist_call diff --git a/codegen/src/generators/persist/table/mod.rs b/codegen/src/generators/persist/table/mod.rs index 1d339ed3..7aefec4a 100644 --- a/codegen/src/generators/persist/table/mod.rs +++ b/codegen/src/generators/persist/table/mod.rs @@ -18,6 +18,8 @@ impl PersistGenerator { let index_fns = self.gen_table_index_fns()?; let select_query_executor_impl = self.gen_table_select_query_executor_impl(); let column_range_type = self.gen_table_column_range_type(); + let columnar_methods = crate::generators::columnar::table_methods(&self.name, &self.columns); + let table_ident = WorktableNameGenerator::from_table_name(self.name.to_string()).get_work_table_ident(); Ok(quote! { #page_size_consts @@ -27,6 +29,9 @@ impl PersistGenerator { #index_fns #select_query_executor_impl #column_range_type + impl #table_ident { + #columnar_methods + } }) } diff --git a/codegen/src/generators/read_only/index/mod.rs b/codegen/src/generators/read_only/index/mod.rs index 048c679e..d8c4eac5 100644 --- a/codegen/src/generators/read_only/index/mod.rs +++ b/codegen/src/generators/read_only/index/mod.rs @@ -10,6 +10,7 @@ use quote::quote; impl ReadOnlyGenerator { pub fn gen_index_def(&mut self) -> syn::Result { + let columnar_def = crate::generators::columnar::definitions(&self.name, &self.columns); let type_def = self.gen_type_def()?; let impl_def = self.gen_secondary_index_impl_def(); let info_def = self.gen_secondary_index_info_impl_def(); @@ -17,6 +18,7 @@ impl ReadOnlyGenerator { let available_indexes = self.gen_available_indexes(); Ok(quote! { + #columnar_def #type_def #impl_def #info_def @@ -71,11 +73,13 @@ impl ReadOnlyGenerator { #[derive(Debug, MemStat, PersistIndex)] #[index(read_only)] }; + let columnar_field = crate::generators::columnar::index_struct_field(&self.name, &self.columns, true); Ok(quote! { #derive pub struct #ident { - #(#index_rows),* + #(#index_rows,)* + #columnar_field } }) } @@ -138,12 +142,14 @@ impl ReadOnlyGenerator { Ok::<_, syn::Error>(res) }) .collect::, syn::Error>>()?; + let columnar_field = crate::generators::columnar::index_default_field(&self.columns); Ok(quote! { impl Default for #index_type_ident { fn default() -> Self { Self { #(#index_rows)* + #columnar_field } } } diff --git a/codegen/src/generators/read_only/index/usual.rs b/codegen/src/generators/read_only/index/usual.rs index 0af30608..6fa0b940 100644 --- a/codegen/src/generators/read_only/index/usual.rs +++ b/codegen/src/generators/read_only/index/usual.rs @@ -69,11 +69,13 @@ impl ReadOnlyGenerator { } }) .collect::>(); + let columnar_save = crate::generators::columnar::save_row(&self.columns); quote! { fn save_row(&self, row: #row_type_ident, link: Link) -> core::result::Result<(), IndexError<#available_index_ident>> { let mut inserted_indexes: Vec<#available_index_ident> = vec![]; #(#save_rows)* + #columnar_save core::result::Result::Ok(()) } } @@ -147,6 +149,7 @@ impl ReadOnlyGenerator { (insert, remove) }) .unzip(); + let columnar_reinsert = crate::generators::columnar::reinsert_row(&self.columns); quote! { fn reinsert_row(&self, @@ -159,6 +162,7 @@ impl ReadOnlyGenerator { let mut inserted_indexes: Vec<#available_index_ident> = vec![]; #(#insert_rows)* #(#remove_rows)* + #columnar_reinsert core::result::Result::Ok(()) } } @@ -189,10 +193,12 @@ impl ReadOnlyGenerator { } }) .collect::>(); + let columnar_delete = crate::generators::columnar::delete_row(&self.columns); quote! { fn delete_row(&self, row: #row_type_ident, link: Link) -> core::result::Result<(), IndexError<#available_index_ident>> { #(#delete_rows)* + #columnar_delete core::result::Result::Ok(()) } } diff --git a/codegen/src/generators/read_only/table/mod.rs b/codegen/src/generators/read_only/table/mod.rs index 72bc8c0d..ccfdcfe2 100644 --- a/codegen/src/generators/read_only/table/mod.rs +++ b/codegen/src/generators/read_only/table/mod.rs @@ -18,6 +18,8 @@ impl ReadOnlyGenerator { let index_fns = self.gen_table_index_fns()?; let select_query_executor_impl = self.gen_table_select_query_executor_impl(); let column_range_type = self.gen_table_column_range_type(); + let columnar_methods = crate::generators::columnar::table_methods(&self.name, &self.columns); + let table_ident = WorktableNameGenerator::from_table_name(self.name.to_string()).get_work_table_ident(); Ok(quote! { #page_size_consts @@ -27,6 +29,9 @@ impl ReadOnlyGenerator { #index_fns #select_query_executor_impl #column_range_type + impl #table_ident { + #columnar_methods + } }) } diff --git a/codegen/src/persist_index/generator.rs b/codegen/src/persist_index/generator.rs index 9c536283..084fcffb 100644 --- a/codegen/src/persist_index/generator.rs +++ b/codegen/src/persist_index/generator.rs @@ -17,6 +17,7 @@ pub struct Generator { pub struct_def: ItemStruct, pub field_types: HashMap, pub attributes: PersistIndexAttributes, + pub skipped_fields: Vec, } pub(super) struct IndexLayout { @@ -87,11 +88,29 @@ impl WorktableNameGenerator { } impl Generator { - pub fn with_attributes(struct_def: ItemStruct, attributes: PersistIndexAttributes) -> Self { + pub fn with_attributes(mut struct_def: ItemStruct, attributes: PersistIndexAttributes) -> Self { let mut fields = vec![]; let mut types = vec![]; + let mut skipped_fields = vec![]; for field in &struct_def.fields { + let skipped = field.attrs.iter().any(|attribute| { + if !attribute.path().is_ident("index") { + return false; + } + let mut skipped = false; + let _ = attribute.parse_nested_meta(|meta| { + if meta.path.is_ident("skip") { + skipped = true; + } + Ok(()) + }); + skipped + }); + if skipped { + skipped_fields.push(field.ident.clone().expect("index fields should always be named fields")); + continue; + } fields.push(field.ident.clone().expect("index fields should always be named fields")); let syn::Type::Path(type_path) = &field.ty else { @@ -119,12 +138,21 @@ impl Generator { types.push(ty.to_token_stream()); } + if let syn::Fields::Named(named) = &mut struct_def.fields { + named.named = named + .named + .iter() + .filter(|field| !skipped_fields.iter().any(|ident| field.ident.as_ref() == Some(ident))) + .cloned() + .collect(); + } let map = fields.into_iter().zip(types).collect::>(); Self { struct_def, field_types: map, attributes, + skipped_fields, } } @@ -577,6 +605,7 @@ impl Generator { } }) .collect::>>()?; + let skipped_fields = &self.skipped_fields; Ok(quote! { fn from_persisted(persisted: Self::PersistedIndex) -> Self { @@ -584,6 +613,7 @@ impl Generator { Self { #(#idents,)* + #(#skipped_fields: Default::default(),)* } } }) diff --git a/codegen/src/persist_index/mod.rs b/codegen/src/persist_index/mod.rs index ef17c461..6063d218 100644 --- a/codegen/src/persist_index/mod.rs +++ b/codegen/src/persist_index/mod.rs @@ -83,4 +83,26 @@ mod tests { "read_only index should have from_persisted method" ); } + + #[test] + fn skipped_derived_field_is_not_part_of_persisted_index_format() { + let input = quote! { + #[derive(Debug, Default)] + pub struct DerivedIndex { + durable: TreeIndex, + #[index(skip)] + columnar: ParkingRwLock, + } + }; + + let output = expand(input).unwrap().to_string(); + let persisted_type = output + .split("struct DerivedIndexPersisted") + .nth(1) + .expect("persisted index type"); + let persisted_fields = persisted_type.split('}').next().unwrap(); + assert!(persisted_fields.contains("durable")); + assert!(!persisted_fields.contains("columnar")); + assert!(output.contains("columnar : Default :: default")); + } } diff --git a/codegen/src/worktable/mod.rs b/codegen/src/worktable/mod.rs index f4567674..546df1a5 100644 --- a/codegen/src/worktable/mod.rs +++ b/codegen/src/worktable/mod.rs @@ -8,6 +8,7 @@ pub fn expand(input: TokenStream) -> syn::Result { let mut columns = None; let mut queries = None; let mut indexes = None; + let mut columnar_indexes = None; let mut config = None; let name = parser.parse_name()?; @@ -23,6 +24,10 @@ pub fn expand(input: TokenStream) -> syn::Result { let res = parser.parse_indexes()?; indexes = Some(res); } + "columnar_indexes" => { + let res = parser.parse_columnar_indexes()?; + columnar_indexes = Some(res); + } "queries" => { let res = parser.parse_queries()?; queries = Some(res) @@ -51,8 +56,12 @@ pub fn expand(input: TokenStream) -> syn::Result { if let Some(i) = indexes { columns.indexes = i } + if let Some(i) = columnar_indexes { + columns.columnar_indexes = i; + } validate_index_backends(&columns, persistence)?; + validate_columnar_indexes(&columns)?; if persistence.is_persisted() { crate::generators::persist::expand(name, columns, queries, config, version) @@ -61,6 +70,49 @@ pub fn expand(input: TokenStream) -> syn::Result { } } +fn validate_columnar_indexes(columns: &Columns) -> syn::Result<()> { + for index in columns.columnar_indexes.values() { + if columns.columnar_fields.contains_key(&index.name) { + return Err(syn::Error::new( + index.name.span(), + format!( + "columnar index `{}` conflicts with a columnar field name and would generate duplicate scan methods", + index.name + ), + )); + } + for field in &index.columns { + if !columns.columns_map.contains_key(field) { + return Err(syn::Error::new( + field.span(), + format!("columnar index `{}` references unknown field `{field}`", index.name), + )); + } + if !columns.columnar_fields.contains_key(field) { + return Err(syn::Error::new( + field.span(), + format!( + "columnar index `{}` requires field `{field}` to declare `columnar(...)`", + index.name + ), + )); + } + } + for field in &index.cluster_by { + if !index.columns.contains(field) { + return Err(syn::Error::new( + field.span(), + format!( + "columnar index `{}` clusters by `{field}`, which is absent from `columns`", + index.name + ), + )); + } + } + } + Ok(()) +} + fn validate_index_backends(columns: &Columns, persistence: Persistence) -> syn::Result<()> { let explicit_backend = if columns.primary_index_backend.requires_explicit_persistence() { Some(( @@ -160,6 +212,57 @@ mod tests { assert!(error.to_string().contains("keep `primary_key`")); } + #[test] + fn columnar_index_requires_columnar_fields() { + let error = expand(quote! { + name: InvalidColumnarIndex, + persist: false, + columns: { + id: u64 primary_key, + host_id: u64, + }, + columnar_indexes: { + host_lookup: { + columns: [host_id], + cluster_by: [host_id], + }, + }, + }) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("requires field `host_id` to declare `columnar(...)`") + ); + } + + #[test] + fn columnar_field_and_index_generate_scan_projection_and_lookup_apis() { + let output = expand(quote! { + name: ColumnarCodegen, + persist: false, + columns: { + id: u64 primary_key, + host_id: u64 columnar(chunk_rows(1024), compression(auto)), + timestamp: i64 columnar(chunk_rows(2048), compression(none)), + }, + columnar_indexes: { + host_time: { + columns: [host_id, timestamp], + cluster_by: [host_id, timestamp], + }, + }, + }) + .unwrap() + .to_string(); + + assert!(output.contains("columnar_scan_host_id")); + assert!(output.contains("columnar_project_timestamp")); + assert!(output.contains("columnar_select_host_time")); + assert!(output.contains("ColumnarColumn :: new (1024")); + } + fn assert_composite_primary_key_field_order(output: proc_macro2::TokenStream) { let output = output.to_string(); let get_primary_key = output diff --git a/docs/columnar-index-plan.md b/docs/columnar-index-plan.md new file mode 100644 index 00000000..977301d0 --- /dev/null +++ b/docs/columnar-index-plan.md @@ -0,0 +1,164 @@ +# Columnar fields and indexes + +Status: initial implementation in `feat/columnar-fields-indexes`. + +## Syntax + +Columnar storage is a property of an individual field. It is not a table +layout, and row storage remains authoritative. + +```rust +worktable!( + name: HistoricalCpu, + persist: true, + columns: { + id: u128 primary_key, + host_id: u64 columnar( + chunk_rows(65_536), + compression(auto), + ), + timestamp: i64 columnar( + chunk_rows(65_536), + compression(delta), + ), + temperature: i64 columnar( + chunk_rows(32_768), + compression(auto), + ), + label: String, + }, + columnar_indexes: { + host_time: { + columns: [host_id, timestamp, temperature], + cluster_by: [host_id, timestamp], + }, + }, +); +``` + +`columnar(...)` creates a base column replica. A field does not need to be in a +`columnar_indexes` declaration to benefit from sequential scan or projection. +For example, `temperature` can be projected after `host_time` produces logical +row IDs, while a columnar `status` field that appears in no index can still be +scanned directly. + +`columnar_indexes` declares ordering and lookup metadata over existing base +columns. `cluster_by` belongs here because it describes index order, not field +storage order. Conventional WorkTable indexes are unchanged and may coexist +with columnar indexes. + +## Implemented model + +The initial implementation adds: + +- per-field `columnar(chunk_rows(...), compression(...))` parsing and + validation; +- a `columnar_indexes` section with `columns` and `cluster_by` validation; +- a stable `ColumnRowId`, independent of physical `data_bucket::Link` values; +- separately chunked vectors for every columnar field; +- a shared primary-key-to-row-ID directory; +- ordered clustered metadata backed by a `BTreeMap` and row-ID sets; +- generated exact-lookup, ordered-index-scan, field-scan, and projection APIs; +- maintenance for inserts, updates, in-place updates, deletes, reinserts, and + vacuum link changes; +- derived-state rebuild after persisted/read-only load without changing the + existing WorkTable disk format. + +For the example above, generated APIs include: + +```rust +let ids = table.columnar_select_host_time(host_id, timestamp); +let temperatures = table.columnar_project_temperature(&ids); +let primary_keys = table.columnar_resolve_primary_keys(&ids); +let all_temperatures = table.columnar_scan_temperature(); +let clustered_ids = table.columnar_scan_host_time(); +``` + +Field scans and projections return owned values in this first API. That keeps +locks out of the public return type and gives callers a coherent batch they can +retain independently of later mutations. + +## Stable identity and mutation flow + +The row store remains the source of truth: + +```text +primary key -> WorkTable row/link + -> ColumnRowId directory + -> per-field chunks + -> zero or more clustered columnar indexes +``` + +A vacuum may change the WorkTable link without changing `ColumnRowId`. An +update with the same primary key also retains the row ID. Delete removes the +directory entry, field slots, and clustered entries; IDs are not reused during +the process lifetime. + +Generated mutation paths use the existing per-key mutation gate. Direct row +insert/reinsert/delete hooks update columnar state under its own lock. Update +paths that mutate archived fields in place mark the replica dirty; the next +columnar access rebuilds it from authoritative rows while preserving IDs for +surviving primary keys. This is deliberately a correctness-first design. The +dirty rebuild can later become an incremental difference application once its +concurrency invariants and benchmark benefit are established. + +## Chunk alignment + +Each field owns its `chunk_rows` setting. Different values remain correct +because all access is joined by `ColumnRowId`; equal values provide an aligned +fast path for multi-column vector work. The runtime does not require aligned +physical chunks. + +## Persistence + +This change does not introduce a columnar on-disk format. The row store and +existing indexes retain their current formats. Generated columnar state is +marked as derived and skipped by `PersistIndex`; a loaded table rebuilds it +from authoritative rows on first columnar access. + +That choice keeps this PR format-compatible and lets benchmarks answer whether +native column checkpoints are worth their complexity. A later format can add +sealed immutable chunks, manifests, checksums, and recovery watermarks without +changing the DSL or logical row identity. + +## Compression boundary + +The DSL accepts `none`, `auto`, `delta`, `rle`, and `dictionary`, and generated +columns retain the requested policy as metadata. Mutable chunks are currently +stored unencoded: `auto` resolves to no encoding, and the explicit codecs are +not yet applied. `ColumnCompression::is_encoded()` therefore returns `false`. + +This is intentional rather than a compression claim. Encoding belongs on +sealed/immutable chunks so point updates do not repeatedly rewrite compressed +buffers. Codec implementation and per-type validation are follow-up work and +must be benchmarked independently. + +## Current concurrency boundary + +Columnar state is derived and protected by a table-local read/write lock. +Ordinary row reads do not touch it, so declaring a columnar field does not add a +lock to the existing select path. Columnar reads clone a result batch while +holding the replica read lock. Mutations update or dirty the replica only after +the authoritative row operation succeeds. + +Before calling this production-ready for HFT workloads, benchmarks must cover: + +- row-operation throughput with no columnar access; +- insert/update/delete overhead with columnar fields and indexes; +- exact lookup and ordered scan throughput; +- p50/p95/p99 latency under mixed readers and writers; +- dirty-rebuild latency after in-place updates; +- memory amplification by field type, chunk size, and index cardinality. + +## Next implementation slices + +1. Add range predicates and generated projection batches that fetch several + fields in one lock acquisition. +2. Replace dirty full rebuilds with typed incremental mutations for archived + in-place updates. +3. Add null bitmaps and specialized fixed-width chunk kernels. +4. Seal cold chunks and implement actual delta/RLE/dictionary codecs. +5. Benchmark row-store random projection against native column checkpoints, + then add a disk format only if the result justifies it. +6. Add a cost model that chooses conventional index lookup, clustered + columnar lookup, or base-column scan. diff --git a/src/columnar.rs b/src/columnar.rs new file mode 100644 index 00000000..18158ab2 --- /dev/null +++ b/src/columnar.rs @@ -0,0 +1,226 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use crate::mem_stat::MemStat; + +/// A stable logical row identifier used by generated columnar replicas. +/// +/// It is deliberately independent of [`data_bucket::Link`]: vacuum may move a +/// row between physical pages without changing its columnar identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ColumnRowId(u64); + +impl ColumnRowId { + pub fn new(value: u64) -> Self { + Self(value) + } + + pub fn get(self) -> u64 { + self.0 + } +} + +impl MemStat for ColumnRowId { + fn heap_size(&self) -> usize { + 0 + } + + fn used_size(&self) -> usize { + 0 + } +} + +/// Compression requested for a generated columnar field. +/// +/// The first implementation stores mutable chunks without encoding them. +/// `Auto` therefore resolves to `None`; the explicit variants are retained in +/// metadata so immutable/sealed-chunk codecs can be added without changing the +/// macro syntax. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ColumnCompression { + None, + #[default] + Auto, + Delta, + Rle, + Dictionary, +} + +impl ColumnCompression { + pub fn is_encoded(self) -> bool { + false + } +} + +impl MemStat for ColumnCompression { + fn heap_size(&self) -> usize { + 0 + } + + fn used_size(&self) -> usize { + 0 + } +} + +/// Chunked, row-id-addressed storage for one generated columnar field. +#[derive(Debug)] +pub struct ColumnarColumn { + chunk_rows: usize, + compression: ColumnCompression, + chunks: Vec>>, +} + +impl ColumnarColumn { + pub fn new(chunk_rows: usize, compression: ColumnCompression) -> Self { + assert!(chunk_rows > 0, "columnar chunks cannot be empty"); + Self { + chunk_rows, + compression, + chunks: Vec::new(), + } + } + + pub fn chunk_rows(&self) -> usize { + self.chunk_rows + } + + pub fn compression(&self) -> ColumnCompression { + self.compression + } + + pub fn set(&mut self, row_id: ColumnRowId, value: T) { + let row = row_id.get() as usize; + let chunk_index = row / self.chunk_rows; + let offset = row % self.chunk_rows; + while self.chunks.len() <= chunk_index { + self.chunks.push(Vec::new()); + } + let chunk = &mut self.chunks[chunk_index]; + if chunk.len() <= offset { + chunk.resize_with(offset + 1, || None); + } + chunk[offset] = Some(value); + } + + pub fn remove(&mut self, row_id: ColumnRowId) -> Option { + let row = row_id.get() as usize; + self.chunks + .get_mut(row / self.chunk_rows) + .and_then(|chunk| chunk.get_mut(row % self.chunk_rows)) + .and_then(Option::take) + } + + pub fn get(&self, row_id: ColumnRowId) -> Option<&T> { + let row = row_id.get() as usize; + self.chunks + .get(row / self.chunk_rows) + .and_then(|chunk| chunk.get(row % self.chunk_rows)) + .and_then(Option::as_ref) + } + + pub fn iter(&self) -> impl Iterator { + let chunk_rows = self.chunk_rows; + self.chunks.iter().enumerate().flat_map(move |(chunk_index, chunk)| { + chunk.iter().enumerate().filter_map(move |(offset, value)| { + value.as_ref().map(|value| { + let row = chunk_index * chunk_rows + offset; + (ColumnRowId::new(row as u64), value) + }) + }) + }) + } +} + +impl MemStat for ColumnarColumn { + fn heap_size(&self) -> usize { + self.chunks.heap_size() + } + + fn used_size(&self) -> usize { + self.chunks.used_size() + } +} + +/// Ordered metadata for one generated `columnar_indexes` declaration. +#[derive(Debug)] +pub struct ClusteredColumnarIndex { + rows: BTreeMap>, +} + +impl Default for ClusteredColumnarIndex { + fn default() -> Self { + Self { rows: BTreeMap::new() } + } +} + +impl ClusteredColumnarIndex { + pub fn insert(&mut self, key: K, row_id: ColumnRowId) { + self.rows.entry(key).or_default().insert(row_id); + } + + pub fn remove(&mut self, key: &K, row_id: ColumnRowId) { + let remove_key = self.rows.get_mut(key).is_some_and(|rows| { + rows.remove(&row_id); + rows.is_empty() + }); + if remove_key { + self.rows.remove(key); + } + } + + pub fn exact(&self, key: &K) -> Vec { + self.rows + .get(key) + .map(|rows| rows.iter().copied().collect()) + .unwrap_or_default() + } + + pub fn ordered_row_ids(&self) -> Vec { + self.rows.values().flat_map(|rows| rows.iter().copied()).collect() + } +} + +impl MemStat for ClusteredColumnarIndex { + fn heap_size(&self) -> usize { + self.rows.heap_size() + } + + fn used_size(&self) -> usize { + self.rows.used_size() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chunks_are_addressed_by_stable_row_id() { + let mut column = ColumnarColumn::new(2, ColumnCompression::Auto); + column.set(ColumnRowId::new(3), 30); + column.set(ColumnRowId::new(0), 10); + + assert_eq!(column.chunk_rows(), 2); + assert_eq!(column.get(ColumnRowId::new(3)), Some(&30)); + assert_eq!( + column.iter().map(|(id, value)| (id.get(), *value)).collect::>(), + [(0, 10), (3, 30)] + ); + + assert_eq!(column.remove(ColumnRowId::new(0)), Some(10)); + assert!(column.get(ColumnRowId::new(0)).is_none()); + } + + #[test] + fn clustered_index_preserves_key_order() { + let mut index = ClusteredColumnarIndex::default(); + index.insert((2, 1), ColumnRowId::new(1)); + index.insert((1, 9), ColumnRowId::new(2)); + index.insert((1, 9), ColumnRowId::new(0)); + + assert_eq!(index.exact(&(1, 9)), [ColumnRowId::new(0), ColumnRowId::new(2)]); + assert_eq!( + index.ordered_row_ids(), + [ColumnRowId::new(0), ColumnRowId::new(2), ColumnRowId::new(1)] + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9e28c540..a730483c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ #![doc = include_str!("../docs/crate.md")] +mod columnar; pub mod in_memory; mod index; pub mod lock; @@ -14,6 +15,7 @@ mod util; #[cfg(feature = "s3-support")] pub mod features; +pub use columnar::{ClusteredColumnarIndex, ColumnCompression, ColumnRowId, ColumnarColumn}; pub use index::*; pub use persistence::{LoadMode, PersistedWorkTable, PersistenceConfig, PersistenceLoadError}; pub use row::*; @@ -47,12 +49,12 @@ pub mod prelude { pub use crate::table::system_info::{IndexInfo, IndexKind, SystemInfo}; pub use crate::util::{OffsetEqLink, OrderedF32Def, OrderedF64Def}; pub use crate::{ - ArcticIndex, ArcticKey, AvailableIndex, CongeeIndex, CongeeKey, Difference, IndexError, IndexMap, - IndexMultiMap, MultiPairRecreate, PersistentArcticIndex, PersistentArtIndex, PersistentCongeeIndex, - PersistentWtiIndex, PrimaryIndex, TableIndex, TableIndexCdc, TableRow, TableSecondaryIndex, - TableSecondaryIndexCdc, TableSecondaryIndexEventsOps, TableSecondaryIndexInfo, UniqueIndex, UnsizedNode, - UpstreamIndexMap, UpstreamIndexPair, WorkTable, WorkTableError, vacuum::EmptyDataVacuum, - vacuum::VacuumPersistence, vacuum::WorkTableVacuum, + ArcticIndex, ArcticKey, AvailableIndex, ClusteredColumnarIndex, ColumnCompression, ColumnRowId, ColumnarColumn, + CongeeIndex, CongeeKey, Difference, IndexError, IndexMap, IndexMultiMap, MultiPairRecreate, + PersistentArcticIndex, PersistentArtIndex, PersistentCongeeIndex, PersistentWtiIndex, PrimaryIndex, TableIndex, + TableIndexCdc, TableRow, TableSecondaryIndex, TableSecondaryIndexCdc, TableSecondaryIndexEventsOps, + TableSecondaryIndexInfo, UniqueIndex, UnsizedNode, UpstreamIndexMap, UpstreamIndexPair, WorkTable, + WorkTableError, vacuum::EmptyDataVacuum, vacuum::VacuumPersistence, vacuum::WorkTableVacuum, }; pub use data_bucket::{ DATA_VERSION, DataPage, GENERAL_HEADER_SIZE, GeneralHeader, GeneralPage, INNER_PAGE_SIZE, IndexPage, Interval, diff --git a/src/mem_stat/mod.rs b/src/mem_stat/mod.rs index 666d61d1..1bfad87e 100644 --- a/src/mem_stat/mod.rs +++ b/src/mem_stat/mod.rs @@ -1,6 +1,6 @@ mod primitives; -use std::collections::HashMap; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt::Debug; use std::rc::Rc; use std::sync::Arc; @@ -31,6 +31,33 @@ pub trait MemStat { fn used_size(&self) -> usize; } +macro_rules! impl_tuple_mem_stat { + ($($name:ident),+) => { + impl<$($name: MemStat),+> MemStat for ($($name,)+) { + fn heap_size(&self) -> usize { + #[allow(non_snake_case)] + let ($($name,)+) = self; + 0usize $(+ $name.heap_size())+ + } + + fn used_size(&self) -> usize { + #[allow(non_snake_case)] + let ($($name,)+) = self; + 0usize $(+ $name.used_size())+ + } + } + }; +} + +impl_tuple_mem_stat!(A); +impl_tuple_mem_stat!(A, B); +impl_tuple_mem_stat!(A, B, C); +impl_tuple_mem_stat!(A, B, C, D); +impl_tuple_mem_stat!(A, B, C, D, E); +impl_tuple_mem_stat!(A, B, C, D, E, F); +impl_tuple_mem_stat!(A, B, C, D, E, F, G); +impl_tuple_mem_stat!(A, B, C, D, E, F, G, H); + impl MemStat for Option { fn heap_size(&self) -> usize { self.as_ref().map_or(0, |v| v.heap_size()) @@ -228,6 +255,40 @@ impl MemStat for HashMap { } } +impl MemStat for BTreeMap { + fn heap_size(&self) -> usize { + self.len() * std::mem::size_of::<(K, V)>() + + self + .iter() + .map(|(key, value)| key.heap_size() + value.heap_size()) + .sum::() + } + + fn used_size(&self) -> usize { + self.heap_size() + } +} + +impl MemStat for BTreeSet { + fn heap_size(&self) -> usize { + self.len() * std::mem::size_of::() + self.iter().map(MemStat::heap_size).sum::() + } + + fn used_size(&self) -> usize { + self.heap_size() + } +} + +impl MemStat for parking_lot::RwLock { + fn heap_size(&self) -> usize { + self.read().heap_size() + } + + fn used_size(&self) -> usize { + self.read().used_size() + } +} + impl MemStat for OrderedFloat where T: MemStat, diff --git a/tests/worktable/columnar.rs b/tests/worktable/columnar.rs new file mode 100644 index 00000000..d2183002 --- /dev/null +++ b/tests/worktable/columnar.rs @@ -0,0 +1,159 @@ +use std::sync::Arc; +use worktable::prelude::*; +use worktable::worktable; + +worktable!( + name: ColumnarMetrics, + persist: false, + columns: { + id: u64 primary_key, + host_id: u64 columnar(chunk_rows(2), compression(auto)), + timestamp: i64 columnar(chunk_rows(3), compression(none)), + temperature: i64 columnar(chunk_rows(2), compression(auto)), + label: String, + }, + columnar_indexes: { + host_time: { + columns: [host_id, timestamp, temperature], + cluster_by: [host_id, timestamp], + }, + }, + queries: { + update: { + TemperatureById(temperature) by id, + }, + in_place: { + TimestampById(timestamp) by id, + } + }, +); + +// Compile coverage for the persisted derive path. The columnar replica is +// intentionally skipped by the existing index file format and rebuilt from +// authoritative rows after load. +worktable!( + name: PersistedColumnarMetrics, + persist: true, + columns: { + id: u64 primary_key, + host_id: u64 columnar(chunk_rows(4), compression(auto)), + timestamp: i64 columnar(chunk_rows(4), compression(none)), + }, + columnar_indexes: { + host_time: { + columns: [host_id, timestamp], + cluster_by: [host_id, timestamp], + }, + }, +); + +#[tokio::test] +async fn columnar_fields_and_clustered_index_follow_mutations() { + let table = ColumnarMetricsWorkTable::default(); + table + .insert(ColumnarMetricsRow { + id: 1, + host_id: 2, + timestamp: 20, + temperature: 72, + label: "second".to_string(), + }) + .unwrap(); + table + .insert(ColumnarMetricsRow { + id: 2, + host_id: 1, + timestamp: 10, + temperature: 68, + label: "first".to_string(), + }) + .unwrap(); + + let host_two = table.columnar_select_host_time(2, 20); + assert_eq!(host_two.len(), 1); + assert_eq!(table.columnar_resolve_primary_keys(&host_two)[0].1.0, 1); + assert_eq!(table.columnar_project_temperature(&host_two)[0].1, 72); + + let ordered = table.columnar_scan_host_time(); + let projected = table.columnar_project_host_id(&ordered); + assert_eq!(projected.iter().map(|(_, value)| *value).collect::>(), [1, 2]); + + table + .update(ColumnarMetricsRow { + id: 1, + host_id: 3, + timestamp: 30, + temperature: 75, + label: "updated".to_string(), + }) + .await + .unwrap(); + + assert!(table.columnar_select_host_time(2, 20).is_empty()); + let updated = table.columnar_select_host_time(3, 30); + assert_eq!(updated, host_two, "row identity survives an update"); + assert_eq!(table.columnar_project_temperature(&updated)[0].1, 75); + + table + .update_temperature_by_id(TemperatureByIdQuery { temperature: 76 }, 1) + .await + .unwrap(); + assert_eq!(table.columnar_project_temperature(&updated)[0].1, 76); + + table + .update_timestamp_by_id_in_place(|value| *value = 40.into(), 1) + .await + .unwrap(); + assert!(table.columnar_select_host_time(3, 30).is_empty()); + assert_eq!(table.columnar_select_host_time(3, 40), updated); + + table.delete(2).await.unwrap(); + assert_eq!(table.columnar_scan_host_id().len(), 1); + assert_eq!(table.columnar_scan_host_time(), updated); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn concurrent_reinsert_and_columnar_refresh_preserve_row_identity() { + let table = Arc::new(ColumnarMetricsWorkTable::default()); + table + .insert(ColumnarMetricsRow { + id: 7, + host_id: 1, + timestamp: 0, + temperature: 1, + label: "short".to_string(), + }) + .unwrap(); + let stable_id = table.columnar_select_host_time(1, 0)[0]; + + let updater = { + let table = Arc::clone(&table); + tokio::spawn(async move { + for value in 1..=200 { + table + .update(ColumnarMetricsRow { + id: 7, + host_id: 1, + timestamp: value, + temperature: value, + label: if value % 2 == 0 { + "a much longer row value".to_string() + } else { + "tiny".to_string() + }, + }) + .await + .unwrap(); + } + }) + }; + + for _ in 0..200 { + for (row_id, _) in table.columnar_scan_timestamp() { + assert_eq!(row_id, stable_id); + } + } + updater.await.unwrap(); + + assert_eq!(table.columnar_select_host_time(1, 200), [stable_id]); +} diff --git a/tests/worktable/mod.rs b/tests/worktable/mod.rs index 5a111ca3..bd0ce985 100644 --- a/tests/worktable/mod.rs +++ b/tests/worktable/mod.rs @@ -2,6 +2,7 @@ mod array; mod base; mod bench; mod borrowed_primary_key; +mod columnar; mod config; mod count; mod custom_pk; From a60876a46f75dccaef377f09aac2ae23060b0e79 Mon Sep 17 00:00:00 2001 From: meh Date: Thu, 6 Aug 2026 09:20:29 +0700 Subject: [PATCH 2/2] fix: harden columnar side-index design --- .gitattributes | 1 + codegen/src/common/model/column.rs | 4 +- codegen/src/common/model/columnar.rs | 42 +- codegen/src/common/model/config.rs | 17 +- codegen/src/common/model/mod.rs | 5 +- codegen/src/common/parser/columnar.rs | 113 +++-- codegen/src/common/parser/columns.rs | 20 +- codegen/src/common/parser/config.rs | 62 ++- codegen/src/generators/columnar.rs | 262 ++++++++---- .../generators/in_memory/queries/update.rs | 31 ++ codegen/src/generators/persist/index/cdc.rs | 4 +- .../src/generators/persist/queries/update.rs | 22 + codegen/src/worktable/mod.rs | 134 +++++- codegen/src/worktable_version/mod.rs | 25 ++ docs/columnar-fields-and-indexes-guide-v3.md | 404 ++++++++++++++++++ docs/columnar-index-plan.md | 244 +++++------ ...rktable-columnar-side-indexes-guide-v3.pdf | Bin 0 -> 27746 bytes src/columnar.rs | 227 +++++++--- src/index/table_secondary_index/mod.rs | 8 + src/lib.rs | 18 +- src/table/mod.rs | 68 +++ tests/worktable/columnar.rs | 183 +++++++- 22 files changed, 1505 insertions(+), 389 deletions(-) create mode 100644 .gitattributes create mode 100644 docs/columnar-fields-and-indexes-guide-v3.md create mode 100644 output/pdf/worktable-columnar-side-indexes-guide-v3.pdf diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d72fd520 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.pdf binary diff --git a/codegen/src/common/model/column.rs b/codegen/src/common/model/column.rs index 8047d4ae..81ef9057 100644 --- a/codegen/src/common/model/column.rs +++ b/codegen/src/common/model/column.rs @@ -2,7 +2,7 @@ use indexmap::IndexMap; use std::collections::HashMap; use crate::common::model::index::Index; -use crate::common::model::{ColumnarFieldConfig, ColumnarIndex, GeneratorType, IndexBackend}; +use crate::common::model::{ColumnSlotIdType, ColumnarFieldConfig, ColumnarIndex, GeneratorType, IndexBackend}; use proc_macro2::{Ident, TokenStream}; use quote::quote; use syn::spanned::Spanned; @@ -19,6 +19,7 @@ pub struct Columns { pub indexes: IndexMap, pub columnar_fields: IndexMap, pub columnar_indexes: IndexMap, + pub column_slot_id: ColumnSlotIdType, pub primary_keys: Vec, pub primary_index_backend: IndexBackend, pub generator_type: GeneratorType, @@ -99,6 +100,7 @@ impl Columns { indexes: Default::default(), columnar_fields, columnar_indexes: Default::default(), + column_slot_id: Default::default(), primary_keys: pk, primary_index_backend: primary_index_backend.unwrap_or_default(), generator_type: gen_type.expect("set"), diff --git a/codegen/src/common/model/columnar.rs b/codegen/src/common/model/columnar.rs index d6fb4bc7..0508159a 100644 --- a/codegen/src/common/model/columnar.rs +++ b/codegen/src/common/model/columnar.rs @@ -2,39 +2,51 @@ use proc_macro2::Ident; pub const DEFAULT_COLUMNAR_CHUNK_ROWS: usize = 65_536; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ColumnSlotIdType { + U8, + U16, + #[default] + U32, + U64, +} + +impl ColumnSlotIdType { + pub(crate) fn type_name(self) -> &'static str { + match self { + Self::U8 => "ColumnSlotId8", + Self::U16 => "ColumnSlotId16", + Self::U32 => "ColumnSlotId32", + Self::U64 => "ColumnSlotId64", + } + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum ColumnCompression { - None, #[default] - Auto, - Delta, - Rle, - Dictionary, + None, } impl ColumnCompression { pub(crate) fn name(self) -> &'static str { match self { Self::None => "none", - Self::Auto => "auto", - Self::Delta => "delta", - Self::Rle => "rle", - Self::Dictionary => "dictionary", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ColumnarFieldConfig { - pub chunk_rows: usize, + pub chunk_rows: Option, pub compression: ColumnCompression, } impl Default for ColumnarFieldConfig { fn default() -> Self { Self { - chunk_rows: DEFAULT_COLUMNAR_CHUNK_ROWS, - compression: ColumnCompression::Auto, + chunk_rows: None, + compression: ColumnCompression::None, } } } @@ -42,6 +54,10 @@ impl Default for ColumnarFieldConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ColumnarIndex { pub name: Ident, - pub columns: Vec, pub cluster_by: Vec, } + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ColumnarIndexes { + pub indexes: indexmap::IndexMap, +} diff --git a/codegen/src/common/model/config.rs b/codegen/src/common/model/config.rs index f61c3e59..f3f23baa 100644 --- a/codegen/src/common/model/config.rs +++ b/codegen/src/common/model/config.rs @@ -1,7 +1,22 @@ use proc_macro2::Ident; -#[derive(Debug, Default)] +use crate::common::model::{ColumnSlotIdType, DEFAULT_COLUMNAR_CHUNK_ROWS}; + +#[derive(Debug)] pub struct Config { pub page_size: Option, pub row_derives: Vec, + pub columnar_slot_id: ColumnSlotIdType, + pub columnar_chunk_rows: usize, +} + +impl Default for Config { + fn default() -> Self { + Self { + page_size: None, + row_derives: Vec::new(), + columnar_slot_id: ColumnSlotIdType::default(), + columnar_chunk_rows: DEFAULT_COLUMNAR_CHUNK_ROWS, + } + } } diff --git a/codegen/src/common/model/mod.rs b/codegen/src/common/model/mod.rs index 0be5541a..91775eb5 100644 --- a/codegen/src/common/model/mod.rs +++ b/codegen/src/common/model/mod.rs @@ -8,7 +8,10 @@ mod primary_key; mod queries; pub use column::{Columns, Row}; -pub use columnar::{ColumnCompression, ColumnarFieldConfig, ColumnarIndex}; +pub use columnar::{ + ColumnCompression, ColumnSlotIdType, ColumnarFieldConfig, ColumnarIndex, ColumnarIndexes, + DEFAULT_COLUMNAR_CHUNK_ROWS, +}; pub use config::Config; pub use index::{Index, IndexBackend}; pub use operation::Operation; diff --git a/codegen/src/common/parser/columnar.rs b/codegen/src/common/parser/columnar.rs index e133cde8..0a63cb29 100644 --- a/codegen/src/common/parser/columnar.rs +++ b/codegen/src/common/parser/columnar.rs @@ -5,7 +5,7 @@ use proc_macro2::{Delimiter, Ident, TokenTree}; use syn::spanned::Spanned as _; use crate::common::Parser; -use crate::common::model::{ColumnCompression, ColumnarFieldConfig, ColumnarIndex}; +use crate::common::model::{ColumnCompression, ColumnarFieldConfig, ColumnarIndex, ColumnarIndexes}; impl Parser { pub(super) fn try_parse_columnar_field(&mut self) -> syn::Result> { @@ -18,15 +18,14 @@ impl Parser { let attribute_span = attribute.span(); self.input_iter.next(); - let Some(TokenTree::Group(group)) = self.input_iter.next() else { - return Err(syn::Error::new( - attribute_span, - "expected `columnar(...)` after the field type", - )); + let Some(TokenTree::Group(group)) = self.input_iter.peek() else { + return Ok(Some(ColumnarFieldConfig::default())); }; + let group = group.clone(); if group.delimiter() != Delimiter::Parenthesis { return Err(syn::Error::new(group.span(), "expected `columnar(...)`")); } + self.input_iter.next(); let mut config = ColumnarFieldConfig::default(); let mut saw_chunk_rows = false; @@ -73,7 +72,7 @@ impl Parser { if parsed == 0 { return Err(syn::Error::new(rows.span(), "`chunk_rows` must be greater than zero")); } - config.chunk_rows = parsed; + config.chunk_rows = Some(parsed); } "compression" => { if saw_compression { @@ -89,14 +88,18 @@ impl Parser { } config.compression = match compression.to_string().as_str() { "none" => ColumnCompression::None, - "auto" => ColumnCompression::Auto, - "delta" => ColumnCompression::Delta, - "rle" => ColumnCompression::Rle, - "dictionary" => ColumnCompression::Dictionary, + "auto" | "delta" | "rle" | "dictionary" => { + return Err(syn::Error::new( + compression.span(), + format!( + "compression({compression}) is declared but not implemented in this release; only compression(none) is currently supported" + ), + )); + } _ => { return Err(syn::Error::new( compression.span(), - "unknown compression; expected `none`, `auto`, `delta`, `rle`, or `dictionary`", + "unknown compression; only `none` is currently supported", )); } }; @@ -114,7 +117,7 @@ impl Parser { Ok(Some(config)) } - pub fn parse_columnar_indexes(&mut self) -> syn::Result> { + pub fn parse_columnar_indexes(&mut self) -> syn::Result { let section = self .input_iter .next() @@ -149,6 +152,7 @@ impl Parser { return Err(syn::Error::new(name.span(), "expected a columnar index name")); }; parser.parse_colon()?; + let definition = parser .input_iter .next() @@ -161,7 +165,6 @@ impl Parser { } let mut definition_parser = Parser::new(definition.stream()); - let mut columns = None; let mut cluster_by = None; while definition_parser.has_next() { let property = definition_parser @@ -169,56 +172,56 @@ impl Parser { .next() .ok_or_else(|| syn::Error::new(definition.span(), "expected a columnar index property"))?; let TokenTree::Ident(property) = property else { - return Err(syn::Error::new(property.span(), "expected `columns` or `cluster_by`")); + return Err(syn::Error::new(property.span(), "expected `cluster_by`")); }; definition_parser.parse_colon()?; - let values = parse_ident_list(&mut definition_parser, property.span())?; match property.to_string().as_str() { - "columns" if columns.is_none() => columns = Some(values), - "cluster_by" if cluster_by.is_none() => cluster_by = Some(values), - "columns" | "cluster_by" => { + "cluster_by" if cluster_by.is_none() => { + cluster_by = Some(parse_ident_list(&mut definition_parser, property.span())?) + } + "cluster_by" => { return Err(syn::Error::new(property.span(), "duplicate columnar index property")); } + "columns" => { + return Err(syn::Error::new( + property.span(), + "`columns` has no independent columnar-index semantics; remove it because projected fields are selected from base column stores", + )); + } + "include" => { + return Err(syn::Error::new( + property.span(), + "`include` is reserved for a future covering columnar projection and is not implemented", + )); + } _ => { return Err(syn::Error::new( property.span(), - "unknown columnar index property; expected `columns` or `cluster_by`", + "unknown columnar index property; expected `cluster_by`", )); } } definition_parser.try_parse_comma()?; } - let columns = - columns.ok_or_else(|| syn::Error::new(name.span(), "columnar index requires `columns: [...]`"))?; - if columns.is_empty() { - return Err(syn::Error::new(name.span(), "columnar index `columns` cannot be empty")); - } - let cluster_by = cluster_by.unwrap_or_else(|| columns.clone()); + let cluster_by = cluster_by + .ok_or_else(|| syn::Error::new(name.span(), "columnar index requires `cluster_by: [...]`"))?; if cluster_by.is_empty() { return Err(syn::Error::new( name.span(), "columnar index `cluster_by` cannot be empty", )); } - ensure_unique(&columns, "columnar index `columns` contains a duplicate")?; ensure_unique(&cluster_by, "columnar index `cluster_by` contains a duplicate")?; if indexes.contains_key(&name) { return Err(syn::Error::new(name.span(), "duplicate columnar index name")); } - indexes.insert( - name.clone(), - ColumnarIndex { - name, - columns, - cluster_by, - }, - ); + indexes.insert(name.clone(), ColumnarIndex { name, cluster_by }); parser.try_parse_comma()?; } self.try_parse_comma()?; - Ok(indexes) + Ok(ColumnarIndexes { indexes }) } } @@ -269,19 +272,19 @@ mod tests { #[test] fn parses_columnar_field_options() { let mut parser = Parser::new(quote! { - columnar(chunk_rows(65_536), compression(delta)) + columnar(chunk_rows(65_536), compression(none)) }); let config = parser.try_parse_columnar_field().unwrap().unwrap(); - assert_eq!(config.chunk_rows, 65_536); - assert_eq!(config.compression, ColumnCompression::Delta); + assert_eq!(config.chunk_rows, Some(65_536)); + assert_eq!(config.compression, ColumnCompression::None); } #[test] fn empty_columnar_field_uses_defaults() { - let mut parser = Parser::new(quote! { columnar() }); + let mut parser = Parser::new(quote! { columnar }); let config = parser.try_parse_columnar_field().unwrap().unwrap(); assert_eq!(config.chunk_rows, ColumnarFieldConfig::default().chunk_rows); - assert_eq!(config.compression, ColumnCompression::Auto); + assert_eq!(config.compression, ColumnCompression::None); } #[test] @@ -289,20 +292,36 @@ mod tests { let mut parser = Parser::new(quote! { columnar_indexes: { host_time: { - columns: [host_id, timestamp], cluster_by: [host_id, timestamp], }, }, }); let indexes = parser.parse_columnar_indexes().unwrap(); - let index = indexes.values().next().unwrap(); - assert_eq!( - index.columns.iter().map(ToString::to_string).collect::>(), - ["host_id", "timestamp"] - ); + let index = indexes.indexes.values().next().unwrap(); assert_eq!( index.cluster_by.iter().map(ToString::to_string).collect::>(), ["host_id", "timestamp"] ); } + + #[test] + fn rejects_inert_columns_property() { + let mut parser = Parser::new(quote! { + columnar_indexes: { + host_time: { + columns: [host_id, timestamp], + cluster_by: [host_id, timestamp], + }, + }, + }); + let error = parser.parse_columnar_indexes().unwrap_err(); + assert!(error.to_string().contains("no independent columnar-index semantics")); + } + + #[test] + fn rejects_unimplemented_compression() { + let mut parser = Parser::new(quote! { columnar(compression(dictionary)) }); + let error = parser.try_parse_columnar_field().unwrap_err(); + assert!(error.to_string().contains("not implemented")); + } } diff --git a/codegen/src/common/parser/columns.rs b/codegen/src/common/parser/columns.rs index ede52016..52ce47ce 100644 --- a/codegen/src/common/parser/columns.rs +++ b/codegen/src/common/parser/columns.rs @@ -108,10 +108,18 @@ impl Parser { false }; - let index_backend = self.try_parse_index_backend()?; - let columnar = self.try_parse_columnar_field()?; + let index_backend = self.try_parse_index_backend()?; + + if let Some(next) = self.input_iter.peek() + && !matches!(next, TokenTree::Punct(punct) if punct.as_char() == ',') + { + return Err(syn::Error::new( + next.span(), + "unexpected column attribute; expected attributes in `primary_key`, generator, `optional`, `columnar`, `using` order", + )); + } self.try_parse_comma()?; Ok(Row { @@ -258,7 +266,7 @@ mod tests { #[test] fn test_row_parse_no_comma() { - let row_tokens = quote! {id: i64 primary_key TreeIndex}; + let row_tokens = quote! {id: i64 primary_key}; let mut parser = Parser::new(row_tokens); let row = parser.parse_row(); @@ -330,13 +338,13 @@ mod tests { #[test] fn test_columnar_field_parse() { let row_tokens = quote! { - host_id: u64 columnar(chunk_rows(65_536), compression(auto)), + host_id: u64 columnar(chunk_rows(65_536), compression(none)), }; let mut parser = Parser::new(row_tokens); let row = parser.parse_row().unwrap(); let config = row.columnar.unwrap(); - assert_eq!(config.chunk_rows, 65_536); - assert_eq!(config.compression, crate::common::model::ColumnCompression::Auto); + assert_eq!(config.chunk_rows, Some(65_536)); + assert_eq!(config.compression, crate::common::model::ColumnCompression::None); } #[test] diff --git a/codegen/src/common/parser/config.rs b/codegen/src/common/parser/config.rs index df7c35ef..ec21374c 100644 --- a/codegen/src/common/parser/config.rs +++ b/codegen/src/common/parser/config.rs @@ -1,10 +1,11 @@ +use std::collections::HashSet; use std::str::FromStr; use proc_macro2::{Delimiter, TokenTree}; use syn::spanned::Spanned; use crate::common::Parser; -use crate::common::model::Config; +use crate::common::model::{ColumnSlotIdType, Config}; const CONFIG_FIELD_NAME: &str = "config"; @@ -49,11 +50,13 @@ impl Parser { let mut parser = Parser::new(tt); let mut config = Config::default(); parser.parse_config(&mut config)?; + self.try_parse_comma()?; Ok(config) } pub fn parse_config(&mut self, config: &mut Config) -> syn::Result> { + let mut seen = HashSet::new(); while self.peek_next().is_some() { let Some(_) = self.input_iter.peek() else { return Ok(None); @@ -65,9 +68,17 @@ impl Parser { return Err(syn::Error::new(ident.span(), "Expected identifier.")); }; + let name_string = name.to_string(); + if !seen.insert(name_string.clone()) { + return Err(syn::Error::new( + name.span(), + format!("Duplicate `{name_string}` config"), + )); + } + self.parse_colon()?; - match name.to_string().as_str() { + match name_string.as_str() { "page_size" => { let value = self.input_iter.next().ok_or(syn::Error::new( self.input.span(), @@ -86,8 +97,53 @@ impl Parser { config.page_size = Some(u32::from_str(value.as_str()).unwrap()) } + "columnar_slot_id" => { + let value = self.input_iter.next().ok_or(syn::Error::new( + self.input.span(), + "Expected ColumnSlotId8, ColumnSlotId16, ColumnSlotId32, or ColumnSlotId64", + ))?; + let TokenTree::Ident(value) = value else { + return Err(syn::Error::new(value.span(), "Expected a column slot ID type.")); + }; + config.columnar_slot_id = match value.to_string().as_str() { + "ColumnSlotId8" => ColumnSlotIdType::U8, + "ColumnSlotId16" => ColumnSlotIdType::U16, + "ColumnSlotId32" => ColumnSlotIdType::U32, + "ColumnSlotId64" => ColumnSlotIdType::U64, + _ => { + return Err(syn::Error::new( + value.span(), + "Expected ColumnSlotId8, ColumnSlotId16, ColumnSlotId32, or ColumnSlotId64", + )); + } + }; + self.try_parse_comma()?; + } + "columnar_chunk_rows" => { + let value = self.input_iter.next().ok_or(syn::Error::new( + self.input.span(), + "Expected a positive columnar chunk row count", + ))?; + let TokenTree::Literal(value) = value else { + return Err(syn::Error::new(value.span(), "Expected an integer.")); + }; + let parsed = value + .to_string() + .replace('_', "") + .parse::() + .map_err(|_| syn::Error::new(value.span(), "Invalid columnar chunk row count"))?; + if parsed == 0 { + return Err(syn::Error::new( + value.span(), + "columnar_chunk_rows must be greater than zero", + )); + } + config.columnar_chunk_rows = parsed; + self.try_parse_comma()?; + } "row_derives" => { - const CONFIG_VARIANTS: [&str; 2] = ["page_size", "row_derives"]; + const CONFIG_VARIANTS: [&str; 4] = + ["page_size", "row_derives", "columnar_slot_id", "columnar_chunk_rows"]; let mut derives = vec![]; diff --git a/codegen/src/generators/columnar.rs b/codegen/src/generators/columnar.rs index 6a741607..2193c1c9 100644 --- a/codegen/src/generators/columnar.rs +++ b/codegen/src/generators/columnar.rs @@ -17,6 +17,10 @@ fn index_field(index: &Ident) -> Ident { format_ident!("columnar_index_{}", index) } +fn slot_id_type(columns: &Columns) -> Ident { + Ident::new(columns.column_slot_id.type_name(), Span::mixed_site()) +} + fn compression_variant(compression: ColumnCompression) -> Ident { Ident::new( &compression.name().from_case(Case::Snake).to_case(Case::Pascal), @@ -72,7 +76,29 @@ pub(crate) fn save_row(columns: &Columns) -> TokenStream { if columns.columnar_fields.is_empty() { quote! {} } else { - quote! { self.columnar.write().save_row(&row); } + quote! { + if let Err(bits) = self.columnar.write().save_row(&row) { + return Err(IndexError::ColumnSlotIdExhausted { + bits, + inserted_already: inserted_indexes.clone(), + }); + } + } + } +} + +pub(crate) fn save_row_cdc(columns: &Columns) -> TokenStream { + if columns.columnar_fields.is_empty() { + quote! {} + } else { + quote! { + if let Err(bits) = self.columnar.write().save_row(&row) { + return (partial_events, Err(IndexError::ColumnSlotIdExhausted { + bits, + inserted_already: inserted_indexes.clone(), + })); + } + } } } @@ -80,7 +106,29 @@ pub(crate) fn reinsert_row(columns: &Columns) -> TokenStream { if columns.columnar_fields.is_empty() { quote! {} } else { - quote! { self.columnar.write().replace_row(&row_old, &row_new); } + quote! { + if let Err(bits) = self.columnar.write().replace_row(&row_old, &row_new) { + return Err(IndexError::ColumnSlotIdExhausted { + bits, + inserted_already: inserted_indexes.clone(), + }); + } + } + } +} + +pub(crate) fn reinsert_row_cdc(columns: &Columns) -> TokenStream { + if columns.columnar_fields.is_empty() { + quote! {} + } else { + quote! { + if let Err(bits) = self.columnar.write().replace_row(&row_old, &row_new) { + return (partial_events, Err(IndexError::ColumnSlotIdExhausted { + bits, + inserted_already: inserted_indexes.clone(), + })); + } + } } } @@ -117,6 +165,7 @@ pub(crate) fn definitions(table: &Ident, columns: &Columns) -> TokenStream { let row = names.get_row_type_ident(); let pk = names.get_primary_key_type_ident(); let data = data_ident(table); + let slot_id = slot_id_type(columns); let column_fields = columns.columnar_fields.iter().map(|(field, _)| { let storage = column_field(field); @@ -125,14 +174,14 @@ pub(crate) fn definitions(table: &Ident, columns: &Columns) -> TokenStream { }); let column_defaults = columns.columnar_fields.iter().map(|(field, config)| { let storage = column_field(field); - let chunk_rows = Literal::usize_unsuffixed(config.chunk_rows); + let chunk_rows = Literal::usize_unsuffixed(config.chunk_rows.expect("columnar defaults applied")); let compression = compression_variant(config.compression); quote! { #storage: ColumnarColumn::new(#chunk_rows, ColumnCompression::#compression), } }); let index_fields = columns.columnar_indexes.values().map(|index| { let field = index_field(&index.name); let ty = key_type(columns, &index.cluster_by); - quote! { #field: ClusteredColumnarIndex<#ty>, } + quote! { #field: ClusteredColumnarIndex<#ty, #slot_id>, } }); let index_defaults = columns.columnar_indexes.values().map(|index| { let field = index_field(&index.name); @@ -141,43 +190,47 @@ pub(crate) fn definitions(table: &Ident, columns: &Columns) -> TokenStream { let set_columns = columns.columnar_fields.keys().map(|field| { let storage = column_field(field); - quote! { self.#storage.set(row_id, row.#field.clone()); } + quote! { self.#storage.set(slot_id, row.#field.clone()); } }); let remove_columns = columns.columnar_fields.keys().map(|field| { let storage = column_field(field); - quote! { self.#storage.remove(row_id); } + quote! { self.#storage.remove(slot_id); } }); let insert_indexes = columns.columnar_indexes.values().map(|index| { let field = index_field(&index.name); let key = row_key(columns, &index.cluster_by, quote! { row }); - quote! { self.#field.insert(#key, row_id); } + quote! { self.#field.insert(#key, slot_id); } }); let delete_indexes = columns.columnar_indexes.values().map(|index| { let field = index_field(&index.name); let key = row_key(columns, &index.cluster_by, quote! { row }); - quote! { self.#field.remove(&#key, row_id); } + quote! { self.#field.remove(&#key, slot_id); } }); let replace_remove_indexes = columns.columnar_indexes.values().map(|index| { let field = index_field(&index.name); let key = row_key(columns, &index.cluster_by, quote! { row_old }); - quote! { self.#field.remove(&#key, row_id); } + quote! { self.#field.remove(&#key, slot_id); } }); let replace_insert_indexes = columns.columnar_indexes.values().map(|index| { let field = index_field(&index.name); let key = row_key(columns, &index.cluster_by, quote! { row_new }); - quote! { self.#field.insert(#key, row_id); } + quote! { self.#field.insert(#key, slot_id); } }); let replace_columns = columns.columnar_fields.keys().map(|field| { let storage = column_field(field); - quote! { self.#storage.set(row_id, row_new.#field.clone()); } + quote! { self.#storage.set(slot_id, row_new.#field.clone()); } }); quote! { #[derive(Debug, MemStat)] struct #data { - next_row_id: u64, + next_slot_position: Option, + free_slot_ids: std::collections::BTreeSet<#slot_id>, + slot_generations: Vec, + incarnation: u64, + slots_high_water: usize, dirty: bool, - row_ids: std::collections::BTreeMap<#pk, ColumnRowId>, + slots: std::collections::BTreeMap<#pk, (#slot_id, u64)>, primary_keys: ColumnarColumn<#pk>, #(#column_fields)* #(#index_fields)* @@ -186,11 +239,13 @@ pub(crate) fn definitions(table: &Ident, columns: &Columns) -> TokenStream { impl Default for #data { fn default() -> Self { Self { - next_row_id: 0, - // Persisted and read-only tables reconstruct this derived - // replica from authoritative rows on first access. + next_slot_position: Some(0), + free_slot_ids: Default::default(), + slot_generations: Default::default(), + incarnation: next_columnar_incarnation(), + slots_high_water: 0, dirty: true, - row_ids: Default::default(), + slots: Default::default(), primary_keys: ColumnarColumn::new(65_536, ColumnCompression::None), #(#column_defaults)* #(#index_defaults)* @@ -199,46 +254,82 @@ pub(crate) fn definitions(table: &Ident, columns: &Columns) -> TokenStream { } impl #data { - fn save_row(&mut self, row: &#row) { + fn allocate_slot(&mut self) -> Result<(#slot_id, u64), u8> { + if let Some(slot_id) = self.free_slot_ids.pop_first() { + let generation = self.slot_generations[slot_id.slot()]; + return Ok((slot_id, generation)); + } + let position = self + .next_slot_position + .ok_or(<#slot_id as ColumnSlotId>::BITS)?; + let slot_id = <#slot_id as ColumnSlotId>::try_from_position(position) + .ok_or(<#slot_id as ColumnSlotId>::BITS)?; + self.next_slot_position = position.checked_add(1); + let slot = slot_id.slot(); + if self.slot_generations.len() <= slot { + self.slot_generations.resize(slot + 1, 0); + } + Ok((slot_id, self.slot_generations[slot])) + } + + fn save_row(&mut self, row: &#row) -> Result<(), u8> { let primary_key = row.get_primary_key(); - let row_id = if let Some(row_id) = self.row_ids.get(&primary_key).copied() { - row_id + let (slot_id, _) = if let Some(slot) = self.slots.get(&primary_key).copied() { + slot } else { - let row_id = ColumnRowId::new(self.next_row_id); - self.next_row_id = self.next_row_id.saturating_add(1); - self.row_ids.insert(primary_key.clone(), row_id); - self.primary_keys.set(row_id, primary_key); - row_id + let slot = self.allocate_slot()?; + self.slots.insert(primary_key.clone(), slot); + self.primary_keys.set(slot.0, primary_key); + self.slots_high_water = self.slots_high_water.max(self.slots.len()); + slot }; #(#set_columns)* #(#insert_indexes)* + Ok(()) } fn delete_row(&mut self, row: &#row) { let primary_key = row.get_primary_key(); - let Some(row_id) = self.row_ids.remove(&primary_key) else { + let Some((slot_id, generation)) = self.slots.remove(&primary_key) else { return; }; #(#delete_indexes)* #(#remove_columns)* - self.primary_keys.remove(row_id); + self.primary_keys.remove(slot_id); + if let Some(next_generation) = generation.checked_add(1) { + self.slot_generations[slot_id.slot()] = next_generation; + self.free_slot_ids.insert(slot_id); + } } - fn replace_row(&mut self, row_old: &#row, row_new: &#row) { + fn replace_row(&mut self, row_old: &#row, row_new: &#row) -> Result<(), u8> { let old_primary_key = row_old.get_primary_key(); let new_primary_key = row_new.get_primary_key(); if old_primary_key != new_primary_key { self.delete_row(row_old); - self.save_row(row_new); - return; + return self.save_row(row_new); } - let Some(row_id) = self.row_ids.get(&old_primary_key).copied() else { - self.save_row(row_new); - return; + let Some((slot_id, _)) = self.slots.get(&old_primary_key).copied() else { + return self.save_row(row_new); }; #(#replace_remove_indexes)* #(#replace_columns)* #(#replace_insert_indexes)* + Ok(()) + } + + fn row_ref(&self, slot_id: #slot_id) -> Option> { + let primary_key = self.primary_keys.get(slot_id)?.clone(); + let (current_slot, generation) = self.slots.get(&primary_key).copied()?; + (current_slot == slot_id).then(|| { + ColumnarRowRef::__new(primary_key, slot_id, generation, self.incarnation) + }) + } + + fn validates(&self, row_ref: &ColumnarRowRef<#pk, #slot_id>) -> bool { + row_ref.__incarnation() == self.incarnation + && self.slots.get(row_ref.primary_key()).copied() + == Some((row_ref.__slot_id(), row_ref.__generation())) } fn mark_dirty(&mut self) { @@ -257,6 +348,8 @@ pub(crate) fn table_methods(table: &Ident, columns: &Columns) -> TokenStream { let row = names.get_row_type_ident(); let pk = names.get_primary_key_type_ident(); let data = data_ident(table); + let slot_id = slot_id_type(columns); + let row_ref = quote! { ColumnarRowRef<#pk, #slot_id> }; let field_methods = columns.columnar_fields.iter().map(|(field, _)| { let storage = column_field(field); @@ -264,26 +357,32 @@ pub(crate) fn table_methods(table: &Ident, columns: &Columns) -> TokenStream { let project = format_ident!("columnar_project_{}", field); let ty = columns.columns_map.get(field).expect("columnar field exists"); quote! { - pub fn #scan(&self) -> Vec<(ColumnRowId, #ty)> { + pub fn #scan(&self) -> Result, WorkTableError> { loop { - self.ensure_columnar_current(); + self.ensure_columnar_current()?; let columnar = self.0.indexes.columnar.read(); if !columnar.dirty { - return columnar.#storage.iter() - .map(|(row_id, value)| (row_id, value.clone())) - .collect(); + return Ok(columnar.#storage.iter::<#slot_id>() + .filter_map(|(slot_id, value)| { + columnar.row_ref(slot_id).map(|row_ref| (row_ref, value.clone())) + }) + .collect()); } } } - pub fn #project(&self, row_ids: &[ColumnRowId]) -> Vec<(ColumnRowId, #ty)> { + pub fn #project(&self, rows: &[#row_ref]) -> Result, WorkTableError> { loop { - self.ensure_columnar_current(); + self.ensure_columnar_current()?; let columnar = self.0.indexes.columnar.read(); if !columnar.dirty { - return row_ids.iter().filter_map(|row_id| { - columnar.#storage.get(*row_id).cloned().map(|value| (*row_id, value)) - }).collect(); + return Ok(rows.iter().filter_map(|row_ref| { + columnar.validates(row_ref).then(|| { + columnar.#storage.get(row_ref.__slot_id()) + .cloned() + .map(|value| (row_ref.clone(), value)) + }).flatten() + }).collect()); } } } @@ -307,23 +406,27 @@ pub(crate) fn table_methods(table: &Ident, columns: &Columns) -> TokenStream { } }); quote! { - pub fn #select(&self, #(#args),*) -> Vec { + pub fn #select(&self, #(#args),*) -> Result, WorkTableError> { let key = (#(#key_fields,)*); loop { - self.ensure_columnar_current(); + self.ensure_columnar_current()?; let columnar = self.0.indexes.columnar.read(); if !columnar.dirty { - return columnar.#storage.exact(&key); + return Ok(columnar.#storage.exact(&key).into_iter() + .filter_map(|slot_id| columnar.row_ref(slot_id)) + .collect()); } } } - pub fn #scan(&self) -> Vec { + pub fn #scan(&self) -> Result, WorkTableError> { loop { - self.ensure_columnar_current(); + self.ensure_columnar_current()?; let columnar = self.0.indexes.columnar.read(); if !columnar.dirty { - return columnar.#storage.ordered_row_ids(); + return Ok(columnar.#storage.ordered_slot_ids().into_iter() + .filter_map(|slot_id| columnar.row_ref(slot_id)) + .collect()); } } } @@ -331,15 +434,14 @@ pub(crate) fn table_methods(table: &Ident, columns: &Columns) -> TokenStream { }); quote! { - fn ensure_columnar_current(&self) { + fn ensure_columnar_current(&self) -> Result<(), WorkTableError> { // Take the writer lock before reading authoritative rows. A row // mutation publishes to this same lock after changing row storage, // so it either lands in this rebuild or dirties/updates the replica - // after the rebuild. Scanning first and locking later would allow a - // stale rebuild to overwrite a concurrent mutation. + // after the rebuild. let mut columnar = self.0.indexes.columnar.write(); if !columnar.dirty { - return; + return Ok(()); } let rows: Vec<#row> = { let read_guard = self.0.data.read_guard(); @@ -348,42 +450,46 @@ pub(crate) fn table_methods(table: &Ident, columns: &Columns) -> TokenStream { self.0.data.select_non_ghosted(link.0).ok() }).collect() }; - // Rebuild derived vectors and clustered metadata while retaining - // every assigned row id. A concurrent reinsert may temporarily - // publish a ghost link in the primary index while waiting for this - // columnar lock; dropping a key that is absent from this scan would - // then change its stable row id. Delete paths remove their mapping - // explicitly, so a dirty refresh never infers deletion from an - // absent/transient row. + // Retain every assigned primary-key/slot pair. A concurrent + // reinsert may temporarily publish a ghost link while waiting for + // this lock; absence from this scan is not proof of deletion. let mut rebuilt: #data = Default::default(); - rebuilt.next_row_id = columnar.next_row_id; - rebuilt.row_ids = std::mem::take(&mut columnar.row_ids); + rebuilt.next_slot_position = columnar.next_slot_position; + rebuilt.free_slot_ids = std::mem::take(&mut columnar.free_slot_ids); + rebuilt.slot_generations = std::mem::take(&mut columnar.slot_generations); + rebuilt.incarnation = columnar.incarnation; + rebuilt.slots_high_water = columnar.slots_high_water; + rebuilt.slots = std::mem::take(&mut columnar.slots); rebuilt.primary_keys = std::mem::replace( &mut columnar.primary_keys, ColumnarColumn::new(65_536, ColumnCompression::None), ); for row in &rows { - rebuilt.save_row(row); + rebuilt.save_row(row).map_err(WorkTableError::ColumnSlotIdExhausted)?; } rebuilt.dirty = false; *columnar = rebuilt; + Ok(()) } - /// Resolves logical columnar row ids back to authoritative WorkTable - /// primary keys without exposing physical data-page links. - pub fn columnar_resolve_primary_keys( - &self, - row_ids: &[ColumnRowId], - ) -> Vec<(ColumnRowId, #pk)> { - loop { - self.ensure_columnar_current(); - let columnar = self.0.indexes.columnar.read(); - if !columnar.dirty { - return row_ids.iter().filter_map(|row_id| { - columnar.primary_keys.get(*row_id).cloned().map(|key| (*row_id, key)) - }).collect(); - } - } + pub fn columnar_slots_in_use(&self) -> usize { + self.0.indexes.columnar.read().slots.len() + } + + pub fn columnar_slots_high_water(&self) -> usize { + self.0.indexes.columnar.read().slots_high_water + } + + /// Returns whether a fallback mutation has invalidated the derived + /// columnar replica. The next columnar read rebuilds it automatically. + pub fn columnar_is_dirty(&self) -> bool { + self.0.indexes.columnar.read().dirty + } + + /// Rebuilds a dirty derived columnar replica at an application-chosen + /// point instead of charging the first later columnar reader. + pub fn rebuild_columnar(&self) -> Result<(), WorkTableError> { + self.ensure_columnar_current() } #(#field_methods)* diff --git a/codegen/src/generators/in_memory/queries/update.rs b/codegen/src/generators/in_memory/queries/update.rs index bfb30e9b..28d85d4a 100644 --- a/codegen/src/generators/in_memory/queries/update.rs +++ b/codegen/src/generators/in_memory/queries/update.rs @@ -527,6 +527,28 @@ impl InMemoryGenerator { Err(WorkTableError::AlreadyExists(at.to_string_value())) } + IndexError::ColumnSlotIdExhausted { + bits, + inserted_already, + } => { + let (rollback_secondary_events, _): (#secondary_events_ident, _) = self.0.indexes.delete_from_indexes_cdc( + row_new.merge(row_old.clone()), + link, + inserted_already + ); + + let mut merged_events = secondary_events.clone(); + merged_events.extend(rollback_secondary_events); + + let ack_op = Operation::Acknowledge(AcknowledgeOperation { + id: OperationId::Single(uuid::Uuid::now_v7()), + primary_key_events: vec![], + secondary_keys_events: merged_events, + }); + self.1.apply_operation(ack_op); + + Err(WorkTableError::ColumnSlotIdExhausted(bits)) + } IndexError::NotFound => Err(WorkTableError::NotFound), }; } @@ -551,6 +573,15 @@ impl InMemoryGenerator { Err(WorkTableError::AlreadyExists(at.to_string_value())) } + IndexError::ColumnSlotIdExhausted { + bits, + inserted_already, + } => { + self.0.indexes + .delete_from_indexes(row_new.merge(row_old.clone()), link, inserted_already)?; + + Err(WorkTableError::ColumnSlotIdExhausted(bits)) + } IndexError::NotFound => Err(WorkTableError::NotFound), }; } diff --git a/codegen/src/generators/persist/index/cdc.rs b/codegen/src/generators/persist/index/cdc.rs index 550560f4..59f1a1a8 100644 --- a/codegen/src/generators/persist/index/cdc.rs +++ b/codegen/src/generators/persist/index/cdc.rs @@ -68,7 +68,7 @@ impl PersistGenerator { }) .collect::>(); let idents = self.columns.indexes.values().map(|idx| &idx.name).collect::>(); - let columnar_save = crate::generators::columnar::save_row(&self.columns); + let columnar_save = crate::generators::columnar::save_row_cdc(&self.columns); quote! { fn save_row_cdc(&self, row: #row_type_ident, link: Link) -> (#events_ident, Result<(), IndexError<#available_index_ident>>) { @@ -150,7 +150,7 @@ impl PersistGenerator { }) .unzip(); let idents = self.columns.indexes.values().map(|idx| &idx.name).collect::>(); - let columnar_reinsert = crate::generators::columnar::reinsert_row(&self.columns); + let columnar_reinsert = crate::generators::columnar::reinsert_row_cdc(&self.columns); quote! { fn reinsert_row_cdc( diff --git a/codegen/src/generators/persist/queries/update.rs b/codegen/src/generators/persist/queries/update.rs index 9b72a58d..00bb3b88 100644 --- a/codegen/src/generators/persist/queries/update.rs +++ b/codegen/src/generators/persist/queries/update.rs @@ -358,6 +358,28 @@ impl PersistGenerator { Err(WorkTableError::AlreadyExists(at.to_string_value())) } + IndexError::ColumnSlotIdExhausted { + bits, + inserted_already, + } => { + let (rollback_secondary_events, _): (#secondary_events_ident, _) = self.0.indexes.delete_from_indexes_cdc( + row_new.merge(row_old.clone()), + link, + inserted_already + ); + + let mut merged_events = secondary_events.clone(); + merged_events.extend(rollback_secondary_events); + + let ack_op = Operation::Acknowledge(AcknowledgeOperation { + id: OperationId::Single(uuid::Uuid::now_v7()), + primary_key_events: vec![], + secondary_keys_events: merged_events, + }); + self.1.apply_operation(ack_op)?; + + Err(WorkTableError::ColumnSlotIdExhausted(bits)) + } IndexError::NotFound => Err(WorkTableError::NotFound), }; } diff --git a/codegen/src/worktable/mod.rs b/codegen/src/worktable/mod.rs index 546df1a5..0313e5c7 100644 --- a/codegen/src/worktable/mod.rs +++ b/codegen/src/worktable/mod.rs @@ -57,7 +57,34 @@ pub fn expand(input: TokenStream) -> syn::Result { columns.indexes = i } if let Some(i) = columnar_indexes { - columns.columnar_indexes = i; + columns.columnar_indexes = i.indexes; + } + + let columnar_chunk_rows = config + .as_ref() + .map(|config| config.columnar_chunk_rows) + .unwrap_or(crate::common::model::DEFAULT_COLUMNAR_CHUNK_ROWS); + columns.column_slot_id = config + .as_ref() + .map(|config| config.columnar_slot_id) + .unwrap_or_default(); + for field in columns.columnar_fields.values_mut() { + let chunk_rows = field.chunk_rows.unwrap_or(columnar_chunk_rows); + let (smaller, larger) = if chunk_rows <= columnar_chunk_rows { + (chunk_rows, columnar_chunk_rows) + } else { + (columnar_chunk_rows, chunk_rows) + }; + let nested = larger % smaller == 0 && (larger / smaller).is_power_of_two(); + if !nested { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + format!( + "columnar chunk_rows({chunk_rows}) must be a power-of-two multiple or divisor of config.columnar_chunk_rows ({columnar_chunk_rows})" + ), + )); + } + field.chunk_rows = Some(chunk_rows); } validate_index_backends(&columns, persistence)?; @@ -71,6 +98,22 @@ pub fn expand(input: TokenStream) -> syn::Result { } fn validate_columnar_indexes(columns: &Columns) -> syn::Result<()> { + for primary_key in &columns.primary_keys { + if columns.columnar_fields.contains_key(primary_key) { + return Err(syn::Error::new( + primary_key.span(), + "the primary key participates in columnar identity implicitly and must not declare `columnar`", + )); + } + } + + if !columns.columnar_indexes.is_empty() && columns.columnar_fields.is_empty() { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + "`columnar_indexes` requires at least one field declaring `columnar`", + )); + } + for index in columns.columnar_indexes.values() { if columns.columnar_fields.contains_key(&index.name) { return Err(syn::Error::new( @@ -81,7 +124,7 @@ fn validate_columnar_indexes(columns: &Columns) -> syn::Result<()> { ), )); } - for field in &index.columns { + for field in &index.cluster_by { if !columns.columns_map.contains_key(field) { return Err(syn::Error::new( field.span(), @@ -98,17 +141,6 @@ fn validate_columnar_indexes(columns: &Columns) -> syn::Result<()> { )); } } - for field in &index.cluster_by { - if !index.columns.contains(field) { - return Err(syn::Error::new( - field.span(), - format!( - "columnar index `{}` clusters by `{field}`, which is absent from `columns`", - index.name - ), - )); - } - } } Ok(()) } @@ -223,7 +255,6 @@ mod tests { }, columnar_indexes: { host_lookup: { - columns: [host_id], cluster_by: [host_id], }, }, @@ -233,7 +264,7 @@ mod tests { assert!( error .to_string() - .contains("requires field `host_id` to declare `columnar(...)`") + .contains("requires at least one field declaring `columnar`") ); } @@ -244,12 +275,11 @@ mod tests { persist: false, columns: { id: u64 primary_key, - host_id: u64 columnar(chunk_rows(1024), compression(auto)), + host_id: u64 columnar(chunk_rows(1024), compression(none)), timestamp: i64 columnar(chunk_rows(2048), compression(none)), }, columnar_indexes: { host_time: { - columns: [host_id, timestamp], cluster_by: [host_id, timestamp], }, }, @@ -263,6 +293,76 @@ mod tests { assert!(output.contains("ColumnarColumn :: new (1024")); } + #[test] + fn columnar_config_is_table_scoped_and_row_derives_stops_at_new_keys() { + let output = expand(quote! { + name: ColumnarConfig, + persist: false, + columns: { + id: u64 primary_key, + value: u64 columnar, + }, + config: { + row_derives: Default, + columnar_slot_id: ColumnSlotId16, + columnar_chunk_rows: 1024, + }, + }) + .unwrap() + .to_string(); + + assert!(output.contains("ColumnSlotId16")); + assert!(output.contains("ColumnarColumn :: new (1024")); + } + + #[test] + fn columnar_chunk_override_must_nest_with_table_default() { + let error = expand(quote! { + name: InvalidColumnarChunk, + persist: false, + columns: { + id: u64 primary_key, + value: u64 columnar(chunk_rows(50_000)), + }, + }) + .unwrap_err(); + + assert!(error.to_string().contains("power-of-two multiple or divisor")); + } + + #[test] + fn primary_key_cannot_redeclare_columnar_identity() { + let error = expand(quote! { + name: InvalidColumnarPrimaryKey, + persist: false, + columns: { + id: u64 primary_key columnar, + }, + }) + .unwrap_err(); + + assert!(error.to_string().contains("must not declare `columnar`")); + } + + #[test] + fn duplicate_columnar_config_is_rejected() { + let error = expand(quote! { + name: DuplicateColumnarConfig, + persist: false, + columns: { + id: u64 primary_key, + value: u64 columnar, + }, + config: { + columnar_slot_id: ColumnSlotId16, + columnar_slot_id: ColumnSlotId32, + }, + }) + .unwrap_err(); + + assert!(error.to_string().contains("Duplicate `columnar_slot_id`")); + } + fn assert_composite_primary_key_field_order(output: proc_macro2::TokenStream) { let output = output.to_string(); let get_primary_key = output diff --git a/codegen/src/worktable_version/mod.rs b/codegen/src/worktable_version/mod.rs index ea7c1ed2..abe55ad9 100644 --- a/codegen/src/worktable_version/mod.rs +++ b/codegen/src/worktable_version/mod.rs @@ -16,6 +16,12 @@ pub fn expand(input: TokenStream) -> syn::Result { match ident.to_string().as_str() { "columns" => columns = Some(parser.parse_columns()?), "indexes" => indexes = Some(parser.parse_indexes()?), + "columnar_indexes" => { + return Err(Error::new( + ident.span(), + "worktable_version! does not support columnar_indexes", + )); + } "queries" => { return Err(Error::new(ident.span(), "worktable_version! does not support queries")); } @@ -120,6 +126,25 @@ mod tests { assert!(res.is_err(), "should reject config section"); } + #[test] + fn test_rejects_columnar_indexes_explicitly() { + let input = quote! { + name: UserV1, + columns: { + id: u64 primary_key, + value: u64, + }, + columnar_indexes: { + value_idx: { + cluster_by: [value], + }, + }, + }; + + let error = expand(input).unwrap_err(); + assert!(error.to_string().contains("does not support columnar_indexes")); + } + #[test] fn test_explicit_version() { let input = quote! { diff --git a/docs/columnar-fields-and-indexes-guide-v3.md b/docs/columnar-fields-and-indexes-guide-v3.md new file mode 100644 index 00000000..7fb66481 --- /dev/null +++ b/docs/columnar-fields-and-indexes-guide-v3.md @@ -0,0 +1,404 @@ +# WorkTable tabular + columnar side indexes + +> **v3 review boundary:** this guide describes the implemented tabular + columnar side-index +> flavor, and labels the separate full-columnar roadmap explicitly. + +WorkTable can maintain selected fields in derived, chunked **side indexes** while its ordinary row +store and primary key remain authoritative. Point-oriented application code keeps the existing +WorkTable model, while analytical code gains cheaper columnar-flavored scans and projections over +the duplicated selected values. + +The first implementation is intentionally format-compatible and uncompressed. It establishes the +DSL, identity, mutation, validation, and recovery boundaries before adding sealed chunks, +compression, a vector execution engine, or a new disk format. + +## The three WorkTable storage flavors + +| Flavor | Authoritative representation | What it provides | Status | +|---|---|---|---| +| **Tabular** | Existing WorkTable rows | Point lookup, mutation, ordinary indexes and persistence | Already well covered | +| **Tabular + columnar side indexes** | Existing WorkTable rows, plus derived side structures | Cheap columnar-flavored field scans, projections and ordered lookup at the cost of duplicating selected values/index metadata | **This proposal and PR** | +| **Columnar** | A true vector/column representation | Vector batches, encoded or compressed segments, columnar-native execution and persistence | Not covered | + +The middle flavor is not a full column store. Calling it one would erase the most useful property +of the proposal: it adds a bounded, opt-in analytical acceleration structure without replacing the +tabular engine. It is analogous to adding an index, except that a useful side index must duplicate +the selected field values as well as ordering metadata. If it stored only keys and slot positions, +every projection would still perform random gathers from tabular rows and lose most of its +columnar flavor. + +## Complete schema + +```rust +use worktable::prelude::*; +use worktable::worktable; + +// WorkTable's current column parser accepts a single type identifier. +type DiagnosticBlob = Vec; + +worktable!( + name: HistoricalCpu, + persist: true, + + columns: { + id: u128 primary_key, + + // Bare `columnar` uses the table defaults. + host_id: u64 columnar, + captured_at_ns: u64 columnar, + cpu_percent: f32 columnar, + + // A non-indexed columnar field. It has contiguous field storage and + // can be scanned or projected even though no index clusters by it. + status: String columnar(chunk_rows(16_384)), + + // An ordinary row-only field. + diagnostic_blob: DiagnosticBlob, + }, + + columnar_indexes: { + host_time: { + cluster_by: [host_id, captured_at_ns], + }, + }, + + config: { + columnar_slot_id: ColumnSlotId32, + columnar_chunk_rows: 65_536, + }, +); +``` + +Both columnar `config` entries shown above are defaults and may be omitted. + +There is deliberately no table-level `layout: columnar`: this proposal does not select the third +flavor. A field opts into a derived side index. There is also deliberately no `columns: [...]` list inside a columnar index: fields named +by `cluster_by` are its key, and projected values come from base column stores. + +## Three independent choices + +### 1. Which fields have column storage? + +Add `columnar` to a non-primary-key field: + +```rust +cpu_percent: f32 columnar, +status: String columnar(chunk_rows(16_384)), +latency_ns: u64 optional columnar(compression(none)), +``` + +This duplicates that field in a chunked derived side index. It does not create an ordered search index and it +does not change row persistence. A field may be columnar without appearing in any +`columnar_indexes` entry; `status` in the complete example is one such field. + +`columnar` occurs after `optional` and before `using`. The table default is 65,536 rows per chunk. +A per-field `chunk_rows(N)` override must be a power-of-two multiple or divisor of the table +default, which keeps cross-column chunk boundaries nestable. + +Mutable chunks are currently unencoded. Omitted compression and `compression(none)` mean the same +thing. `auto`, `delta`, `rle`, and `dictionary` are reserved and fail macro expansion instead of +silently behaving like `none`. + +### 2. Which access paths are ordered? + +```rust +columnar_indexes: { + host_time: { + cluster_by: [host_id, captured_at_ns], + }, +}, +``` + +`cluster_by` orders the index metadata, not the physical base columns. In the current +implementation, the access path is a `BTreeMap` from the composite key to a set of column slot IDs. +The base columns remain in canonical slot order. + +Every `cluster_by` field must itself declare `columnar`. `include` is reserved for a future genuine +covering projection and is rejected today. + +### 3. How wide is the compact slot position? + +The table-wide setting is: + +```rust +config: { + columnar_slot_id: ColumnSlotId16, +}, +``` + +Available types and theoretical live-slot capacities are: + +| Type | Positions | Typical reason to choose it | +|---|---:|---| +| `ColumnSlotId8` | 256 | tests or a strictly bounded tiny table | +| `ColumnSlotId16` | 65,536 | embedded or hard-bounded live window | +| `ColumnSlotId32` | 4,294,967,296 | default; broad capacity with compact metadata | +| `ColumnSlotId64` | 18,446,744,073,709,551,616 | explicit very-large logical range | + +These are capacity bounds, not promises that the process can allocate that many rows. Address +space, memory, and other table structures impose practical limits first—especially for 64-bit +slots. + +Choosing a width that covers the maximum number of simultaneously live columnar rows is the +schema author's responsibility. WorkTable does not silently widen, truncate, wrap, evict another +row, or reinterpret the setting. A write beyond the selected range returns: + +```rust +WorkTableError::ColumnSlotIdExhausted(bits) +``` + +The failed insert is rolled back and existing rows remain valid. Operators can monitor: + +```rust +table.columnar_slots_in_use(); +table.columnar_slots_high_water(); +``` + +## A slot is not an identity or sort key + +The primary key remains load-bearing. A `ColumnSlotId` is only a compact position shared by the +derived field chunks and columnar indexes. It is not: + +- a replacement primary key; +- the row's rank in `cluster_by` order; +- a stable external identifier; +- a durable identifier across restart; or +- a public tuple value applications should store. + +Generated results carry an opaque reference: + +```rust +pub struct ColumnarRowRef { /* private */ } + +impl ColumnarRowRef { + pub fn primary_key(&self) -> &PrimaryKey; +} +``` + +`ColumnarRowRef` deliberately does not implement serialization. Durable application references +must store the primary key. + +### Delete/reinsert and ABA safety + +A bounded slot allocator must reuse positions. Primary key plus slot alone is insufficient: delete +and reinsert of the same primary key into the same slot would make an old reference appear valid. + +WorkTable therefore validates four pieces of state: + +```text +primary key + slot position + u64 slot generation + table incarnation +``` + +The generation is separate from the configured slot width, so choosing `ColumnSlotId8` still gives +256 live slots rather than carving generation bits out of those eight bits. A delete increments the +slot's generation before reuse. Generation never wraps: if its `u64` counter is ever exhausted, +that slot is permanently retired. A process-local table incarnation invalidates references created +by another table instance or before a persisted table is reopened. + +This is stronger for retained owned references than relying only on an epoch grace period: an +epoch protects active readers, but it cannot protect a reference an application stores after the +read guard ends. + +## Current side-index physical model + +For each generated table with at least one columnar field, WorkTable keeps the tabular row and adds: + +```text +authoritative primary key -> authoritative WorkTable row/link + -> (ColumnSlotId, generation) directory + -> chunked base field replicas + -> zero or more clustered BTreeMap access paths +``` + +Each duplicated side field currently uses `Vec>>`. The outer vector holds chunks and the inner +vector is indexed by the slot offset. A separate primary-key column supports reference validation. +This first layout is deliberately generic: + +- fixed-width values are not yet exposed through SIMD/vector kernels; +- optional fields currently store the Rust `Option` representation rather than a validity + bitmap; +- `String` values are owned values rather than offsets into a byte arena; and +- chunks are mutable and uncompressed. + +These are implementation boundaries, not compression or vectorization claims. + +## Generated API in this implementation + +The current generated methods return owned collections: + +```rust +// Exact lookup requires the complete composite clustered key. +let refs = table.columnar_select_host_time(host_id, captured_at_ns)?; + +// Gather a selected field through validated opaque references. +let cpu = table.columnar_project_cpu_percent(&refs)?; + +// A direct scan needs no columnar index. +let statuses = table.columnar_scan_status()?; + +// Scan in the clustered index's key order. +let ordered_refs = table.columnar_scan_host_time()?; +``` + +The owned `Vec` results keep locks out of the public return type and may be retained safely. They +also materialize the result, so this API is not the final high-volume execution surface. + +Not yet implemented: + +- prefix equality and range predicates on a composite `cluster_by` key; +- namespaced predicate/projection builders; +- zero-copy or callback-based `scan_batches`; +- a single multi-field projection API; and +- row-gather fallback for row-only fields. + +Those remain the next query-API slice. Documentation and benchmarks must not present them as +shipping behavior. + +## Mutation and consistency behavior + +Insert, ordinary update, delete, and reinsert maintain the derived directory, field chunks, and +clustered metadata before the mutation call completes. A same-primary-key update retains its slot +and generation. Vacuum may move the authoritative physical link without changing the columnar +slot. + +The complete derived state is protected by one table-local read/write lock. Consequences: + +- an individual scan or projection observes one coherent columnar snapshot; +- all columnar fields changed by one maintenance operation publish together; +- concurrent columnar writers are table-serialized; +- ordinary row-only selects do not take the columnar lock; and +- two separate API calls are two snapshots, not a transactionally pinned multi-call view. + +Some archived in-place mutation paths cannot apply a typed column delta yet. They mark the +derived replica dirty. The next columnar read rebuilds it from authoritative rows. Applications +can see and schedule that cost explicitly: + +```rust +if table.columnar_is_dirty() { + table.rebuild_columnar()?; +} +``` + +The current rebuild is whole-table and holds the columnar writer lock. Per-chunk dirty tracking is +planned; it is not implemented in this release. + +## Persistence and recovery + +For `persist: true`, ordinary WorkTable rows and indexes retain their existing formats. The +columnar side index is marked derived, omitted from the persisted index structure, and rebuilt from +authoritative rows after load. Therefore this change introduces no new on-disk columnar format. + +`ColumnSlotId` assignments are not promised to survive restart. The process/table incarnation in +`ColumnarRowRef` prevents an old in-memory reference from being accepted by a reopened instance. +Use primary keys for durable identity. + +Native columnar checkpoints are a later design choice. They should be added only if benchmarked +restart time or row-store gather cost justifies another durable format and recovery protocol. + +## Relationship to SAP HANA's unified table + +Sikka et al.'s SAP HANA paper is the most relevant architectural contrast because it explains how +a true column-oriented system can serve transactional and analytical work on one logical table. +HANA primarily informs WorkTable's possible third flavor, while this PR deliberately implements +the cheaper middle flavor. The similarity is the use of different physical representations for +different access patterns; the authority and execution models differ. + +| Dimension | SAP HANA (SIGMOD 2012) | WorkTable side indexes / PR implementation | +|---|---|---| +| Logical goal | OLTP and OLAP through one unified table interface | Preserve the tabular engine and add opt-in columnar-flavored side indexes | +| Write path | Uncompressed row-oriented L1 delta | Existing authoritative WorkTable row storage | +| Intermediate form | Dictionary-encoded, unsorted column L2 delta | Mutable, uncompressed side-index vectors | +| Read-optimized form | Compressed main store with sorted dictionaries and bit-packed values | Not implemented yet | +| Record position | RowId created on entry; positional alignment across columns | Primary key is authoritative; opaque slot aligns derived columns | +| Reorganization | Asynchronous L1→L2 and snapshot-safe L2→main merges | Synchronous maintenance; whole-table rebuild only for dirty fallback paths | +| Readers during merge | Old/new versions retained until transactions using the old version finish | One table-local columnar `RwLock`; no versioned columnar merge yet | +| Point access | Inverted indexes across delta and main structures | Generated `BTreeMap` clustered metadata plus the normal WorkTable indexes | +| Execution | Row/column iterators and vectorized block-at-a-time operators | Owned `Vec` scans/projections in this first API | +| Durability | REDO for incoming changes plus savepoints for column structures | Existing WorkTable persistence is authoritative; columnar state rebuilds after load | + +The closest honest analogy is: **WorkTable's tabular engine plays a role similar to HANA's +write-optimized L1, while this PR adds optional uncompressed side indexes. It does not implement +HANA's L2/main column-store lifecycle and does not claim the third, fully columnar flavor.** + +The HANA result points to a defensible next architecture for WorkTable: + +- keep a small mutable delta that accepts foreground mutations cheaply; +- seal cold column chunks into immutable snapshots; +- build dictionaries, bit packing, delta/RLE, and zone metadata off the foreground path; +- publish a new manifest atomically while readers finish on the previous snapshot; +- reclaim old snapshots only after the read grace period; and +- checkpoint sealed chunks separately from the authoritative row representation only when the + recovery and performance measurements justify it. + +That architecture belongs to a future full-columnar effort and would also remove the current +whole-table dirty-rebuild cliff. It is a roadmap informed by HANA's record-lifecycle design, not a +performance claim for this PR or a requirement for the side-index flavor. + +Reference: Vishal Sikka, Franz Färber, Wolfgang Lehner, Sang Kyun Cha, Thomas Peh, and Christof +Bornhövd. “Efficient Transaction Processing in SAP HANA Database: The End of a Column Store Myth.” +SIGMOD 2012, pp. 731–741. DOI: 10.1145/2213836.2213946. + +## Compile-time validation + +The macro rejects: + +- `columnar` on a primary-key field; +- an unknown or non-columnar field in `cluster_by`; +- empty or duplicate `cluster_by` entries; +- a columnar field/index generated-method name collision; +- the removed `columns:` index property; +- the reserved `include:` property; +- unsupported compression policies; +- zero or non-nesting chunk sizes; +- duplicate table config entries; +- unknown or out-of-order column attributes; and +- `columnar_indexes` in `worktable_version!`. + +## Benchmark contract before performance claims + +For each supported primary-index backend (`WorkTablesIndex`, `congee-wt`, and `arctic-wt` where the +`Using` and persistence rules permit it), measure: + +- row select throughput and p50/p95/p99 with no columnar declarations, fields only, and fields plus + clustered metadata; +- insert, same-key update, indexed-key update, delete, and fixed-window delete/reinsert churn; +- full field scan, exact clustered lookup, and projection gather; +- single-thread and 1→core-count concurrent readers/writers; +- memory amplification, allocation count, code size, and slot-directory overhead by width; +- first-read dirty rebuild versus application-scheduled rebuild; +- persisted reload and rebuild time; and +- correctness counters alongside throughput, especially under slot reuse and concurrent mutation. + +Until those measurements exist, the safe statement is that the feature adds correctness-tested +columnar side indexes—not that it is a full column store, faster for every workload, or ready for +latency-sensitive HFT deployment. + +## Staged roadmap + +- **Query surface:** namespaced predicate builders, prefix/range selection, combined projection, + and bounded `scan_batches`. +- **Incremental maintenance:** per-chunk dirtiness and typed in-place deltas. +- **Column encodings:** validity bitmaps, fixed-width vector kernels, and offset/byte storage for + variable-width fields. +- **Separate full-columnar design:** mutable delta, sealed immutable chunks, background merge, + versioned publication/reclamation, and vector execution. This is a different flavor, not a + silent expansion of the side-index feature. +- **Compression:** type-checked dictionary, delta, RLE, bit packing, and an evidence-based `auto`. +- **Optional native persistence:** manifests, checksums, recovery watermarks, and crash tests. +- **Optimizer:** choose row lookup, ordinary index, clustered side-index lookup, or base-field scan + from measured costs. + +This ordering keeps correctness and compatibility ahead of compression claims while leaving the +DSL stable for the later physical evolution. + +## Reviewer decision points + +- Is “tabular + columnar side indexes” the right permanent name for this middle flavor? +- Is full-width `ColumnSlotId8|16|32|64` plus a separate `u64` generation preferable to hiding a + smaller slot/generation bit split inside the configured width? +- Is the owned, fully materialized phase-one API acceptable if `scan_batches` is the next query + slice and no vector-execution claim is made now? +- Is rebuild-on-load the correct compatibility choice until native side-index checkpoints show a + measured recovery benefit? +- Should the future full-columnar flavor receive distinct DSL rather than changing the meaning of + today's field-level `columnar` attribute? diff --git a/docs/columnar-index-plan.md b/docs/columnar-index-plan.md index 977301d0..c1b3ad40 100644 --- a/docs/columnar-index-plan.md +++ b/docs/columnar-index-plan.md @@ -1,11 +1,21 @@ -# Columnar fields and indexes +# Columnar side-index implementation plan -Status: initial implementation in `feat/columnar-fields-indexes`. +Status: phase-one implementation in `feat/columnar-fields-indexes`. The complete user and reviewer +guide is [`columnar-fields-and-indexes-guide-v3.md`](columnar-fields-and-indexes-guide-v3.md). -## Syntax +## Scope boundary -Columnar storage is a property of an individual field. It is not a table -layout, and row storage remains authoritative. +WorkTable has three distinct storage flavors: + +1. **Tabular** — the existing authoritative row engine. +2. **Tabular + columnar side indexes** — the scope of this branch. Selected field values and + clustered keys are duplicated into derived structures for cheaper columnar-flavored access. +3. **Columnar** — an authoritative vector layout, vectorized execution, sealed/encoded segments, + and native columnar persistence. This is not implemented by this branch. + +The phase-one feature must not be marketed or documented as the third flavor. + +## Accepted DSL ```rust worktable!( @@ -13,152 +23,120 @@ worktable!( persist: true, columns: { id: u128 primary_key, - host_id: u64 columnar( - chunk_rows(65_536), - compression(auto), - ), - timestamp: i64 columnar( - chunk_rows(65_536), - compression(delta), - ), - temperature: i64 columnar( - chunk_rows(32_768), - compression(auto), - ), - label: String, + host_id: u64 columnar, + captured_at_ns: u64 columnar, + temperature: i64 columnar(chunk_rows(32_768), compression(none)), + status: String columnar, + diagnostic_blob: DiagnosticBlob, }, columnar_indexes: { host_time: { - columns: [host_id, timestamp, temperature], - cluster_by: [host_id, timestamp], + cluster_by: [host_id, captured_at_ns], }, }, + config: { + columnar_slot_id: ColumnSlotId32, + columnar_chunk_rows: 65_536, + }, ); ``` -`columnar(...)` creates a base column replica. A field does not need to be in a -`columnar_indexes` declaration to benefit from sequential scan or projection. -For example, `temperature` can be projected after `host_time` produces logical -row IDs, while a columnar `status` field that appears in no index can still be -scanned directly. +- Bare `columnar` uses defaults. +- `compression(none)` is the only accepted policy until a codec exists. +- `cluster_by` orders index metadata, not the side-field vectors. +- `columns:` inside a columnar index has been removed as semantically redundant. +- Slot width is a table setting, independent of whether the table declares any clustered side + index. + +## Identity and capacity + +`ColumnSlotId8|16|32|64` uses its complete unsigned range for side-index slot positions. It neither +replaces the primary key nor represents sort rank. + +An opaque `ColumnarRowRef` carries: + +```text +primary key + slot + separate u64 generation + table incarnation +``` + +Delete increments the generation before slot reuse. Generation never wraps; an exhausted slot is +retired. Table incarnation invalidates retained refs across a new/reopened instance. The ref is not +serializable and exposes only the authoritative primary key. -`columnar_indexes` declares ordering and lookup metadata over existing base -columns. `cluster_by` belongs here because it describes index order, not field -storage order. Conventional WorkTable indexes are unchanged and may coexist -with columnar indexes. +The schema author is responsible for selecting a width that covers maximum simultaneously live +side-indexed rows. Exceeding it returns `WorkTableError::ColumnSlotIdExhausted(bits)` and rolls back +the insert. The implementation never widens, truncates, wraps, or evicts automatically. -## Implemented model +## Implemented side structures -The initial implementation adds: +Generated tables maintain under one table-local `RwLock`: -- per-field `columnar(chunk_rows(...), compression(...))` parsing and - validation; -- a `columnar_indexes` section with `columns` and `cluster_by` validation; -- a stable `ColumnRowId`, independent of physical `data_bucket::Link` values; -- separately chunked vectors for every columnar field; -- a shared primary-key-to-row-ID directory; -- ordered clustered metadata backed by a `BTreeMap` and row-ID sets; -- generated exact-lookup, ordered-index-scan, field-scan, and projection APIs; -- maintenance for inserts, updates, in-place updates, deletes, reinserts, and - vacuum link changes; -- derived-state rebuild after persisted/read-only load without changing the - existing WorkTable disk format. +- primary-key → `(ColumnSlotId, generation)` directory; +- reusable slot set and generation vector; +- process/table incarnation; +- chunked `Vec>>` for each opted-in field; +- primary-key side column for ref validation; and +- a `BTreeMap>` for each `columnar_indexes` entry. -For the example above, generated APIs include: +Insert/update/delete/reinsert hooks maintain these after the authoritative row mutation. A vacuum +link change does not change the slot. In-place paths without a typed delta mark the side indexes +dirty; `rebuild_columnar()` lets applications pay the whole-table rebuild cost deliberately. + +Persisted tables skip these derived fields in their existing index disk format and rebuild them +from authoritative rows after load. This branch adds no on-disk format. + +## Current generated operations ```rust -let ids = table.columnar_select_host_time(host_id, timestamp); -let temperatures = table.columnar_project_temperature(&ids); -let primary_keys = table.columnar_resolve_primary_keys(&ids); -let all_temperatures = table.columnar_scan_temperature(); -let clustered_ids = table.columnar_scan_host_time(); +table.columnar_select_host_time(host_id, captured_at_ns)?; +table.columnar_scan_host_time()?; +table.columnar_scan_status()?; +table.columnar_project_temperature(&row_refs)?; +table.columnar_is_dirty(); +table.rebuild_columnar()?; +table.columnar_slots_in_use(); +table.columnar_slots_high_water(); ``` -Field scans and projections return owned values in this first API. That keeps -locks out of the public return type and gives callers a coherent batch they can -retain independently of later mutations. +They return owned `Vec` collections. Full-key equality is the only clustered predicate in phase +one. -## Stable identity and mutation flow +## Phase-one correctness gates -The row store remains the source of truth: +- Same-primary-key delete/reinsert into the same slot must invalidate the old ref. +- A ref from another table incarnation must fail validation. +- Slot exhaustion must roll back the authoritative mutation. +- All four slot widths must enforce their numeric range without wrapping. +- Mutation paths must update or dirty side indexes before returning. +- A dirty rebuild must preserve live slot/generation mappings. +- Macro validation must reject primary-key `columnar`, unknown/non-columnar cluster keys, duplicate + keys/names/config, inert compression, non-nesting chunks, `columns:`, and reserved `include:`. +- Persisted load must reconstruct side indexes without changing the current disk format. -```text -primary key -> WorkTable row/link - -> ColumnRowId directory - -> per-field chunks - -> zero or more clustered columnar indexes -``` +## Performance gates + +Before an HFT-facing claim or default: + +- compare tabular baseline against fields-only and fields-plus-clustered side indexes; +- measure row select, insert, update, delete, churn, exact lookup, scan, and gather; +- report p50/p95/p99, allocations, memory, and code size; +- run 1→core-count concurrency with correctness counters; +- measure first-reader and explicit dirty rebuild costs; and +- repeat across supported WorkTablesIndex, congee-wt, and arctic-wt `Using` configurations. + +## Follow-up within the side-index flavor + +1. Namespaced builders and prefix/range predicates. +2. Bounded `scan_batches` and one-lock multi-field projection. +3. Per-chunk dirty tracking and typed in-place deltas. +4. Validity bitmaps, fixed-width kernels, and variable-width offset buffers. +5. Optional sealed side-index snapshots if benchmarks justify persistence. + +## Separate full-columnar flavor + +SAP HANA's unified-table record lifecycle is useful prior art for a future third flavor: an +uncompressed row write delta, a column delta, a compressed main, asynchronous merge, old/new +snapshot coexistence, and vector/block execution. That is a separate architecture and performance +contract. It must not arrive by quietly changing what `columnar` side indexes mean. -A vacuum may change the WorkTable link without changing `ColumnRowId`. An -update with the same primary key also retains the row ID. Delete removes the -directory entry, field slots, and clustered entries; IDs are not reused during -the process lifetime. - -Generated mutation paths use the existing per-key mutation gate. Direct row -insert/reinsert/delete hooks update columnar state under its own lock. Update -paths that mutate archived fields in place mark the replica dirty; the next -columnar access rebuilds it from authoritative rows while preserving IDs for -surviving primary keys. This is deliberately a correctness-first design. The -dirty rebuild can later become an incremental difference application once its -concurrency invariants and benchmark benefit are established. - -## Chunk alignment - -Each field owns its `chunk_rows` setting. Different values remain correct -because all access is joined by `ColumnRowId`; equal values provide an aligned -fast path for multi-column vector work. The runtime does not require aligned -physical chunks. - -## Persistence - -This change does not introduce a columnar on-disk format. The row store and -existing indexes retain their current formats. Generated columnar state is -marked as derived and skipped by `PersistIndex`; a loaded table rebuilds it -from authoritative rows on first columnar access. - -That choice keeps this PR format-compatible and lets benchmarks answer whether -native column checkpoints are worth their complexity. A later format can add -sealed immutable chunks, manifests, checksums, and recovery watermarks without -changing the DSL or logical row identity. - -## Compression boundary - -The DSL accepts `none`, `auto`, `delta`, `rle`, and `dictionary`, and generated -columns retain the requested policy as metadata. Mutable chunks are currently -stored unencoded: `auto` resolves to no encoding, and the explicit codecs are -not yet applied. `ColumnCompression::is_encoded()` therefore returns `false`. - -This is intentional rather than a compression claim. Encoding belongs on -sealed/immutable chunks so point updates do not repeatedly rewrite compressed -buffers. Codec implementation and per-type validation are follow-up work and -must be benchmarked independently. - -## Current concurrency boundary - -Columnar state is derived and protected by a table-local read/write lock. -Ordinary row reads do not touch it, so declaring a columnar field does not add a -lock to the existing select path. Columnar reads clone a result batch while -holding the replica read lock. Mutations update or dirty the replica only after -the authoritative row operation succeeds. - -Before calling this production-ready for HFT workloads, benchmarks must cover: - -- row-operation throughput with no columnar access; -- insert/update/delete overhead with columnar fields and indexes; -- exact lookup and ordered scan throughput; -- p50/p95/p99 latency under mixed readers and writers; -- dirty-rebuild latency after in-place updates; -- memory amplification by field type, chunk size, and index cardinality. - -## Next implementation slices - -1. Add range predicates and generated projection batches that fetch several - fields in one lock acquisition. -2. Replace dirty full rebuilds with typed incremental mutations for archived - in-place updates. -3. Add null bitmaps and specialized fixed-width chunk kernels. -4. Seal cold chunks and implement actual delta/RLE/dictionary codecs. -5. Benchmark row-store random projection against native column checkpoints, - then add a disk format only if the result justifies it. -6. Add a cost model that chooses conventional index lookup, clustered - columnar lookup, or base-column scan. +See the v3 guide for the detailed comparison and citation. diff --git a/output/pdf/worktable-columnar-side-indexes-guide-v3.pdf b/output/pdf/worktable-columnar-side-indexes-guide-v3.pdf new file mode 100644 index 0000000000000000000000000000000000000000..38ac26c71522f5712560b3523acc1673bfddc942 GIT binary patch literal 27746 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 DD Self { - Self(value) + fn try_from_position(position: u64) -> Option; + fn position(self) -> u64; + + fn slot(self) -> usize { + usize::try_from(self.position()).expect("column slot ID exceeds this target's address space") } +} + +macro_rules! column_slot_id { + ($name:ident, $inner:ty, $bits:literal) => { + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name($inner); + + impl ColumnSlotId for $name { + const BITS: u8 = $bits; + + fn try_from_position(position: u64) -> Option { + <$inner>::try_from(position).ok().map(Self) + } + + fn position(self) -> u64 { + self.0 as u64 + } + } + + impl MemStat for $name { + fn heap_size(&self) -> usize { + 0 + } + + fn used_size(&self) -> usize { + 0 + } + } + }; +} + +column_slot_id!(ColumnSlotId8, u8, 8); +column_slot_id!(ColumnSlotId16, u16, 16); +column_slot_id!(ColumnSlotId32, u32, 32); +column_slot_id!(ColumnSlotId64, u64, 64); + +static NEXT_COLUMNAR_INCARNATION: AtomicU64 = AtomicU64::new(1); + +/// Returns a process-local table incarnation used to invalidate retained +/// columnar references when a table is rebuilt or reopened. +#[doc(hidden)] +pub fn next_columnar_incarnation() -> u64 { + NEXT_COLUMNAR_INCARNATION + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| value.checked_add(1)) + .expect("columnar table incarnation space is exhausted") +} - pub fn get(self) -> u64 { - self.0 +/// Identity carried by generated columnar query results. +/// +/// The primary key remains authoritative. The slot, generation, and table +/// incarnation are private validation metadata and are deliberately not +/// serializable or exposed as ordering keys. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ColumnarRowRef { + primary_key: PrimaryKey, + slot_id: SlotId, + generation: u64, + incarnation: u64, +} + +impl ColumnarRowRef { + /// Returns the authoritative WorkTable primary key. + pub fn primary_key(&self) -> &PrimaryKey { + &self.primary_key + } + + /// Constructor used by generated WorkTable code. + #[doc(hidden)] + pub fn __new(primary_key: PrimaryKey, slot_id: SlotId, generation: u64, incarnation: u64) -> Self { + Self { + primary_key, + slot_id, + generation, + incarnation, + } + } + + #[doc(hidden)] + pub fn __slot_id(&self) -> SlotId + where + SlotId: Copy, + { + self.slot_id + } + + #[doc(hidden)] + pub fn __generation(&self) -> u64 { + self.generation + } + + #[doc(hidden)] + pub fn __incarnation(&self) -> u64 { + self.incarnation } } -impl MemStat for ColumnRowId { +impl MemStat for ColumnarRowRef { fn heap_size(&self) -> usize { - 0 + self.primary_key.heap_size() + self.slot_id.heap_size() } fn used_size(&self) -> usize { - 0 + self.primary_key.used_size() + self.slot_id.used_size() } } -/// Compression requested for a generated columnar field. +/// Compression used by a generated columnar field. /// -/// The first implementation stores mutable chunks without encoding them. -/// `Auto` therefore resolves to `None`; the explicit variants are retained in -/// metadata so immutable/sealed-chunk codecs can be added without changing the -/// macro syntax. +/// Mutable chunks are currently unencoded; unsupported policies are rejected +/// by the macro instead of being accepted as inert configuration. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum ColumnCompression { - None, #[default] - Auto, - Delta, - Rle, - Dictionary, + None, } impl ColumnCompression { @@ -61,7 +150,7 @@ impl MemStat for ColumnCompression { } } -/// Chunked, row-id-addressed storage for one generated columnar field. +/// Chunked storage for one generated columnar field. #[derive(Debug)] pub struct ColumnarColumn { chunk_rows: usize, @@ -87,8 +176,8 @@ impl ColumnarColumn { self.compression } - pub fn set(&mut self, row_id: ColumnRowId, value: T) { - let row = row_id.get() as usize; + pub fn set(&mut self, slot_id: SlotId, value: T) { + let row = slot_id.slot(); let chunk_index = row / self.chunk_rows; let offset = row % self.chunk_rows; while self.chunks.len() <= chunk_index { @@ -101,29 +190,31 @@ impl ColumnarColumn { chunk[offset] = Some(value); } - pub fn remove(&mut self, row_id: ColumnRowId) -> Option { - let row = row_id.get() as usize; + pub fn remove(&mut self, slot_id: SlotId) -> Option { + let row = slot_id.slot(); self.chunks .get_mut(row / self.chunk_rows) .and_then(|chunk| chunk.get_mut(row % self.chunk_rows)) .and_then(Option::take) } - pub fn get(&self, row_id: ColumnRowId) -> Option<&T> { - let row = row_id.get() as usize; + pub fn get(&self, slot_id: SlotId) -> Option<&T> { + let row = slot_id.slot(); self.chunks .get(row / self.chunk_rows) .and_then(|chunk| chunk.get(row % self.chunk_rows)) .and_then(Option::as_ref) } - pub fn iter(&self) -> impl Iterator { + pub fn iter(&self) -> impl Iterator { let chunk_rows = self.chunk_rows; self.chunks.iter().enumerate().flat_map(move |(chunk_index, chunk)| { chunk.iter().enumerate().filter_map(move |(offset, value)| { value.as_ref().map(|value| { - let row = chunk_index * chunk_rows + offset; - (ColumnRowId::new(row as u64), value) + let position = (chunk_index * chunk_rows + offset) as u64; + let slot_id = SlotId::try_from_position(position) + .expect("stored column position fits its configured column slot ID"); + (slot_id, value) }) }) }) @@ -142,24 +233,24 @@ impl MemStat for ColumnarColumn { /// Ordered metadata for one generated `columnar_indexes` declaration. #[derive(Debug)] -pub struct ClusteredColumnarIndex { - rows: BTreeMap>, +pub struct ClusteredColumnarIndex { + rows: BTreeMap>, } -impl Default for ClusteredColumnarIndex { +impl Default for ClusteredColumnarIndex { fn default() -> Self { Self { rows: BTreeMap::new() } } } -impl ClusteredColumnarIndex { - pub fn insert(&mut self, key: K, row_id: ColumnRowId) { - self.rows.entry(key).or_default().insert(row_id); +impl ClusteredColumnarIndex { + pub fn insert(&mut self, key: K, slot_id: SlotId) { + self.rows.entry(key).or_default().insert(slot_id); } - pub fn remove(&mut self, key: &K, row_id: ColumnRowId) { + pub fn remove(&mut self, key: &K, slot_id: SlotId) { let remove_key = self.rows.get_mut(key).is_some_and(|rows| { - rows.remove(&row_id); + rows.remove(&slot_id); rows.is_empty() }); if remove_key { @@ -167,19 +258,19 @@ impl ClusteredColumnarIndex { } } - pub fn exact(&self, key: &K) -> Vec { + pub fn exact(&self, key: &K) -> Vec { self.rows .get(key) .map(|rows| rows.iter().copied().collect()) .unwrap_or_default() } - pub fn ordered_row_ids(&self) -> Vec { + pub fn ordered_slot_ids(&self) -> Vec { self.rows.values().flat_map(|rows| rows.iter().copied()).collect() } } -impl MemStat for ClusteredColumnarIndex { +impl MemStat for ClusteredColumnarIndex { fn heap_size(&self) -> usize { self.rows.heap_size() } @@ -194,33 +285,53 @@ mod tests { use super::*; #[test] - fn chunks_are_addressed_by_stable_row_id() { - let mut column = ColumnarColumn::new(2, ColumnCompression::Auto); - column.set(ColumnRowId::new(3), 30); - column.set(ColumnRowId::new(0), 10); + fn chunks_are_addressed_by_configured_slot_id() { + let mut column = ColumnarColumn::new(2, ColumnCompression::None); + column.set(ColumnSlotId16(3), 30); + column.set(ColumnSlotId16(0), 10); assert_eq!(column.chunk_rows(), 2); - assert_eq!(column.get(ColumnRowId::new(3)), Some(&30)); + assert_eq!(column.get(ColumnSlotId16(3)), Some(&30)); assert_eq!( - column.iter().map(|(id, value)| (id.get(), *value)).collect::>(), + column + .iter::() + .map(|(id, value)| (id.0, *value)) + .collect::>(), [(0, 10), (3, 30)] ); - assert_eq!(column.remove(ColumnRowId::new(0)), Some(10)); - assert!(column.get(ColumnRowId::new(0)).is_none()); + assert_eq!(column.remove(ColumnSlotId16(0)), Some(10)); + assert!(column.get(ColumnSlotId16(0)).is_none()); } #[test] fn clustered_index_preserves_key_order() { let mut index = ClusteredColumnarIndex::default(); - index.insert((2, 1), ColumnRowId::new(1)); - index.insert((1, 9), ColumnRowId::new(2)); - index.insert((1, 9), ColumnRowId::new(0)); + index.insert((2, 1), ColumnSlotId8(1)); + index.insert((1, 9), ColumnSlotId8(2)); + index.insert((1, 9), ColumnSlotId8(0)); - assert_eq!(index.exact(&(1, 9)), [ColumnRowId::new(0), ColumnRowId::new(2)]); + assert_eq!(index.exact(&(1, 9)), [ColumnSlotId8(0), ColumnSlotId8(2)]); + assert_eq!( + index.ordered_slot_ids(), + [ColumnSlotId8(0), ColumnSlotId8(2), ColumnSlotId8(1)] + ); + } + + #[test] + fn widths_have_expected_capacity_boundaries() { + assert_eq!(ColumnSlotId8::try_from_position(255), Some(ColumnSlotId8(255))); + assert_eq!(ColumnSlotId8::try_from_position(256), None); + assert_eq!(ColumnSlotId16::try_from_position(65_535), Some(ColumnSlotId16(65_535))); + assert_eq!(ColumnSlotId16::try_from_position(65_536), None); + assert_eq!( + ColumnSlotId32::try_from_position(u32::MAX as u64), + Some(ColumnSlotId32(u32::MAX)) + ); + assert_eq!(ColumnSlotId32::try_from_position(u32::MAX as u64 + 1), None); assert_eq!( - index.ordered_row_ids(), - [ColumnRowId::new(0), ColumnRowId::new(2), ColumnRowId::new(1)] + ColumnSlotId64::try_from_position(u64::MAX), + Some(ColumnSlotId64(u64::MAX)) ); } } diff --git a/src/index/table_secondary_index/mod.rs b/src/index/table_secondary_index/mod.rs index 69434a82..5713c590 100644 --- a/src/index/table_secondary_index/mod.rs +++ b/src/index/table_secondary_index/mod.rs @@ -93,6 +93,10 @@ pub enum IndexError { at: IndexNameEnum, inserted_already: Vec, }, + ColumnSlotIdExhausted { + bits: u8, + inserted_already: Vec, + }, NotFound, } @@ -106,6 +110,10 @@ where at, inserted_already: _, } => WorkTableError::AlreadyExists(at.to_string_value()), + IndexError::ColumnSlotIdExhausted { + bits, + inserted_already: _, + } => WorkTableError::ColumnSlotIdExhausted(bits), IndexError::NotFound => WorkTableError::NotFound, } } diff --git a/src/lib.rs b/src/lib.rs index a730483c..8c366d95 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,7 +15,10 @@ mod util; #[cfg(feature = "s3-support")] pub mod features; -pub use columnar::{ClusteredColumnarIndex, ColumnCompression, ColumnRowId, ColumnarColumn}; +pub use columnar::{ + ClusteredColumnarIndex, ColumnCompression, ColumnSlotId, ColumnSlotId8, ColumnSlotId16, ColumnSlotId32, + ColumnSlotId64, ColumnarColumn, ColumnarRowRef, next_columnar_incarnation, +}; pub use index::*; pub use persistence::{LoadMode, PersistedWorkTable, PersistenceConfig, PersistenceLoadError}; pub use row::*; @@ -49,12 +52,13 @@ pub mod prelude { pub use crate::table::system_info::{IndexInfo, IndexKind, SystemInfo}; pub use crate::util::{OffsetEqLink, OrderedF32Def, OrderedF64Def}; pub use crate::{ - ArcticIndex, ArcticKey, AvailableIndex, ClusteredColumnarIndex, ColumnCompression, ColumnRowId, ColumnarColumn, - CongeeIndex, CongeeKey, Difference, IndexError, IndexMap, IndexMultiMap, MultiPairRecreate, - PersistentArcticIndex, PersistentArtIndex, PersistentCongeeIndex, PersistentWtiIndex, PrimaryIndex, TableIndex, - TableIndexCdc, TableRow, TableSecondaryIndex, TableSecondaryIndexCdc, TableSecondaryIndexEventsOps, - TableSecondaryIndexInfo, UniqueIndex, UnsizedNode, UpstreamIndexMap, UpstreamIndexPair, WorkTable, - WorkTableError, vacuum::EmptyDataVacuum, vacuum::VacuumPersistence, vacuum::WorkTableVacuum, + ArcticIndex, ArcticKey, AvailableIndex, ClusteredColumnarIndex, ColumnCompression, ColumnSlotId, ColumnSlotId8, + ColumnSlotId16, ColumnSlotId32, ColumnSlotId64, ColumnarColumn, ColumnarRowRef, CongeeIndex, CongeeKey, + Difference, IndexError, IndexMap, IndexMultiMap, MultiPairRecreate, PersistentArcticIndex, PersistentArtIndex, + PersistentCongeeIndex, PersistentWtiIndex, PrimaryIndex, TableIndex, TableIndexCdc, TableRow, + TableSecondaryIndex, TableSecondaryIndexCdc, TableSecondaryIndexEventsOps, TableSecondaryIndexInfo, + UniqueIndex, UnsizedNode, UpstreamIndexMap, UpstreamIndexPair, WorkTable, WorkTableError, + next_columnar_incarnation, vacuum::EmptyDataVacuum, vacuum::VacuumPersistence, vacuum::WorkTableVacuum, }; pub use data_bucket::{ DATA_VERSION, DataPage, GENERAL_HEADER_SIZE, GeneralHeader, GeneralPage, INNER_PAGE_SIZE, IndexPage, Interval, diff --git a/src/table/mod.rs b/src/table/mod.rs index 3af0b187..1c3573e9 100644 --- a/src/table/mod.rs +++ b/src/table/mod.rs @@ -254,6 +254,13 @@ where Err(WorkTableError::AlreadyExists(at.to_string_value())) } + IndexError::ColumnSlotIdExhausted { bits, inserted_already } => { + self.primary_index.remove(&pk, link); + self.indexes.delete_from_indexes(row, link, inserted_already)?; + self.data.delete(link).map_err(WorkTableError::PagesError)?; + + Err(WorkTableError::ColumnSlotIdExhausted(bits)) + } IndexError::NotFound => Err(WorkTableError::NotFound), }; } @@ -338,6 +345,32 @@ where (ack_op, WorkTableError::AlreadyExists(at.to_string_value())) } } + IndexError::ColumnSlotIdExhausted { bits, inserted_already } => { + let (_, rollback_pk_events) = self.primary_index.remove_cdc(pk.clone(), link); + let rollback_pk_events = convert_change_events(rollback_pk_events); + + let (rollback_secondary_events, _) = + self.indexes + .delete_from_indexes_cdc(row.clone(), link, inserted_already); + + let mut merged_primary_events = primary_key_events.clone(); + merged_primary_events.extend(rollback_pk_events); + + let mut merged_secondary_events = secondary_events.clone(); + merged_secondary_events.extend(rollback_secondary_events); + + let ack_op = Operation::Acknowledge(AcknowledgeOperation { + id: OperationId::Single(Uuid::now_v7()), + primary_key_events: merged_primary_events, + secondary_keys_events: merged_secondary_events, + }); + + if let Err(e) = self.data.delete(link) { + (ack_op, WorkTableError::PagesError(e)) + } else { + (ack_op, WorkTableError::ColumnSlotIdExhausted(bits)) + } + } IndexError::NotFound => { let ack_op = Operation::Acknowledge(AcknowledgeOperation { id: OperationId::Single(Uuid::now_v7()), @@ -439,6 +472,13 @@ where Err(WorkTableError::AlreadyExists(at.to_string_value())) } + IndexError::ColumnSlotIdExhausted { bits, inserted_already } => { + self.primary_index.insert(pk.clone(), old_link); + self.indexes.delete_from_indexes(row_new, new_link, inserted_already)?; + self.data.delete(new_link).map_err(WorkTableError::PagesError)?; + + Err(WorkTableError::ColumnSlotIdExhausted(bits)) + } IndexError::NotFound => Err(WorkTableError::NotFound), }; } @@ -536,6 +576,32 @@ where (ack_op, WorkTableError::AlreadyExists(at.to_string_value())) } } + IndexError::ColumnSlotIdExhausted { bits, inserted_already } => { + let (_, rollback_pk_events) = self.primary_index.insert_cdc(pk.clone(), old_link); + let rollback_pk_events = convert_change_events(rollback_pk_events); + + let (rollback_secondary_events, _) = + self.indexes + .delete_from_indexes_cdc(row_new, new_link, inserted_already); + + let mut merged_primary_events = primary_key_events.clone(); + merged_primary_events.extend(rollback_pk_events); + + let mut merged_secondary_events = secondary_events.clone(); + merged_secondary_events.extend(rollback_secondary_events); + + let ack_op = Operation::Acknowledge(AcknowledgeOperation { + id: OperationId::Single(Uuid::now_v7()), + primary_key_events: merged_primary_events, + secondary_keys_events: merged_secondary_events, + }); + + if let Err(e) = self.data.delete(new_link) { + (ack_op, WorkTableError::PagesError(e)) + } else { + (ack_op, WorkTableError::ColumnSlotIdExhausted(bits)) + } + } IndexError::NotFound => { let ack_op = Operation::Acknowledge(AcknowledgeOperation { id: OperationId::Single(Uuid::now_v7()), @@ -591,6 +657,8 @@ pub enum WorkTableError { AlreadyExists(#[error(not(source))] String), #[display("Row with this primary key already exists")] PrimaryAlreadyExists, + #[display("ColumnSlotId{} capacity is exhausted", _0)] + ColumnSlotIdExhausted(#[error(not(source))] u8), SerializeError, SecondaryIndexError, PrimaryUpdateTry, diff --git a/tests/worktable/columnar.rs b/tests/worktable/columnar.rs index d2183002..fa12ec4c 100644 --- a/tests/worktable/columnar.rs +++ b/tests/worktable/columnar.rs @@ -7,14 +7,17 @@ worktable!( persist: false, columns: { id: u64 primary_key, - host_id: u64 columnar(chunk_rows(2), compression(auto)), - timestamp: i64 columnar(chunk_rows(3), compression(none)), - temperature: i64 columnar(chunk_rows(2), compression(auto)), + host_id: u64 columnar(chunk_rows(2), compression(none)), + timestamp: i64 columnar, + temperature: i64 columnar(chunk_rows(2)), label: String, }, + config: { + columnar_slot_id: ColumnSlotId16, + columnar_chunk_rows: 4, + }, columnar_indexes: { host_time: { - columns: [host_id, timestamp, temperature], cluster_by: [host_id, timestamp], }, }, @@ -28,6 +31,19 @@ worktable!( }, ); +worktable!( + name: TinyColumnarIds, + persist: false, + columns: { + id: u16 primary_key, + value: u16 columnar(chunk_rows(32), compression(none)), + }, + config: { + columnar_slot_id: ColumnSlotId8, + columnar_chunk_rows: 32, + }, +); + // Compile coverage for the persisted derive path. The columnar replica is // intentionally skipped by the existing index file format and rebuilt from // authoritative rows after load. @@ -36,17 +52,44 @@ worktable!( persist: true, columns: { id: u64 primary_key, - host_id: u64 columnar(chunk_rows(4), compression(auto)), + host_id: u64 columnar(chunk_rows(4), compression(none)), timestamp: i64 columnar(chunk_rows(4), compression(none)), }, columnar_indexes: { host_time: { - columns: [host_id, timestamp], cluster_by: [host_id, timestamp], }, }, ); +worktable!( + name: CongeeColumnarSideIndex, + persist: false, + columns: { + id: u64 primary_key using congee, + value: u64 columnar, + }, + columnar_indexes: { + value_order: { + cluster_by: [value], + }, + }, +); + +worktable!( + name: ArcticColumnarSideIndex, + persist: false, + columns: { + id: u64 primary_key using arctic, + value: u64 columnar, + }, + columnar_indexes: { + value_order: { + cluster_by: [value], + }, + }, +); + #[tokio::test] async fn columnar_fields_and_clustered_index_follow_mutations() { let table = ColumnarMetricsWorkTable::default(); @@ -69,13 +112,13 @@ async fn columnar_fields_and_clustered_index_follow_mutations() { }) .unwrap(); - let host_two = table.columnar_select_host_time(2, 20); + let host_two = table.columnar_select_host_time(2, 20).unwrap(); assert_eq!(host_two.len(), 1); - assert_eq!(table.columnar_resolve_primary_keys(&host_two)[0].1.0, 1); - assert_eq!(table.columnar_project_temperature(&host_two)[0].1, 72); + assert_eq!(host_two[0].primary_key().0, 1); + assert_eq!(table.columnar_project_temperature(&host_two).unwrap()[0].1, 72); - let ordered = table.columnar_scan_host_time(); - let projected = table.columnar_project_host_id(&ordered); + let ordered = table.columnar_scan_host_time().unwrap(); + let projected = table.columnar_project_host_id(&ordered).unwrap(); assert_eq!(projected.iter().map(|(_, value)| *value).collect::>(), [1, 2]); table @@ -89,27 +132,30 @@ async fn columnar_fields_and_clustered_index_follow_mutations() { .await .unwrap(); - assert!(table.columnar_select_host_time(2, 20).is_empty()); - let updated = table.columnar_select_host_time(3, 30); + assert!(table.columnar_select_host_time(2, 20).unwrap().is_empty()); + let updated = table.columnar_select_host_time(3, 30).unwrap(); assert_eq!(updated, host_two, "row identity survives an update"); - assert_eq!(table.columnar_project_temperature(&updated)[0].1, 75); + assert_eq!(table.columnar_project_temperature(&updated).unwrap()[0].1, 75); table .update_temperature_by_id(TemperatureByIdQuery { temperature: 76 }, 1) .await .unwrap(); - assert_eq!(table.columnar_project_temperature(&updated)[0].1, 76); + assert_eq!(table.columnar_project_temperature(&updated).unwrap()[0].1, 76); table .update_timestamp_by_id_in_place(|value| *value = 40.into(), 1) .await .unwrap(); - assert!(table.columnar_select_host_time(3, 30).is_empty()); - assert_eq!(table.columnar_select_host_time(3, 40), updated); + assert!(table.columnar_is_dirty()); + table.rebuild_columnar().unwrap(); + assert!(!table.columnar_is_dirty()); + assert!(table.columnar_select_host_time(3, 30).unwrap().is_empty()); + assert_eq!(table.columnar_select_host_time(3, 40).unwrap(), updated); table.delete(2).await.unwrap(); - assert_eq!(table.columnar_scan_host_id().len(), 1); - assert_eq!(table.columnar_scan_host_time(), updated); + assert_eq!(table.columnar_scan_host_id().unwrap().len(), 1); + assert_eq!(table.columnar_scan_host_time().unwrap(), updated); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -124,7 +170,7 @@ async fn concurrent_reinsert_and_columnar_refresh_preserve_row_identity() { label: "short".to_string(), }) .unwrap(); - let stable_id = table.columnar_select_host_time(1, 0)[0]; + let stable_id = table.columnar_select_host_time(1, 0).unwrap()[0].clone(); let updater = { let table = Arc::clone(&table); @@ -149,11 +195,104 @@ async fn concurrent_reinsert_and_columnar_refresh_preserve_row_identity() { }; for _ in 0..200 { - for (row_id, _) in table.columnar_scan_timestamp() { + for (row_id, _) in table.columnar_scan_timestamp().unwrap() { assert_eq!(row_id, stable_id); } } updater.await.unwrap(); - assert_eq!(table.columnar_select_host_time(1, 200), [stable_id]); + assert_eq!(table.columnar_select_host_time(1, 200).unwrap(), [stable_id]); +} + +#[tokio::test] +async fn configured_slot_id_capacity_is_checked_and_deleted_slots_are_safe_to_reuse() { + let table = TinyColumnarIdsWorkTable::default(); + for id in 0..=u8::MAX as u16 { + table.insert(TinyColumnarIdsRow { id, value: id }).unwrap(); + } + + let stale = table + .columnar_scan_value() + .unwrap() + .into_iter() + .find(|(row_ref, _)| row_ref.primary_key().0 == 7) + .unwrap() + .0; + let error = table.insert(TinyColumnarIdsRow { id: 256, value: 256 }).unwrap_err(); + assert!(matches!(error, WorkTableError::ColumnSlotIdExhausted(8))); + assert!( + table.select(256).is_none(), + "capacity failure rolls back the authoritative row" + ); + + table.delete(7).await.unwrap(); + table.insert(TinyColumnarIdsRow { id: 256, value: 256 }).unwrap(); + + let replacement = table + .columnar_scan_value() + .unwrap() + .into_iter() + .find(|(row_ref, _)| row_ref.primary_key().0 == 256) + .unwrap() + .0; + assert!( + table.columnar_project_value(&[stale]).unwrap().is_empty(), + "a recycled slot cannot alias a different primary key" + ); + + table.delete(256).await.unwrap(); + table.insert(TinyColumnarIdsRow { id: 256, value: 999 }).unwrap(); + assert!( + table.columnar_project_value(&[replacement]).unwrap().is_empty(), + "delete and reinsert of the same primary key cannot revive a stale row reference" + ); + assert_eq!(table.columnar_slots_in_use(), 256); + assert_eq!(table.columnar_slots_high_water(), 256); +} + +#[test] +fn row_refs_are_scoped_to_one_table_incarnation() { + let first = TinyColumnarIdsWorkTable::default(); + first.insert(TinyColumnarIdsRow { id: 1, value: 11 }).unwrap(); + let retained = first.columnar_scan_value().unwrap()[0].0.clone(); + + let second = TinyColumnarIdsWorkTable::default(); + second.insert(TinyColumnarIdsRow { id: 1, value: 22 }).unwrap(); + + assert!( + second.columnar_project_value(&[retained]).unwrap().is_empty(), + "a ref from another table instance must not alias the same primary key and slot" + ); +} + +#[tokio::test] +async fn columnar_side_indexes_compose_with_congee_and_arctic_using_backends() { + macro_rules! exercise { + ($table:ident, $row:ident) => {{ + let table = $table::default(); + table.insert($row { id: 1, value: 20 }).unwrap(); + table.insert($row { id: 2, value: 10 }).unwrap(); + + let ordered = table.columnar_scan_value_order().unwrap(); + assert_eq!( + table + .columnar_project_value(&ordered) + .unwrap() + .into_iter() + .map(|(_, value)| value) + .collect::>(), + [10, 20] + ); + + table.update($row { id: 1, value: 5 }).await.unwrap(); + assert_eq!(table.columnar_select_value_order(20).unwrap(), []); + assert_eq!(table.columnar_select_value_order(5).unwrap().len(), 1); + + table.delete(2).await.unwrap(); + assert_eq!(table.columnar_scan_value().unwrap().len(), 1); + }}; + } + + exercise!(CongeeColumnarSideIndexWorkTable, CongeeColumnarSideIndexRow); + exercise!(ArcticColumnarSideIndexWorkTable, ArcticColumnarSideIndexRow); }