diff --git a/.DS_Store b/.DS_Store index 3a10d1d..893d4ad 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/crates/binary/README.md b/crates/binary/README.md index 28fde6f..9e42a2f 100644 --- a/crates/binary/README.md +++ b/crates/binary/README.md @@ -62,7 +62,7 @@ fn main() -> cynos_core::Result<()> { - Use `SchemaLayout::from_projection()` for projected queries. - Use `SchemaLayout::from_schemas()` when encoding joined rows. -- Enable the `wasm` feature if you want `wasm-bindgen` exports such as `BinaryResult::asView()`. +- Enable the `wasm` feature if you want `wasm-bindgen` exports such as `BinaryResult::asView()` and `BinaryResult::intoTransferable()`. ## License diff --git a/crates/binary/src/encoder.rs b/crates/binary/src/encoder.rs index 54c6a67..befcdec 100644 --- a/crates/binary/src/encoder.rs +++ b/crates/binary/src/encoder.rs @@ -39,6 +39,14 @@ impl BinaryEncoder { /// Encode a batch of rows pub fn encode_rows(&mut self, rows: &[Rc]) { + self.encode_rows_iter(rows.iter()); + } + + /// Encode a batch of rows from any iterator over shared rows. + pub fn encode_rows_iter<'a, I>(&mut self, rows: I) + where + I: IntoIterator>, + { // Reserve space for header (will be written at the end) if self.buffer.is_empty() { self.buffer.resize(HEADER_SIZE, 0); diff --git a/crates/binary/src/lib.rs b/crates/binary/src/lib.rs index 8eadf59..2a76017 100644 --- a/crates/binary/src/lib.rs +++ b/crates/binary/src/lib.rs @@ -132,6 +132,15 @@ impl BinaryResult { js_sys::Uint8Array::from(&self.buffer[..]) } + /// Copy the buffer into a standalone Uint8Array suitable for `postMessage` + /// transfer lists and other ownership-taking APIs. + /// + /// Unlike `asView()`, the returned bytes are no longer tied to WASM memory. + #[wasm_bindgen(js_name = intoTransferable)] + pub fn into_transferable(self) -> js_sys::Uint8Array { + js_sys::Uint8Array::from(&self.buffer[..]) + } + /// Get a zero-copy Uint8Array view into WASM memory. /// WARNING: This view becomes invalid if WASM memory grows or if this BinaryResult is freed. /// The caller must ensure the BinaryResult outlives any use of the returned view. diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a9d3897..2857b0a 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -49,8 +49,8 @@ mod value; pub use error::{Error, Result}; pub use row::{ - next_row_id, reserve_row_ids, set_next_row_id, set_next_row_id_if_greater, Row, RowId, - DUMMY_ROW_ID, + aggregate_group_row_id, join_row_id, left_join_null_row_id, next_row_id, reserve_row_ids, + right_join_null_row_id, set_next_row_id, set_next_row_id_if_greater, Row, RowId, DUMMY_ROW_ID, }; pub use types::DataType; pub use value::{JsonbValue, Value}; diff --git a/crates/core/src/row.rs b/crates/core/src/row.rs index 1b4cbc0..154c3dc 100644 --- a/crates/core/src/row.rs +++ b/crates/core/src/row.rs @@ -4,6 +4,7 @@ use crate::value::Value; use alloc::vec::Vec; +use core::hash::{Hash, Hasher}; use core::sync::atomic::{AtomicU64, Ordering}; /// Unique identifier for a row. @@ -13,9 +14,63 @@ pub type RowId = u64; /// (e.g., the result of joining two rows). pub const DUMMY_ROW_ID: RowId = u64::MAX; +const ROW_ID_HASH_OFFSET: u64 = 0xCBF2_9CE4_8422_2325; +const ROW_ID_HASH_PRIME: u64 = 0x0000_0001_0000_01B3; +const JOIN_ROW_ID_DOMAIN: u64 = 0x4A4F_494E_5F49_4E4E; +const LEFT_JOIN_NULL_ROW_ID_DOMAIN: u64 = 0x4A4F_494E_5F4C_4E55; +const RIGHT_JOIN_NULL_ROW_ID_DOMAIN: u64 = 0x4A4F_494E_5F52_4E55; +const AGGREGATE_GROUP_ROW_ID_DOMAIN: u64 = 0x4147_475F_4752_4F55; + /// Global row ID counter for generating unique row IDs. static NEXT_ROW_ID: AtomicU64 = AtomicU64::new(0); +struct RowIdHasher { + state: u64, +} + +impl RowIdHasher { + #[inline] + fn new(domain: u64) -> Self { + Self { + state: ROW_ID_HASH_OFFSET ^ domain.rotate_left(7), + } + } +} + +impl Hasher for RowIdHasher { + #[inline] + fn finish(&self) -> u64 { + self.state + } + + #[inline] + fn write(&mut self, bytes: &[u8]) { + for &byte in bytes { + self.state ^= u64::from(byte); + self.state = self.state.wrapping_mul(ROW_ID_HASH_PRIME); + } + } +} + +#[inline] +fn finalize_derived_row_id(id: u64) -> RowId { + if id == DUMMY_ROW_ID { + DUMMY_ROW_ID.wrapping_sub(1) + } else { + id + } +} + +#[inline] +fn hash_row_id(domain: u64, feed: F) -> RowId +where + F: FnOnce(&mut RowIdHasher), +{ + let mut hasher = RowIdHasher::new(domain); + feed(&mut hasher); + finalize_derived_row_id(hasher.finish()) +} + /// Gets the next unique row ID. pub fn next_row_id() -> RowId { NEXT_ROW_ID.fetch_add(1, Ordering::SeqCst) @@ -37,6 +92,42 @@ pub fn set_next_row_id_if_greater(id: RowId) { NEXT_ROW_ID.fetch_max(id, Ordering::SeqCst); } +/// Derives a deterministic row ID for an inner join row. +#[inline] +pub fn join_row_id(left_id: RowId, right_id: RowId) -> RowId { + hash_row_id(JOIN_ROW_ID_DOMAIN, |hasher| { + left_id.hash(hasher); + right_id.hash(hasher); + }) +} + +/// Derives a deterministic row ID for a left/full outer join row with a NULL right side. +#[inline] +pub fn left_join_null_row_id(left_id: RowId) -> RowId { + hash_row_id(LEFT_JOIN_NULL_ROW_ID_DOMAIN, |hasher| { + left_id.hash(hasher); + }) +} + +/// Derives a deterministic row ID for a right/full outer join row with a NULL left side. +#[inline] +pub fn right_join_null_row_id(right_id: RowId) -> RowId { + hash_row_id(RIGHT_JOIN_NULL_ROW_ID_DOMAIN, |hasher| { + right_id.hash(hasher); + }) +} + +/// Derives a deterministic row ID for an aggregate group row from its group key. +#[inline] +pub fn aggregate_group_row_id(group_key: &[Value]) -> RowId { + hash_row_id(AGGREGATE_GROUP_ROW_ID_DOMAIN, |hasher| { + group_key.len().hash(hasher); + for value in group_key { + value.hash(hasher); + } + }) +} + /// A row in a database table. #[derive(Clone, Debug)] pub struct Row { @@ -243,4 +334,29 @@ mod tests { assert!(row.is_dummy()); assert_eq!(row.version(), 5); } + + #[test] + fn test_join_row_id_is_deterministic() { + assert_eq!(join_row_id(1, 2), join_row_id(1, 2)); + assert_ne!(join_row_id(1, 2), join_row_id(2, 1)); + } + + #[test] + fn test_derived_row_id_domains_do_not_collide_for_same_input() { + let base = 42; + assert_ne!(join_row_id(base, 7), left_join_null_row_id(base)); + assert_ne!(left_join_null_row_id(base), right_join_null_row_id(base)); + } + + #[test] + fn test_aggregate_group_row_id_depends_on_group_key() { + assert_eq!( + aggregate_group_row_id(&[Value::Int64(1), Value::String("A".into())]), + aggregate_group_row_id(&[Value::Int64(1), Value::String("A".into())]), + ); + assert_ne!( + aggregate_group_row_id(&[Value::Int64(1)]), + aggregate_group_row_id(&[Value::Int64(2)]), + ); + } } diff --git a/crates/core/src/schema/index.rs b/crates/core/src/schema/index.rs index 09a834c..4306d76 100644 --- a/crates/core/src/schema/index.rs +++ b/crates/core/src/schema/index.rs @@ -72,6 +72,10 @@ pub struct IndexDef { unique: bool, /// Index type. index_type: IndexType, + /// Optional normalized JSON paths covered by a GIN index. + /// + /// `None` means the index covers the full JSON tree. + gin_paths: Option>, } impl IndexDef { @@ -87,6 +91,7 @@ impl IndexDef { columns, unique: false, index_type: IndexType::BTree, + gin_paths: None, } } @@ -102,6 +107,12 @@ impl IndexDef { self } + /// Restricts a GIN index to a set of normalized JSON paths. + pub fn with_gin_paths(mut self, paths: Vec) -> Self { + self.gin_paths = if paths.is_empty() { None } else { Some(paths) }; + self + } + /// Returns the index name. #[inline] pub fn name(&self) -> &str { @@ -137,6 +148,24 @@ impl IndexDef { self.index_type } + /// Returns the normalized JSON paths covered by this GIN index. + #[inline] + pub fn gin_paths(&self) -> Option<&[String]> { + self.gin_paths.as_deref() + } + + /// Returns whether this GIN index can answer lookups for the given path. + #[inline] + pub fn supports_gin_path(&self, path: &str) -> bool { + if self.index_type != IndexType::Gin { + return false; + } + + self.gin_paths.as_ref().map_or(true, |paths| { + paths.iter().any(|candidate| candidate == path) + }) + } + /// Returns whether this is a single-column index. #[inline] pub fn is_single_column(&self) -> bool { @@ -158,6 +187,7 @@ impl PartialEq for IndexDef { #[cfg(test)] mod tests { use super::*; + use alloc::string::ToString; use alloc::vec; #[test] @@ -199,4 +229,18 @@ mod tests { assert!(!idx.is_single_column()); assert_eq!(idx.columns().len(), 2); } + + #[test] + fn test_gin_index_paths() { + let idx = IndexDef::new("idx_profile", "users", vec![IndexedColumn::new("profile")]) + .index_type(IndexType::Gin) + .with_gin_paths(vec!["customer.tier".into(), "risk.bucket".into()]); + + assert!(idx.supports_gin_path("customer.tier")); + assert!(!idx.supports_gin_path("flags.strategic")); + assert_eq!( + idx.gin_paths().unwrap(), + ["customer.tier".to_string(), "risk.bucket".to_string()] + ); + } } diff --git a/crates/core/src/schema/table.rs b/crates/core/src/schema/table.rs index 8f91ab7..11066f1 100644 --- a/crates/core/src/schema/table.rs +++ b/crates/core/src/schema/table.rs @@ -7,6 +7,7 @@ use crate::error::{Error, Result}; use crate::types::DataType; use alloc::format; use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; /// A table definition in the database schema. @@ -286,6 +287,43 @@ impl TableBuilder { Ok(self) } + /// Adds a path-restricted GIN index for a JSONB column. + pub fn add_jsonb_index( + mut self, + name: impl Into, + column: &str, + paths: &[&str], + ) -> Result { + let name = name.into(); + Self::check_naming_rules(&name)?; + + let column_def = self + .columns + .iter() + .find(|c| c.name() == column) + .ok_or_else(|| Error::InvalidSchema { + message: format!("Column not found: {}", column), + })?; + + if column_def.data_type() != DataType::Jsonb { + return Err(Error::InvalidSchema { + message: format!("JSONB index requires Jsonb column: {}", column), + }); + } + + let normalized_paths = paths + .iter() + .filter(|path| !path.is_empty()) + .map(|path| (*path).to_string()) + .collect(); + + let idx = IndexDef::new(name, &self.name, vec![IndexedColumn::new(column)]) + .index_type(IndexType::Gin) + .with_gin_paths(normalized_paths); + self.indices.push(idx); + Ok(self) + } + /// Adds a hash index for point-lookups on scalar columns. pub fn add_hash_index( mut self, @@ -522,6 +560,31 @@ mod tests { assert_eq!(index.get_index_type(), IndexType::Hash); assert!(index.is_unique()); } + + #[test] + fn test_add_jsonb_index_paths() { + let table = TableBuilder::new("issues") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("metadata", DataType::Jsonb) + .unwrap() + .add_jsonb_index( + "idx_issues_metadata", + "metadata", + &["customer.tier", "risk.bucket"], + ) + .unwrap() + .build() + .unwrap(); + + let index = table.get_index("idx_issues_metadata").unwrap(); + assert_eq!(index.get_index_type(), IndexType::Gin); + assert_eq!( + index.gin_paths().unwrap(), + ["customer.tier".to_string(), "risk.bucket".to_string()] + ); + } } #[test] diff --git a/crates/database/Cargo.toml b/crates/database/Cargo.toml index 301aa79..6eaa691 100644 --- a/crates/database/Cargo.toml +++ b/crates/database/Cargo.toml @@ -46,7 +46,7 @@ harness = false default = ["jsonb", "incremental"] jsonb = [] incremental = [] -benchmark = [] +benchmark = ["cynos-storage/benchmark"] [package.metadata.wasm-pack.profile.release] wasm-opt = false diff --git a/crates/database/README.md b/crates/database/README.md index cbff126..287702a 100644 --- a/crates/database/README.md +++ b/crates/database/README.md @@ -44,6 +44,7 @@ import { col, createDatabase, initCynos, + snapshotSchemaLayout, } from '@cynos/core'; await initCynos(); @@ -73,6 +74,13 @@ const stop = stream.subscribe((rows) => { console.log('current result', rows); }); +const binaryLayout = snapshotSchemaLayout(stream.getSchemaLayout()); +const stopBinary = stream.subscribeBinary((binary) => { + const transferable = binary.intoTransferable(); + const rs = new ResultSet(transferable, binaryLayout); + console.log('binary snapshot', rs.toArray()); +}); + const trace = db .select('*') .from('users') diff --git a/crates/database/src/convert.rs b/crates/database/src/convert.rs index e4f23d7..06702f0 100644 --- a/crates/database/src/convert.rs +++ b/crates/database/src/convert.rs @@ -4,12 +4,162 @@ //! internal types (Value, Row, etc.). use alloc::collections::BTreeMap; -use alloc::rc::Rc; +use alloc::rc::{Rc, Weak}; use alloc::string::String; use alloc::vec::Vec; use cynos_core::schema::Table; use cynos_core::{DataType, Row, Value}; +use hashbrown::HashMap; use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; + +type JsonbJsCache = HashMap, JsValue>; +type GraphqlFieldKeyCache = HashMap, JsValue>; +type GraphqlObjectJsCache = + HashMap<*const [cynos_gql::ResponseField], GraphqlSharedJsCacheEntry>; +type GraphqlListJsCache = + HashMap<*const [cynos_gql::ResponseValue], GraphqlSharedJsCacheEntry>; + +struct GraphqlSharedJsCacheEntry { + weak: Weak<[T]>, + value: JsValue, +} + +#[derive(Default)] +pub(crate) struct GraphqlJsEncodeCache { + jsonb_cache: JsonbJsCache, + jsonb_cache_bytes: usize, + field_key_cache: GraphqlFieldKeyCache, + object_cache: GraphqlObjectJsCache, + list_cache: GraphqlListJsCache, +} + +#[derive(Default)] +pub(crate) struct GraphqlRootListJsCache { + list_len: Option, + array: Option, +} + +impl GraphqlJsEncodeCache { + fn maybe_prune(&mut self) { + self.maybe_prune_shared_with_limits(65_536, 49_152); + self.maybe_prune_field_keys_with_limits(4_096, 3_072); + self.maybe_prune_jsonb_with_limits(8_192, 6_144, 4 * 1024 * 1024, 3 * 1024 * 1024); + } + + fn maybe_prune_shared_with_limits(&mut self, max_entries: usize, target_entries: usize) { + if self.object_cache.len() + self.list_cache.len() <= max_entries { + return; + } + + self.object_cache + .retain(|_, entry| entry.weak.upgrade().is_some()); + self.list_cache + .retain(|_, entry| entry.weak.upgrade().is_some()); + + let target_entries = target_entries.min(max_entries); + while self.object_cache.len() + self.list_cache.len() > target_entries { + let total_len = self.object_cache.len() + self.list_cache.len(); + let to_remove = total_len.saturating_sub(target_entries); + if self.object_cache.len() >= self.list_cache.len() { + self.evict_object_entries(to_remove); + } else { + self.evict_list_entries(to_remove); + } + } + } + + fn maybe_prune_field_keys_with_limits(&mut self, max_entries: usize, target_entries: usize) { + if self.field_key_cache.len() <= max_entries { + return; + } + + let target_entries = target_entries.min(max_entries); + let to_remove = self.field_key_cache.len().saturating_sub(target_entries); + if to_remove == 0 { + return; + } + + let keys: Vec<_> = self + .field_key_cache + .keys() + .take(to_remove) + .cloned() + .collect(); + for key in keys { + self.field_key_cache.remove(key.as_ref()); + } + } + + fn maybe_prune_jsonb_with_limits( + &mut self, + max_entries: usize, + target_entries: usize, + max_bytes: usize, + target_bytes: usize, + ) { + if self.jsonb_cache.len() <= max_entries && self.jsonb_cache_bytes <= max_bytes { + return; + } + + let target_entries = target_entries.min(max_entries); + let target_bytes = target_bytes.min(max_bytes); + let mut projected_len = self.jsonb_cache.len(); + let mut projected_bytes = self.jsonb_cache_bytes; + let mut keys_to_remove = Vec::new(); + + for key in self.jsonb_cache.keys() { + if projected_len <= target_entries && projected_bytes <= target_bytes { + break; + } + projected_len = projected_len.saturating_sub(1); + projected_bytes = projected_bytes.saturating_sub(key.len()); + keys_to_remove.push(key.clone()); + } + + for key in keys_to_remove { + if self.jsonb_cache.remove(key.as_slice()).is_some() { + self.jsonb_cache_bytes = self.jsonb_cache_bytes.saturating_sub(key.len()); + } + } + } + + fn evict_object_entries(&mut self, count: usize) { + let keys: Vec<_> = self.object_cache.keys().take(count.max(1)).copied().collect(); + for key in keys { + self.object_cache.remove(&key); + } + } + + fn evict_list_entries(&mut self, count: usize) { + let keys: Vec<_> = self.list_cache.keys().take(count.max(1)).copied().collect(); + for key in keys { + self.list_cache.remove(&key); + } + } + + fn value_to_js(&mut self, value: &Value) -> JsValue { + match value { + Value::Jsonb(jsonb) => { + if let Some(cached) = self.jsonb_cache.get(jsonb.0.as_slice()) { + return cached.clone(); + } + + let parsed = value_to_js(value); + self.jsonb_cache_bytes = self.jsonb_cache_bytes.saturating_add(jsonb.0.len()); + self.jsonb_cache.insert(jsonb.0.clone(), parsed.clone()); + self.maybe_prune_jsonb_with_limits( + 8_192, + 6_144, + 4 * 1024 * 1024, + 3 * 1024 * 1024, + ); + parsed + } + _ => value_to_js(value), + } + } +} /// Converts a JavaScript value to an Cynos Value. /// @@ -129,16 +279,28 @@ pub fn value_to_js(value: &Value) -> JsValue { } } -/// Converts an Cynos Row to a JavaScript object. -/// -/// The returned object has properties named after the table columns. -pub fn row_to_js(row: &Row, schema: &Table) -> JsValue { +fn value_to_js_with_cache(value: &Value, jsonb_cache: &mut JsonbJsCache) -> JsValue { + match value { + Value::Jsonb(j) => { + if let Some(cached) = jsonb_cache.get(j.0.as_slice()) { + return cached.clone(); + } + + let parsed = value_to_js(value); + jsonb_cache.insert(j.0.clone(), parsed.clone()); + parsed + } + _ => value_to_js(value), + } +} + +fn row_to_js_with_cache(row: &Row, schema: &Table, jsonb_cache: &mut JsonbJsCache) -> JsValue { let obj = js_sys::Object::new(); let columns = schema.columns(); for (i, col) in columns.iter().enumerate() { if let Some(value) = row.get(i) { - let js_val = value_to_js(value); + let js_val = value_to_js_with_cache(value, jsonb_cache); js_sys::Reflect::set(&obj, &JsValue::from_str(col.name()), &js_val).ok(); } } @@ -146,6 +308,14 @@ pub fn row_to_js(row: &Row, schema: &Table) -> JsValue { obj.into() } +/// Converts an Cynos Row to a JavaScript object. +/// +/// The returned object has properties named after the table columns. +pub fn row_to_js(row: &Row, schema: &Table) -> JsValue { + let mut jsonb_cache = JsonbJsCache::new(); + row_to_js_with_cache(row, schema, &mut jsonb_cache) +} + /// Converts a JavaScript object to an Cynos Row. /// /// The object properties are matched against the table schema columns. @@ -204,9 +374,10 @@ pub fn js_array_to_rows( /// Converts a vector of Rows to a JavaScript array of objects. pub fn rows_to_js_array(rows: &[Rc], schema: &Table) -> JsValue { let arr = js_sys::Array::new_with_length(rows.len() as u32); + let mut jsonb_cache = JsonbJsCache::new(); for (i, row) in rows.iter().enumerate() { - let obj = row_to_js(row, schema); + let obj = row_to_js_with_cache(row, schema, &mut jsonb_cache); arr.set(i as u32, obj); } @@ -220,6 +391,7 @@ pub fn rows_to_js_array(rows: &[Rc], schema: &Table) -> JsValue { /// in the order they appear in the row. pub fn projected_rows_to_js_array(rows: &[Rc], column_names: &[String]) -> JsValue { let arr = js_sys::Array::new_with_length(rows.len() as u32); + let mut jsonb_cache = JsonbJsCache::new(); // Extract just the column part from qualified names and count occurrences let mut name_counts: hashbrown::HashMap<&str, usize> = hashbrown::HashMap::new(); @@ -255,7 +427,7 @@ pub fn projected_rows_to_js_array(rows: &[Rc], column_names: &[String]) -> let obj = js_sys::Object::new(); for (col_idx, col_name) in final_names.iter().enumerate() { if let Some(value) = row.get(col_idx) { - let js_val = value_to_js(value); + let js_val = value_to_js_with_cache(value, &mut jsonb_cache); js_sys::Reflect::set(&obj, &JsValue::from_str(col_name), &js_val).ok(); } } @@ -272,6 +444,7 @@ pub fn projected_rows_to_js_array(rows: &[Rc], column_names: &[String]) -> /// For duplicate column names across tables, we use `table.column` format to distinguish them. pub fn joined_rows_to_js_array(rows: &[Rc], schemas: &[&Table]) -> JsValue { let arr = js_sys::Array::new_with_length(rows.len() as u32); + let mut jsonb_cache = JsonbJsCache::new(); // First pass: count occurrences of each column name let mut name_counts: hashbrown::HashMap<&str, usize> = hashbrown::HashMap::new(); @@ -301,7 +474,7 @@ pub fn joined_rows_to_js_array(rows: &[Rc], schemas: &[&Table]) -> JsValue let obj = js_sys::Object::new(); for (col_idx, col_name) in column_names.iter().enumerate() { if let Some(value) = row.get(col_idx) { - let js_val = value_to_js(value); + let js_val = value_to_js_with_cache(value, &mut jsonb_cache); js_sys::Reflect::set(&obj, &JsValue::from_str(col_name), &js_val).ok(); } } @@ -342,12 +515,173 @@ pub fn js_to_gql_variables(js: Option<&JsValue>) -> Result Result { + let mut cache = GraphqlJsEncodeCache::default(); + gql_response_to_js_with_cache(response, &mut cache) +} + +pub(crate) fn gql_response_to_js_with_cache( + response: &cynos_gql::GraphqlResponse, + cache: &mut GraphqlJsEncodeCache, +) -> Result { let obj = js_sys::Object::new(); - let data = gql_value_to_js(&response.data); + let data = gql_value_to_js_with_cache(&response.data, cache); js_sys::Reflect::set(&obj, &JsValue::from_str("data"), &data)?; Ok(obj.into()) } +pub(crate) fn gql_response_to_js_with_root_list_patch( + response: &cynos_gql::GraphqlResponse, + cache: &mut GraphqlJsEncodeCache, + root_list_cache: &mut GraphqlRootListJsCache, + patch: Option<&cynos_gql::GraphqlRootListPatch>, +) -> Result { + let cynos_gql::ResponseValue::Object(data_fields) = &response.data else { + return gql_response_to_js_with_cache(response, cache); + }; + if data_fields.len() != 1 { + return gql_response_to_js_with_cache(response, cache); + } + + let root_field = &data_fields[0]; + let cynos_gql::ResponseValue::List(root_items) = &root_field.value else { + return gql_response_to_js_with_cache(response, cache); + }; + + let array = match (root_list_cache.list_len, root_list_cache.array.as_ref(), patch) { + ( + Some(previous_len), + Some(previous_array), + Some(cynos_gql::GraphqlRootListPatch::StablePositions(positions)), + ) if previous_len == root_items.len() => { + let next = previous_array.slice(0, previous_len as u32); + for &position in positions { + if let Some(value) = root_items.get(position) { + next.set(position as u32, gql_value_to_js_with_cache(value, cache)); + } + } + next + } + ( + Some(previous_len), + Some(previous_array), + Some(cynos_gql::GraphqlRootListPatch::Splice { + removed_positions, + inserted_positions, + updated_positions, + }), + ) => { + let next = previous_array.slice(0, previous_len as u32); + for (start, delete_count) in contiguous_remove_groups(removed_positions) { + array_splice(&next, start as u32, delete_count as u32, &[])?; + } + for (start, end) in contiguous_insert_groups(inserted_positions) { + let items = (start..end) + .filter_map(|position| root_items.get(position)) + .map(|value| gql_value_to_js_with_cache(value, cache)) + .collect::>(); + array_splice(&next, start as u32, 0, &items)?; + } + for &position in updated_positions { + if let Some(value) = root_items.get(position) { + next.set(position as u32, gql_value_to_js_with_cache(value, cache)); + } + } + next + } + _ => encode_graphql_root_list(root_items, cache), + }; + + root_list_cache.list_len = Some(root_items.len()); + root_list_cache.array = Some(array.clone()); + + let data = js_sys::Object::new(); + let root_key = cache + .field_key_cache + .entry(root_field.name.clone()) + .or_insert_with(|| JsValue::from_str(root_field.name.as_ref())) + .clone(); + js_sys::Reflect::set(&data, &root_key, &array.clone().into())?; + + let obj = js_sys::Object::new(); + js_sys::Reflect::set(&obj, &JsValue::from_str("data"), &data.into())?; + Ok(obj.into()) +} + +fn encode_graphql_root_list( + root_items: &[cynos_gql::ResponseValue], + cache: &mut GraphqlJsEncodeCache, +) -> js_sys::Array { + let array = js_sys::Array::new_with_length(root_items.len() as u32); + for (index, value) in root_items.iter().enumerate() { + array.set(index as u32, gql_value_to_js_with_cache(value, cache)); + } + array +} + +fn array_splice( + array: &js_sys::Array, + start: u32, + delete_count: u32, + items: &[JsValue], +) -> Result<(), JsValue> { + let splice = js_sys::Reflect::get(array.as_ref(), &JsValue::from_str("splice"))? + .dyn_into::()?; + let args = js_sys::Array::new_with_length((2 + items.len()) as u32); + args.set(0, JsValue::from_f64(start as f64)); + args.set(1, JsValue::from_f64(delete_count as f64)); + for (index, item) in items.iter().enumerate() { + args.set((index + 2) as u32, item.clone()); + } + splice.apply(array.as_ref(), &args)?; + Ok(()) +} + +fn contiguous_remove_groups(removed_positions: &[usize]) -> Vec<(usize, usize)> { + if removed_positions.is_empty() { + return Vec::new(); + } + + let mut positions = removed_positions.to_vec(); + positions.sort_unstable(); + + let mut groups = Vec::new(); + let mut start = positions[0]; + let mut len = 1usize; + for &position in positions.iter().skip(1) { + if position == start + len { + len += 1; + } else { + groups.push((start, len)); + start = position; + len = 1; + } + } + groups.push((start, len)); + groups.reverse(); + groups +} + +fn contiguous_insert_groups(inserted_positions: &[usize]) -> Vec<(usize, usize)> { + if inserted_positions.is_empty() { + return Vec::new(); + } + + let mut groups = Vec::new(); + let mut start = inserted_positions[0]; + let mut end = start + 1; + for &position in inserted_positions.iter().skip(1) { + if position == end { + end += 1; + } else { + groups.push((start, end)); + start = position; + end = position + 1; + } + } + groups.push((start, end)); + groups +} + fn js_to_gql_input_value(js: &JsValue) -> Result { if js.is_null() || js.is_undefined() { return Ok(cynos_gql::InputValue::Null); @@ -425,28 +759,70 @@ fn js_to_gql_input_value(js: &JsValue) -> Result Err(JsValue::from_str("Unsupported GraphQL input value")) } -fn gql_value_to_js(value: &cynos_gql::ResponseValue) -> JsValue { +fn gql_value_to_js_with_cache( + value: &cynos_gql::ResponseValue, + cache: &mut GraphqlJsEncodeCache, +) -> JsValue { match value { cynos_gql::ResponseValue::Null => JsValue::NULL, - cynos_gql::ResponseValue::Scalar(value) => value_to_js(value), + cynos_gql::ResponseValue::Scalar(value) => cache.value_to_js(value), cynos_gql::ResponseValue::List(values) => { + let cache_key = Rc::as_ptr(values); + if let Some(cached) = cache.list_cache.get(&cache_key) { + if let Some(shared) = cached.weak.upgrade() { + if Rc::ptr_eq(&shared, values) { + return cached.value.clone(); + } + } + } let array = js_sys::Array::new_with_length(values.len() as u32); for (index, value) in values.iter().enumerate() { - array.set(index as u32, gql_value_to_js(value)); + array.set(index as u32, gql_value_to_js_with_cache(value, cache)); } - array.into() + let js_value: JsValue = array.into(); + cache.list_cache.insert( + cache_key, + GraphqlSharedJsCacheEntry { + weak: Rc::downgrade(values), + value: js_value.clone(), + }, + ); + cache.maybe_prune(); + js_value } cynos_gql::ResponseValue::Object(fields) => { + let cache_key = Rc::as_ptr(fields); + if let Some(cached) = cache.object_cache.get(&cache_key) { + if let Some(shared) = cached.weak.upgrade() { + if Rc::ptr_eq(&shared, fields) { + return cached.value.clone(); + } + } + } let object = js_sys::Object::new(); - for field in fields { + for field in fields.as_ref() { + let key = cache + .field_key_cache + .entry(field.name.clone()) + .or_insert_with(|| JsValue::from_str(field.name.as_ref())) + .clone(); js_sys::Reflect::set( &object, - &JsValue::from_str(&field.name), - &gql_value_to_js(&field.value), + &key, + &gql_value_to_js_with_cache(&field.value, cache), ) .ok(); } - object.into() + let js_value: JsValue = object.into(); + cache.object_cache.insert( + cache_key, + GraphqlSharedJsCacheEntry { + weak: Rc::downgrade(fields), + value: js_value.clone(), + }, + ); + cache.maybe_prune(); + js_value } } } @@ -486,6 +862,7 @@ pub fn infer_type(js: &JsValue) -> Option { #[cfg(test)] mod tests { use super::*; + use alloc::rc::Rc; use wasm_bindgen_test::*; wasm_bindgen_test_configure!(run_in_browser); @@ -497,6 +874,65 @@ mod tests { assert_eq!(result, Value::Boolean(true)); } + #[test] + fn test_graphql_js_encode_cache_prunes_jsonb_incrementally() { + let mut cache = GraphqlJsEncodeCache::default(); + for seed in 0..5u8 { + let key = vec![seed; 3]; + cache.jsonb_cache_bytes += key.len(); + cache.jsonb_cache.insert(key, JsValue::NULL); + } + + cache.maybe_prune_jsonb_with_limits(4, 3, 12, 9); + + assert!(cache.jsonb_cache.len() <= 3); + assert!(cache.jsonb_cache_bytes <= 9); + assert!(!cache.jsonb_cache.is_empty()); + } + + #[test] + fn test_graphql_js_encode_cache_prunes_dead_shared_entries_without_full_clear() { + let mut cache = GraphqlJsEncodeCache::default(); + let live_fields: Rc<[cynos_gql::ResponseField]> = Rc::from( + vec![cynos_gql::ResponseField { + name: Rc::::from("live"), + value: cynos_gql::ResponseValue::Null, + }] + .into_boxed_slice(), + ); + let live_key = Rc::as_ptr(&live_fields); + cache.object_cache.insert( + live_key, + GraphqlSharedJsCacheEntry { + weak: Rc::downgrade(&live_fields), + value: JsValue::NULL, + }, + ); + + for _ in 0..4 { + let fields: Rc<[cynos_gql::ResponseField]> = Rc::from( + vec![cynos_gql::ResponseField { + name: Rc::::from("dead"), + value: cynos_gql::ResponseValue::Null, + }] + .into_boxed_slice(), + ); + cache.object_cache.insert( + Rc::as_ptr(&fields), + GraphqlSharedJsCacheEntry { + weak: Rc::downgrade(&fields), + value: JsValue::NULL, + }, + ); + drop(fields); + } + + cache.maybe_prune_shared_with_limits(2, 1); + + assert_eq!(cache.object_cache.len() + cache.list_cache.len(), 1); + assert!(cache.object_cache.contains_key(&live_key)); + } + #[wasm_bindgen_test] fn test_js_to_value_int32() { let js = JsValue::from_f64(42.0); diff --git a/crates/database/src/database.rs b/crates/database/src/database.rs index fe1d967..edfc96a 100644 --- a/crates/database/src/database.rs +++ b/crates/database/src/database.rs @@ -6,20 +6,34 @@ use crate::binary_protocol::SchemaLayoutCache; use crate::convert::{gql_response_to_js, js_to_gql_variables}; use crate::dataflow_compiler::compile_to_dataflow; -use crate::live_runtime::{LiveDependencySet, LivePlan, LiveRegistry}; +use crate::live_runtime::{ + collect_trace_bootstrap_source_bindings, LiveDependencySet, LivePlan, LiveRegistry, + RowsSnapshotDependencyGraph, RowsSnapshotDirectedJoinEdge, RowsSnapshotLookupPrimitive, + RowsSnapshotRootSubsetMetadata, RowsSnapshotRootSubsetPlan, RowsSnapshotRootSubsetVariants, +}; +#[cfg(feature = "benchmark")] +use crate::profiling::SnapshotInitProfile; +use crate::profiling::{ + DeltaFlushProfile, IvmBridgeProfile, SnapshotFlushProfile, TraceInitProfile, +}; use crate::query_builder::{DeleteBuilder, InsertBuilder, SelectBuilder, UpdateBuilder}; use crate::reactive_bridge::JsGraphqlSubscription; use crate::table::{JsTable, JsTableBuilder}; -use crate::transaction::JsTransaction; +use crate::transaction::{CommitProfile, JsTransaction}; use alloc::rc::Rc; use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::cell::RefCell; +use cynos_core::schema::{IndexType, Table}; use cynos_core::Row; +use cynos_gql::bind::{BoundFilter, BoundRootField, BoundRootFieldKind}; use cynos_gql::{PreparedQuery as GqlPreparedQuery, SchemaCache as GraphqlSchemaCache}; -use cynos_incremental::Delta; +use cynos_incremental::{CompiledBootstrapPlan, CompiledIvmPlan, Delta}; +use cynos_query::planner::PhysicalPlan; use cynos_query::plan_cache::PlanCache; use cynos_reactive::TableId; +#[cfg(feature = "benchmark")] +use cynos_storage::StorageInsertProfile; use cynos_storage::TableCache; use wasm_bindgen::prelude::*; @@ -41,6 +55,9 @@ pub struct Database { plan_cache: Rc>, graphql_schema_cache: Rc>, schema_epoch: Rc>, + last_commit_profile: Rc>>, + #[cfg(feature = "benchmark")] + last_insert_profile: Rc>>, } /// A prepared GraphQL query that reuses the parsed document across executions. @@ -75,6 +92,9 @@ impl Database { plan_cache: Rc::new(RefCell::new(PlanCache::default_size())), graphql_schema_cache: Rc::new(RefCell::new(GraphqlSchemaCache::new())), schema_epoch: Rc::new(RefCell::new(0)), + last_commit_profile: Rc::new(RefCell::new(None)), + #[cfg(feature = "benchmark")] + last_insert_profile: Rc::new(RefCell::new(None)), } } @@ -177,6 +197,8 @@ impl Database { self.cache.clone(), self.query_registry.clone(), self.table_id_map.clone(), + #[cfg(feature = "benchmark")] + self.last_insert_profile.clone(), table, ) } @@ -207,9 +229,83 @@ impl Database { self.cache.clone(), self.query_registry.clone(), self.table_id_map.clone(), + self.last_commit_profile.clone(), ) } + #[wasm_bindgen(js_name = takeLastCommitProfile)] + pub fn take_last_commit_profile(&self) -> JsValue { + let Some(profile) = self.last_commit_profile.borrow_mut().take() else { + return JsValue::NULL; + }; + + commit_profile_to_js_value(profile) + } + + #[wasm_bindgen(js_name = takeLastDeltaFlushProfile)] + pub fn take_last_delta_flush_profile(&self) -> JsValue { + let Some(profile) = self.query_registry.borrow().take_last_delta_flush_profile() else { + return JsValue::NULL; + }; + + delta_flush_profile_to_js_value(profile) + } + + #[wasm_bindgen(js_name = takeLastIvmBridgeProfile)] + pub fn take_last_ivm_bridge_profile(&self) -> JsValue { + let Some(profile) = self.query_registry.borrow().take_last_ivm_bridge_profile() else { + return JsValue::NULL; + }; + + ivm_bridge_profile_to_js_value(profile) + } + + #[wasm_bindgen(js_name = takeLastTraceInitProfile)] + pub fn take_last_trace_init_profile(&self) -> JsValue { + let Some(profile) = self.query_registry.borrow().take_last_trace_init_profile() else { + return JsValue::NULL; + }; + + trace_init_profile_to_js_value(profile) + } + + #[cfg(feature = "benchmark")] + #[wasm_bindgen(js_name = takeLastSnapshotInitProfile)] + pub fn take_last_snapshot_init_profile(&self) -> JsValue { + let Some(profile) = self + .query_registry + .borrow() + .take_last_snapshot_init_profile() + else { + return JsValue::NULL; + }; + + snapshot_init_profile_to_js_value(profile) + } + + #[cfg(feature = "benchmark")] + #[wasm_bindgen(js_name = takeLastInsertProfile)] + pub fn take_last_insert_profile(&self) -> JsValue { + let Some(profile) = self.last_insert_profile.borrow_mut().take() else { + return JsValue::NULL; + }; + + storage_insert_profile_to_js_value(profile) + } + + #[wasm_bindgen(js_name = takeLastSnapshotFlushProfile)] + pub fn take_last_snapshot_flush_profile(&self) -> JsValue { + let Some(profile) = self + .query_registry + .borrow() + .take_last_snapshot_flush_profile() + else { + return JsValue::NULL; + }; + + snapshot_flush_profile_to_js_value(profile) + } + /// Clears all data from all tables. pub fn clear(&self) { self.cache.borrow_mut().clear(); @@ -583,6 +679,774 @@ fn bind_graphql_operation( Ok((catalog, bound)) } +fn commit_profile_to_js_value(profile: CommitProfile) -> JsValue { + let object = js_sys::Object::new(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("storageCommitMs"), + &JsValue::from_f64(profile.storage_commit_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("registryFlushMs"), + &JsValue::from_f64(profile.registry_flush_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("totalCommitMs"), + &JsValue::from_f64(profile.total_commit_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("changedTableCount"), + &JsValue::from_f64(profile.changed_table_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("changedRowCount"), + &JsValue::from_f64(profile.changed_row_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("deltaRowCount"), + &JsValue::from_f64(profile.delta_row_count as f64), + ) + .ok(); + object.into() +} + +fn delta_flush_profile_to_js_value(profile: DeltaFlushProfile) -> JsValue { + let object = js_sys::Object::new(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("deltaTableCount"), + &JsValue::from_f64(profile.delta_table_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("deltaQueryCount"), + &JsValue::from_f64(profile.delta_query_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("rowsQueryCount"), + &JsValue::from_f64(profile.rows_query_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("graphqlQueryCount"), + &JsValue::from_f64(profile.graphql_query_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("deltaRowCount"), + &JsValue::from_f64(profile.delta_row_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("cloneMs"), + &JsValue::from_f64(profile.clone_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("queryOnTableChangeMs"), + &JsValue::from_f64(profile.query_on_table_change_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("sourceDispatchMs"), + &JsValue::from_f64(profile.source_dispatch_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("unaryExecuteMs"), + &JsValue::from_f64(profile.unary_execute_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("joinExecuteMs"), + &JsValue::from_f64(profile.join_execute_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("aggregateExecuteMs"), + &JsValue::from_f64(profile.aggregate_execute_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("resultApplyMs"), + &JsValue::from_f64(profile.result_apply_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("graphqlViewUpdateMs"), + &JsValue::from_f64(profile.graphql_view_update_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("graphqlInvalidationMs"), + &JsValue::from_f64(profile.graphql_invalidation_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("graphqlRenderMs"), + &JsValue::from_f64(profile.graphql_render_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("graphqlEncodeMs"), + &JsValue::from_f64(profile.graphql_encode_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("graphqlEmitMs"), + &JsValue::from_f64(profile.graphql_emit_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("totalMs"), + &JsValue::from_f64(profile.total_ms), + ) + .ok(); + object.into() +} + +fn trace_init_profile_to_js_value(profile: TraceInitProfile) -> JsValue { + let object = js_sys::Object::new(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("compilePlanMs"), + &JsValue::from_f64(profile.compile_plan_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("compileToDataflowMs"), + &JsValue::from_f64(profile.compile_to_dataflow_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("compileIvmPlanMs"), + &JsValue::from_f64(profile.compile_ivm_plan_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("compileTraceProgramMs"), + &JsValue::from_f64(profile.compile_trace_program_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("initialQueryMs"), + &JsValue::from_f64(profile.initial_query_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("sourceBootstrapMs"), + &JsValue::from_f64(profile.source_bootstrap_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("sourceAccessMs"), + &JsValue::from_f64(profile.source_access_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("sourceEmitMs"), + &JsValue::from_f64(profile.source_emit_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("bootstrapScanMs"), + &JsValue::from_f64(profile.bootstrap_scan_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("bootstrapExecuteMs"), + &JsValue::from_f64(profile.bootstrap_execute_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("filterBootstrapMs"), + &JsValue::from_f64(profile.filter_bootstrap_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("projectBootstrapMs"), + &JsValue::from_f64(profile.project_bootstrap_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("mapBootstrapMs"), + &JsValue::from_f64(profile.map_bootstrap_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("joinBootstrapMs"), + &JsValue::from_f64(profile.join_bootstrap_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("aggregateBootstrapMs"), + &JsValue::from_f64(profile.aggregate_bootstrap_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("rootSinkMs"), + &JsValue::from_f64(profile.root_sink_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("materializedViewInitMs"), + &JsValue::from_f64(profile.materialized_view_init_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("totalMs"), + &JsValue::from_f64(profile.total_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("sourceTableCount"), + &JsValue::from_f64(profile.source_table_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("initialRowCount"), + &JsValue::from_f64(profile.initial_row_count as f64), + ) + .ok(); + object.into() +} + +#[cfg(feature = "benchmark")] +fn snapshot_init_profile_to_js_value(profile: SnapshotInitProfile) -> JsValue { + let object = js_sys::Object::new(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("logicalPlanMs"), + &JsValue::from_f64(profile.logical_plan_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("describeOutputMs"), + &JsValue::from_f64(profile.describe_output_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("binaryLayoutMs"), + &JsValue::from_f64(profile.binary_layout_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("partialRefreshPlanMs"), + &JsValue::from_f64(profile.partial_refresh_plan_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("compileMainPlanMs"), + &JsValue::from_f64(profile.compile_main_plan_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("rootSubsetPlanMs"), + &JsValue::from_f64(profile.root_subset_plan_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("initialQueryMs"), + &JsValue::from_f64(profile.initial_query_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("dependencyBindingsMs"), + &JsValue::from_f64(profile.dependency_bindings_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("initialResultAdaptMs"), + &JsValue::from_f64(profile.initial_result_adapt_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("observableInitMs"), + &JsValue::from_f64(profile.observable_init_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("totalMs"), + &JsValue::from_f64(profile.total_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("initialRowCount"), + &JsValue::from_f64(profile.initial_row_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("visibleRowCount"), + &JsValue::from_f64(profile.visible_row_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("partialRefreshEnabled"), + &JsValue::from_bool(profile.partial_refresh_enabled), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("rootSubsetEnabled"), + &JsValue::from_bool(profile.root_subset_enabled), + ) + .ok(); + object.into() +} + +fn snapshot_flush_profile_to_js_value(profile: SnapshotFlushProfile) -> JsValue { + let object = js_sys::Object::new(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("changedTableCount"), + &JsValue::from_f64(profile.changed_table_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("rowsQueryCount"), + &JsValue::from_f64(profile.rows_query_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("graphqlQueryCount"), + &JsValue::from_f64(profile.graphql_query_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("invalidationMergeMs"), + &JsValue::from_f64(profile.invalidation_merge_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("rowsQueryOnChangeMs"), + &JsValue::from_f64(profile.rows_query_on_change_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("graphqlQueryOnChangeMs"), + &JsValue::from_f64(profile.graphql_query_on_change_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("graphqlRootRefreshMs"), + &JsValue::from_f64(profile.graphql_root_refresh_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("graphqlBatchInvalidationMs"), + &JsValue::from_f64(profile.graphql_batch_invalidation_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("graphqlRenderMs"), + &JsValue::from_f64(profile.graphql_render_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("graphqlEncodeMs"), + &JsValue::from_f64(profile.graphql_encode_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("graphqlEmitMs"), + &JsValue::from_f64(profile.graphql_emit_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("reactivePatchAttemptCount"), + &JsValue::from_f64(profile.reactive_patch_attempt_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("reactivePatchHitCount"), + &JsValue::from_f64(profile.reactive_patch_hit_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("reactivePatchMs"), + &JsValue::from_f64(profile.reactive_patch_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("partialRefreshAttemptCount"), + &JsValue::from_f64(profile.partial_refresh_attempt_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("partialRefreshHitCount"), + &JsValue::from_f64(profile.partial_refresh_hit_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("partialRefreshCollectMs"), + &JsValue::from_f64(profile.partial_refresh_collect_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("partialRefreshRequeryMs"), + &JsValue::from_f64(profile.partial_refresh_requery_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("partialRefreshApplyMs"), + &JsValue::from_f64(profile.partial_refresh_apply_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("partialRefreshCompareMs"), + &JsValue::from_f64(profile.partial_refresh_compare_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("rootSubsetAttemptCount"), + &JsValue::from_f64(profile.root_subset_attempt_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("rootSubsetHitCount"), + &JsValue::from_f64(profile.root_subset_hit_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("rootSubsetCollectMs"), + &JsValue::from_f64(profile.root_subset_collect_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("rootSubsetRequeryMs"), + &JsValue::from_f64(profile.root_subset_requery_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("rootSubsetApplyMs"), + &JsValue::from_f64(profile.root_subset_apply_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("rootSubsetCompareMs"), + &JsValue::from_f64(profile.root_subset_compare_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("fullRequeryCount"), + &JsValue::from_f64(profile.full_requery_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("fullRequeryMs"), + &JsValue::from_f64(profile.full_requery_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("fullRequeryCompareMs"), + &JsValue::from_f64(profile.full_requery_compare_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("callbackMs"), + &JsValue::from_f64(profile.callback_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("totalMs"), + &JsValue::from_f64(profile.total_ms), + ) + .ok(); + object.into() +} + +fn ivm_bridge_profile_to_js_value(profile: IvmBridgeProfile) -> JsValue { + let object = js_sys::Object::new(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("callbackCount"), + &JsValue::from_f64(profile.callback_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("addedRowCount"), + &JsValue::from_f64(profile.added_row_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("removedRowCount"), + &JsValue::from_f64(profile.removed_row_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("serializeAddedMs"), + &JsValue::from_f64(profile.serialize_added_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("serializeRemovedMs"), + &JsValue::from_f64(profile.serialize_removed_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("assembleDeltaMs"), + &JsValue::from_f64(profile.assemble_delta_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("callbackCallMs"), + &JsValue::from_f64(profile.callback_call_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("totalMs"), + &JsValue::from_f64(profile.total_ms), + ) + .ok(); + object.into() +} + +#[cfg(feature = "benchmark")] +fn storage_insert_profile_to_js_value(profile: StorageInsertProfile) -> JsValue { + let object = js_sys::Object::new(); + let gin = js_sys::Object::new(); + + js_sys::Reflect::set( + &object, + &JsValue::from_str("rowCount"), + &JsValue::from_f64(profile.row_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("secondaryIndexCount"), + &JsValue::from_f64(profile.secondary_index_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("ginIndexCount"), + &JsValue::from_f64(profile.gin_index_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("validationMs"), + &JsValue::from_f64(profile.validation_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("rowIdIndexMs"), + &JsValue::from_f64(profile.row_id_index_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("primaryIndexMs"), + &JsValue::from_f64(profile.primary_index_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("secondaryIndexMs"), + &JsValue::from_f64(profile.secondary_index_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("ginCollectMs"), + &JsValue::from_f64(profile.gin_collect_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("ginFlushMs"), + &JsValue::from_f64(profile.gin_flush_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("rowSlotMs"), + &JsValue::from_f64(profile.row_slot_ms), + ) + .ok(); + js_sys::Reflect::set( + &object, + &JsValue::from_str("totalMs"), + &JsValue::from_f64(profile.total_ms), + ) + .ok(); + + js_sys::Reflect::set( + &gin, + &JsValue::from_str("parseJsonMs"), + &JsValue::from_f64(profile.gin.parse_json_ms), + ) + .ok(); + js_sys::Reflect::set( + &gin, + &JsValue::from_str("pathLookupMs"), + &JsValue::from_f64(profile.gin.path_lookup_ms), + ) + .ok(); + js_sys::Reflect::set( + &gin, + &JsValue::from_str("scalarEmitMs"), + &JsValue::from_f64(profile.gin.scalar_emit_ms), + ) + .ok(); + js_sys::Reflect::set( + &gin, + &JsValue::from_str("containsStringifyMs"), + &JsValue::from_f64(profile.gin.contains_stringify_ms), + ) + .ok(); + js_sys::Reflect::set( + &gin, + &JsValue::from_str("containsTrigramEmitMs"), + &JsValue::from_f64(profile.gin.contains_trigram_emit_ms), + ) + .ok(); + js_sys::Reflect::set( + &gin, + &JsValue::from_str("parseCallCount"), + &JsValue::from_f64(profile.gin.parse_call_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &gin, + &JsValue::from_str("selectedPathEvalCount"), + &JsValue::from_f64(profile.gin.selected_path_eval_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &gin, + &JsValue::from_str("selectedPathHitCount"), + &JsValue::from_f64(profile.gin.selected_path_hit_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &gin, + &JsValue::from_str("pathKeyEmitCount"), + &JsValue::from_f64(profile.gin.path_key_emit_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &gin, + &JsValue::from_str("scalarValueCount"), + &JsValue::from_f64(profile.gin.scalar_value_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &gin, + &JsValue::from_str("containsValueCount"), + &JsValue::from_f64(profile.gin.contains_value_count as f64), + ) + .ok(); + js_sys::Reflect::set( + &gin, + &JsValue::from_str("containsTrigramCount"), + &JsValue::from_f64(profile.gin.contains_trigram_count as f64), + ) + .ok(); + + js_sys::Reflect::set(&object, &JsValue::from_str("gin"), &gin).ok(); + object.into() +} + fn execute_graphql_bound_operation( cache: Rc>, query_registry: Rc>, @@ -695,8 +1559,15 @@ fn compile_graphql_live_plan( let compiled_plan = crate::query_engine::compile_cached_plan( &cache_borrow, &root_plan.table_name, - root_plan.logical_plan, + root_plan.logical_plan.clone(), ); + let root_subset_refresh = build_graphql_root_subset_plan( + &cache_borrow, + &table_id_map.borrow(), + &field, + &root_plan, + &compiled_plan, + )?; let initial_output = crate::query_engine::execute_compiled_physical_plan_with_summary( &cache_borrow, &compiled_plan, @@ -711,6 +1582,7 @@ fn compile_graphql_live_plan( catalog, field, dependency_table_bindings, + root_subset_refresh, )) } @@ -761,6 +1633,334 @@ fn build_graphql_dependency_set( )) } +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct GraphqlSnapshotJoinEdge { + left_table: String, + left_column: String, + right_table: String, + right_column: String, +} + +fn graphql_snapshot_compiled_lookup_primitive( + schema: &Table, + column_name: &str, +) -> RowsSnapshotLookupPrimitive { + if let Some(primary_key) = schema.primary_key() { + if primary_key.columns().len() == 1 + && primary_key + .columns() + .first() + .map(|column| column.name.as_str()) + == Some(column_name) + { + return RowsSnapshotLookupPrimitive::PrimaryKey; + } + } + + if let Some(index_name) = schema + .indices() + .iter() + .find(|index| { + index.get_index_type() != IndexType::Gin + && index.columns().len() == 1 + && index.columns().first().map(|column| column.name.as_str()) == Some(column_name) + }) + .map(|index| index.name().to_string()) + { + return RowsSnapshotLookupPrimitive::SingleColumnIndex { index_name }; + } + + RowsSnapshotLookupPrimitive::ScanFallback +} + +fn graphql_snapshot_plan_allows_root_subset_refresh(plan: &PhysicalPlan) -> bool { + match plan { + PhysicalPlan::TableScan { .. } + | PhysicalPlan::IndexScan { .. } + | PhysicalPlan::IndexGet { .. } + | PhysicalPlan::IndexInGet { .. } + | PhysicalPlan::GinIndexScan { .. } + | PhysicalPlan::GinIndexScanMulti { .. } + | PhysicalPlan::Empty => true, + PhysicalPlan::Filter { input, .. } + | PhysicalPlan::Project { input, .. } + | PhysicalPlan::NoOp { input } => graphql_snapshot_plan_allows_root_subset_refresh(input), + PhysicalPlan::HashJoin { left, right, .. } + | PhysicalPlan::SortMergeJoin { left, right, .. } + | PhysicalPlan::NestedLoopJoin { left, right, .. } => { + graphql_snapshot_plan_allows_root_subset_refresh(left) + && graphql_snapshot_plan_allows_root_subset_refresh(right) + } + PhysicalPlan::IndexNestedLoopJoin { outer, .. } => { + graphql_snapshot_plan_allows_root_subset_refresh(outer) + } + PhysicalPlan::HashAggregate { .. } + | PhysicalPlan::Sort { .. } + | PhysicalPlan::TopN { .. } + | PhysicalPlan::Limit { .. } + | PhysicalPlan::CrossProduct { .. } + | PhysicalPlan::Union { .. } => false, + } +} + +fn graphql_snapshot_plan_supports_root_subset_refresh( + plan: &PhysicalPlan, + root_table: &str, +) -> bool { + plan.collect_tables().iter().any(|table| table == root_table) + && graphql_snapshot_plan_allows_root_subset_refresh(plan) +} + +fn collect_graphql_filter_join_edges( + filter: &BoundFilter, + edges: &mut hashbrown::HashSet, +) { + match filter { + BoundFilter::And(filters) | BoundFilter::Or(filters) => { + for child in filters { + collect_graphql_filter_join_edges(child, edges); + } + } + BoundFilter::Column(_) => {} + BoundFilter::Relation(predicate) => { + edges.insert(GraphqlSnapshotJoinEdge { + left_table: predicate.relation.child_table.clone(), + left_column: predicate.relation.child_column.clone(), + right_table: predicate.relation.parent_table.clone(), + right_column: predicate.relation.parent_column.clone(), + }); + collect_graphql_filter_join_edges(predicate.filter.as_ref(), edges); + } + } +} + +fn compile_graphql_snapshot_dependency_graph( + cache: &TableCache, + table_id_map: &hashbrown::HashMap, + root_table: &str, + join_edges: &[GraphqlSnapshotJoinEdge], +) -> Result { + let root_table_id = table_id_map + .get(root_table) + .copied() + .ok_or_else(|| JsValue::from_str(&alloc::format!("Table ID not found: {}", root_table)))?; + let mut table_names = hashbrown::HashMap::new(); + table_names.insert(root_table_id, root_table.to_string()); + + let mut undirected_edges_by_source = + hashbrown::HashMap::>::new(); + for edge in join_edges { + let left_table_id = table_id_map.get(&edge.left_table).copied().ok_or_else(|| { + JsValue::from_str(&alloc::format!("Table ID not found: {}", edge.left_table)) + })?; + let right_table_id = table_id_map + .get(&edge.right_table) + .copied() + .ok_or_else(|| { + JsValue::from_str(&alloc::format!("Table ID not found: {}", edge.right_table)) + })?; + + let left_store = cache.get_table(&edge.left_table).ok_or_else(|| { + JsValue::from_str(&alloc::format!("Table not found: {}", edge.left_table)) + })?; + let right_store = cache.get_table(&edge.right_table).ok_or_else(|| { + JsValue::from_str(&alloc::format!("Table not found: {}", edge.right_table)) + })?; + + let left_column_index = left_store + .schema() + .get_column_index(&edge.left_column) + .ok_or_else(|| { + JsValue::from_str(&alloc::format!( + "Column not found: {}.{}", + edge.left_table, + edge.left_column + )) + })?; + let right_column_index = right_store + .schema() + .get_column_index(&edge.right_column) + .ok_or_else(|| { + JsValue::from_str(&alloc::format!( + "Column not found: {}.{}", + edge.right_table, + edge.right_column + )) + })?; + + table_names.insert(left_table_id, edge.left_table.clone()); + table_names.insert(right_table_id, edge.right_table.clone()); + + undirected_edges_by_source + .entry(left_table_id) + .or_insert_with(Vec::new) + .push(RowsSnapshotDirectedJoinEdge { + source_column_index: left_column_index, + target_table_id: right_table_id, + target_table: edge.right_table.clone(), + target_column_index: right_column_index, + lookup: graphql_snapshot_compiled_lookup_primitive( + right_store.schema(), + &edge.right_column, + ), + }); + undirected_edges_by_source + .entry(right_table_id) + .or_insert_with(Vec::new) + .push(RowsSnapshotDirectedJoinEdge { + source_column_index: right_column_index, + target_table_id: left_table_id, + target_table: edge.left_table.clone(), + target_column_index: left_column_index, + lookup: graphql_snapshot_compiled_lookup_primitive( + left_store.schema(), + &edge.left_column, + ), + }); + } + + let mut distance_to_root = hashbrown::HashMap::::new(); + let mut queue = alloc::collections::VecDeque::new(); + distance_to_root.insert(root_table_id, 0); + queue.push_back(root_table_id); + + while let Some(table_id) = queue.pop_front() { + let next_distance = distance_to_root.get(&table_id).copied().unwrap_or(0) + 1; + for edge in undirected_edges_by_source + .get(&table_id) + .map(Vec::as_slice) + .unwrap_or(&[]) + { + if distance_to_root.contains_key(&edge.target_table_id) { + continue; + } + distance_to_root.insert(edge.target_table_id, next_distance); + queue.push_back(edge.target_table_id); + } + } + + if distance_to_root.len() != table_names.len() { + return Err(JsValue::from_str( + "graphql snapshot dependency graph is disconnected from the root table", + )); + } + + let mut edges_by_source = + hashbrown::HashMap::>::new(); + for (source_table_id, candidate_edges) in undirected_edges_by_source { + let Some(source_distance) = distance_to_root.get(&source_table_id).copied() else { + continue; + }; + let directed_edges: Vec<_> = candidate_edges + .into_iter() + .filter(|edge| { + distance_to_root + .get(&edge.target_table_id) + .map(|target_distance| *target_distance < source_distance) + .unwrap_or(false) + }) + .collect(); + if !directed_edges.is_empty() { + edges_by_source.insert(source_table_id, directed_edges); + } + } + + Ok(RowsSnapshotDependencyGraph { + root_table_id, + table_names, + edges_by_source, + }) +} + +fn build_graphql_root_subset_plan( + cache: &TableCache, + table_id_map: &hashbrown::HashMap, + field: &BoundRootField, + root_plan: &cynos_gql::RootFieldPlan, + compiled_plan: &crate::query_engine::CompiledPhysicalPlan, +) -> Result, JsValue> { + let (root_table, query) = match &field.kind { + BoundRootFieldKind::Collection { + table_name, query, .. + } => (table_name.clone(), query), + _ => return Ok(None), + }; + + if !query.order_by.is_empty() || query.limit.is_some() || query.offset > 0 { + return Ok(None); + } + + if !graphql_snapshot_plan_supports_root_subset_refresh( + compiled_plan.physical_plan(), + &root_table, + ) { + return Ok(None); + } + + let root_store = cache + .get_table(&root_table) + .ok_or_else(|| JsValue::from_str(&alloc::format!("Table not found: {}", root_table)))?; + let root_pk = match root_store.schema().primary_key() { + Some(pk) if !pk.columns().is_empty() => pk, + _ => return Ok(None), + }; + + let mut root_pk_store_indices = Vec::with_capacity(root_pk.columns().len()); + for pk_column in root_pk.columns() { + let Some(store_index) = root_store.schema().get_column_index(&pk_column.name) else { + return Ok(None); + }; + root_pk_store_indices.push(store_index); + } + let root_pk_output_indices = root_pk_store_indices.clone(); + + let mut edge_set = hashbrown::HashSet::new(); + if let Some(filter) = &query.filter { + collect_graphql_filter_join_edges(filter, &mut edge_set); + } + let mut join_edges: Vec<_> = edge_set.into_iter().collect(); + join_edges.sort_unstable_by(|left, right| { + left.left_table + .cmp(&right.left_table) + .then_with(|| left.left_column.cmp(&right.left_column)) + .then_with(|| left.right_table.cmp(&right.right_table)) + .then_with(|| left.right_column.cmp(&right.right_column)) + }); + + let dependency_graph = + compile_graphql_snapshot_dependency_graph(cache, table_id_map, &root_table, &join_edges)?; + + let subset_compiled_plan_small = crate::query_engine::compile_cached_plan_with_profile( + cache, + &root_table, + root_plan.logical_plan.clone(), + crate::query_engine::CompilePlanProfile::RootSubset( + crate::query_engine::RootSubsetPlanningProfile::small(), + ), + ); + let subset_compiled_plan_large = crate::query_engine::compile_cached_plan_with_profile( + cache, + &root_table, + root_plan.logical_plan.clone(), + crate::query_engine::CompilePlanProfile::RootSubset( + crate::query_engine::RootSubsetPlanningProfile::large(), + ), + ); + + Ok(Some(RowsSnapshotRootSubsetPlan { + metadata: RowsSnapshotRootSubsetMetadata { + root_table, + root_pk_store_indices, + root_pk_output_indices, + dependency_graph, + }, + compiled_plans: RowsSnapshotRootSubsetVariants { + small: subset_compiled_plan_small, + large: subset_compiled_plan_large, + }, + })) +} + fn build_graphql_delta_live_plan( cache: &TableCache, table_id_map: &hashbrown::HashMap, @@ -785,17 +1985,26 @@ fn build_graphql_delta_live_plan( else { return Ok(None); }; + let compiled_ivm_plan = CompiledIvmPlan::compile(&compile_result.dataflow); + let compiled_bootstrap_plan = CompiledBootstrapPlan::compile(&compile_result.dataflow); let initial_rows = crate::query_engine::execute_physical_plan(cache, &physical_plan).map_err(|error| { JsValue::from_str(&alloc::format!("Query execution error: {:?}", error)) })?; - let initial_owned = initial_rows.iter().map(|row| (**row).clone()).collect(); + let source_bindings = collect_trace_bootstrap_source_bindings( + &physical_plan, + &compile_result.table_ids, + &table_schemas, + ); Ok(Some(LivePlan::graphql_delta( dependency_set, compile_result.dataflow, - initial_owned, + compiled_ivm_plan, + compiled_bootstrap_plan, + initial_rows, + source_bindings, catalog, field, dependency_table_bindings, @@ -923,6 +2132,117 @@ mod tests { db } + fn setup_graphql_users_profiles_db() -> Database { + let db = setup_graphql_users_db(); + let profiles = db + .create_table("profiles") + .column( + "id", + JsDataType::Int64, + Some(ColumnOptions::new().set_primary_key(true)), + ) + .column( + "user_id", + JsDataType::Int64, + Some(ColumnOptions::new().set_unique(true)), + ) + .column("bio", JsDataType::String, None) + .foreign_key( + "fk_profiles_user", + "user_id", + "users", + "id", + Some( + ForeignKeyOptions::new() + .set_field_name("user") + .set_reverse_field_name("profile"), + ), + ); + db.register_table(&profiles).unwrap(); + db + } + + fn setup_graphql_issue_filter_db() -> Database { + let db = Database::new("graphql_issue_filter"); + + let projects = db + .create_table("projects") + .column( + "id", + JsDataType::Int64, + Some(ColumnOptions::new().set_primary_key(true)), + ) + .column("healthScore", JsDataType::Int32, None); + db.register_table(&projects).unwrap(); + + let project_counters = db + .create_table("projectCounters") + .column( + "projectId", + JsDataType::Int64, + Some(ColumnOptions::new().set_primary_key(true)), + ) + .column("openIssueCount", JsDataType::Int32, None) + .foreign_key( + "fk_project_counters_project", + "projectId", + "projects", + "id", + Some( + ForeignKeyOptions::new() + .set_field_name("project") + .set_reverse_field_name("counter"), + ), + ); + db.register_table(&project_counters).unwrap(); + + let project_snapshots = db + .create_table("projectSnapshots") + .column( + "projectId", + JsDataType::Int64, + Some(ColumnOptions::new().set_primary_key(true)), + ) + .column("velocity", JsDataType::Int32, None) + .foreign_key( + "fk_project_snapshots_project", + "projectId", + "projects", + "id", + Some( + ForeignKeyOptions::new() + .set_field_name("project") + .set_reverse_field_name("snapshot"), + ), + ); + db.register_table(&project_snapshots).unwrap(); + + let issues = db + .create_table("issues") + .column( + "id", + JsDataType::Int64, + Some(ColumnOptions::new().set_primary_key(true)), + ) + .column("projectId", JsDataType::Int64, None) + .column("title", JsDataType::String, None) + .column("status", JsDataType::String, None) + .foreign_key( + "fk_issues_project", + "projectId", + "projects", + "id", + Some( + ForeignKeyOptions::new() + .set_field_name("project") + .set_reverse_field_name("issues"), + ), + ); + db.register_table(&issues).unwrap(); + + db + } + fn collect_titles(array: &js_sys::Array) -> Vec { let mut titles = Vec::with_capacity(array.length() as usize); for index in 0..array.length() { @@ -937,6 +2257,20 @@ mod tests { titles } + fn collect_ids(array: &js_sys::Array, field_name: &str) -> Vec { + let mut ids = Vec::with_capacity(array.length() as usize); + for index in 0..array.length() { + let item = array.get(index); + let id = js_sys::Reflect::get(&item, &JsValue::from_str(field_name)) + .unwrap() + .as_f64() + .unwrap() as i64; + ids.push(id); + } + ids.sort_unstable(); + ids + } + fn compile_subscription_engine(db: &Database, query: &str) -> LiveEngineKind { let prepared = GqlPreparedQuery::parse_with_operation(query, None).unwrap(); let variables = cynos_gql::VariableValues::default(); @@ -1347,6 +2681,36 @@ mod tests { assert_eq!(engine, LiveEngineKind::Snapshot); } + #[wasm_bindgen_test] + fn test_graphql_live_selector_chooses_delta_for_unique_reverse_limit_one() { + let db = setup_graphql_users_profiles_db(); + let engine = compile_subscription_engine( + &db, + "subscription UserCard { usersByPk(pk: { id: 1 }) { id name profile(limit: 1) { id bio } } }", + ); + assert_eq!(engine, LiveEngineKind::Delta); + } + + #[wasm_bindgen_test] + fn test_graphql_live_selector_chooses_delta_for_relation_filtered_root_subscription() { + let db = setup_graphql_issue_filter_db(); + let engine = compile_subscription_engine( + &db, + "subscription IssueFeed { issues(where: { AND: [{ status: { eq: \"open\" } }, { project: { AND: [{ healthScore: { gte: 45 } }, { counter: { openIssueCount: { gte: 5 } } }, { snapshot: { velocity: { gte: 18 } } }] } }] }) { id title project { id counter(limit: 1) { openIssueCount } snapshot(limit: 1) { velocity } } } }", + ); + assert_eq!(engine, LiveEngineKind::Delta); + } + + #[wasm_bindgen_test] + fn test_graphql_live_selector_falls_back_to_snapshot_for_non_unique_reverse_limit_one() { + let db = setup_graphql_users_posts_db(); + let engine = compile_subscription_engine( + &db, + "subscription UserCard { usersByPk(pk: { id: 1 }) { id name posts(limit: 1) { id title } } }", + ); + assert_eq!(engine, LiveEngineKind::Snapshot); + } + #[wasm_bindgen_test] fn test_database_graphql_delta_subscription_tracks_scalar_root_updates() { let db = setup_graphql_users_db(); @@ -1695,6 +3059,250 @@ mod tests { assert_eq!(title, "Updated"); } + #[wasm_bindgen_test] + fn test_database_graphql_delta_subscription_tracks_unique_reverse_limit_one_updates() { + let db = setup_graphql_users_profiles_db(); + + db.cache + .borrow_mut() + .get_table_mut("users") + .unwrap() + .insert(Row::new( + 1, + alloc::vec![Value::Int64(1), Value::String("Alice".into())], + )) + .unwrap(); + + let subscription = db + .subscribe_graphql( + "subscription UserCard { usersByPk(pk: { id: 1 }) { id name profile(limit: 1) { id bio } } }", + None, + None, + ) + .unwrap(); + + assert_eq!( + compile_subscription_engine( + &db, + "subscription UserCard { usersByPk(pk: { id: 1 }) { id name profile(limit: 1) { id bio } } }", + ), + LiveEngineKind::Delta + ); + + let initial = subscription.get_result(); + let initial_data = js_sys::Reflect::get(&initial, &JsValue::from_str("data")).unwrap(); + let initial_user = js_sys::Reflect::get(&initial_data, &JsValue::from_str("usersByPk")) + .unwrap(); + let initial_profile = js_sys::Array::from( + &js_sys::Reflect::get(&initial_user, &JsValue::from_str("profile")).unwrap(), + ); + assert_eq!(initial_profile.length(), 0); + + db.graphql( + "mutation { insertProfiles(input: [{ id: 10, user_id: 1, bio: \"First\" }]) { id bio } }", + None, + None, + ) + .unwrap(); + db.query_registry.borrow_mut().flush(); + + let inserted = subscription.get_result(); + let inserted_data = js_sys::Reflect::get(&inserted, &JsValue::from_str("data")).unwrap(); + let inserted_user = + js_sys::Reflect::get(&inserted_data, &JsValue::from_str("usersByPk")).unwrap(); + let inserted_profile = js_sys::Array::from( + &js_sys::Reflect::get(&inserted_user, &JsValue::from_str("profile")).unwrap(), + ); + assert_eq!(inserted_profile.length(), 1); + let inserted_bio = js_sys::Reflect::get(&inserted_profile.get(0), &JsValue::from_str("bio")) + .unwrap() + .as_string() + .unwrap(); + assert_eq!(inserted_bio, "First"); + + db.graphql( + "mutation { updateProfiles(where: { id: { eq: 10 } }, set: { bio: \"Updated\" }) { id bio } }", + None, + None, + ) + .unwrap(); + db.query_registry.borrow_mut().flush(); + + let updated = subscription.get_result(); + let updated_data = js_sys::Reflect::get(&updated, &JsValue::from_str("data")).unwrap(); + let updated_user = + js_sys::Reflect::get(&updated_data, &JsValue::from_str("usersByPk")).unwrap(); + let updated_profile = js_sys::Array::from( + &js_sys::Reflect::get(&updated_user, &JsValue::from_str("profile")).unwrap(), + ); + assert_eq!(updated_profile.length(), 1); + let updated_bio = js_sys::Reflect::get(&updated_profile.get(0), &JsValue::from_str("bio")) + .unwrap() + .as_string() + .unwrap(); + assert_eq!(updated_bio, "Updated"); + } + + #[wasm_bindgen_test] + fn test_database_graphql_delta_subscription_tracks_relation_filtered_root_membership() { + let db = setup_graphql_issue_filter_db(); + + db.cache + .borrow_mut() + .get_table_mut("projects") + .unwrap() + .insert(Row::new(1, alloc::vec![Value::Int64(1), Value::Int32(30)])) + .unwrap(); + db.cache + .borrow_mut() + .get_table_mut("projectCounters") + .unwrap() + .insert(Row::new(10, alloc::vec![Value::Int64(1), Value::Int32(6)])) + .unwrap(); + db.cache + .borrow_mut() + .get_table_mut("projectSnapshots") + .unwrap() + .insert(Row::new(11, alloc::vec![Value::Int64(1), Value::Int32(20)])) + .unwrap(); + db.cache + .borrow_mut() + .get_table_mut("issues") + .unwrap() + .insert(Row::new( + 100, + alloc::vec![ + Value::Int64(100), + Value::Int64(1), + Value::String("Issue".into()), + Value::String("open".into()), + ], + )) + .unwrap(); + + let query = "subscription IssueFeed { issues(where: { AND: [{ status: { eq: \"open\" } }, { project: { AND: [{ healthScore: { gte: 45 } }, { counter: { openIssueCount: { gte: 5 } } }, { snapshot: { velocity: { gte: 18 } } }] } }] }) { id title project { id counter(limit: 1) { openIssueCount } snapshot(limit: 1) { velocity } } } }"; + assert_eq!(compile_subscription_engine(&db, query), LiveEngineKind::Delta); + + let subscription = db.subscribe_graphql(query, None, None).unwrap(); + + let initial = subscription.get_result(); + let initial_data = js_sys::Reflect::get(&initial, &JsValue::from_str("data")).unwrap(); + let initial_issues = js_sys::Array::from( + &js_sys::Reflect::get(&initial_data, &JsValue::from_str("issues")).unwrap(), + ); + assert_eq!(initial_issues.length(), 0); + + db.graphql( + "mutation { updateProjects(where: { id: { eq: 1 } }, set: { healthScore: 50 }) { id } }", + None, + None, + ) + .unwrap(); + db.query_registry.borrow_mut().flush(); + + let inserted = subscription.get_result(); + let inserted_data = js_sys::Reflect::get(&inserted, &JsValue::from_str("data")).unwrap(); + let inserted_issues = js_sys::Array::from( + &js_sys::Reflect::get(&inserted_data, &JsValue::from_str("issues")).unwrap(), + ); + assert_eq!(collect_ids(&inserted_issues, "id"), vec![100]); + + db.graphql( + "mutation { updateProjectSnapshots(where: { projectId: { eq: 1 } }, set: { velocity: 5 }) { projectId } }", + None, + None, + ) + .unwrap(); + db.query_registry.borrow_mut().flush(); + + let removed = subscription.get_result(); + let removed_data = js_sys::Reflect::get(&removed, &JsValue::from_str("data")).unwrap(); + let removed_issues = js_sys::Array::from( + &js_sys::Reflect::get(&removed_data, &JsValue::from_str("issues")).unwrap(), + ); + assert_eq!(removed_issues.length(), 0); + } + + #[wasm_bindgen_test] + fn test_database_graphql_snapshot_subscription_tracks_relation_filtered_root_membership() { + let db = setup_graphql_issue_filter_db(); + + db.cache + .borrow_mut() + .get_table_mut("projects") + .unwrap() + .insert(Row::new(1, alloc::vec![Value::Int64(1), Value::Int32(30)])) + .unwrap(); + db.cache + .borrow_mut() + .get_table_mut("projectCounters") + .unwrap() + .insert(Row::new(10, alloc::vec![Value::Int64(1), Value::Int32(6)])) + .unwrap(); + db.cache + .borrow_mut() + .get_table_mut("projectSnapshots") + .unwrap() + .insert(Row::new(11, alloc::vec![Value::Int64(1), Value::Int32(20)])) + .unwrap(); + db.cache + .borrow_mut() + .get_table_mut("issues") + .unwrap() + .insert(Row::new( + 100, + alloc::vec![ + Value::Int64(100), + Value::Int64(1), + Value::String("Issue".into()), + Value::String("open".into()), + ], + )) + .unwrap(); + + let query = "subscription IssueFeedSnapshot { issues(where: { AND: [{ status: { eq: \"open\" } }, { project: { AND: [{ healthScore: { gte: 45 } }, { counter: { openIssueCount: { gte: 5 } } }, { snapshot: { velocity: { gte: 18 } } }] } }] }) { id title project { id counter(orderBy: [{ field: OPENISSUECOUNT, direction: DESC }], limit: 1) { openIssueCount } snapshot(limit: 1) { velocity } } } }"; + assert_eq!(compile_subscription_engine(&db, query), LiveEngineKind::Snapshot); + + let subscription = db.subscribe_graphql(query, None, None).unwrap(); + + let initial = subscription.get_result(); + let initial_data = js_sys::Reflect::get(&initial, &JsValue::from_str("data")).unwrap(); + let initial_issues = js_sys::Array::from( + &js_sys::Reflect::get(&initial_data, &JsValue::from_str("issues")).unwrap(), + ); + assert_eq!(initial_issues.length(), 0); + + db.graphql( + "mutation { updateProjects(where: { id: { eq: 1 } }, set: { healthScore: 50 }) { id } }", + None, + None, + ) + .unwrap(); + db.query_registry.borrow_mut().flush(); + + let inserted = subscription.get_result(); + let inserted_data = js_sys::Reflect::get(&inserted, &JsValue::from_str("data")).unwrap(); + let inserted_issues = js_sys::Array::from( + &js_sys::Reflect::get(&inserted_data, &JsValue::from_str("issues")).unwrap(), + ); + assert_eq!(collect_ids(&inserted_issues, "id"), vec![100]); + + db.graphql( + "mutation { updateProjectSnapshots(where: { projectId: { eq: 1 } }, set: { velocity: 5 }) { projectId } }", + None, + None, + ) + .unwrap(); + db.query_registry.borrow_mut().flush(); + + let removed = subscription.get_result(); + let removed_data = js_sys::Reflect::get(&removed, &JsValue::from_str("data")).unwrap(); + let removed_issues = js_sys::Array::from( + &js_sys::Reflect::get(&removed_data, &JsValue::from_str("issues")).unwrap(), + ); + assert_eq!(removed_issues.length(), 0); + } + #[wasm_bindgen_test] fn test_database_graphql_snapshot_subscription_reparents_nested_relation_membership() { let db = setup_graphql_users_posts_db(); diff --git a/crates/database/src/dataflow_compiler.rs b/crates/database/src/dataflow_compiler.rs index cd80740..ddc297b 100644 --- a/crates/database/src/dataflow_compiler.rs +++ b/crates/database/src/dataflow_compiler.rs @@ -16,9 +16,11 @@ use alloc::string::String; use alloc::vec::Vec; use cynos_core::{schema::Table, Row, Value}; use cynos_incremental::{ - AggregateType, DataflowNode, JoinType as IvmJoinType, KeyExtractorFn, TableId, + AggregateType, DataflowNode, JoinKeySpec, JoinType as IvmJoinType, TableId, TraceTupleArena, + TraceTupleHandle, }; use cynos_index::KeyRange; +use cynos_jsonb::{JsonPath, JsonbObject, JsonbValue}; use cynos_query::ast::JoinType as QueryJoinType; use cynos_query::ast::{AggregateFunc, BinaryOp, Expr, UnaryOp}; use cynos_query::planner::{IndexBounds, PhysicalPlan}; @@ -28,7 +30,7 @@ use hashbrown::HashMap; pub struct CompileResult { /// The dataflow node graph for incremental maintenance pub dataflow: DataflowNode, - /// Mapping from table name → table ID used in the dataflow + /// Mapping from table name → table ID used by this dataflow graph pub table_ids: HashMap, } @@ -113,6 +115,10 @@ impl CompileLayout { } indices } + + fn total_width(&self) -> usize { + self.table_column_counts.iter().sum() + } } struct CompiledNode { @@ -126,6 +132,46 @@ struct IndexedColumnRef { index: usize, } +struct TableIdResolver<'a> { + existing: &'a HashMap, + used: HashMap, + next_id: TableId, +} + +impl<'a> TableIdResolver<'a> { + fn new(existing: &'a HashMap) -> Self { + let next_id = existing + .values() + .copied() + .max() + .unwrap_or(0) + .saturating_add(1); + Self { + existing, + used: HashMap::new(), + next_id, + } + } + + fn resolve(&mut self, table: &str) -> TableId { + if let Some(existing) = self.used.get(table) { + return *existing; + } + + let table_id = self.existing.get(table).copied().unwrap_or_else(|| { + let allocated = self.next_id; + self.next_id = self.next_id.saturating_add(1); + allocated + }); + self.used.insert(table.into(), table_id); + table_id + } + + fn into_used(self) -> HashMap { + self.used + } +} + /// Compiles a PhysicalPlan into a DataflowNode for IVM. /// /// Returns None if the plan contains non-incrementalizable operators @@ -139,20 +185,20 @@ pub fn compile_to_dataflow( return None; } - let mut table_ids = table_id_map.clone(); + let mut table_ids = TableIdResolver::new(table_id_map); let compiled = compile_node(plan, &mut table_ids, table_schemas)?; Some(CompileResult { dataflow: compiled.dataflow, - table_ids, + table_ids: table_ids.into_used(), }) } fn compile_source_node( table: &str, - table_ids: &mut HashMap, + table_ids: &mut TableIdResolver<'_>, table_schemas: &HashMap, ) -> Option { - let table_id = get_or_assign_table_id(table, table_ids); + let table_id = table_ids.resolve(table); let column_count = table_schemas.get(table)?.columns().len(); Some(CompiledNode { dataflow: DataflowNode::source(table_id), @@ -163,17 +209,19 @@ fn compile_source_node( fn compile_filtered_source( table: &str, predicate: Option, - table_ids: &mut HashMap, + table_ids: &mut TableIdResolver<'_>, table_schemas: &HashMap, ) -> Option { let source = compile_source_node(table, table_ids, table_schemas)?; if let Some(predicate) = predicate { let bound_predicate = bind_expr_to_layout(&predicate, &source.layout); let pred_fn = compile_predicate(&bound_predicate); + let trace_pred_fn = compile_trace_predicate(&bound_predicate); return Some(CompiledNode { dataflow: DataflowNode::Filter { input: Box::new(source.dataflow), predicate: pred_fn, + trace_predicate: Some(trace_pred_fn), }, layout: source.layout, }); @@ -324,7 +372,7 @@ fn build_index_scan_predicate( fn compile_node( plan: &PhysicalPlan, - table_ids: &mut HashMap, + table_ids: &mut TableIdResolver<'_>, table_schemas: &HashMap, ) -> Option { match plan { @@ -387,10 +435,12 @@ fn compile_node( let input_node = compile_node(input, table_ids, table_schemas)?; let bound_predicate = bind_expr_to_layout(predicate, &input_node.layout); let pred_fn = compile_predicate(&bound_predicate); + let trace_pred_fn = compile_trace_predicate(&bound_predicate); Some(CompiledNode { dataflow: DataflowNode::Filter { input: Box::new(input_node.dataflow), predicate: pred_fn, + trace_predicate: Some(trace_pred_fn), }, layout: input_node.layout, }) @@ -417,14 +467,17 @@ fn compile_node( } else { // Has computed expressions — use Map node let exprs = bound_columns; + let trace_mapper = compile_trace_mapper(&exprs); + let row_exprs = exprs.clone(); Some(CompiledNode { dataflow: DataflowNode::Map { input: Box::new(input_node.dataflow), mapper: Box::new(move |row: &Row| { let values: Vec = - exprs.iter().map(|expr| eval_expr(expr, row)).collect(); - Row::dummy(values) + row_exprs.iter().map(|expr| eval_expr(expr, row)).collect(); + Row::new_with_version(row.id(), row.version(), values) }), + trace_mapper: Some(trace_mapper), }, layout: input_node.layout.projected(columns.len()), }) @@ -465,6 +518,8 @@ fn compile_node( left_key, right_key, join_type: ivm_join_type, + left_width: left_node.layout.total_width(), + right_width: right_node.layout.total_width(), }; Some(reorder_join_output(join_node, raw_layout, output_tables)) } @@ -479,7 +534,7 @@ fn compile_node( .. } => { let outer_node = compile_node(outer, table_ids, table_schemas)?; - let inner_table_id = get_or_assign_table_id(inner_table, table_ids); + let inner_table_id = table_ids.resolve(inner_table); let inner_column_count = table_schemas.get(inner_table)?.columns().len(); let inner_layout = CompileLayout::table(inner_table, inner_column_count); let inner_node = CompiledNode { @@ -502,6 +557,8 @@ fn compile_node( left_key, right_key, join_type: ivm_join_type, + left_width: left_node.layout.total_width(), + right_width: right_node.layout.total_width(), }; Some(reorder_join_output(join_node, raw_layout, output_tables)) @@ -516,9 +573,11 @@ fn compile_node( dataflow: DataflowNode::Join { left: Box::new(left_node.dataflow), right: Box::new(right_node.dataflow), - left_key: Box::new(|_| vec![Value::Int64(0)]), - right_key: Box::new(|_| vec![Value::Int64(0)]), + left_key: JoinKeySpec::Constant(alloc::vec![Value::Int64(0)]), + right_key: JoinKeySpec::Constant(alloc::vec![Value::Int64(0)]), join_type: IvmJoinType::Inner, + left_width: left_node.layout.total_width(), + right_width: right_node.layout.total_width(), }, layout: raw_layout, }) @@ -594,50 +653,95 @@ fn compile_node( /// Compiles an Expr predicate into a closure for DataflowNode::Filter. fn compile_predicate(expr: &Expr) -> Box bool + Send + Sync> { let expr = expr.clone(); - Box::new(move |row: &Row| match eval_expr(&expr, row) { - Value::Boolean(b) => b, - _ => false, + Box::new(move |row: &Row| { + let mut value_at = |index: usize| row.get(index).cloned(); + matches!(eval_expr_with(&expr, &mut value_at), Value::Boolean(true)) + }) +} + +fn compile_trace_predicate( + expr: &Expr, +) -> Box bool + Send + Sync> { + let expr = expr.clone(); + Box::new(move |arena: &TraceTupleArena, handle: &TraceTupleHandle| { + let mut value_at = |index: usize| arena.value_at(handle, index); + matches!(eval_expr_with(&expr, &mut value_at), Value::Boolean(true)) + }) +} + +fn compile_trace_mapper( + exprs: &[Expr], +) -> Box Row + Send + Sync> { + let exprs = exprs.to_vec(); + Box::new(move |arena: &TraceTupleArena, handle: &TraceTupleHandle| { + let values = exprs + .iter() + .map(|expr| { + let mut value_at = |index: usize| arena.value_at(handle, index); + eval_expr_with(expr, &mut value_at) + }) + .collect(); + Row::new_with_version(arena.row_id(handle), arena.version(handle), values) }) } /// Evaluates an expression against a row. fn eval_expr(expr: &Expr, row: &Row) -> Value { + let mut value_at = |index: usize| row.get(index).cloned(); + eval_expr_with(expr, &mut value_at) +} + +fn eval_expr_with(expr: &Expr, value_at: &mut F) -> Value +where + F: FnMut(usize) -> Option, +{ match expr { - Expr::Column(col_ref) => row.get(col_ref.index).cloned().unwrap_or(Value::Null), + Expr::Column(col_ref) => value_at(col_ref.index).unwrap_or(Value::Null), Expr::Literal(val) => val.clone(), Expr::BinaryOp { left, op, right } => { - let lval = eval_expr(left, row); - let rval = eval_expr(right, row); + let lval = eval_expr_with(left, value_at); + let rval = eval_expr_with(right, value_at); eval_binary_op(&lval, op, &rval) } Expr::UnaryOp { op, expr: inner } => { - let val = eval_expr(inner, row); + let val = eval_expr_with(inner, value_at); eval_unary_op(op, &val) } + Expr::Function { name, args } => { + let arg_values: Vec = args + .iter() + .map(|arg| eval_expr_with(arg, value_at)) + .collect(); + eval_function(name, &arg_values) + } Expr::In { expr, list } => { - let val = eval_expr(expr, row); - let found = list.iter().any(|item| eval_expr(item, row) == val); + let val = eval_expr_with(expr, value_at); + let found = list + .iter() + .any(|item| eval_expr_with(item, value_at) == val); Value::Boolean(found) } Expr::NotIn { expr, list } => { - let val = eval_expr(expr, row); - let found = list.iter().any(|item| eval_expr(item, row) == val); + let val = eval_expr_with(expr, value_at); + let found = list + .iter() + .any(|item| eval_expr_with(item, value_at) == val); Value::Boolean(!found) } Expr::Between { expr, low, high } => { - let val = eval_expr(expr, row); - let lo = eval_expr(low, row); - let hi = eval_expr(high, row); + let val = eval_expr_with(expr, value_at); + let lo = eval_expr_with(low, value_at); + let hi = eval_expr_with(high, value_at); Value::Boolean(val >= lo && val <= hi) } Expr::NotBetween { expr, low, high } => { - let val = eval_expr(expr, row); - let lo = eval_expr(low, row); - let hi = eval_expr(high, row); + let val = eval_expr_with(expr, value_at); + let lo = eval_expr_with(low, value_at); + let hi = eval_expr_with(high, value_at); Value::Boolean(val < lo || val > hi) } Expr::Like { expr, pattern } => { - let val = eval_expr(expr, row); + let val = eval_expr_with(expr, value_at); if let Value::String(s) = val { Value::Boolean(cynos_core::pattern_match::like(&s, pattern)) } else { @@ -645,7 +749,7 @@ fn eval_expr(expr: &Expr, row: &Row) -> Value { } } Expr::NotLike { expr, pattern } => { - let val = eval_expr(expr, row); + let val = eval_expr_with(expr, value_at); if let Value::String(s) = val { Value::Boolean(!cynos_core::pattern_match::like(&s, pattern)) } else { @@ -653,7 +757,7 @@ fn eval_expr(expr: &Expr, row: &Row) -> Value { } } Expr::Match { expr, pattern } => { - let val = eval_expr(expr, row); + let val = eval_expr_with(expr, value_at); if let Value::String(s) = val { Value::Boolean(cynos_core::pattern_match::regex(&s, pattern)) } else { @@ -661,18 +765,344 @@ fn eval_expr(expr: &Expr, row: &Row) -> Value { } } Expr::NotMatch { expr, pattern } => { - let val = eval_expr(expr, row); + let val = eval_expr_with(expr, value_at); if let Value::String(s) = val { Value::Boolean(!cynos_core::pattern_match::regex(&s, pattern)) } else { Value::Boolean(true) } } - // Function and Aggregate are not expected in filter predicates + // Aggregate expressions are not expected in row-level evaluation. _ => Value::Null, } } +fn eval_function(name: &str, args: &[Value]) -> Value { + if name.eq_ignore_ascii_case("ABS") { + return match args.first() { + Some(Value::Int32(value)) => Value::Int32(value.abs()), + Some(Value::Int64(value)) => Value::Int64(value.abs()), + Some(Value::Float64(value)) => Value::Float64(value.abs()), + _ => Value::Null, + }; + } + + if name.eq_ignore_ascii_case("UPPER") { + return match args.first() { + Some(Value::String(value)) => Value::String(value.to_uppercase().into()), + _ => Value::Null, + }; + } + + if name.eq_ignore_ascii_case("LOWER") { + return match args.first() { + Some(Value::String(value)) => Value::String(value.to_lowercase().into()), + _ => Value::Null, + }; + } + + if name.eq_ignore_ascii_case("LENGTH") { + return match args.first() { + Some(Value::String(value)) => Value::Int64(value.len() as i64), + _ => Value::Null, + }; + } + + if name.eq_ignore_ascii_case("COALESCE") { + for value in args { + if !value.is_null() { + return value.clone(); + } + } + return Value::Null; + } + + if name.eq_ignore_ascii_case("JSONB_PATH_EQ") { + return match (args.first(), args.get(1), args.get(2)) { + (Some(Value::Jsonb(jsonb)), Some(Value::String(path)), Some(expected)) => { + jsonb_path_eq(jsonb, path, expected) + } + _ => Value::Boolean(false), + }; + } + + if name.eq_ignore_ascii_case("JSONB_CONTAINS") { + return match (args.first(), args.get(1), args.get(2)) { + (Some(Value::Jsonb(jsonb)), Some(Value::String(path)), Some(expected)) => { + jsonb_contains(jsonb, path, expected) + } + _ => Value::Boolean(false), + }; + } + + if name.eq_ignore_ascii_case("JSONB_EXISTS") { + return match (args.first(), args.get(1)) { + (Some(Value::Jsonb(jsonb)), Some(Value::String(path))) => { + jsonb_path_exists(jsonb, path) + } + _ => Value::Boolean(false), + }; + } + + Value::Null +} + +fn jsonb_path_eq(jsonb: &cynos_core::JsonbValue, path: &str, expected: &Value) -> Value { + let json_value = match parse_json_bytes(&jsonb.0) { + Some(value) => value, + None => return Value::Boolean(false), + }; + + let json_path = match JsonPath::parse(path) { + Ok(path) => path, + Err(_) => return Value::Boolean(false), + }; + + let results = json_value.query(&json_path); + match results.first() { + Some(actual) => Value::Boolean(compare_jsonb_with_value(actual, expected)), + None => Value::Boolean(false), + } +} + +fn jsonb_path_exists(jsonb: &cynos_core::JsonbValue, path: &str) -> Value { + let json_value = match parse_json_bytes(&jsonb.0) { + Some(value) => value, + None => return Value::Boolean(false), + }; + + let json_path = match JsonPath::parse(path) { + Ok(path) => path, + Err(_) => return Value::Boolean(false), + }; + + Value::Boolean(!json_value.query(&json_path).is_empty()) +} + +fn jsonb_contains(jsonb: &cynos_core::JsonbValue, path: &str, expected: &Value) -> Value { + let json_value = match parse_json_bytes(&jsonb.0) { + Some(value) => value, + None => return Value::Boolean(false), + }; + + let json_path = match JsonPath::parse(path) { + Ok(path) => path, + Err(_) => return Value::Boolean(false), + }; + + let results = json_value.query(&json_path); + let Some(actual) = results.first() else { + return Value::Boolean(false); + }; + + let contains = match expected { + Value::String(expected_string) => { + jsonb_value_to_string(actual).contains(expected_string.as_str()) + } + _ => compare_jsonb_with_value(actual, expected), + }; + + Value::Boolean(contains) +} + +fn parse_json_bytes(bytes: &[u8]) -> Option { + let json_str = core::str::from_utf8(bytes).ok()?; + parse_json_text(json_str) +} + +fn parse_json_text(s: &str) -> Option { + let s = s.trim(); + if s == "null" { + return Some(JsonbValue::Null); + } + if s == "true" { + return Some(JsonbValue::Bool(true)); + } + if s == "false" { + return Some(JsonbValue::Bool(false)); + } + if let Ok(number) = s.parse::() { + return Some(JsonbValue::Number(number)); + } + if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 { + let inner = &s[1..s.len() - 1]; + return Some(JsonbValue::String(unescape_json(inner))); + } + if s.starts_with('{') { + return parse_json_object(s); + } + if s.starts_with('[') { + return parse_json_array(s); + } + None +} + +fn unescape_json(s: &str) -> String { + let mut result = String::new(); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('n') => result.push('\n'), + Some('t') => result.push('\t'), + Some('r') => result.push('\r'), + Some('"') => result.push('"'), + Some('\\') => result.push('\\'), + Some('/') => result.push('/'), + Some(other) => { + result.push('\\'); + result.push(other); + } + None => result.push('\\'), + } + } else { + result.push(c); + } + } + result +} + +fn parse_json_object(s: &str) -> Option { + let s = s.trim(); + if !s.starts_with('{') || !s.ends_with('}') { + return None; + } + + let inner = s[1..s.len() - 1].trim(); + if inner.is_empty() { + return Some(JsonbValue::Object(JsonbObject::new())); + } + + let mut obj = JsonbObject::new(); + for pair in split_json_top_level(inner, ',') { + let pair = pair.trim(); + let colon_pos = find_json_colon(pair)?; + let key_str = pair[..colon_pos].trim(); + let val_str = pair[colon_pos + 1..].trim(); + if key_str.starts_with('"') && key_str.ends_with('"') && key_str.len() >= 2 { + let key = unescape_json(&key_str[1..key_str.len() - 1]); + let value = parse_json_text(val_str)?; + obj.insert(key, value); + } else { + return None; + } + } + + Some(JsonbValue::Object(obj)) +} + +fn parse_json_array(s: &str) -> Option { + let s = s.trim(); + if !s.starts_with('[') || !s.ends_with(']') { + return None; + } + + let inner = s[1..s.len() - 1].trim(); + if inner.is_empty() { + return Some(JsonbValue::Array(Vec::new())); + } + + let mut arr = Vec::new(); + for elem in split_json_top_level(inner, ',') { + arr.push(parse_json_text(elem.trim())?); + } + Some(JsonbValue::Array(arr)) +} + +fn split_json_top_level(s: &str, sep: char) -> Vec<&str> { + let mut parts = Vec::new(); + let mut depth = 0i32; + let mut in_string = false; + let mut escape = false; + let mut start = 0usize; + + for (index, c) in s.char_indices() { + if escape { + escape = false; + continue; + } + if c == '\\' && in_string { + escape = true; + continue; + } + if c == '"' { + in_string = !in_string; + continue; + } + if in_string { + continue; + } + if c == '{' || c == '[' { + depth += 1; + } else if c == '}' || c == ']' { + depth -= 1; + } else if c == sep && depth == 0 { + parts.push(&s[start..index]); + start = index + c.len_utf8(); + } + } + + if start <= s.len() { + parts.push(&s[start..]); + } + parts +} + +fn find_json_colon(s: &str) -> Option { + let mut in_string = false; + let mut escape = false; + for (index, c) in s.char_indices() { + if escape { + escape = false; + continue; + } + if c == '\\' && in_string { + escape = true; + continue; + } + if c == '"' { + in_string = !in_string; + continue; + } + if !in_string && c == ':' { + return Some(index); + } + } + None +} + +fn compare_jsonb_with_value(jsonb: &JsonbValue, value: &Value) -> bool { + match (jsonb, value) { + (JsonbValue::Null, Value::Null) => true, + (JsonbValue::Bool(left), Value::Boolean(right)) => left == right, + (JsonbValue::Number(left), Value::Int32(right)) => { + (*left - *right as f64).abs() < f64::EPSILON + } + (JsonbValue::Number(left), Value::Int64(right)) => { + (*left - *right as f64).abs() < f64::EPSILON + } + (JsonbValue::Number(left), Value::Float64(right)) => (*left - *right).abs() < f64::EPSILON, + (JsonbValue::String(left), Value::String(right)) => left == right, + _ => false, + } +} + +fn jsonb_value_to_string(value: &JsonbValue) -> String { + match value { + JsonbValue::Null => String::from("null"), + JsonbValue::Bool(boolean) => { + if *boolean { + String::from("true") + } else { + String::from("false") + } + } + JsonbValue::Number(number) => alloc::format!("{}", number), + JsonbValue::String(string) => string.clone(), + _ => alloc::format!("{:?}", value), + } +} + fn eval_binary_op(left: &Value, op: &BinaryOp, right: &Value) -> Value { match op { BinaryOp::Eq => Value::Boolean(left == right), @@ -774,7 +1204,7 @@ fn extract_join_keys( condition: &Expr, left_layout: &CompileLayout, right_layout: &CompileLayout, -) -> (KeyExtractorFn, KeyExtractorFn) { +) -> (JoinKeySpec, JoinKeySpec) { let mut left_indices = Vec::new(); let mut right_indices = Vec::new(); collect_equi_join_keys( @@ -787,24 +1217,14 @@ fn extract_join_keys( if left_indices.is_empty() || right_indices.is_empty() { return ( - Box::new(|row: &Row| row.values().to_vec()), - Box::new(|row: &Row| row.values().to_vec()), + JoinKeySpec::Dynamic(Box::new(|row: &Row| row.values().to_vec())), + JoinKeySpec::Dynamic(Box::new(|row: &Row| row.values().to_vec())), ); } ( - Box::new(move |row: &Row| { - left_indices - .iter() - .map(|&idx| row.get(idx).cloned().unwrap_or(Value::Null)) - .collect() - }), - Box::new(move |row: &Row| { - right_indices - .iter() - .map(|&idx| row.get(idx).cloned().unwrap_or(Value::Null)) - .collect() - }), + JoinKeySpec::Columns(left_indices), + JoinKeySpec::Columns(right_indices), ) } @@ -955,11 +1375,6 @@ fn extract_column_ref(expr: &Expr) -> Option<&cynos_query::ast::ColumnRef> { } } -fn get_or_assign_table_id(table: &str, table_ids: &mut HashMap) -> TableId { - let next_id = table_ids.len() as TableId; - *table_ids.entry(table.into()).or_insert(next_id) -} - fn convert_join_type(jt: &QueryJoinType) -> IvmJoinType { match jt { QueryJoinType::Inner | QueryJoinType::Cross => IvmJoinType::Inner, @@ -1000,6 +1415,10 @@ mod tests { .collect() } + fn jsonb_value(json: &str) -> Value { + Value::Jsonb(cynos_core::JsonbValue::new(json.as_bytes().to_vec())) + } + #[test] fn test_compile_table_scan() { let plan = PhysicalPlan::table_scan("users"); @@ -1025,7 +1444,12 @@ mod tests { let table_schemas = table_schemas(&[("users", &["id", "age"])]); let result = compile_to_dataflow(&plan, &table_ids, &table_schemas).unwrap(); - assert!(matches!(result.dataflow, DataflowNode::Filter { .. })); + match result.dataflow { + DataflowNode::Filter { + trace_predicate, .. + } => assert!(trace_predicate.is_some()), + _ => panic!("Expected Filter node"), + } } #[test] @@ -1144,6 +1568,29 @@ mod tests { } } + #[test] + fn test_compile_computed_project_includes_trace_mapper() { + let plan = PhysicalPlan::project( + PhysicalPlan::table_scan("users"), + alloc::vec![ + Expr::column("users", "id", 0), + Expr::Function { + name: "UPPER".into(), + args: alloc::vec![Expr::column("users", "name", 1)], + }, + ], + ); + let mut table_ids = HashMap::new(); + table_ids.insert("users".into(), 1u32); + let table_schemas = table_schemas(&[("users", &["id", "name"])]); + + let result = compile_to_dataflow(&plan, &table_ids, &table_schemas).unwrap(); + match result.dataflow { + DataflowNode::Map { trace_mapper, .. } => assert!(trace_mapper.is_some()), + _ => panic!("Expected Map node"), + } + } + #[test] fn test_compile_reordered_join_wraps_join_with_projection() { use cynos_query::ast::JoinType; @@ -1408,4 +1855,264 @@ mod tests { assert_eq!(rows[0].get(2), Some(&Value::Int64(10))); assert_eq!(rows[0].get(3), Some(&Value::String("Engineering".into()))); } + + #[test] + fn test_filter_with_json_function_propagates_updates() { + use cynos_incremental::{Delta, MaterializedView}; + + let projects = TableBuilder::new("projects") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("healthScore", DataType::Int32) + .unwrap() + .add_column("metadata", DataType::Jsonb) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .build() + .unwrap(); + + let mut table_schemas = HashMap::new(); + table_schemas.insert("projects".into(), projects); + + let plan = PhysicalPlan::filter( + PhysicalPlan::table_scan("projects"), + Expr::and( + Expr::ge( + Expr::column("projects", "healthScore", 1), + Expr::Literal(Value::Int32(45)), + ), + Expr::jsonb_path_eq( + Expr::column("projects", "metadata", 2), + "$.risk.bucket", + Value::String("high".into()), + ), + ), + ); + + let mut table_ids = HashMap::new(); + table_ids.insert("projects".into(), 1u32); + + let result = compile_to_dataflow(&plan, &table_ids, &table_schemas).unwrap(); + let mut view = MaterializedView::new(result.dataflow); + + let initial_row = Row::new( + 1, + vec![ + Value::Int64(1), + Value::Int32(61), + jsonb_value(r#"{"risk":{"bucket":"high"}}"#), + ], + ); + let updated_row = Row::new( + 1, + vec![ + Value::Int64(1), + Value::Int32(12), + jsonb_value(r#"{"risk":{"bucket":"high"}}"#), + ], + ); + + let initial_deltas = + view.on_table_change(1, alloc::vec![Delta::insert(initial_row.clone())]); + assert_eq!(initial_deltas.len(), 1); + assert!(initial_deltas[0].is_insert()); + assert_eq!(view.len(), 1); + + let update_deltas = view.on_table_change( + 1, + alloc::vec![Delta::delete(initial_row), Delta::insert(updated_row)], + ); + assert_eq!(update_deltas.len(), 1); + assert!(update_deltas[0].is_delete()); + assert_eq!(view.len(), 0); + } + + #[test] + fn test_filter_with_json_function_over_multi_join_propagates_updates() { + use cynos_incremental::{Delta, MaterializedView}; + use cynos_query::ast::JoinType; + + let projects = TableBuilder::new("projects") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("organizationId", DataType::Int64) + .unwrap() + .add_column("healthScore", DataType::Int32) + .unwrap() + .add_column("metadata", DataType::Jsonb) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .build() + .unwrap(); + let organizations = TableBuilder::new("organizations") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("name", DataType::String) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .build() + .unwrap(); + let counters = TableBuilder::new("projectCounters") + .unwrap() + .add_column("projectId", DataType::Int64) + .unwrap() + .add_column("openIssueCount", DataType::Int32) + .unwrap() + .add_primary_key(&["projectId"], false) + .unwrap() + .build() + .unwrap(); + let snapshots = TableBuilder::new("projectSnapshots") + .unwrap() + .add_column("projectId", DataType::Int64) + .unwrap() + .add_column("velocity", DataType::Int32) + .unwrap() + .add_primary_key(&["projectId"], false) + .unwrap() + .build() + .unwrap(); + + let mut table_schemas = HashMap::new(); + table_schemas.insert("projects".into(), projects); + table_schemas.insert("organizations".into(), organizations); + table_schemas.insert("projectCounters".into(), counters); + table_schemas.insert("projectSnapshots".into(), snapshots); + + let project_org_join = PhysicalPlan::hash_join_with_output_tables( + PhysicalPlan::table_scan("projects"), + PhysicalPlan::table_scan("organizations"), + Expr::eq( + Expr::column("projects", "organizationId", 1), + Expr::column("organizations", "id", 0), + ), + JoinType::LeftOuter, + alloc::vec!["projects".into(), "organizations".into()], + ); + let with_counters = PhysicalPlan::hash_join_with_output_tables( + project_org_join, + PhysicalPlan::table_scan("projectCounters"), + Expr::eq( + Expr::column("projects", "id", 0), + Expr::column("projectCounters", "projectId", 0), + ), + JoinType::LeftOuter, + alloc::vec![ + "projects".into(), + "organizations".into(), + "projectCounters".into(), + ], + ); + let with_snapshots = PhysicalPlan::hash_join_with_output_tables( + with_counters, + PhysicalPlan::table_scan("projectSnapshots"), + Expr::eq( + Expr::column("projects", "id", 0), + Expr::column("projectSnapshots", "projectId", 0), + ), + JoinType::LeftOuter, + alloc::vec![ + "projects".into(), + "organizations".into(), + "projectCounters".into(), + "projectSnapshots".into(), + ], + ); + let plan = PhysicalPlan::filter( + with_snapshots, + Expr::and( + Expr::and( + Expr::and( + Expr::ge( + Expr::column("projects", "healthScore", 2), + Expr::Literal(Value::Int32(45)), + ), + Expr::jsonb_path_eq( + Expr::column("projects", "metadata", 3), + "$.risk.bucket", + Value::String("high".into()), + ), + ), + Expr::ge( + Expr::column("projectCounters", "openIssueCount", 1), + Expr::Literal(Value::Int32(4)), + ), + ), + Expr::ge( + Expr::column("projectSnapshots", "velocity", 1), + Expr::Literal(Value::Int32(20)), + ), + ), + ); + + let mut table_ids = HashMap::new(); + table_ids.insert("projects".into(), 1u32); + table_ids.insert("organizations".into(), 2u32); + table_ids.insert("projectCounters".into(), 3u32); + table_ids.insert("projectSnapshots".into(), 4u32); + + let result = compile_to_dataflow(&plan, &table_ids, &table_schemas).unwrap(); + let mut view = MaterializedView::new(result.dataflow); + + view.on_table_change( + 2, + alloc::vec![Delta::insert(Row::new( + 10, + vec![Value::Int64(10), Value::String("Org".into())], + ))], + ); + view.on_table_change( + 3, + alloc::vec![Delta::insert(Row::new( + 1, + vec![Value::Int64(1), Value::Int32(7)], + ))], + ); + view.on_table_change( + 4, + alloc::vec![Delta::insert(Row::new( + 1, + vec![Value::Int64(1), Value::Int32(35)], + ))], + ); + + let initial_row = Row::new( + 1, + vec![ + Value::Int64(1), + Value::Int64(10), + Value::Int32(61), + jsonb_value(r#"{"risk":{"bucket":"high"}}"#), + ], + ); + let updated_row = Row::new( + 1, + vec![ + Value::Int64(1), + Value::Int64(10), + Value::Int32(12), + jsonb_value(r#"{"risk":{"bucket":"high"}}"#), + ], + ); + + let initial_deltas = + view.on_table_change(1, alloc::vec![Delta::insert(initial_row.clone())]); + assert_eq!(initial_deltas.len(), 1); + assert!(initial_deltas[0].is_insert()); + assert_eq!(view.len(), 1); + + let update_deltas = view.on_table_change( + 1, + alloc::vec![Delta::delete(initial_row), Delta::insert(updated_row)], + ); + assert_eq!(update_deltas.len(), 1); + assert!(update_deltas[0].is_delete()); + assert_eq!(view.len(), 0); + } } diff --git a/crates/database/src/lib.rs b/crates/database/src/lib.rs index 22c50b8..afa47ef 100644 --- a/crates/database/src/lib.rs +++ b/crates/database/src/lib.rs @@ -43,6 +43,7 @@ pub mod database; pub mod dataflow_compiler; pub mod expr; pub mod live_runtime; +mod profiling; pub mod query_builder; pub mod query_engine; pub mod reactive_bridge; diff --git a/crates/database/src/live_runtime.rs b/crates/database/src/live_runtime.rs index 0be72e8..20949da 100644 --- a/crates/database/src/live_runtime.rs +++ b/crates/database/src/live_runtime.rs @@ -1,17 +1,29 @@ use crate::binary_protocol::SchemaLayout; -use crate::query_engine::{CompiledPhysicalPlan, QueryResultSummary}; +#[cfg(feature = "benchmark")] +use crate::profiling::{GraphqlDeltaProfile, SnapshotInitProfile}; +use crate::profiling::{ + now_ms, DeltaFlushProfile, IvmBridgeProfile, IvmBridgeProfiler, SnapshotFlushProfile, + TraceInitProfile, +}; +use crate::query_engine::{CompiledPhysicalPlan, QueryResultSummary, RootSubsetPlanVariant}; use crate::reactive_bridge::{ GraphqlDeltaObservable, GraphqlSubscriptionObservable, JsGraphqlSubscription, JsIvmObservableQuery, JsObservableQuery, ReQueryObservable, }; +use alloc::collections::BTreeSet; use alloc::rc::Rc; use alloc::string::String; use alloc::vec::Vec; use core::cell::RefCell; use cynos_core::schema::Table; -use cynos_core::Row; +use cynos_core::{Row, Value}; use cynos_gql::{bind::BoundRootField, GraphqlCatalog}; -use cynos_incremental::{DataflowNode, Delta, TableId}; +#[cfg(feature = "benchmark")] +use cynos_incremental::TraceUpdateProfile; +use cynos_incremental::{CompiledBootstrapPlan, CompiledIvmPlan, DataflowNode, Delta, TableId}; +use cynos_index::KeyRange; +use cynos_query::ast::SortOrder; +use cynos_query::planner::{IndexBounds, PhysicalPlan}; use cynos_reactive::ObservableQuery; use cynos_storage::TableCache; use hashbrown::{HashMap, HashSet}; @@ -59,6 +71,57 @@ impl LiveDependencySet { } } +#[derive(Clone, Debug)] +pub(crate) enum TraceBootstrapAccessPath { + FullScan, + IndexScanScalar { + index: String, + range: Option>, + limit: Option, + offset: usize, + reverse: bool, + }, + IndexScanComposite { + index: String, + range: Option>>, + limit: Option, + offset: usize, + reverse: bool, + }, + IndexGet { + index: String, + key: Value, + limit: Option, + }, + IndexInGet { + index: String, + keys: Vec, + }, + GinKeyValue { + index: String, + key: String, + value: String, + }, + GinKey { + index: String, + key: String, + }, + GinMulti { + index: String, + pairs: Vec<(String, String)>, + match_all: bool, + }, +} + +#[derive(Clone, Debug)] +pub(crate) struct TraceBootstrapSourceBinding { + pub source_index: usize, + pub table_id: TableId, + pub table_name: String, + pub access_path: TraceBootstrapAccessPath, + pub covers_source_filter: bool, +} + #[derive(Clone, Debug)] pub(crate) enum RowsProjection { Full { schema: Table }, @@ -83,12 +146,19 @@ impl RowsProjection { self, inner: Rc>, binary_layout: SchemaLayout, + bridge_profiler: Rc>, ) -> JsIvmObservableQuery { match self { - Self::Full { schema } => JsIvmObservableQuery::new(inner, schema, binary_layout), - Self::Projection { schema, columns } => { - JsIvmObservableQuery::new_with_projection(inner, schema, columns, binary_layout) + Self::Full { schema } => { + JsIvmObservableQuery::new(inner, schema, binary_layout, bridge_profiler) } + Self::Projection { schema, columns } => JsIvmObservableQuery::new_with_projection( + inner, + schema, + columns, + binary_layout, + bridge_profiler, + ), } } } @@ -101,7 +171,11 @@ pub(crate) struct SnapshotKernelPlan { pub(crate) struct DeltaKernelPlan { pub dataflow: DataflowNode, - pub initial_rows: Vec, + pub compiled_ivm_plan: CompiledIvmPlan, + pub compiled_bootstrap_plan: CompiledBootstrapPlan, + pub initial_rows: Vec>, + pub source_bindings: Vec, + pub trace_init_profile: Option, } pub(crate) enum KernelPlan { @@ -112,6 +186,103 @@ pub(crate) enum KernelPlan { pub(crate) struct RowsSnapshotAdapterPlan { pub projection: RowsProjection, pub binary_layout: SchemaLayout, + pub dependency_table_bindings: Vec<(TableId, String)>, + pub partial_refresh: Option, + pub root_subset_refresh: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct RowsSnapshotOrderKey { + pub output_index: usize, + pub order: SortOrder, +} + +#[derive(Clone, Debug)] +pub(crate) struct RowsSnapshotJoinEdge { + pub left_table: String, + pub left_column: String, + pub right_table: String, + pub right_column: String, +} + +#[derive(Clone, Debug)] +pub(crate) enum RowsSnapshotLookupPrimitive { + PrimaryKey, + SingleColumnIndex { index_name: String }, + ScanFallback, +} + +#[derive(Clone, Debug)] +pub(crate) struct RowsSnapshotDirectedJoinEdge { + pub source_column_index: usize, + pub target_table_id: TableId, + pub target_table: String, + pub target_column_index: usize, + pub lookup: RowsSnapshotLookupPrimitive, +} + +#[derive(Clone, Debug)] +pub(crate) struct RowsSnapshotDependencyGraph { + pub root_table_id: TableId, + pub table_names: HashMap, + pub edges_by_source: HashMap>, +} + +impl RowsSnapshotDependencyGraph { + pub fn table_name(&self, table_id: TableId) -> Option<&str> { + self.table_names.get(&table_id).map(String::as_str) + } + + pub fn edges_from(&self, table_id: TableId) -> &[RowsSnapshotDirectedJoinEdge] { + self.edges_by_source + .get(&table_id) + .map(Vec::as_slice) + .unwrap_or(&[]) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct RowsSnapshotPartialRefreshMetadata { + pub root_table: String, + pub root_pk_output_indices: Vec, + pub order_keys: Vec, + pub visible_offset: usize, + pub visible_limit: usize, + pub overscan: usize, + pub candidate_limit: usize, + pub dependency_graph: RowsSnapshotDependencyGraph, +} + +#[derive(Clone, Debug)] +pub(crate) struct RowsSnapshotRootSubsetMetadata { + pub root_table: String, + pub root_pk_store_indices: Vec, + pub root_pk_output_indices: Vec, + pub dependency_graph: RowsSnapshotDependencyGraph, +} + +pub(crate) struct RowsSnapshotRootSubsetPlan { + pub metadata: RowsSnapshotRootSubsetMetadata, + pub compiled_plans: RowsSnapshotRootSubsetVariants, +} + +pub(crate) struct RowsSnapshotRootSubsetVariants { + pub small: CompiledPhysicalPlan, + pub large: CompiledPhysicalPlan, +} + +impl RowsSnapshotRootSubsetVariants { + pub fn select(&self, variant: RootSubsetPlanVariant) -> &CompiledPhysicalPlan { + match variant { + RootSubsetPlanVariant::Small => &self.small, + RootSubsetPlanVariant::Large => &self.large, + } + } +} + +pub(crate) struct RowsSnapshotPartialRefreshState { + pub metadata: RowsSnapshotPartialRefreshMetadata, + pub initial_candidate_rows: Vec>, } pub(crate) struct RowsDeltaAdapterPlan { @@ -123,6 +294,7 @@ pub(crate) struct GraphqlSnapshotAdapterPlan { pub catalog: GraphqlCatalog, pub field: BoundRootField, pub dependency_table_bindings: Vec<(TableId, String)>, + pub root_subset_refresh: Option, } pub(crate) struct GraphqlDeltaAdapterPlan { @@ -138,6 +310,420 @@ pub(crate) enum AdapterPlan { GraphqlDelta(GraphqlDeltaAdapterPlan), } +fn trace_bootstrap_sql_point_lookup_keys(key: &Value) -> Vec { + match key { + Value::Int32(value) => alloc::vec![Value::Int32(*value), Value::Int64(*value as i64)], + Value::Int64(value) => { + let mut keys = alloc::vec![Value::Int64(*value)]; + if i32::try_from(*value).is_ok() { + keys.push(Value::Int32(*value as i32)); + } + keys + } + _ => alloc::vec![key.clone()], + } +} + +fn trace_index_column_arity(schema: &Table, index_name: &str) -> Option { + schema + .get_index(index_name) + .or_else(|| { + schema + .indices() + .iter() + .find(|candidate| candidate.normalized_name() == index_name) + }) + .or_else(|| { + schema.primary_key().filter(|candidate| { + candidate.name() == index_name || candidate.normalized_name() == index_name + }) + }) + .map(|index| index.columns().len()) +} + +fn trace_index_scan_access_path( + schema: Option<&Table>, + index: &str, + bounds: &IndexBounds, + limit: Option, + offset: Option, + reverse: bool, +) -> TraceBootstrapAccessPath { + let offset = offset.unwrap_or(0); + match bounds { + IndexBounds::Scalar(range) => TraceBootstrapAccessPath::IndexScanScalar { + index: index.into(), + range: Some(range.clone()), + limit, + offset, + reverse, + }, + IndexBounds::Composite(range) => TraceBootstrapAccessPath::IndexScanComposite { + index: index.into(), + range: Some(range.clone()), + limit, + offset, + reverse, + }, + IndexBounds::Unbounded => { + let arity = schema + .and_then(|schema| trace_index_column_arity(schema, index)) + .unwrap_or(1); + if arity > 1 { + TraceBootstrapAccessPath::IndexScanComposite { + index: index.into(), + range: None, + limit, + offset, + reverse, + } + } else { + TraceBootstrapAccessPath::IndexScanScalar { + index: index.into(), + range: None, + limit, + offset, + reverse, + } + } + } + } +} + +fn push_trace_bootstrap_source_binding( + bindings: &mut Vec, + table_ids: &HashMap, + table_name: &str, + access_path: TraceBootstrapAccessPath, + covers_source_filter: bool, +) { + let Some(table_id) = table_ids.get(table_name).copied() else { + return; + }; + + bindings.push(TraceBootstrapSourceBinding { + source_index: bindings.len(), + table_id, + table_name: table_name.into(), + access_path, + covers_source_filter, + }); +} + +fn collect_trace_bootstrap_source_bindings_into( + plan: &PhysicalPlan, + table_ids: &HashMap, + table_schemas: &HashMap, + bindings: &mut Vec, +) { + match plan { + PhysicalPlan::TableScan { table } => push_trace_bootstrap_source_binding( + bindings, + table_ids, + table, + TraceBootstrapAccessPath::FullScan, + false, + ), + PhysicalPlan::IndexScan { + table, + index, + bounds, + limit, + offset, + reverse, + } => push_trace_bootstrap_source_binding( + bindings, + table_ids, + table, + trace_index_scan_access_path( + table_schemas.get(table), + index, + bounds, + *limit, + *offset, + *reverse, + ), + true, + ), + PhysicalPlan::IndexGet { + table, + index, + key, + limit, + } => push_trace_bootstrap_source_binding( + bindings, + table_ids, + table, + TraceBootstrapAccessPath::IndexGet { + index: index.clone(), + key: key.clone(), + limit: *limit, + }, + true, + ), + PhysicalPlan::IndexInGet { table, index, keys } => push_trace_bootstrap_source_binding( + bindings, + table_ids, + table, + TraceBootstrapAccessPath::IndexInGet { + index: index.clone(), + keys: keys.clone(), + }, + true, + ), + PhysicalPlan::GinIndexScan { + table, + index, + key, + value, + query_type, + .. + } => { + let access_path = match (query_type.as_str(), value.as_ref()) { + ("eq", Some(value)) => TraceBootstrapAccessPath::GinKeyValue { + index: index.clone(), + key: key.clone(), + value: value.clone(), + }, + ("contains", _) | ("exists", _) => TraceBootstrapAccessPath::GinKey { + index: index.clone(), + key: key.clone(), + }, + _ => TraceBootstrapAccessPath::FullScan, + }; + push_trace_bootstrap_source_binding(bindings, table_ids, table, access_path, true); + } + PhysicalPlan::GinIndexScanMulti { + table, + index, + pairs, + match_all, + .. + } => push_trace_bootstrap_source_binding( + bindings, + table_ids, + table, + TraceBootstrapAccessPath::GinMulti { + index: index.clone(), + pairs: pairs.clone(), + match_all: *match_all, + }, + true, + ), + PhysicalPlan::Filter { input, .. } + | PhysicalPlan::Project { input, .. } + | PhysicalPlan::HashAggregate { input, .. } + | PhysicalPlan::Sort { input, .. } + | PhysicalPlan::TopN { input, .. } + | PhysicalPlan::Limit { input, .. } + | PhysicalPlan::NoOp { input } => { + collect_trace_bootstrap_source_bindings_into(input, table_ids, table_schemas, bindings); + } + PhysicalPlan::HashJoin { left, right, .. } + | PhysicalPlan::SortMergeJoin { left, right, .. } + | PhysicalPlan::NestedLoopJoin { left, right, .. } + | PhysicalPlan::CrossProduct { left, right } + | PhysicalPlan::Union { left, right, .. } => { + collect_trace_bootstrap_source_bindings_into(left, table_ids, table_schemas, bindings); + collect_trace_bootstrap_source_bindings_into(right, table_ids, table_schemas, bindings); + } + PhysicalPlan::IndexNestedLoopJoin { + outer, + inner_table, + outer_is_left, + .. + } => { + if *outer_is_left { + collect_trace_bootstrap_source_bindings_into( + outer, + table_ids, + table_schemas, + bindings, + ); + push_trace_bootstrap_source_binding( + bindings, + table_ids, + inner_table, + TraceBootstrapAccessPath::FullScan, + false, + ); + } else { + push_trace_bootstrap_source_binding( + bindings, + table_ids, + inner_table, + TraceBootstrapAccessPath::FullScan, + false, + ); + collect_trace_bootstrap_source_bindings_into( + outer, + table_ids, + table_schemas, + bindings, + ); + } + } + PhysicalPlan::Empty => {} + } +} + +pub(crate) fn collect_trace_bootstrap_source_bindings( + plan: &PhysicalPlan, + table_ids: &HashMap, + table_schemas: &HashMap, +) -> Vec { + let mut bindings = Vec::new(); + collect_trace_bootstrap_source_bindings_into(plan, table_ids, table_schemas, &mut bindings); + bindings +} + +fn visit_trace_bootstrap_binding_rows( + cache: &TableCache, + binding: &TraceBootstrapSourceBinding, + emit: &mut dyn FnMut(Rc), +) { + let Some(store) = cache.get_table(&binding.table_name) else { + return; + }; + + match &binding.access_path { + TraceBootstrapAccessPath::FullScan => { + store.visit_rows(|row| { + emit(Rc::clone(row)); + true + }); + } + TraceBootstrapAccessPath::IndexScanScalar { + index, + range, + limit, + offset, + reverse, + } => { + store.visit_index_scan_with_options( + index, + range.as_ref(), + *limit, + *offset, + *reverse, + |row| { + emit(Rc::clone(row)); + true + }, + ); + } + TraceBootstrapAccessPath::IndexScanComposite { + index, + range, + limit, + offset, + reverse, + } => { + store.visit_index_scan_composite_with_options( + index, + range.as_ref(), + *limit, + *offset, + *reverse, + |row| { + emit(Rc::clone(row)); + true + }, + ); + } + TraceBootstrapAccessPath::IndexGet { index, key, limit } => { + let mut seen_ids = BTreeSet::new(); + let mut remaining = limit.unwrap_or(usize::MAX); + let enforce_limit = limit.is_some(); + + for probe_key in trace_bootstrap_sql_point_lookup_keys(key) { + if enforce_limit && remaining == 0 { + break; + } + + let range = KeyRange::only(probe_key); + let mut keep_scanning = true; + store.visit_index_scan_with_options( + index, + Some(&range), + if enforce_limit { Some(remaining) } else { None }, + 0, + false, + |row| { + if !seen_ids.insert(row.id()) { + return true; + } + + emit(Rc::clone(row)); + if enforce_limit { + remaining = remaining.saturating_sub(1); + } + keep_scanning = !enforce_limit || remaining > 0; + keep_scanning + }, + ); + if !keep_scanning { + break; + } + } + } + TraceBootstrapAccessPath::IndexInGet { index, keys } => { + let mut seen_ids = BTreeSet::new(); + for key in keys { + for probe_key in trace_bootstrap_sql_point_lookup_keys(key) { + let range = KeyRange::only(probe_key); + store.visit_index_scan_with_options( + index, + Some(&range), + None, + 0, + false, + |row| { + if seen_ids.insert(row.id()) { + emit(Rc::clone(row)); + } + true + }, + ); + } + } + } + TraceBootstrapAccessPath::GinKeyValue { index, key, value } => { + store.visit_gin_index_by_key_value(index, key, value, |row| { + emit(Rc::clone(row)); + true + }); + } + TraceBootstrapAccessPath::GinKey { index, key } => { + store.visit_gin_index_by_key(index, key, |row| { + emit(Rc::clone(row)); + true + }); + } + TraceBootstrapAccessPath::GinMulti { + index, + pairs, + match_all, + } => { + let pair_refs: Vec<(&str, &str)> = pairs + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); + if *match_all { + store.visit_gin_index_by_key_values_all(index, &pair_refs, |row| { + emit(Rc::clone(row)); + true + }); + } else { + store.visit_gin_index_by_key_values_any(index, &pair_refs, |row| { + emit(Rc::clone(row)); + true + }); + } + } + } +} + pub(crate) struct LivePlanDescriptor { pub engine: LiveEngineKind, #[allow(dead_code)] @@ -159,6 +745,9 @@ impl LivePlan { initial_summary: QueryResultSummary, projection: RowsProjection, binary_layout: SchemaLayout, + dependency_table_bindings: Vec<(TableId, String)>, + partial_refresh: Option, + root_subset_refresh: Option, ) -> Self { Self { descriptor: LivePlanDescriptor { @@ -174,6 +763,9 @@ impl LivePlan { adapter: AdapterPlan::RowsSnapshot(RowsSnapshotAdapterPlan { projection, binary_layout, + dependency_table_bindings, + partial_refresh, + root_subset_refresh, }), } } @@ -181,9 +773,13 @@ impl LivePlan { pub fn rows_delta( dependencies: LiveDependencySet, dataflow: DataflowNode, - initial_rows: Vec, + compiled_ivm_plan: CompiledIvmPlan, + compiled_bootstrap_plan: CompiledBootstrapPlan, + initial_rows: Vec>, + source_bindings: Vec, projection: RowsProjection, binary_layout: SchemaLayout, + trace_init_profile: TraceInitProfile, ) -> Self { Self { descriptor: LivePlanDescriptor { @@ -193,7 +789,11 @@ impl LivePlan { }, kernel: KernelPlan::Delta(DeltaKernelPlan { dataflow, + compiled_ivm_plan, + compiled_bootstrap_plan, initial_rows, + source_bindings, + trace_init_profile: Some(trace_init_profile), }), adapter: AdapterPlan::RowsDelta(RowsDeltaAdapterPlan { projection, @@ -210,6 +810,7 @@ impl LivePlan { catalog: GraphqlCatalog, field: BoundRootField, dependency_table_bindings: Vec<(TableId, String)>, + root_subset_refresh: Option, ) -> Self { Self { descriptor: LivePlanDescriptor { @@ -226,6 +827,7 @@ impl LivePlan { catalog, field, dependency_table_bindings, + root_subset_refresh, }), } } @@ -233,7 +835,10 @@ impl LivePlan { pub fn graphql_delta( dependencies: LiveDependencySet, dataflow: DataflowNode, - initial_rows: Vec, + compiled_ivm_plan: CompiledIvmPlan, + compiled_bootstrap_plan: CompiledBootstrapPlan, + initial_rows: Vec>, + source_bindings: Vec, catalog: GraphqlCatalog, field: BoundRootField, dependency_table_bindings: Vec<(TableId, String)>, @@ -246,7 +851,11 @@ impl LivePlan { }, kernel: KernelPlan::Delta(DeltaKernelPlan { dataflow, + compiled_ivm_plan, + compiled_bootstrap_plan, initial_rows, + source_bindings, + trace_init_profile: None, }), adapter: AdapterPlan::GraphqlDelta(GraphqlDeltaAdapterPlan { catalog, @@ -282,6 +891,9 @@ impl LivePlan { cache, kernel.initial_rows, kernel.initial_summary, + adapter.dependency_table_bindings, + adapter.partial_refresh, + adapter.root_subset_refresh, ))); registry.borrow_mut().register_snapshot( SnapshotSubscription::Rows(observable.clone()), @@ -294,6 +906,7 @@ impl LivePlan { pub fn materialize_rows_delta( self, + cache: Rc>, registry: Rc>, ) -> JsIvmObservableQuery { let dependencies = self.descriptor.dependencies; @@ -310,16 +923,83 @@ impl LivePlan { } }; - let observable = Rc::new(RefCell::new(ObservableQuery::with_initial( - kernel.dataflow, - kernel.initial_rows, - ))); + let bridge_profiler = registry.borrow().ivm_bridge_profiler(); + let mut trace_init_profile = kernel.trace_init_profile.unwrap_or_default(); + trace_init_profile.source_table_count = kernel + .source_bindings + .iter() + .map(|binding| binding.table_id) + .collect::>() + .len(); + trace_init_profile.initial_row_count = kernel.initial_rows.len(); + let init_started_at = now_ms(); + let cache_for_loader = cache.clone(); + let source_bindings = kernel.source_bindings.clone(); + let source_filter_coverage: Vec = kernel + .source_bindings + .iter() + .map(|binding| binding.covers_source_filter) + .collect(); + let source_access_ms = Rc::new(RefCell::new(0.0)); + let source_emit_ms = Rc::new(RefCell::new(0.0)); + let source_access_ms_ref = source_access_ms.clone(); + let source_emit_ms_ref = source_emit_ms.clone(); + let (observable, bootstrap_profile) = + ObservableQuery::with_compiled_source_visitor_profiled_with_filter_coverage( + kernel.dataflow, + kernel.compiled_ivm_plan, + kernel.compiled_bootstrap_plan, + kernel.initial_rows, + move |table_id, source_index, emit| { + let load_started_at = now_ms(); + let mut emit_local_ms = 0.0; + { + let cache = cache_for_loader.borrow(); + let Some(binding) = source_bindings.get(source_index) else { + return; + }; + debug_assert_eq!(binding.source_index, source_index); + debug_assert_eq!(binding.table_id, table_id); + let mut timed_emit = |row: Rc| { + let emit_started_at = now_ms(); + emit(row); + emit_local_ms += now_ms() - emit_started_at; + }; + visit_trace_bootstrap_binding_rows(&cache, binding, &mut timed_emit); + } + let total_ms = now_ms() - load_started_at; + *source_emit_ms_ref.borrow_mut() += emit_local_ms; + *source_access_ms_ref.borrow_mut() += (total_ms - emit_local_ms).max(0.0); + }, + Some(source_filter_coverage), + Some(now_ms), + ); + let observable = Rc::new(RefCell::new(observable)); + trace_init_profile.source_access_ms = *source_access_ms.borrow(); + trace_init_profile.source_emit_ms = *source_emit_ms.borrow(); + trace_init_profile.source_bootstrap_ms = + trace_init_profile.source_access_ms + trace_init_profile.source_emit_ms; + trace_init_profile.bootstrap_scan_ms = trace_init_profile.source_bootstrap_ms; + trace_init_profile.materialized_view_init_ms = now_ms() - init_started_at; + trace_init_profile.filter_bootstrap_ms = bootstrap_profile.filter_bootstrap_ms; + trace_init_profile.project_bootstrap_ms = bootstrap_profile.project_bootstrap_ms; + trace_init_profile.map_bootstrap_ms = bootstrap_profile.map_bootstrap_ms; + trace_init_profile.join_bootstrap_ms = bootstrap_profile.join_bootstrap_ms; + trace_init_profile.aggregate_bootstrap_ms = bootstrap_profile.aggregate_bootstrap_ms; + trace_init_profile.root_sink_ms = bootstrap_profile.root_sink_ms; + trace_init_profile.bootstrap_execute_ms = (trace_init_profile.materialized_view_init_ms + - trace_init_profile.source_bootstrap_ms) + .max(0.0); + trace_init_profile.total_ms += trace_init_profile.materialized_view_init_ms; + registry + .borrow() + .record_trace_init_profile(trace_init_profile.clone()); registry .borrow_mut() .register_delta(DeltaSubscription::Rows(observable.clone()), &dependencies); adapter .projection - .into_delta_js(observable, adapter.binary_layout) + .into_delta_js(observable, adapter.binary_layout, bridge_profiler) } pub fn materialize_graphql_snapshot( @@ -350,6 +1030,7 @@ impl LivePlan { adapter.catalog, adapter.field, adapter.dependency_table_bindings, + adapter.root_subset_refresh, root_table_ids, kernel.initial_rows, kernel.initial_summary, @@ -382,14 +1063,29 @@ impl LivePlan { } }; - let observable = Rc::new(RefCell::new(GraphqlDeltaObservable::new( - kernel.dataflow, - cache, - adapter.catalog, - adapter.field, - adapter.dependency_table_bindings, - kernel.initial_rows, - ))); + let cache_for_loader = cache.clone(); + let source_bindings = kernel.source_bindings.clone(); + let observable = Rc::new(RefCell::new( + GraphqlDeltaObservable::new_with_compiled_source_visitor( + kernel.dataflow, + kernel.compiled_ivm_plan, + kernel.compiled_bootstrap_plan, + cache, + adapter.catalog, + adapter.field, + adapter.dependency_table_bindings, + kernel.initial_rows, + move |table_id, source_index, emit| { + let cache_ref = cache_for_loader.borrow(); + let Some(binding) = source_bindings.get(source_index) else { + return; + }; + debug_assert_eq!(binding.source_index, source_index); + debug_assert_eq!(binding.table_id, table_id); + visit_trace_bootstrap_binding_rows(&cache_ref, binding, emit); + }, + ), + )); registry.borrow_mut().register_delta( DeltaSubscription::Graphql(observable.clone()), &dependencies, @@ -419,6 +1115,12 @@ pub(crate) enum DeltaSubscription { Graphql(Rc>), } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DeltaSubscriptionKind { + Rows, + Graphql, +} + impl DeltaSubscription { fn subscription_count(&self) -> usize { match self { @@ -427,12 +1129,78 @@ impl DeltaSubscription { } } + fn kind(&self) -> DeltaSubscriptionKind { + match self { + Self::Rows(_) => DeltaSubscriptionKind::Rows, + Self::Graphql(_) => DeltaSubscriptionKind::Graphql, + } + } + + #[allow(dead_code)] fn on_table_change(&self, table_id: TableId, deltas: Vec>) { match self { Self::Rows(query) => query.borrow_mut().on_table_change(table_id, deltas), Self::Graphql(query) => query.borrow_mut().on_table_change(table_id, deltas), } } + + #[cfg(feature = "benchmark")] + fn on_table_change_profiled( + &self, + table_id: TableId, + deltas: Vec>, + ) -> DeltaQueryUpdateProfile { + match self { + Self::Rows(query) => DeltaQueryUpdateProfile::from_trace( + query + .borrow_mut() + .on_table_change_profiled(table_id, deltas, Some(now_ms)), + ), + Self::Graphql(query) => DeltaQueryUpdateProfile::from_graphql( + query.borrow_mut().on_table_change_profiled(table_id, deltas), + ), + } + } +} + +#[cfg(feature = "benchmark")] +#[derive(Clone, Debug, Default)] +struct DeltaQueryUpdateProfile { + source_dispatch_ms: f64, + unary_execute_ms: f64, + join_execute_ms: f64, + aggregate_execute_ms: f64, + result_apply_ms: f64, + graphql_view_update_ms: f64, + graphql_invalidation_ms: f64, + graphql_render_ms: f64, + graphql_encode_ms: f64, + graphql_emit_ms: f64, +} + +#[cfg(feature = "benchmark")] +impl DeltaQueryUpdateProfile { + fn from_trace(profile: TraceUpdateProfile) -> Self { + Self { + source_dispatch_ms: profile.source_dispatch_ms, + unary_execute_ms: profile.unary_execute_ms, + join_execute_ms: profile.join_execute_ms, + aggregate_execute_ms: profile.aggregate_execute_ms, + result_apply_ms: profile.result_apply_ms, + ..Self::default() + } + } + + fn from_graphql(profile: GraphqlDeltaProfile) -> Self { + Self { + graphql_view_update_ms: profile.view_update_ms, + graphql_invalidation_ms: profile.invalidation_ms, + graphql_render_ms: profile.render_ms, + graphql_encode_ms: profile.encode_ms, + graphql_emit_ms: profile.emit_ms, + ..Self::default() + } + } } pub(crate) struct LiveRegistry { @@ -442,6 +1210,12 @@ pub(crate) struct LiveRegistry { pending_deltas: Rc>>>>, flush_scheduled: Rc>, self_ref: Option>>, + last_trace_init_profile: RefCell>, + #[cfg(feature = "benchmark")] + last_snapshot_init_profile: RefCell>, + last_delta_flush_profile: RefCell>, + last_snapshot_flush_profile: RefCell>, + ivm_bridge_profiler: Rc>, #[cfg(target_arch = "wasm32")] flush_closure: Option>, } @@ -455,6 +1229,12 @@ impl LiveRegistry { pending_deltas: Rc::new(RefCell::new(HashMap::new())), flush_scheduled: Rc::new(RefCell::new(false)), self_ref: None, + last_trace_init_profile: RefCell::new(None), + #[cfg(feature = "benchmark")] + last_snapshot_init_profile: RefCell::new(None), + last_delta_flush_profile: RefCell::new(None), + last_snapshot_flush_profile: RefCell::new(None), + ivm_bridge_profiler: Rc::new(RefCell::new(IvmBridgeProfiler::default())), #[cfg(target_arch = "wasm32")] flush_closure: None, } @@ -486,9 +1266,23 @@ impl LiveRegistry { } } - fn flush_snapshot_lane(&self, changes: HashMap>) { - let mut merged_rows: HashMap>, HashSet)> = - HashMap::new(); + fn flush_snapshot_lane( + &self, + changes: HashMap>, + delta_changes: &HashMap>>, + ) { + let started_at = now_ms(); + let mut profile = SnapshotFlushProfile { + changed_table_count: changes.len(), + ..SnapshotFlushProfile::default() + }; + let mut merged_rows: HashMap< + usize, + ( + Rc>, + HashMap>, + ), + > = HashMap::new(); let mut merged_graphql: HashMap< usize, ( @@ -497,6 +1291,7 @@ impl LiveRegistry { ), > = HashMap::new(); + let merge_started_at = now_ms(); for (table_id, changed_ids) in changes { if let Some(queries) = self.snapshot_queries.get(&table_id) { for query in queries { @@ -504,8 +1299,12 @@ impl LiveRegistry { SnapshotSubscription::Rows(query) => { let entry = merged_rows .entry(Rc::as_ptr(query) as usize) - .or_insert_with(|| (query.clone(), HashSet::new())); - entry.1.extend(changed_ids.iter().copied()); + .or_insert_with(|| (query.clone(), HashMap::new())); + entry + .1 + .entry(table_id) + .or_insert_with(HashSet::new) + .extend(changed_ids.iter().copied()); } SnapshotSubscription::Graphql(query) => { let entry = merged_graphql @@ -517,14 +1316,39 @@ impl LiveRegistry { } } } - - for (_, (query, changed_ids)) in merged_rows { - query.borrow_mut().on_change(&changed_ids); + profile.invalidation_merge_ms = now_ms() - merge_started_at; + + for (_, (query, changes)) in merged_rows { + let query_started_at = now_ms(); + let sample = query + .borrow_mut() + .on_change_with_deltas_profiled(&changes, delta_changes); + profile.rows_query_on_change_ms += now_ms() - query_started_at; + profile.record_rows_query(&sample); } for (_, (query, changes)) in merged_graphql { - query.borrow_mut().on_change(&changes); + let query_started_at = now_ms(); + #[cfg(feature = "benchmark")] + let sample = query + .borrow_mut() + .on_change_with_deltas_profiled(&changes, delta_changes); + #[cfg(not(feature = "benchmark"))] + query.borrow_mut().on_change_with_deltas(&changes, delta_changes); + profile.graphql_query_count += 1; + profile.graphql_query_on_change_ms += now_ms() - query_started_at; + #[cfg(feature = "benchmark")] + { + profile.graphql_root_refresh_ms += sample.root_refresh_ms; + profile.graphql_batch_invalidation_ms += sample.batch_invalidation_ms; + profile.graphql_render_ms += sample.render_ms; + profile.graphql_encode_ms += sample.encode_ms; + profile.graphql_emit_ms += sample.emit_ms; + } } + + profile.total_ms = now_ms() - started_at; + *self.last_snapshot_flush_profile.borrow_mut() = Some(profile); } pub fn on_table_change(&mut self, table_id: TableId, changed_ids: &HashSet) { @@ -575,13 +1399,51 @@ impl LiveRegistry { } fn flush_delta_lane(&self, delta_changes: &HashMap>>) { + let started_at = now_ms(); + let mut profile = DeltaFlushProfile::default(); + self.ivm_bridge_profiler.borrow_mut().begin_flush(); + for (table_id, deltas) in delta_changes { + profile.delta_table_count += 1; + profile.delta_row_count += deltas.len(); if let Some(queries) = self.delta_queries.get(table_id) { for query in queries { - query.on_table_change(*table_id, deltas.clone()); + profile.delta_query_count += 1; + match query.kind() { + DeltaSubscriptionKind::Rows => profile.rows_query_count += 1, + DeltaSubscriptionKind::Graphql => profile.graphql_query_count += 1, + } + + let clone_started_at = now_ms(); + let deltas_clone = deltas.clone(); + profile.clone_ms += now_ms() - clone_started_at; + + let query_started_at = now_ms(); + #[cfg(feature = "benchmark")] + let update_profile = query.on_table_change_profiled(*table_id, deltas_clone); + #[cfg(not(feature = "benchmark"))] + query.on_table_change(*table_id, deltas_clone); + profile.query_on_table_change_ms += now_ms() - query_started_at; + #[cfg(feature = "benchmark")] + { + profile.source_dispatch_ms += update_profile.source_dispatch_ms; + profile.unary_execute_ms += update_profile.unary_execute_ms; + profile.join_execute_ms += update_profile.join_execute_ms; + profile.aggregate_execute_ms += update_profile.aggregate_execute_ms; + profile.result_apply_ms += update_profile.result_apply_ms; + profile.graphql_view_update_ms += update_profile.graphql_view_update_ms; + profile.graphql_invalidation_ms += update_profile.graphql_invalidation_ms; + profile.graphql_render_ms += update_profile.graphql_render_ms; + profile.graphql_encode_ms += update_profile.graphql_encode_ms; + profile.graphql_emit_ms += update_profile.graphql_emit_ms; + } } } } + + self.ivm_bridge_profiler.borrow_mut().end_flush(); + profile.total_ms = now_ms() - started_at; + *self.last_delta_flush_profile.borrow_mut() = Some(profile); } fn schedule_flush(&mut self) { @@ -605,7 +1467,7 @@ impl LiveRegistry { { let registry = self_ref_clone.borrow(); registry.flush_delta_lane(&delta_changes); - registry.flush_snapshot_lane(changes); + registry.flush_snapshot_lane(changes, &delta_changes); } { @@ -638,7 +1500,7 @@ impl LiveRegistry { let changes: HashMap> = self.pending_changes.borrow_mut().drain().collect(); - self.flush_snapshot_lane(changes); + self.flush_snapshot_lane(changes, &delta_changes); self.gc_dead_queries(); } @@ -653,7 +1515,7 @@ impl LiveRegistry { let changes: HashMap> = self.pending_changes.borrow_mut().drain().collect(); - self.flush_snapshot_lane(changes); + self.flush_snapshot_lane(changes, &delta_changes); self.gc_dead_queries(); } @@ -686,6 +1548,40 @@ impl LiveRegistry { snapshot_count + delta_count } + pub fn take_last_delta_flush_profile(&self) -> Option { + self.last_delta_flush_profile.borrow_mut().take() + } + + pub fn record_trace_init_profile(&self, profile: TraceInitProfile) { + *self.last_trace_init_profile.borrow_mut() = Some(profile); + } + + pub fn take_last_trace_init_profile(&self) -> Option { + self.last_trace_init_profile.borrow_mut().take() + } + + #[cfg(feature = "benchmark")] + pub fn record_snapshot_init_profile(&self, profile: SnapshotInitProfile) { + *self.last_snapshot_init_profile.borrow_mut() = Some(profile); + } + + #[cfg(feature = "benchmark")] + pub fn take_last_snapshot_init_profile(&self) -> Option { + self.last_snapshot_init_profile.borrow_mut().take() + } + + pub fn take_last_snapshot_flush_profile(&self) -> Option { + self.last_snapshot_flush_profile.borrow_mut().take() + } + + pub fn take_last_ivm_bridge_profile(&self) -> Option { + self.ivm_bridge_profiler.borrow_mut().take_last() + } + + pub fn ivm_bridge_profiler(&self) -> Rc> { + self.ivm_bridge_profiler.clone() + } + #[allow(dead_code)] pub fn has_pending_changes(&self) -> bool { !self.pending_changes.borrow().is_empty() || !self.pending_deltas.borrow().is_empty() @@ -697,3 +1593,293 @@ impl Default for LiveRegistry { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use cynos_core::schema::TableBuilder; + use cynos_core::{DataType, JsonbValue, Row, Value}; + use cynos_query::ast::{Expr, JoinType}; + use cynos_storage::TableCache; + + fn jsonb_value(json: &str) -> Value { + Value::Jsonb(JsonbValue::new(json.as_bytes().to_vec())) + } + + fn build_trace_source_test_schemas() -> HashMap { + let users = TableBuilder::new("users") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("team_id", DataType::Int64) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .add_index("idx_users_team_id", &["team_id"], false) + .unwrap() + .build() + .unwrap(); + + let issues = TableBuilder::new("issues") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("user_id", DataType::Int64) + .unwrap() + .add_column("metadata", DataType::Jsonb) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .add_jsonb_index("idx_issues_metadata_status", "metadata", &["status"]) + .unwrap() + .build() + .unwrap(); + + let mut schemas = HashMap::new(); + schemas.insert("users".into(), users); + schemas.insert("issues".into(), issues); + schemas + } + + #[test] + fn test_collect_trace_bootstrap_source_bindings_preserves_source_order_and_access_paths() { + let table_schemas = build_trace_source_test_schemas(); + let mut table_ids = HashMap::new(); + table_ids.insert("users".into(), 1u32); + table_ids.insert("issues".into(), 2u32); + + let users_pk = table_schemas + .get("users") + .and_then(Table::primary_key) + .map(|index| index.name().to_string()) + .unwrap(); + + let plan = PhysicalPlan::HashJoin { + left: Box::new(PhysicalPlan::IndexGet { + table: "users".into(), + index: users_pk, + key: Value::Int64(7), + limit: None, + }), + right: Box::new(PhysicalPlan::GinIndexScan { + table: "issues".into(), + index: "idx_issues_metadata_status".into(), + key: "status".into(), + value: Some("open".into()), + query_type: "eq".into(), + recheck: Some(Expr::eq( + Expr::Function { + name: "JSONB_EXTRACT".into(), + args: alloc::vec![ + Expr::column("issues", "metadata", 2), + Expr::literal("$.status"), + ], + }, + Expr::literal("open"), + )), + }), + condition: Expr::eq( + Expr::column("users", "id", 0), + Expr::column("issues", "user_id", 1), + ), + join_type: JoinType::Inner, + output_tables: alloc::vec!["users".into(), "issues".into()], + }; + + let bindings = collect_trace_bootstrap_source_bindings(&plan, &table_ids, &table_schemas); + assert_eq!(bindings.len(), 2); + assert_eq!(bindings[0].source_index, 0); + assert_eq!(bindings[0].table_id, 1); + assert_eq!(bindings[0].table_name, "users"); + assert!(matches!( + bindings[0].access_path, + TraceBootstrapAccessPath::IndexGet { .. } + )); + assert!(bindings[0].covers_source_filter); + assert_eq!(bindings[1].source_index, 1); + assert_eq!(bindings[1].table_id, 2); + assert_eq!(bindings[1].table_name, "issues"); + assert!(matches!( + bindings[1].access_path, + TraceBootstrapAccessPath::GinKeyValue { .. } + )); + assert!(bindings[1].covers_source_filter); + } + + #[test] + fn test_collect_trace_bootstrap_source_bindings_keeps_per_source_bindings_for_same_table() { + let table_schemas = build_trace_source_test_schemas(); + let mut table_ids = HashMap::new(); + table_ids.insert("users".into(), 1u32); + + let users_pk = table_schemas + .get("users") + .and_then(Table::primary_key) + .map(|index| index.name().to_string()) + .unwrap(); + + let plan = PhysicalPlan::HashJoin { + left: Box::new(PhysicalPlan::TableScan { + table: "users".into(), + }), + right: Box::new(PhysicalPlan::IndexGet { + table: "users".into(), + index: users_pk, + key: Value::Int64(42), + limit: None, + }), + condition: Expr::eq( + Expr::column("users", "id", 0), + Expr::column("users", "team_id", 1), + ), + join_type: JoinType::Inner, + output_tables: alloc::vec!["users".into(), "users".into()], + }; + + let bindings = collect_trace_bootstrap_source_bindings(&plan, &table_ids, &table_schemas); + assert_eq!(bindings.len(), 2); + assert_eq!(bindings[0].table_id, 1); + assert_eq!(bindings[1].table_id, 1); + assert_eq!(bindings[0].source_index, 0); + assert_eq!(bindings[1].source_index, 1); + assert!(matches!( + bindings[0].access_path, + TraceBootstrapAccessPath::FullScan + )); + assert!(!bindings[0].covers_source_filter); + assert!(matches!( + bindings[1].access_path, + TraceBootstrapAccessPath::IndexGet { .. } + )); + assert!(bindings[1].covers_source_filter); + } + + #[test] + fn test_collect_trace_bootstrap_source_bindings_does_not_mark_index_join_fallback_scan_as_covered( + ) { + let table_schemas = build_trace_source_test_schemas(); + let mut table_ids = HashMap::new(); + table_ids.insert("users".into(), 1u32); + + let users_pk = table_schemas + .get("users") + .and_then(Table::primary_key) + .map(|index| index.name().to_string()) + .unwrap(); + + let plan = PhysicalPlan::IndexNestedLoopJoin { + outer: Box::new(PhysicalPlan::IndexGet { + table: "users".into(), + index: users_pk, + key: Value::Int64(42), + limit: None, + }), + inner_table: "users".into(), + inner_index: "idx_users_team_id".into(), + condition: Expr::eq( + Expr::column("users", "id", 0), + Expr::column("users", "team_id", 1), + ), + join_type: JoinType::Inner, + outer_is_left: true, + output_tables: alloc::vec!["users".into(), "users".into()], + }; + + let bindings = collect_trace_bootstrap_source_bindings(&plan, &table_ids, &table_schemas); + assert_eq!(bindings.len(), 2); + assert!(matches!( + bindings[0].access_path, + TraceBootstrapAccessPath::IndexGet { .. } + )); + assert!(bindings[0].covers_source_filter); + assert!(matches!( + bindings[1].access_path, + TraceBootstrapAccessPath::FullScan + )); + assert!(!bindings[1].covers_source_filter); + } + + #[test] + fn test_visit_trace_bootstrap_binding_rows_uses_index_and_gin_access_paths() { + let mut cache = TableCache::new(); + for schema in build_trace_source_test_schemas().into_values() { + cache.create_table(schema).unwrap(); + } + + let users_pk = cache + .get_table("users") + .and_then(|store| store.schema().primary_key()) + .map(|index| index.name().to_string()) + .unwrap(); + + { + let store = cache.get_table_mut("users").unwrap(); + store + .insert(Row::new(1, alloc::vec![Value::Int64(1), Value::Int64(10)])) + .unwrap(); + store + .insert(Row::new(2, alloc::vec![Value::Int64(2), Value::Int64(20)])) + .unwrap(); + } + + { + let store = cache.get_table_mut("issues").unwrap(); + store + .insert(Row::new( + 11, + alloc::vec![ + Value::Int64(11), + Value::Int64(1), + jsonb_value("{\"status\":\"open\"}"), + ], + )) + .unwrap(); + store + .insert(Row::new( + 12, + alloc::vec![ + Value::Int64(12), + Value::Int64(2), + jsonb_value("{\"status\":\"closed\"}"), + ], + )) + .unwrap(); + } + + let index_binding = TraceBootstrapSourceBinding { + source_index: 0, + table_id: 1, + table_name: "users".into(), + access_path: TraceBootstrapAccessPath::IndexGet { + index: users_pk, + key: Value::Int64(2), + limit: None, + }, + covers_source_filter: true, + }; + + let mut index_rows = Vec::new(); + visit_trace_bootstrap_binding_rows(&cache, &index_binding, &mut |row| { + index_rows.push(row.id()); + }); + assert_eq!(index_rows, alloc::vec![2]); + + let gin_binding = TraceBootstrapSourceBinding { + source_index: 1, + table_id: 2, + table_name: "issues".into(), + access_path: TraceBootstrapAccessPath::GinKeyValue { + index: "idx_issues_metadata_status".into(), + key: "status".into(), + value: "open".into(), + }, + covers_source_filter: true, + }; + + let mut gin_rows = Vec::new(); + visit_trace_bootstrap_binding_rows(&cache, &gin_binding, &mut |row| { + gin_rows.push(row.id()); + }); + assert_eq!(gin_rows, alloc::vec![11]); + } +} diff --git a/crates/database/src/profiling.rs b/crates/database/src/profiling.rs new file mode 100644 index 0000000..ad03b52 --- /dev/null +++ b/crates/database/src/profiling.rs @@ -0,0 +1,278 @@ +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsCast; + +#[derive(Clone, Debug, Default)] +pub(crate) struct DeltaFlushProfile { + pub delta_table_count: usize, + pub delta_query_count: usize, + pub rows_query_count: usize, + pub graphql_query_count: usize, + pub delta_row_count: usize, + pub clone_ms: f64, + pub query_on_table_change_ms: f64, + pub source_dispatch_ms: f64, + pub unary_execute_ms: f64, + pub join_execute_ms: f64, + pub aggregate_execute_ms: f64, + pub result_apply_ms: f64, + pub graphql_view_update_ms: f64, + pub graphql_invalidation_ms: f64, + pub graphql_render_ms: f64, + pub graphql_encode_ms: f64, + pub graphql_emit_ms: f64, + pub total_ms: f64, +} + +#[cfg(feature = "benchmark")] +#[derive(Clone, Debug, Default)] +pub(crate) struct GraphqlDeltaProfile { + pub view_update_ms: f64, + pub invalidation_ms: f64, + pub render_ms: f64, + pub encode_ms: f64, + pub emit_ms: f64, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct TraceInitProfile { + pub compile_plan_ms: f64, + pub compile_to_dataflow_ms: f64, + pub compile_ivm_plan_ms: f64, + pub compile_trace_program_ms: f64, + pub initial_query_ms: f64, + pub source_bootstrap_ms: f64, + pub source_access_ms: f64, + pub source_emit_ms: f64, + pub bootstrap_scan_ms: f64, + pub bootstrap_execute_ms: f64, + pub filter_bootstrap_ms: f64, + pub project_bootstrap_ms: f64, + pub map_bootstrap_ms: f64, + pub join_bootstrap_ms: f64, + pub aggregate_bootstrap_ms: f64, + pub root_sink_ms: f64, + pub materialized_view_init_ms: f64, + pub total_ms: f64, + pub source_table_count: usize, + pub initial_row_count: usize, +} + +#[cfg(feature = "benchmark")] +#[derive(Clone, Debug, Default)] +pub(crate) struct SnapshotInitProfile { + pub logical_plan_ms: f64, + pub describe_output_ms: f64, + pub binary_layout_ms: f64, + pub partial_refresh_plan_ms: f64, + pub compile_main_plan_ms: f64, + pub root_subset_plan_ms: f64, + pub initial_query_ms: f64, + pub dependency_bindings_ms: f64, + pub initial_result_adapt_ms: f64, + pub observable_init_ms: f64, + pub total_ms: f64, + pub initial_row_count: usize, + pub visible_row_count: usize, + pub partial_refresh_enabled: bool, + pub root_subset_enabled: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SnapshotRefreshMode { + FullRequery, + CandidateWindowPartialRefresh, + RootSubsetRefresh, +} + +impl Default for SnapshotRefreshMode { + fn default() -> Self { + Self::FullRequery + } +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct SnapshotQueryProfile { + pub refresh_mode: SnapshotRefreshMode, + pub reactive_patch_attempted: bool, + pub reactive_patch_hit: bool, + pub reactive_patch_ms: f64, + pub partial_refresh_attempted: bool, + pub partial_refresh_hit: bool, + pub partial_refresh_collect_ms: f64, + pub partial_refresh_requery_ms: f64, + pub partial_refresh_apply_ms: f64, + pub partial_refresh_compare_ms: f64, + pub root_subset_attempted: bool, + pub root_subset_hit: bool, + pub root_subset_collect_ms: f64, + pub root_subset_requery_ms: f64, + pub root_subset_apply_ms: f64, + pub root_subset_compare_ms: f64, + pub full_requery_ms: f64, + pub full_requery_compare_ms: f64, + pub callback_ms: f64, + pub total_ms: f64, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct SnapshotFlushProfile { + pub changed_table_count: usize, + pub rows_query_count: usize, + pub graphql_query_count: usize, + pub invalidation_merge_ms: f64, + pub rows_query_on_change_ms: f64, + pub graphql_query_on_change_ms: f64, + pub graphql_root_refresh_ms: f64, + pub graphql_batch_invalidation_ms: f64, + pub graphql_render_ms: f64, + pub graphql_encode_ms: f64, + pub graphql_emit_ms: f64, + pub reactive_patch_attempt_count: usize, + pub reactive_patch_hit_count: usize, + pub reactive_patch_ms: f64, + pub partial_refresh_attempt_count: usize, + pub partial_refresh_hit_count: usize, + pub partial_refresh_collect_ms: f64, + pub partial_refresh_requery_ms: f64, + pub partial_refresh_apply_ms: f64, + pub partial_refresh_compare_ms: f64, + pub root_subset_attempt_count: usize, + pub root_subset_hit_count: usize, + pub root_subset_collect_ms: f64, + pub root_subset_requery_ms: f64, + pub root_subset_apply_ms: f64, + pub root_subset_compare_ms: f64, + pub full_requery_count: usize, + pub full_requery_ms: f64, + pub full_requery_compare_ms: f64, + pub callback_ms: f64, + pub total_ms: f64, +} + +#[cfg(feature = "benchmark")] +#[derive(Clone, Debug, Default)] +pub(crate) struct GraphqlSnapshotQueryProfile { + pub root_refresh_ms: f64, + pub batch_invalidation_ms: f64, + pub render_ms: f64, + pub encode_ms: f64, + pub emit_ms: f64, +} + +impl SnapshotFlushProfile { + pub fn record_rows_query(&mut self, sample: &SnapshotQueryProfile) { + self.rows_query_count += 1; + self.callback_ms += sample.callback_ms; + + if sample.reactive_patch_attempted { + self.reactive_patch_attempt_count += 1; + } + if sample.reactive_patch_hit { + self.reactive_patch_hit_count += 1; + } + self.reactive_patch_ms += sample.reactive_patch_ms; + + if sample.partial_refresh_attempted { + self.partial_refresh_attempt_count += 1; + } + if sample.partial_refresh_hit { + self.partial_refresh_hit_count += 1; + } + self.partial_refresh_collect_ms += sample.partial_refresh_collect_ms; + self.partial_refresh_requery_ms += sample.partial_refresh_requery_ms; + self.partial_refresh_apply_ms += sample.partial_refresh_apply_ms; + self.partial_refresh_compare_ms += sample.partial_refresh_compare_ms; + + if sample.root_subset_attempted { + self.root_subset_attempt_count += 1; + } + if sample.root_subset_hit { + self.root_subset_hit_count += 1; + } + self.root_subset_collect_ms += sample.root_subset_collect_ms; + self.root_subset_requery_ms += sample.root_subset_requery_ms; + self.root_subset_apply_ms += sample.root_subset_apply_ms; + self.root_subset_compare_ms += sample.root_subset_compare_ms; + + if sample.full_requery_ms > 0.0 || sample.full_requery_compare_ms > 0.0 { + self.full_requery_count += 1; + } + self.full_requery_ms += sample.full_requery_ms; + self.full_requery_compare_ms += sample.full_requery_compare_ms; + } +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct IvmBridgeProfile { + pub callback_count: usize, + pub added_row_count: usize, + pub removed_row_count: usize, + pub serialize_added_ms: f64, + pub serialize_removed_ms: f64, + pub assemble_delta_ms: f64, + pub callback_call_ms: f64, + pub total_ms: f64, +} + +#[derive(Default)] +pub(crate) struct IvmBridgeProfiler { + current: IvmBridgeProfile, + last: Option, +} + +impl IvmBridgeProfiler { + pub fn begin_flush(&mut self) { + self.current = IvmBridgeProfile::default(); + } + + pub fn record_sample(&mut self, sample: &IvmBridgeProfile) { + self.current.callback_count += sample.callback_count; + self.current.added_row_count += sample.added_row_count; + self.current.removed_row_count += sample.removed_row_count; + self.current.serialize_added_ms += sample.serialize_added_ms; + self.current.serialize_removed_ms += sample.serialize_removed_ms; + self.current.assemble_delta_ms += sample.assemble_delta_ms; + self.current.callback_call_ms += sample.callback_call_ms; + self.current.total_ms += sample.total_ms; + } + + pub fn end_flush(&mut self) { + self.last = Some(self.current.clone()); + } + + pub fn take_last(&mut self) -> Option { + self.last.take() + } +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn now_ms() -> f64 { + let global = js_sys::global(); + if let Ok(performance) = + js_sys::Reflect::get(&global, &wasm_bindgen::JsValue::from_str("performance")) + { + if let Some(now_fn) = + js_sys::Reflect::get(&performance, &wasm_bindgen::JsValue::from_str("now")) + .ok() + .and_then(|value| value.dyn_into::().ok()) + { + if let Ok(now) = now_fn.call0(&performance) { + if let Some(value) = now.as_f64() { + return value; + } + } + } + } + + js_sys::Date::now() +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn now_ms() -> f64 { + use std::time::{SystemTime, UNIX_EPOCH}; + + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs_f64() * 1000.0) + .unwrap_or(0.0) +} diff --git a/crates/database/src/query_builder.rs b/crates/database/src/query_builder.rs index ef0a2a3..c94d66c 100644 --- a/crates/database/src/query_builder.rs +++ b/crates/database/src/query_builder.rs @@ -7,26 +7,38 @@ use crate::binary_protocol::{SchemaLayout, SchemaLayoutCache}; use crate::convert::{js_array_to_rows, js_to_value, projected_rows_to_js_array, rows_to_js_array}; use crate::dataflow_compiler::compile_to_dataflow; use crate::expr::{Expr, ExprInner}; -use crate::live_runtime::{LiveDependencySet, LivePlan, LiveRegistry, RowsProjection}; +use crate::live_runtime::{ + collect_trace_bootstrap_source_bindings, LiveDependencySet, LivePlan, LiveRegistry, + RowsProjection, RowsSnapshotDependencyGraph, RowsSnapshotDirectedJoinEdge, + RowsSnapshotJoinEdge, RowsSnapshotLookupPrimitive, RowsSnapshotOrderKey, + RowsSnapshotPartialRefreshMetadata, RowsSnapshotPartialRefreshState, + RowsSnapshotRootSubsetMetadata, RowsSnapshotRootSubsetPlan, RowsSnapshotRootSubsetVariants, +}; +#[cfg(feature = "benchmark")] +use crate::profiling::SnapshotInitProfile; +use crate::profiling::{now_ms, TraceInitProfile}; use crate::query_engine::{ - compile_cached_plan, compile_plan, execute_compiled_physical_plan, + compile_cached_plan_with_profile, compile_plan, execute_compiled_physical_plan, execute_compiled_physical_plan_with_summary, execute_physical_plan, execute_plan, explain_plan, - CompiledPhysicalPlan, + CompilePlanProfile, CompiledPhysicalPlan, QueryResultSummary, RootSubsetPlanningProfile, }; use crate::reactive_bridge::{JsChangesStream, JsIvmObservableQuery, JsObservableQuery}; use crate::JsSortOrder; use alloc::boxed::Box; +use alloc::collections::VecDeque; use alloc::rc::Rc; use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::cell::RefCell; use cynos_core::schema::Table; use cynos_core::{reserve_row_ids, DataType, Row, Value}; -use cynos_incremental::Delta; +use cynos_incremental::{CompiledBootstrapPlan, CompiledIvmPlan, Delta}; use cynos_query::ast::{AggregateFunc, SortOrder}; use cynos_query::plan_cache::{compute_plan_fingerprint, PlanCache}; -use cynos_query::planner::LogicalPlan; +use cynos_query::planner::{LogicalPlan, PhysicalPlan}; use cynos_reactive::TableId; +#[cfg(feature = "benchmark")] +use cynos_storage::StorageInsertProfile; use cynos_storage::TableCache; use wasm_bindgen::prelude::*; @@ -185,6 +197,41 @@ enum JoinType { } impl SelectBuilder { + fn partial_refresh_overscan(limit: usize) -> usize { + core::cmp::max(256, core::cmp::min(limit / 4, 1_024)) + } + + fn plan_cache_fingerprint(plan: &LogicalPlan, profile: CompilePlanProfile) -> u64 { + let fingerprint = compute_plan_fingerprint(plan); + match profile { + CompilePlanProfile::Default => fingerprint, + CompilePlanProfile::RootSubset(root_subset_profile) => { + const ROOT_SUBSET_TAG: u64 = 0xD8A4_81F2_5B39_C6E7; + let variant_tag = match root_subset_profile.variant { + crate::query_engine::RootSubsetPlanVariant::Small => 0x91u64, + crate::query_engine::RootSubsetPlanVariant::Large => 0xE3u64, + }; + fingerprint.rotate_left(17) ^ ROOT_SUBSET_TAG ^ variant_tag + } + } + } + + fn get_or_compile_cached_plan( + &self, + cache: &TableCache, + table_name: &str, + plan: LogicalPlan, + profile: CompilePlanProfile, + ) -> CompiledPhysicalPlan { + let fingerprint = Self::plan_cache_fingerprint(&plan, profile); + let mut plan_cache = self.plan_cache.borrow_mut(); + plan_cache + .get_or_insert_compiled_with(fingerprint, || { + compile_cached_plan_with_profile(cache, table_name, plan, profile) + }) + .clone() + } + pub(crate) fn new( cache: Rc>, query_registry: Rc>, @@ -239,10 +286,8 @@ impl SelectBuilder { fn get_modifier_column_info(&self, col_name: &str) -> Option<(String, usize, DataType)> { if self.frozen_base.is_some() { self.get_frozen_output_column_info(col_name) - } else if !self.joins.is_empty() { - self.get_join_output_column_info(col_name) } else { - self.get_column_info_any_table(col_name) + self.get_plan_column_info_any_table(col_name) } } @@ -286,6 +331,22 @@ impl SelectBuilder { None } + fn can_preserve_projection_table_identity(&self) -> bool { + let mut seen_tables = hashbrown::HashSet::new(); + + if let Some(main_table) = &self.from_table { + seen_tables.insert(main_table.clone()); + } + + for join in &self.joins { + if !seen_tables.insert(join.table.clone()) { + return false; + } + } + + true + } + /// Gets column info for any table (main table or joined tables). /// Supports qualified column names like `users.name` and table aliases. fn get_column_info_any_table(&self, col_name: &str) -> Option<(String, usize, DataType)> { @@ -359,6 +420,76 @@ impl SelectBuilder { None } + /// Gets column info for plan construction while preserving the real table + /// identity for optimizer passes like predicate pushdown and outer-join + /// simplification. + /// + /// Unlike `get_column_info_any_table`, this resolves aliases back to the + /// underlying table name so filters and order-by expressions on join queries + /// are still attributable to their source tables. + fn get_plan_column_info_any_table(&self, col_name: &str) -> Option<(String, usize, DataType)> { + if let Some(dot_pos) = col_name.find('.') { + let table_part = &col_name[..dot_pos]; + let col_part = &col_name[dot_pos + 1..]; + + if let Some(main_table) = &self.from_table { + if main_table == table_part { + if let Some(schema) = self.get_schema() { + if let Some(col) = schema.get_column(col_part) { + return Some((main_table.clone(), col.index(), col.data_type())); + } + } + } + } + + for join in &self.joins { + let ref_name = join.reference_name(); + if ref_name == table_part || join.table == table_part { + if let Some(store) = self.cache.borrow().get_table(&join.table) { + if let Some(col) = store.schema().get_column(col_part) { + return Some((join.table.clone(), col.index(), col.data_type())); + } + } + } + } + + if let Some(info) = self.cache.borrow().get_table(table_part).and_then(|store| { + store + .schema() + .get_column(col_part) + .map(|c| (table_part.to_string(), c.index(), c.data_type())) + }) { + return Some(info); + } + } + + if let Some(table_name) = &self.from_table { + if let Some(schema) = self.get_schema() { + if let Some(col) = schema.get_column(col_name) { + return Some((table_name.clone(), col.index(), col.data_type())); + } + } + } + + for join in &self.joins { + if let Some(info) = self + .cache + .borrow() + .get_table(&join.table) + .and_then(|store| { + store + .schema() + .get_column(col_name) + .map(|c| (join.table.clone(), c.index(), c.data_type())) + }) + { + return Some(info); + } + } + + None + } + /// Parses the columns JsValue into a list of column names. /// Returns None if selecting all columns (empty array, undefined, or contains "*"). fn parse_columns(&self) -> Option> { @@ -581,6 +712,540 @@ impl SelectBuilder { self.apply_query_modifiers(root) } + fn rewrite_limit_for_candidate_window( + plan: LogicalPlan, + candidate_limit: usize, + ) -> LogicalPlan { + match plan { + LogicalPlan::Filter { input, predicate } => LogicalPlan::Filter { + input: Box::new(Self::rewrite_limit_for_candidate_window( + *input, + candidate_limit, + )), + predicate, + }, + LogicalPlan::Project { input, columns } => LogicalPlan::Project { + input: Box::new(Self::rewrite_limit_for_candidate_window( + *input, + candidate_limit, + )), + columns, + }, + LogicalPlan::Aggregate { + input, + group_by, + aggregates, + } => LogicalPlan::Aggregate { + input: Box::new(Self::rewrite_limit_for_candidate_window( + *input, + candidate_limit, + )), + group_by, + aggregates, + }, + LogicalPlan::Sort { input, order_by } => LogicalPlan::Sort { + input: Box::new(Self::rewrite_limit_for_candidate_window( + *input, + candidate_limit, + )), + order_by, + }, + LogicalPlan::Limit { input, .. } => LogicalPlan::Limit { + input, + limit: candidate_limit, + offset: 0, + }, + other => other, + } + } + + fn resolve_partial_output_index(output: &QueryOutput, name: &str) -> Option { + output.resolve_column(name).map(|(index, _)| index) + } + + fn resolve_partial_column_ref( + &self, + lookup: &str, + explicit_name: Option<&str>, + ) -> Option<(String, String, usize)> { + let (table, index, _) = self.get_plan_column_info_any_table(lookup)?; + let column = explicit_name + .map(ToString::to_string) + .unwrap_or_else(|| lookup.rsplit('.').next().unwrap_or(lookup).to_string()); + Some((table, column, index)) + } + + fn resolve_partial_expr_column( + &self, + column: &crate::expr::Column, + ) -> Option<(String, String, usize)> { + let name = column.name(); + if let Some(table_name) = column.table_name() { + self.resolve_partial_column_ref(&alloc::format!("{}.{}", table_name, name), Some(&name)) + } else { + self.resolve_partial_column_ref(&name, Some(&name)) + } + } + + fn resolve_partial_value_column(&self, value: &JsValue) -> Option<(String, String, usize)> { + if let Some(lookup) = value.as_string() { + let explicit_name = lookup.rsplit('.').next().unwrap_or(lookup.as_str()); + return self.resolve_partial_column_ref(&lookup, Some(explicit_name)); + } + + if !value.is_object() { + return None; + } + + let name = js_sys::Reflect::get(value, &JsValue::from_str("name")) + .ok() + .and_then(|v| v.as_string())?; + let lookup = js_sys::Reflect::get(value, &JsValue::from_str("tableName")) + .ok() + .and_then(|v| v.as_string()) + .map(|table| alloc::format!("{}.{}", table, name)) + .unwrap_or_else(|| name.clone()); + self.resolve_partial_column_ref(&lookup, Some(&name)) + } + + fn collect_partial_join_edges( + &self, + expr: &Expr, + edges: &mut Vec, + ) -> bool { + match expr.inner() { + ExprInner::And { left, right } => { + self.collect_partial_join_edges(left, edges) + && self.collect_partial_join_edges(right, edges) + } + ExprInner::Comparison { column, op, value } if *op == crate::expr::ComparisonOp::Eq => { + let Some((left_table, left_column, _)) = self.resolve_partial_expr_column(column) + else { + return false; + }; + let Some((right_table, right_column, _)) = self.resolve_partial_value_column(value) + else { + return false; + }; + if left_table == right_table { + return false; + } + edges.push(RowsSnapshotJoinEdge { + left_table, + left_column, + right_table, + right_column, + }); + true + } + _ => false, + } + } + + fn resolve_table_id(&self, table_name: &str) -> Result { + self.table_id_map + .borrow() + .get(table_name) + .copied() + .ok_or_else(|| JsValue::from_str(&alloc::format!("Table ID not found: {}", table_name))) + } + + fn compiled_lookup_primitive(schema: &Table, column_name: &str) -> RowsSnapshotLookupPrimitive { + if let Some(primary_key) = schema.primary_key() { + if primary_key.columns().len() == 1 + && primary_key + .columns() + .first() + .map(|column| column.name.as_str()) + == Some(column_name) + { + return RowsSnapshotLookupPrimitive::PrimaryKey; + } + } + + if let Some(index_name) = schema + .indices() + .iter() + .find(|index| { + index.get_index_type() != cynos_core::schema::IndexType::Gin + && index.columns().len() == 1 + && index.columns().first().map(|column| column.name.as_str()) + == Some(column_name) + }) + .map(|index| index.name().to_string()) + { + return RowsSnapshotLookupPrimitive::SingleColumnIndex { index_name }; + } + + RowsSnapshotLookupPrimitive::ScanFallback + } + + fn compile_rows_snapshot_dependency_graph( + &self, + root_table: &str, + join_edges: &[RowsSnapshotJoinEdge], + ) -> Result { + let root_table_id = self.resolve_table_id(root_table)?; + let mut table_names = hashbrown::HashMap::new(); + table_names.insert(root_table_id, root_table.to_string()); + + let mut undirected_edges_by_source = + hashbrown::HashMap::>::new(); + let cache = self.cache.borrow(); + + for edge in join_edges { + let left_table_id = self.resolve_table_id(&edge.left_table)?; + let right_table_id = self.resolve_table_id(&edge.right_table)?; + + let left_store = cache.get_table(&edge.left_table).ok_or_else(|| { + JsValue::from_str(&alloc::format!("Table not found: {}", edge.left_table)) + })?; + let right_store = cache.get_table(&edge.right_table).ok_or_else(|| { + JsValue::from_str(&alloc::format!("Table not found: {}", edge.right_table)) + })?; + + let left_column_index = left_store + .schema() + .get_column_index(&edge.left_column) + .ok_or_else(|| { + JsValue::from_str(&alloc::format!( + "Column not found: {}.{}", + edge.left_table, + edge.left_column + )) + })?; + let right_column_index = right_store + .schema() + .get_column_index(&edge.right_column) + .ok_or_else(|| { + JsValue::from_str(&alloc::format!( + "Column not found: {}.{}", + edge.right_table, + edge.right_column + )) + })?; + + table_names.insert(left_table_id, edge.left_table.clone()); + table_names.insert(right_table_id, edge.right_table.clone()); + + undirected_edges_by_source + .entry(left_table_id) + .or_insert_with(Vec::new) + .push(RowsSnapshotDirectedJoinEdge { + source_column_index: left_column_index, + target_table_id: right_table_id, + target_table: edge.right_table.clone(), + target_column_index: right_column_index, + lookup: Self::compiled_lookup_primitive( + right_store.schema(), + &edge.right_column, + ), + }); + undirected_edges_by_source + .entry(right_table_id) + .or_insert_with(Vec::new) + .push(RowsSnapshotDirectedJoinEdge { + source_column_index: right_column_index, + target_table_id: left_table_id, + target_table: edge.left_table.clone(), + target_column_index: left_column_index, + lookup: Self::compiled_lookup_primitive(left_store.schema(), &edge.left_column), + }); + } + + let mut distance_to_root = hashbrown::HashMap::::new(); + let mut queue = VecDeque::new(); + distance_to_root.insert(root_table_id, 0); + queue.push_back(root_table_id); + + while let Some(table_id) = queue.pop_front() { + let next_distance = distance_to_root + .get(&table_id) + .copied() + .unwrap_or(0) + .saturating_add(1); + for edge in undirected_edges_by_source + .get(&table_id) + .map(Vec::as_slice) + .unwrap_or(&[]) + { + if distance_to_root.contains_key(&edge.target_table_id) { + continue; + } + distance_to_root.insert(edge.target_table_id, next_distance); + queue.push_back(edge.target_table_id); + } + } + + if distance_to_root.len() != table_names.len() { + return Err(JsValue::from_str( + "rows snapshot dependency graph is disconnected from the root table", + )); + } + + let mut edges_by_source = + hashbrown::HashMap::>::new(); + for (source_table_id, candidate_edges) in undirected_edges_by_source { + let Some(source_distance) = distance_to_root.get(&source_table_id).copied() else { + continue; + }; + let directed_edges: Vec<_> = candidate_edges + .into_iter() + .filter(|edge| { + distance_to_root + .get(&edge.target_table_id) + .map(|target_distance| *target_distance < source_distance) + .unwrap_or(false) + }) + .collect(); + if !directed_edges.is_empty() { + edges_by_source.insert(source_table_id, directed_edges); + } + } + + Ok(RowsSnapshotDependencyGraph { + root_table_id, + table_names, + edges_by_source, + }) + } + + fn build_rows_snapshot_partial_refresh_plan( + &self, + output: &QueryOutput, + ) -> Result, JsValue> { + if self.frozen_base.is_some() + || !self.aggregates.is_empty() + || !self.group_by_cols.is_empty() + || self.order_by.is_empty() + { + return Ok(None); + } + + let visible_limit = match self.limit_val { + Some(limit) if limit > 0 => limit, + _ => return Ok(None), + }; + let visible_offset = self.offset_val.unwrap_or(0); + + if self + .joins + .iter() + .any(|join| join.join_type == JoinType::Right) + { + return Ok(None); + } + + let root_table = self + .from_table + .clone() + .ok_or_else(|| JsValue::from_str("FROM table not specified"))?; + + let mut seen_tables = hashbrown::HashSet::new(); + if !seen_tables.insert(root_table.clone()) { + return Ok(None); + } + for join in &self.joins { + if !seen_tables.insert(join.table.clone()) { + return Ok(None); + } + } + + let cache = self.cache.borrow(); + let root_store = cache + .get_table(&root_table) + .ok_or_else(|| JsValue::from_str(&alloc::format!("Table not found: {}", root_table)))?; + let root_pk = match root_store.schema().primary_key() { + Some(pk) if !pk.columns().is_empty() => pk, + _ => return Ok(None), + }; + let mut root_pk_output_indices = Vec::with_capacity(root_pk.columns().len()); + for pk_column in root_pk.columns() { + let qualified = alloc::format!("{}.{}", root_table, pk_column.name); + let Some(output_index) = Self::resolve_partial_output_index(output, &qualified) + .or_else(|| Self::resolve_partial_output_index(output, &pk_column.name)) + else { + return Ok(None); + }; + root_pk_output_indices.push(output_index); + } + + let mut order_keys = Vec::with_capacity(self.order_by.len()); + for (column_name, order) in &self.order_by { + let Some(output_index) = Self::resolve_partial_output_index(output, column_name) else { + return Ok(None); + }; + order_keys.push(RowsSnapshotOrderKey { + output_index, + order: *order, + }); + } + + let mut join_edges = Vec::new(); + for join in &self.joins { + if !self.collect_partial_join_edges(&join.condition, &mut join_edges) { + return Ok(None); + } + } + let dependency_graph = + self.compile_rows_snapshot_dependency_graph(&root_table, &join_edges)?; + + let overscan = Self::partial_refresh_overscan(visible_limit); + let candidate_limit = visible_offset + .saturating_add(visible_limit) + .saturating_add(overscan.saturating_mul(2)); + + Ok(Some(RowsSnapshotPartialRefreshMetadata { + root_table, + root_pk_output_indices, + order_keys, + visible_offset, + visible_limit, + overscan, + candidate_limit, + dependency_graph, + })) + } + + fn plan_supports_root_subset_refresh(plan: &PhysicalPlan, root_table: &str) -> bool { + plan.collect_tables() + .iter() + .any(|table| table == root_table) + && Self::plan_allows_root_subset_refresh(plan) + } + + fn plan_allows_root_subset_refresh(plan: &PhysicalPlan) -> bool { + match plan { + PhysicalPlan::TableScan { .. } + | PhysicalPlan::IndexScan { .. } + | PhysicalPlan::IndexGet { .. } + | PhysicalPlan::IndexInGet { .. } + | PhysicalPlan::GinIndexScan { .. } + | PhysicalPlan::GinIndexScanMulti { .. } + | PhysicalPlan::Empty => true, + PhysicalPlan::Filter { input, .. } + | PhysicalPlan::Project { input, .. } + | PhysicalPlan::NoOp { input } => Self::plan_allows_root_subset_refresh(input), + PhysicalPlan::HashJoin { left, right, .. } + | PhysicalPlan::SortMergeJoin { left, right, .. } + | PhysicalPlan::NestedLoopJoin { left, right, .. } => { + Self::plan_allows_root_subset_refresh(left) + && Self::plan_allows_root_subset_refresh(right) + } + PhysicalPlan::IndexNestedLoopJoin { outer, .. } => { + Self::plan_allows_root_subset_refresh(outer) + } + PhysicalPlan::HashAggregate { .. } + | PhysicalPlan::Sort { .. } + | PhysicalPlan::TopN { .. } + | PhysicalPlan::Limit { .. } + | PhysicalPlan::CrossProduct { .. } + | PhysicalPlan::Union { .. } => false, + } + } + + fn build_rows_snapshot_root_subset_plan( + &self, + cache: &TableCache, + output: &QueryOutput, + logical_plan: &LogicalPlan, + compiled_plan: &CompiledPhysicalPlan, + ) -> Result, JsValue> { + if self.frozen_base.is_some() + || !self.aggregates.is_empty() + || !self.group_by_cols.is_empty() + || !self.order_by.is_empty() + || self.limit_val.is_some() + || self.offset_val.unwrap_or(0) > 0 + { + return Ok(None); + } + + if self + .joins + .iter() + .any(|join| join.join_type == JoinType::Right) + { + return Ok(None); + } + + let root_table = self + .from_table + .clone() + .ok_or_else(|| JsValue::from_str("FROM table not specified"))?; + if !Self::plan_supports_root_subset_refresh(compiled_plan.physical_plan(), &root_table) { + return Ok(None); + } + + let mut seen_tables = hashbrown::HashSet::new(); + if !seen_tables.insert(root_table.clone()) { + return Ok(None); + } + for join in &self.joins { + if !seen_tables.insert(join.table.clone()) { + return Ok(None); + } + } + + let root_store = cache + .get_table(&root_table) + .ok_or_else(|| JsValue::from_str(&alloc::format!("Table not found: {}", root_table)))?; + let root_pk = match root_store.schema().primary_key() { + Some(pk) if !pk.columns().is_empty() => pk, + _ => return Ok(None), + }; + let mut root_pk_output_indices = Vec::with_capacity(root_pk.columns().len()); + let mut root_pk_store_indices = Vec::with_capacity(root_pk.columns().len()); + for pk_column in root_pk.columns() { + let qualified = alloc::format!("{}.{}", root_table, pk_column.name); + let Some(output_index) = Self::resolve_partial_output_index(output, &qualified) + .or_else(|| Self::resolve_partial_output_index(output, &pk_column.name)) + else { + return Ok(None); + }; + let Some(store_index) = root_store.schema().get_column_index(&pk_column.name) else { + return Ok(None); + }; + root_pk_store_indices.push(store_index); + root_pk_output_indices.push(output_index); + } + + let mut join_edges = Vec::new(); + for join in &self.joins { + if !self.collect_partial_join_edges(&join.condition, &mut join_edges) { + return Ok(None); + } + } + let dependency_graph = + self.compile_rows_snapshot_dependency_graph(&root_table, &join_edges)?; + + let subset_compiled_plan_small = self.get_or_compile_cached_plan( + cache, + &root_table, + logical_plan.clone(), + CompilePlanProfile::RootSubset(RootSubsetPlanningProfile::small()), + ); + let subset_compiled_plan_large = self.get_or_compile_cached_plan( + cache, + &root_table, + logical_plan.clone(), + CompilePlanProfile::RootSubset(RootSubsetPlanningProfile::large()), + ); + + Ok(Some(RowsSnapshotRootSubsetPlan { + metadata: RowsSnapshotRootSubsetMetadata { + root_table, + root_pk_store_indices, + root_pk_output_indices, + dependency_graph, + }, + compiled_plans: RowsSnapshotRootSubsetVariants { + small: subset_compiled_plan_small, + large: subset_compiled_plan_large, + }, + })) + } + /// Gets column info for projection, calculating the correct index for JOIN queries. /// For JOIN queries, returns the table-relative index (not the absolute offset). /// The absolute index will be computed at runtime based on actual table order. @@ -591,6 +1256,9 @@ impl SelectBuilder { } if !self.joins.is_empty() { + if self.can_preserve_projection_table_identity() { + return self.get_plan_column_info_any_table(col_name); + } return self.get_join_output_column_info(col_name); } @@ -645,7 +1313,7 @@ impl SelectBuilder { .map(|(index, column)| (String::new(), index, column.data_type)); } - self.get_column_info_for_projection(col_name) + self.get_plan_column_info_any_table(col_name) } fn representative_schema(&self) -> Result { @@ -1364,17 +2032,10 @@ impl SelectBuilder { .ok_or_else(|| JsValue::from_str(&alloc::format!("Table not found: {}", table_name)))?; let plan = self.build_logical_plan(table_name); - let fingerprint = compute_plan_fingerprint(&plan); let result_mapper = self.build_result_mapper(store.schema())?; let binary_layout = self.binary_output_layout(table_name, store.schema())?; - let compiled_plan = { - let mut plan_cache = self.plan_cache.borrow_mut(); - plan_cache - .get_or_insert_compiled_with(fingerprint, || { - compile_cached_plan(&cache, table_name, plan) - }) - .clone() - }; + let compiled_plan = + self.get_or_compile_cached_plan(&cache, table_name, plan, CompilePlanProfile::Default); Ok(PreparedSelectQuery { cache: self.cache.clone(), @@ -1421,6 +2082,10 @@ impl SelectBuilder { /// row-local patches for simple single-table pipelines instead of always /// re-executing the full query. pub fn observe(&self) -> Result { + #[cfg(feature = "benchmark")] + let observe_started_at = now_ms(); + #[cfg(feature = "benchmark")] + let mut snapshot_init_profile = SnapshotInitProfile::default(); let table_name = self .from_table .as_ref() @@ -1433,10 +2098,24 @@ impl SelectBuilder { .ok_or_else(|| JsValue::from_str(&alloc::format!("Table not found: {}", table_name)))?; // Build logical plan and compile to a cached execution artifact for re-execution. + #[cfg(feature = "benchmark")] + let logical_plan_started_at = now_ms(); let logical_plan = self.build_logical_plan(table_name); + #[cfg(feature = "benchmark")] + { + snapshot_init_profile.logical_plan_ms = now_ms() - logical_plan_started_at; + } + #[cfg(feature = "benchmark")] + let describe_output_started_at = now_ms(); let output = self.describe_output()?; + #[cfg(feature = "benchmark")] + { + snapshot_init_profile.describe_output_ms = now_ms() - describe_output_started_at; + } let output_columns = output.column_names(); let schema = store.schema().clone(); + #[cfg(feature = "benchmark")] + let binary_layout_started_at = now_ms(); let binary_layout = if self.frozen_base.is_some() { output.layout() } else if self.joins.is_empty() { @@ -1456,25 +2135,95 @@ impl SelectBuilder { } SchemaLayout::from_schemas(&schemas) }; - let compiled_plan = compile_cached_plan(&cache, table_name, logical_plan.clone()); + #[cfg(feature = "benchmark")] + { + snapshot_init_profile.binary_layout_ms = now_ms() - binary_layout_started_at; + } + #[cfg(feature = "benchmark")] + let partial_refresh_started_at = now_ms(); + let partial_refresh = self.build_rows_snapshot_partial_refresh_plan(&output)?; + #[cfg(feature = "benchmark")] + { + snapshot_init_profile.partial_refresh_plan_ms = now_ms() - partial_refresh_started_at; + snapshot_init_profile.partial_refresh_enabled = partial_refresh.is_some(); + } + let compiled_logical_plan = if let Some(partial) = &partial_refresh { + Self::rewrite_limit_for_candidate_window(logical_plan.clone(), partial.candidate_limit) + } else { + logical_plan.clone() + }; + #[cfg(feature = "benchmark")] + let compile_main_plan_started_at = now_ms(); + let compiled_plan = self.get_or_compile_cached_plan( + &cache, + table_name, + compiled_logical_plan, + CompilePlanProfile::Default, + ); + #[cfg(feature = "benchmark")] + { + snapshot_init_profile.compile_main_plan_ms = now_ms() - compile_main_plan_started_at; + } + #[cfg(feature = "benchmark")] + let root_subset_started_at = now_ms(); + let root_subset_refresh = if partial_refresh.is_none() { + self.build_rows_snapshot_root_subset_plan( + &cache, + &output, + &logical_plan, + &compiled_plan, + )? + } else { + None + }; + #[cfg(feature = "benchmark")] + { + snapshot_init_profile.root_subset_plan_ms = now_ms() - root_subset_started_at; + snapshot_init_profile.root_subset_enabled = root_subset_refresh.is_some(); + } // Get initial result using the compiled plan artifact. + #[cfg(feature = "benchmark")] + let initial_query_started_at = now_ms(); let initial_output = execute_compiled_physical_plan_with_summary(&cache, &compiled_plan) .map_err(|e| JsValue::from_str(&alloc::format!("Query execution error: {:?}", e)))?; + #[cfg(feature = "benchmark")] + { + snapshot_init_profile.initial_query_ms = now_ms() - initial_query_started_at; + snapshot_init_profile.initial_row_count = initial_output.rows.len(); + } - let dependencies = { + #[cfg(feature = "benchmark")] + let dependency_bindings_started_at = now_ms(); + let (dependencies, dependency_table_bindings) = { let table_id_map = self.table_id_map.borrow(); - let table_ids = logical_plan + let mut bindings = logical_plan .collect_tables() .into_iter() .map(|table| { - table_id_map.get(&table).copied().ok_or_else(|| { - JsValue::from_str(&alloc::format!("Table ID not found: {}", table)) - }) + table_id_map + .get(&table) + .copied() + .map(|table_id| (table_id, table.clone())) + .ok_or_else(|| { + JsValue::from_str(&alloc::format!("Table ID not found: {}", table)) + }) }) .collect::, _>>()?; - LiveDependencySet::snapshot(table_ids) + bindings.sort_unstable_by(|(left_id, left_name), (right_id, right_name)| { + left_id + .cmp(right_id) + .then_with(|| left_name.cmp(right_name)) + }); + bindings.dedup_by(|left, right| left.0 == right.0); + let table_ids = bindings.iter().map(|(table_id, _)| *table_id).collect(); + (LiveDependencySet::snapshot(table_ids), bindings) }; + #[cfg(feature = "benchmark")] + { + snapshot_init_profile.dependency_bindings_ms = + now_ms() - dependency_bindings_started_at; + } let projection = if self.frozen_base.is_some() || !self.aggregates.is_empty() @@ -1495,16 +2244,62 @@ impl SelectBuilder { drop(cache); // Release borrow + #[cfg(feature = "benchmark")] + let initial_result_adapt_started_at = now_ms(); + let (initial_rows, initial_summary, partial_refresh) = + if let Some(partial) = partial_refresh { + let visible_rows: Vec> = initial_output + .rows + .iter() + .skip(partial.visible_offset) + .take(partial.visible_limit) + .cloned() + .collect(); + let visible_summary = QueryResultSummary::from_rows(&visible_rows); + ( + visible_rows, + visible_summary, + Some(RowsSnapshotPartialRefreshState { + metadata: partial, + initial_candidate_rows: initial_output.rows, + }), + ) + } else { + (initial_output.rows, initial_output.summary, None) + }; + #[cfg(feature = "benchmark")] + { + snapshot_init_profile.initial_result_adapt_ms = + now_ms() - initial_result_adapt_started_at; + snapshot_init_profile.visible_row_count = initial_rows.len(); + } + let live_plan = LivePlan::rows_snapshot( dependencies, compiled_plan, - initial_output.rows, - initial_output.summary, + initial_rows, + initial_summary, projection, binary_layout, + dependency_table_bindings, + partial_refresh, + root_subset_refresh, ); - Ok(live_plan.materialize_rows_snapshot(cache_ref.clone(), self.query_registry.clone())) + #[cfg(feature = "benchmark")] + let observable_init_started_at = now_ms(); + let observable = + live_plan.materialize_rows_snapshot(cache_ref.clone(), self.query_registry.clone()); + #[cfg(feature = "benchmark")] + { + snapshot_init_profile.observable_init_ms = now_ms() - observable_init_started_at; + snapshot_init_profile.total_ms = now_ms() - observe_started_at; + self.query_registry + .borrow() + .record_snapshot_init_profile(snapshot_init_profile); + } + + Ok(observable) } /// Creates a changes stream (initial + incremental). @@ -1520,6 +2315,8 @@ impl SelectBuilder { /// /// Returns an error if the query is not incrementalizable (e.g. contains ORDER BY / LIMIT). pub fn trace(&self) -> Result { + let trace_started_at = now_ms(); + let mut trace_init_profile = TraceInitProfile::default(); let table_name = self .from_table .as_ref() @@ -1555,7 +2352,9 @@ impl SelectBuilder { } SchemaLayout::from_schemas(&schemas) }; + let compile_plan_started_at = now_ms(); let physical_plan = compile_plan(&cache, table_name, logical_plan); + trace_init_profile.compile_plan_ms = now_ms() - compile_plan_started_at; let mut table_schemas = hashbrown::HashMap::new(); table_schemas.insert(table_name.clone(), store.schema().clone()); for join in &self.joins { @@ -1567,21 +2366,37 @@ impl SelectBuilder { // Compile physical plan to dataflow — errors if not incrementalizable let table_id_map = self.table_id_map.borrow(); + let compile_to_dataflow_started_at = now_ms(); let compile_result = compile_to_dataflow(&physical_plan, &table_id_map, &table_schemas) .ok_or_else(|| JsValue::from_str( "Query is not incrementalizable (contains ORDER BY, LIMIT, or other non-streamable operators). Use observe() instead." ))?; + trace_init_profile.compile_to_dataflow_ms = now_ms() - compile_to_dataflow_started_at; + + let compile_trace_program_started_at = now_ms(); + let compiled_ivm_plan = CompiledIvmPlan::compile(&compile_result.dataflow); + let compile_ivm_finished_at = now_ms(); + trace_init_profile.compile_ivm_plan_ms = + compile_ivm_finished_at - compile_trace_program_started_at; + let compiled_bootstrap_plan = CompiledBootstrapPlan::compile(&compile_result.dataflow); + trace_init_profile.compile_trace_program_ms = now_ms() - compile_trace_program_started_at; // Get initial result using the compiled physical plan + let initial_query_started_at = now_ms(); let initial_rows = execute_physical_plan(&cache, &physical_plan) .map_err(|e| JsValue::from_str(&alloc::format!("Query execution error: {:?}", e)))?; + trace_init_profile.initial_query_ms = now_ms() - initial_query_started_at; let dependencies = LiveDependencySet::snapshot(compile_result.table_ids.values().copied().collect()); + let source_bindings = collect_trace_bootstrap_source_bindings( + &physical_plan, + &compile_result.table_ids, + &table_schemas, + ); drop(cache); drop(table_id_map); - let initial_owned: Vec = initial_rows.iter().map(|rc| (**rc).clone()).collect(); let projection = if self.frozen_base.is_some() || !self.aggregates.is_empty() || !self.group_by_cols.is_empty() @@ -1599,15 +2414,24 @@ impl SelectBuilder { RowsProjection::Full { schema } }; + let initial_row_count = initial_rows.len(); let live_plan = LivePlan::rows_delta( dependencies, compile_result.dataflow, - initial_owned, + compiled_ivm_plan, + compiled_bootstrap_plan, + initial_rows, + source_bindings, projection, binary_layout, + TraceInitProfile { + total_ms: now_ms() - trace_started_at, + initial_row_count, + ..trace_init_profile + }, ); - Ok(live_plan.materialize_rows_delta(self.query_registry.clone())) + Ok(live_plan.materialize_rows_delta(self.cache.clone(), self.query_registry.clone())) } /// Gets the schema layout for binary decoding. @@ -1647,20 +2471,10 @@ impl SelectBuilder { let schema = store.schema(); let layout = self.binary_output_layout(table_name, schema)?; - // Compute plan fingerprint for caching - let fingerprint = compute_plan_fingerprint(&plan); - - // Get or compile physical plan + execution artifact (cached) - let rows = { - let mut plan_cache = self.plan_cache.borrow_mut(); - let compiled_plan = plan_cache.get_or_insert_compiled_with(fingerprint, || { - compile_cached_plan(&cache, table_name, plan) - }); - - // Execute the cached compiled plan - execute_compiled_physical_plan(&cache, compiled_plan) - .map_err(|e| JsValue::from_str(&alloc::format!("Query execution error: {:?}", e)))? - }; + let compiled_plan = + self.get_or_compile_cached_plan(&cache, table_name, plan, CompilePlanProfile::Default); + let rows = execute_compiled_physical_plan(&cache, &compiled_plan) + .map_err(|e| JsValue::from_str(&alloc::format!("Query execution error: {:?}", e)))?; // Encode to binary let mut encoder = crate::binary_protocol::BinaryEncoder::new(layout, rows.len()); @@ -1707,6 +2521,8 @@ pub struct InsertBuilder { cache: Rc>, query_registry: Rc>, table_id_map: Rc>>, + #[cfg(feature = "benchmark")] + last_insert_profile: Rc>>, table_name: String, values_data: Option, } @@ -1716,18 +2532,107 @@ impl InsertBuilder { cache: Rc>, query_registry: Rc>, table_id_map: Rc>>, + #[cfg(feature = "benchmark")] last_insert_profile: Rc< + RefCell>, + >, table: &str, ) -> Self { Self { cache, query_registry, table_id_map, + #[cfg(feature = "benchmark")] + last_insert_profile, table_name: table.to_string(), values_data: None, } } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct InsertExecutionStats { + row_count: usize, +} + +fn execute_insert_values( + cache: &Rc>, + query_registry: &Rc>, + table_id_map: &Rc>>, + #[cfg(feature = "benchmark")] last_insert_profile: &Rc>>, + table_name: &str, + values: &JsValue, +) -> Result { + let schema = cache + .borrow() + .get_table(table_name) + .ok_or_else(|| JsValue::from_str(&alloc::format!("Table not found: {}", table_name)))? + .schema() + .clone(); + + let arr = js_sys::Array::from(values); + let start_row_id = reserve_row_ids(arr.length() as u64); + let rows = js_array_to_rows(values, &schema, start_row_id)?; + + execute_insert_rows( + cache, + query_registry, + table_id_map, + #[cfg(feature = "benchmark")] + last_insert_profile, + table_name, + rows, + ) + .map_err(|e| JsValue::from_str(&alloc::format!("{:?}", e))) +} + +fn execute_insert_rows( + cache: &Rc>, + query_registry: &Rc>, + table_id_map: &Rc>>, + #[cfg(feature = "benchmark")] last_insert_profile: &Rc>>, + table_name: &str, + rows: Vec, +) -> cynos_core::Result { + let table_id = table_id_map.borrow().get(table_name).copied(); + let should_notify = table_id.is_some() && query_registry.borrow().query_count() > 0; + let row_count = rows.len(); + + let inserted_ids = should_notify.then(|| { + rows.iter() + .map(|row| row.id()) + .collect::>() + }); + let deltas = should_notify.then(|| { + rows.iter() + .map(|row| Delta::insert(row.clone())) + .collect::>() + }); + + let mut cache = cache.borrow_mut(); + let store = cache + .get_table_mut(table_name) + .ok_or_else(|| cynos_core::Error::table_not_found(table_name))?; + #[cfg(feature = "benchmark")] + { + let (inserted, profile) = store.insert_batch_profiled(rows, now_ms)?; + debug_assert_eq!(inserted, row_count); + *last_insert_profile.borrow_mut() = Some(profile); + } + #[cfg(not(feature = "benchmark"))] + { + store.insert_batch(rows)?; + } + drop(cache); + + if let (Some(table_id), Some(inserted_ids), Some(deltas)) = (table_id, inserted_ids, deltas) { + query_registry + .borrow_mut() + .on_table_change_delta(table_id, deltas, &inserted_ids); + } + + Ok(InsertExecutionStats { row_count }) +} + #[wasm_bindgen] impl InsertBuilder { /// Sets the values to insert. @@ -1742,46 +2647,16 @@ impl InsertBuilder { .values_data .as_ref() .ok_or_else(|| JsValue::from_str("No values specified"))?; - - let mut cache = self.cache.borrow_mut(); - let store = cache.get_table_mut(&self.table_name).ok_or_else(|| { - JsValue::from_str(&alloc::format!("Table not found: {}", self.table_name)) - })?; - - let schema = store.schema().clone(); - - // Get the count of rows to insert first - let arr = js_sys::Array::from(values); - let row_count = arr.length() as u64; - - // Reserve row IDs for all rows at once to avoid ID conflicts - let start_row_id = reserve_row_ids(row_count); - - // Convert JS values to rows - let rows = js_array_to_rows(values, &schema, start_row_id)?; - let row_count = rows.len(); - - // Build deltas for IVM notification - let deltas: Vec> = rows.iter().map(|r| Delta::insert(r.clone())).collect(); - - // Insert rows and collect their IDs - let mut inserted_ids = hashbrown::HashSet::new(); - for row in rows { - inserted_ids.insert(row.id()); - store - .insert(row) - .map_err(|e| JsValue::from_str(&alloc::format!("{:?}", e)))?; - } - - // Notify query registry with changed IDs and deltas - if let Some(table_id) = self.table_id_map.borrow().get(&self.table_name).copied() { - drop(cache); // Release borrow before notifying - self.query_registry - .borrow_mut() - .on_table_change_delta(table_id, deltas, &inserted_ids); - } - - Ok(JsValue::from_f64(row_count as f64)) + let stats = execute_insert_values( + &self.cache, + &self.query_registry, + &self.table_id_map, + #[cfg(feature = "benchmark")] + &self.last_insert_profile, + &self.table_name, + values, + )?; + Ok(JsValue::from_f64(stats.row_count as f64)) } } @@ -2628,6 +3503,8 @@ pub(crate) fn evaluate_predicate(predicate: &Expr, row: &Row, schema: &Table) -> mod tests { use super::*; use crate::binary_protocol::SchemaLayoutCache; + use crate::dataflow_compiler::compile_to_dataflow; + use crate::query_engine::compile_plan; use alloc::rc::Rc; use core::cell::RefCell; use cynos_core::schema::TableBuilder; @@ -2953,6 +3830,160 @@ mod tests { } } + #[wasm_bindgen_test] + fn test_select_builder_non_self_join_projection_preserves_source_tables_for_optimizer() { + let ctx = build_union_test_context(); + let columns = js_sys::Array::new(); + columns.push(&JsValue::from_str("users.name")); + columns.push(&JsValue::from_str("orders.amount")); + + let query = ctx + .builder_with_columns(columns.into()) + .from("users") + .left_join( + "orders", + &crate::expr::Column::new_simple("users.id").eq(&JsValue::from_str("orders.id")), + ); + + let plan = query.build_logical_plan("users"); + match &plan { + LogicalPlan::Project { columns, .. } => { + assert_eq!(columns.len(), 2); + match &columns[0] { + cynos_query::ast::Expr::Column(col) => { + assert_eq!(col.table, "users"); + assert_eq!(col.index, 1); + } + other => panic!("expected projected column, got {:?}", other), + } + match &columns[1] { + cynos_query::ast::Expr::Column(col) => { + assert_eq!(col.table, "orders"); + assert_eq!(col.index, 1); + } + other => panic!("expected projected column, got {:?}", other), + } + } + other => panic!("expected project plan, got {:?}", other), + } + } + + #[wasm_bindgen_test] + fn test_trace_compile_omits_redundant_outer_join_dependency() { + let ctx = build_union_test_context(); + let columns = js_sys::Array::new(); + columns.push(&JsValue::from_str("users.name")); + + let query = ctx + .builder_with_columns(columns.into()) + .from("users") + .left_join( + "orders", + &crate::expr::Column::new_simple("users.id").eq(&JsValue::from_str("orders.id")), + ); + + let logical_plan = query.build_logical_plan("users"); + let cache = ctx.cache.borrow(); + let physical_plan = compile_plan(&cache, "users", logical_plan); + let physical_text = alloc::format!("{:#?}", physical_plan); + + assert!(!physical_text.contains("table: \"orders\"")); + + let mut table_schemas = hashbrown::HashMap::new(); + table_schemas.insert( + "users".to_string(), + cache.get_table("users").unwrap().schema().clone(), + ); + table_schemas.insert( + "orders".to_string(), + cache.get_table("orders").unwrap().schema().clone(), + ); + + let table_id_map = ctx.table_id_map.borrow(); + let compile_result = compile_to_dataflow(&physical_plan, &table_id_map, &table_schemas) + .expect("redundant join-free projection should stay incrementalizable"); + + assert!(compile_result.table_ids.contains_key("users")); + assert!(!compile_result.table_ids.contains_key("orders")); + } + + #[wasm_bindgen_test] + fn test_join_filters_and_order_by_preserve_source_tables_for_optimizer() { + let ctx = build_union_test_context(); + + let query = ctx + .builder() + .from("users") + .left_join( + "orders", + &crate::expr::Column::new_simple("users.id").eq(&JsValue::from_str("orders.id")), + ) + .where_(&crate::expr::Column::new_simple("orders.amount").eq(&JsValue::from_f64(150.0))) + .order_by("users.id", JsSortOrder::Asc); + + let plan = query.build_logical_plan("users"); + match &plan { + LogicalPlan::Sort { input, order_by } => { + assert_eq!(order_by.len(), 1); + match &order_by[0].0 { + cynos_query::ast::Expr::Column(col) => { + assert_eq!(col.table, "users"); + assert_eq!(col.index, 0); + } + other => panic!("expected ORDER BY column, got {:?}", other), + } + + match input.as_ref() { + LogicalPlan::Filter { predicate, .. } => match predicate { + cynos_query::ast::Expr::BinaryOp { left, .. } => match left.as_ref() { + cynos_query::ast::Expr::Column(col) => { + assert_eq!(col.table, "orders"); + assert_eq!(col.index, 1); + } + other => panic!("expected filter column, got {:?}", other), + }, + other => panic!("expected binary predicate, got {:?}", other), + }, + other => panic!("expected filter under sort, got {:?}", other), + } + } + other => panic!("expected sort plan, got {:?}", other), + } + } + + #[wasm_bindgen_test] + fn test_observe_reuses_cached_default_and_root_subset_plans() { + let ctx = build_union_test_context(); + let columns = js_sys::Array::new(); + columns.push(&JsValue::from_str("users.id")); + columns.push(&JsValue::from_str("users.name")); + columns.push(&JsValue::from_str("orders.amount")); + + let query = ctx + .builder_with_columns(columns.into()) + .from("users") + .left_join( + "orders", + &crate::expr::Column::new_simple("users.id").eq(&JsValue::from_str("orders.id")), + ); + + ctx.plan_cache.borrow_mut().clear(); + + let _first = query.observe().expect("first observe should succeed"); + { + let plan_cache = ctx.plan_cache.borrow(); + assert_eq!(plan_cache.misses(), 2); + assert_eq!(plan_cache.hits(), 0); + assert_eq!(plan_cache.len(), 2); + } + + let _second = query.observe().expect("second observe should reuse cache"); + let plan_cache = ctx.plan_cache.borrow(); + assert_eq!(plan_cache.misses(), 2); + assert_eq!(plan_cache.hits(), 2); + assert_eq!(plan_cache.len(), 2); + } + #[wasm_bindgen_test] fn test_like_match_exact() { assert!(pattern_match::like("hello", "hello")); diff --git a/crates/database/src/query_engine.rs b/crates/database/src/query_engine.rs index 74ff14c..be03a18 100644 --- a/crates/database/src/query_engine.rs +++ b/crates/database/src/query_engine.rs @@ -6,14 +6,21 @@ #[allow(unused_imports)] use alloc::boxed::Box; use alloc::rc::Rc; +use alloc::string::{String, ToString}; use alloc::vec::Vec; use cynos_core::{Row, Value, DUMMY_ROW_ID}; -use cynos_index::KeyRange; -use cynos_query::context::{ExecutionContext, IndexInfo, QueryIndexType, TableStats}; +use cynos_index::{contains_trigram_pairs, KeyRange}; +use cynos_jsonb::JsonPath; +use cynos_query::ast::{BinaryOp, Expr as AstExpr}; +use cynos_query::context::{ + ExecutionContext, IndexInfo, PlannerFeatureFlags, PlanningIntent, PlanningMode, QueryIndexType, + RestrictedAccessMode, TableStats, +}; use cynos_query::executor::{DataSource, ExecutionError, ExecutionResult, PhysicalPlanRunner}; pub use cynos_query::plan_cache::CompiledPhysicalPlan; -use cynos_query::planner::{LogicalPlan, PhysicalPlan, QueryPlanner}; +use cynos_query::planner::{LogicalPlan, PhysicalPlan, PlannerProfile, QueryPlanner}; use cynos_storage::TableCache; +use hashbrown::HashSet; #[cfg(target_arch = "wasm32")] use wasm_bindgen::prelude::*; @@ -32,6 +39,77 @@ pub struct TableCacheDataSource<'a> { cache: &'a TableCache, } +struct TableSubsetDataSource<'a> { + cache: &'a TableCache, + subset_table: &'a str, + sorted_allowed_row_ids: Vec, + subset_execution_mode: SubsetExecutionMode, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CompilePlanProfile { + Default, + RootSubset(RootSubsetPlanningProfile), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct RootSubsetPlanningProfile { + pub variant: RootSubsetPlanVariant, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RootSubsetPlanVariant { + Small, + Large, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SubsetExecutionMode { + SubsetDriven, + IndexDrivenIntersect, +} + +impl RootSubsetPlanningProfile { + pub(crate) fn small() -> Self { + Self { + variant: RootSubsetPlanVariant::Small, + } + } + + pub(crate) fn large() -> Self { + Self { + variant: RootSubsetPlanVariant::Large, + } + } + + fn preferred_access_mode(self) -> RestrictedAccessMode { + match self.variant { + RootSubsetPlanVariant::Small => RestrictedAccessMode::SubsetDriven, + RootSubsetPlanVariant::Large => RestrictedAccessMode::IndexDrivenIntersect, + } + } +} + +impl SubsetExecutionMode { + fn choose(allowed_row_count: usize, table_row_count: usize) -> Self { + match choose_root_subset_plan_variant(allowed_row_count, table_row_count) { + RootSubsetPlanVariant::Small => Self::SubsetDriven, + RootSubsetPlanVariant::Large => Self::IndexDrivenIntersect, + } + } +} + +pub(crate) fn choose_root_subset_plan_variant( + allowed_row_count: usize, + table_row_count: usize, +) -> RootSubsetPlanVariant { + if allowed_row_count <= 8192 || allowed_row_count.saturating_mul(4) <= table_row_count { + RootSubsetPlanVariant::Small + } else { + RootSubsetPlanVariant::Large + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[doc(hidden)] pub struct QueryResultSummary { @@ -141,6 +219,62 @@ impl<'a> TableCacheDataSource<'a> { } } +impl<'a> TableSubsetDataSource<'a> { + fn try_new( + cache: &'a TableCache, + subset_table: &'a str, + allowed_row_ids: &'a HashSet, + ) -> ExecutionResult { + let store = cache + .get_table(subset_table) + .ok_or_else(|| ExecutionError::TableNotFound(subset_table.into()))?; + let mut sorted_allowed_row_ids: Vec = allowed_row_ids.iter().copied().collect(); + sorted_allowed_row_ids.sort_unstable(); + let subset_execution_mode = SubsetExecutionMode::choose(allowed_row_ids.len(), store.len()); + + Ok(Self { + cache, + subset_table, + sorted_allowed_row_ids, + subset_execution_mode, + }) + } + + #[inline] + fn is_subset_table(&self, table: &str) -> bool { + table == self.subset_table + } + + #[inline] + fn restricted_row_ids(&self) -> &[u64] { + &self.sorted_allowed_row_ids + } + + #[inline] + fn subset_driven(&self) -> bool { + self.subset_execution_mode == SubsetExecutionMode::SubsetDriven + } + + fn scalar_range( + range_start: Option<&Value>, + range_end: Option<&Value>, + include_start: bool, + include_end: bool, + ) -> Option> { + match (range_start, range_end) { + (Some(start), Some(end)) => Some(KeyRange::bound( + start.clone(), + end.clone(), + !include_start, + !include_end, + )), + (Some(start), None) => Some(KeyRange::lower_bound(start.clone(), !include_start)), + (None, Some(end)) => Some(KeyRange::upper_bound(end.clone(), !include_end)), + (None, None) => None, + } + } +} + impl<'a> DataSource for TableCacheDataSource<'a> { fn get_table_rows(&self, table: &str) -> ExecutionResult>> { let store = self @@ -355,11 +489,450 @@ impl<'a> DataSource for TableCacheDataSource<'a> { .get_table(table) .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; - let range = KeyRange::only(key.clone()); - store.visit_index_scan_with_options(index, Some(&range), limit, 0, false, |row| { - visitor(row) - }); - Ok(()) + let range = KeyRange::only(key.clone()); + store.visit_index_scan_with_options(index, Some(&range), limit, 0, false, |row| { + visitor(row) + }); + Ok(()) + } + + fn get_column_count(&self, table: &str) -> ExecutionResult { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + Ok(store.schema().columns().len()) + } + + fn get_table_row_count(&self, table: &str) -> ExecutionResult { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + Ok(store.len()) + } + + fn get_gin_index_rows( + &self, + table: &str, + index: &str, + key: &str, + value: &str, + ) -> ExecutionResult>> { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + + Ok(store.gin_index_get_by_key_value(index, key, value)) + } + + fn visit_gin_index_rows( + &self, + table: &str, + index: &str, + key: &str, + value: &str, + mut visitor: F, + ) -> ExecutionResult<()> + where + F: FnMut(&Rc) -> bool, + { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + + store.visit_gin_index_by_key_value(index, key, value, |row| visitor(row)); + Ok(()) + } + + fn get_gin_index_rows_by_key( + &self, + table: &str, + index: &str, + key: &str, + ) -> ExecutionResult>> { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + + Ok(store.gin_index_get_by_key(index, key)) + } + + fn visit_gin_index_rows_by_key( + &self, + table: &str, + index: &str, + key: &str, + mut visitor: F, + ) -> ExecutionResult<()> + where + F: FnMut(&Rc) -> bool, + { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + + store.visit_gin_index_by_key(index, key, |row| visitor(row)); + Ok(()) + } + + fn get_gin_index_rows_multi( + &self, + table: &str, + index: &str, + pairs: &[(&str, &str)], + match_all: bool, + ) -> ExecutionResult>> { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + + Ok(if match_all { + store.gin_index_get_by_key_values_all(index, pairs) + } else { + store.gin_index_get_by_key_values_any(index, pairs) + }) + } + + fn visit_gin_index_rows_multi( + &self, + table: &str, + index: &str, + pairs: &[(&str, &str)], + match_all: bool, + mut visitor: F, + ) -> ExecutionResult<()> + where + F: FnMut(&Rc) -> bool, + { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + + if match_all { + store.visit_gin_index_by_key_values_all(index, pairs, |row| visitor(row)); + } else { + store.visit_gin_index_by_key_values_any(index, pairs, |row| visitor(row)); + } + Ok(()) + } +} + +impl<'a> DataSource for TableSubsetDataSource<'a> { + fn get_table_rows(&self, table: &str) -> ExecutionResult>> { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + if !self.is_subset_table(table) { + return Ok(store.scan().collect()); + } + + Ok(store.get_rows_by_ids(self.restricted_row_ids())) + } + + fn visit_table_rows(&self, table: &str, mut visitor: F) -> ExecutionResult<()> + where + F: FnMut(&Rc) -> bool, + { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + + if !self.is_subset_table(table) { + store.visit_rows(|row| visitor(row)); + return Ok(()); + } + + store.visit_rows_by_ids(self.restricted_row_ids(), |row| visitor(row)); + Ok(()) + } + + fn get_index_range_with_limit( + &self, + table: &str, + index: &str, + range_start: Option<&Value>, + range_end: Option<&Value>, + include_start: bool, + include_end: bool, + limit: Option, + offset: usize, + reverse: bool, + ) -> ExecutionResult>> { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + + let range = Self::scalar_range(range_start, range_end, include_start, include_end); + if !self.is_subset_table(table) { + return Ok(store.index_scan_with_options( + index, + range.as_ref(), + limit, + offset, + reverse, + )); + } + + let mut rows = Vec::new(); + store.visit_index_scan_with_options_restricted( + index, + range.as_ref(), + limit, + offset, + reverse, + self.restricted_row_ids(), + self.subset_driven(), + |row| { + rows.push(row.clone()); + true + }, + ); + Ok(rows) + } + + fn visit_index_range_with_limit( + &self, + table: &str, + index: &str, + range_start: Option<&Value>, + range_end: Option<&Value>, + include_start: bool, + include_end: bool, + limit: Option, + offset: usize, + reverse: bool, + mut visitor: F, + ) -> ExecutionResult<()> + where + F: FnMut(&Rc) -> bool, + { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + let range = Self::scalar_range(range_start, range_end, include_start, include_end); + if !self.is_subset_table(table) { + store.visit_index_scan_with_options( + index, + range.as_ref(), + limit, + offset, + reverse, + |row| visitor(row), + ); + return Ok(()); + } + + store.visit_index_scan_with_options_restricted( + index, + range.as_ref(), + limit, + offset, + reverse, + self.restricted_row_ids(), + self.subset_driven(), + |row| visitor(row), + ); + Ok(()) + } + + fn visit_index_range_composite_with_limit( + &self, + table: &str, + index: &str, + range: Option<&KeyRange>>, + limit: Option, + offset: usize, + reverse: bool, + mut visitor: F, + ) -> ExecutionResult<()> + where + F: FnMut(&Rc) -> bool, + { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + if !self.is_subset_table(table) { + store.visit_index_scan_composite_with_options( + index, + range, + limit, + offset, + reverse, + |row| visitor(row), + ); + return Ok(()); + } + + store.visit_index_scan_composite_with_options_restricted( + index, + range, + limit, + offset, + reverse, + self.restricted_row_ids(), + self.subset_driven(), + |row| visitor(row), + ); + Ok(()) + } + + fn visit_index_point_with_limit( + &self, + table: &str, + index: &str, + key: &Value, + limit: Option, + mut visitor: F, + ) -> ExecutionResult<()> + where + F: FnMut(&Rc) -> bool, + { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + if !self.is_subset_table(table) { + store.visit_index_scan_with_options( + index, + Some(&KeyRange::only(key.clone())), + limit, + 0, + false, + |row| visitor(row), + ); + return Ok(()); + } + + store.visit_index_scan_with_options_restricted( + index, + Some(&KeyRange::only(key.clone())), + limit, + 0, + false, + self.restricted_row_ids(), + self.subset_driven(), + |row| visitor(row), + ); + Ok(()) + } + + fn get_index_range_composite_with_limit( + &self, + table: &str, + index: &str, + range: Option<&KeyRange>>, + limit: Option, + offset: usize, + reverse: bool, + ) -> ExecutionResult>> { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + + if !self.is_subset_table(table) { + return Ok( + store.index_scan_composite_with_options(index, range, limit, offset, reverse) + ); + } + + let mut rows = Vec::new(); + store.visit_index_scan_composite_with_options_restricted( + index, + range, + limit, + offset, + reverse, + self.restricted_row_ids(), + self.subset_driven(), + |row| { + rows.push(row.clone()); + true + }, + ); + Ok(rows) + } + + fn get_index_point( + &self, + table: &str, + index: &str, + key: &Value, + ) -> ExecutionResult>> { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + + if !self.is_subset_table(table) { + return Ok(store.index_scan(index, Some(&KeyRange::only(key.clone())))); + } + + let mut rows = Vec::new(); + store.visit_index_scan_with_options_restricted( + index, + Some(&KeyRange::only(key.clone())), + None, + 0, + false, + self.restricted_row_ids(), + self.subset_driven(), + |row| { + rows.push(row.clone()); + true + }, + ); + Ok(rows) + } + + fn get_index_point_with_limit( + &self, + table: &str, + index: &str, + key: &Value, + limit: Option, + ) -> ExecutionResult>> { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + if !self.is_subset_table(table) { + return Ok(store.index_scan_with_options( + index, + Some(&KeyRange::only(key.clone())), + limit, + 0, + false, + )); + } + + let mut rows = Vec::new(); + store.visit_index_scan_with_options_restricted( + index, + Some(&KeyRange::only(key.clone())), + limit, + 0, + false, + self.restricted_row_ids(), + self.subset_driven(), + |row| { + rows.push(row.clone()); + true + }, + ); + Ok(rows) } fn get_column_count(&self, table: &str) -> ExecutionResult { @@ -371,11 +944,19 @@ impl<'a> DataSource for TableCacheDataSource<'a> { } fn get_table_row_count(&self, table: &str) -> ExecutionResult { + if !self.is_subset_table(table) { + let store = self + .cache + .get_table(table) + .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + return Ok(store.len()); + } + let store = self .cache .get_table(table) .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; - Ok(store.len()) + Ok(store.count_existing_rows_by_ids(self.restricted_row_ids())) } fn get_gin_index_rows( @@ -389,49 +970,91 @@ impl<'a> DataSource for TableCacheDataSource<'a> { .cache .get_table(table) .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; - - Ok(store.gin_index_get_by_key_value(index, key, value)) + if !self.is_subset_table(table) { + return Ok(store.gin_index_get_by_key_value(index, key, value)); + } + let mut rows = Vec::new(); + store.visit_gin_index_by_key_value_restricted( + index, + key, + value, + self.restricted_row_ids(), + self.subset_driven(), + |row| { + rows.push(row.clone()); + true + }, + ); + Ok(rows) } - fn visit_gin_index_rows( + fn get_gin_index_rows_by_key( &self, table: &str, index: &str, key: &str, - value: &str, - mut visitor: F, - ) -> ExecutionResult<()> - where - F: FnMut(&Rc) -> bool, - { + ) -> ExecutionResult>> { let store = self .cache .get_table(table) .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; - - store.visit_gin_index_by_key_value(index, key, value, |row| visitor(row)); - Ok(()) + if !self.is_subset_table(table) { + return Ok(store.gin_index_get_by_key(index, key)); + } + let mut rows = Vec::new(); + store.visit_gin_index_by_key_restricted( + index, + key, + self.restricted_row_ids(), + self.subset_driven(), + |row| { + rows.push(row.clone()); + true + }, + ); + Ok(rows) } - fn get_gin_index_rows_by_key( + fn get_gin_index_rows_multi( &self, table: &str, index: &str, - key: &str, + pairs: &[(&str, &str)], + match_all: bool, ) -> ExecutionResult>> { let store = self .cache .get_table(table) .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + if !self.is_subset_table(table) { + return Ok(if match_all { + store.gin_index_get_by_key_values_all(index, pairs) + } else { + store.gin_index_get_by_key_values_any(index, pairs) + }); + } - Ok(store.gin_index_get_by_key(index, key)) + let mut rows = Vec::new(); + store.visit_gin_index_by_key_values_restricted( + index, + pairs, + match_all, + self.restricted_row_ids(), + self.subset_driven(), + |row| { + rows.push(row.clone()); + true + }, + ); + Ok(rows) } - fn visit_gin_index_rows_by_key( + fn visit_gin_index_rows( &self, table: &str, index: &str, key: &str, + value: &str, mut visitor: F, ) -> ExecutionResult<()> where @@ -441,23 +1064,49 @@ impl<'a> DataSource for TableCacheDataSource<'a> { .cache .get_table(table) .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + if !self.is_subset_table(table) { + store.visit_gin_index_by_key_value(index, key, value, |row| visitor(row)); + return Ok(()); + } - store.visit_gin_index_by_key(index, key, |row| visitor(row)); + store.visit_gin_index_by_key_value_restricted( + index, + key, + value, + self.restricted_row_ids(), + self.subset_driven(), + |row| visitor(row), + ); Ok(()) } - fn get_gin_index_rows_multi( + fn visit_gin_index_rows_by_key( &self, table: &str, index: &str, - pairs: &[(&str, &str)], - ) -> ExecutionResult>> { + key: &str, + mut visitor: F, + ) -> ExecutionResult<()> + where + F: FnMut(&Rc) -> bool, + { let store = self .cache .get_table(table) .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + if !self.is_subset_table(table) { + store.visit_gin_index_by_key(index, key, |row| visitor(row)); + return Ok(()); + } - Ok(store.gin_index_get_by_key_values_all(index, pairs)) + store.visit_gin_index_by_key_restricted( + index, + key, + self.restricted_row_ids(), + self.subset_driven(), + |row| visitor(row), + ); + Ok(()) } fn visit_gin_index_rows_multi( @@ -465,6 +1114,7 @@ impl<'a> DataSource for TableCacheDataSource<'a> { table: &str, index: &str, pairs: &[(&str, &str)], + match_all: bool, mut visitor: F, ) -> ExecutionResult<()> where @@ -474,8 +1124,23 @@ impl<'a> DataSource for TableCacheDataSource<'a> { .cache .get_table(table) .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; + if !self.is_subset_table(table) { + if match_all { + store.visit_gin_index_by_key_values_all(index, pairs, |row| visitor(row)); + } else { + store.visit_gin_index_by_key_values_any(index, pairs, |row| visitor(row)); + } + return Ok(()); + } - store.visit_gin_index_by_key_values_all(index, pairs, |row| visitor(row)); + store.visit_gin_index_by_key_values_restricted( + index, + pairs, + match_all, + self.restricted_row_ids(), + self.subset_driven(), + |row| visitor(row), + ); Ok(()) } } @@ -491,14 +1156,16 @@ fn register_table_context(cache: &TableCache, ctx: &mut ExecutionContext, table_ cynos_core::schema::IndexType::BTree => QueryIndexType::BTree, cynos_core::schema::IndexType::Gin => QueryIndexType::Gin, }; - indexes.push( - IndexInfo::new( - idx.name(), - idx.columns().iter().map(|c| c.name.clone()).collect(), - idx.is_unique(), - ) - .with_type(index_type), - ); + let mut index_info = IndexInfo::new( + idx.name(), + idx.columns().iter().map(|c| c.name.clone()).collect(), + idx.is_unique(), + ) + .with_type(index_type); + if let Some(paths) = idx.gin_paths() { + index_info = index_info.with_gin_paths(paths.to_vec()); + } + indexes.push(index_info); } ctx.register_table( @@ -509,6 +1176,281 @@ fn register_table_context(cache: &TableCache, ctx: &mut ExecutionContext, table_ indexes, }, ); + + for idx in schema.indices() { + if idx.get_index_type() == cynos_core::schema::IndexType::Gin { + continue; + } + if let Some(distinct_keys) = store.secondary_index_distinct_key_count(idx.name()) { + ctx.register_index_distinct_count(table_name, idx.name(), distinct_keys); + } + } + } +} + +fn register_restricted_relation_intent( + cache: &TableCache, + ctx: &mut ExecutionContext, + restricted_table: &str, + profile: RootSubsetPlanningProfile, +) { + let table_rows = cache + .get_table(restricted_table) + .map(|store| store.len()) + .unwrap_or(0); + let effective_subset_rows = match profile.variant { + RootSubsetPlanVariant::Small => { + if table_rows == 0 { + 0 + } else { + table_rows.min(1024) + } + } + RootSubsetPlanVariant::Large => { + if table_rows == 0 { + return; + } + let lower_bound = 8193usize.min(table_rows.max(1)); + let quarter = core::cmp::max(table_rows / 4, 1); + core::cmp::max(lower_bound, quarter) + } + }; + let subset_fraction = if table_rows > 0 { + Some(effective_subset_rows as f64 / table_rows as f64) + } else { + None + }; + + ctx.set_planner_feature_flags(PlannerFeatureFlags { + restricted_relation_cbo: true, + }); + ctx.set_planning_intent(PlanningIntent { + mode: PlanningMode::RestrictedRelation, + restricted_table: Some(restricted_table.to_string()), + exact_subset_rows: Some(effective_subset_rows), + subset_fraction, + preferred_access_mode: profile.preferred_access_mode(), + anchor_table: Some(restricted_table.to_string()), + allow_global_fallback: true, + }); + ctx.register_effective_row_count(restricted_table, effective_subset_rows); +} + +fn register_plan_costs(cache: &TableCache, ctx: &mut ExecutionContext, plan: &LogicalPlan) { + register_plan_gin_costs(cache, ctx, plan); +} + +fn register_plan_gin_costs(cache: &TableCache, ctx: &mut ExecutionContext, plan: &LogicalPlan) { + match plan { + LogicalPlan::Filter { input, predicate } => { + register_plan_gin_costs(cache, ctx, input); + register_predicate_gin_costs(cache, ctx, predicate); + } + LogicalPlan::Project { input, .. } + | LogicalPlan::Aggregate { input, .. } + | LogicalPlan::Sort { input, .. } + | LogicalPlan::Limit { input, .. } => register_plan_gin_costs(cache, ctx, input), + LogicalPlan::Join { + left, + right, + condition, + .. + } => { + register_plan_gin_costs(cache, ctx, left); + register_plan_gin_costs(cache, ctx, right); + register_predicate_gin_costs(cache, ctx, condition); + } + LogicalPlan::CrossProduct { left, right } | LogicalPlan::Union { left, right, .. } => { + register_plan_gin_costs(cache, ctx, left); + register_plan_gin_costs(cache, ctx, right); + } + LogicalPlan::Scan { .. } + | LogicalPlan::IndexScan { .. } + | LogicalPlan::IndexGet { .. } + | LogicalPlan::IndexInGet { .. } + | LogicalPlan::GinIndexScan { .. } + | LogicalPlan::GinIndexScanMulti { .. } + | LogicalPlan::Empty => {} + } +} + +fn register_predicate_gin_costs( + cache: &TableCache, + ctx: &mut ExecutionContext, + predicate: &AstExpr, +) { + match predicate { + AstExpr::BinaryOp { + left, + op: BinaryOp::And | BinaryOp::Or, + right, + } => { + register_predicate_gin_costs(cache, ctx, left); + register_predicate_gin_costs(cache, ctx, right); + } + AstExpr::Function { name, args } => { + let Some(request) = extract_gin_cost_request(name, args) else { + return; + }; + let Some(index) = ctx + .find_gin_index_for_path(&request.table, &request.column, &request.path) + .or_else(|| ctx.find_gin_index(&request.table, &request.column)) + else { + return; + }; + let index_name = index.name.clone(); + let Some(store) = cache.get_table(&request.table) else { + return; + }; + + ctx.register_gin_key_cost( + &request.table, + &index_name, + &request.path, + store.gin_index_cost_key(&index_name, &request.path), + ); + + if let Some(value) = &request.value { + ctx.register_gin_key_value_cost( + &request.table, + &index_name, + &request.path, + value, + store.gin_index_cost_key_value(&index_name, &request.path, value), + ); + } + + for (key, value) in request.prefilter_pairs { + ctx.register_gin_key_value_cost( + &request.table, + &index_name, + &key, + &value, + store.gin_index_cost_key_value(&index_name, &key, &value), + ); + } + } + _ => {} + } +} + +struct GinCostRequest { + table: String, + column: String, + path: String, + value: Option, + prefilter_pairs: Vec<(String, String)>, +} + +fn extract_gin_cost_request(name: &str, args: &[AstExpr]) -> Option { + let upper = name.to_uppercase(); + match upper.as_str() { + "JSONB_PATH_EQ" if args.len() >= 3 => { + let column = match args.first()? { + AstExpr::Column(column) => column, + _ => return None, + }; + let path = normalize_gin_path(&extract_string_literal(args.get(1)?)?)?; + let value = extract_literal_string(args.get(2)?)?; + Some(GinCostRequest { + table: column.table.clone(), + column: column.column.clone(), + path, + value: Some(value), + prefilter_pairs: Vec::new(), + }) + } + "JSONB_EXISTS" if args.len() >= 2 => { + let column = match args.first()? { + AstExpr::Column(column) => column, + _ => return None, + }; + let path = normalize_gin_path(&extract_string_literal(args.get(1)?)?)?; + Some(GinCostRequest { + table: column.table.clone(), + column: column.column.clone(), + path, + value: None, + prefilter_pairs: Vec::new(), + }) + } + "JSONB_CONTAINS" if args.len() >= 3 => { + let column = match args.first()? { + AstExpr::Column(column) => column, + _ => return None, + }; + let path = normalize_gin_path(&extract_string_literal(args.get(1)?)?)?; + let needle = extract_string_literal(args.get(2)?)?; + Some(GinCostRequest { + table: column.table.clone(), + column: column.column.clone(), + path: path.clone(), + value: None, + prefilter_pairs: contains_trigram_pairs(&path, &needle), + }) + } + _ => None, + } +} + +fn extract_string_literal(expr: &AstExpr) -> Option { + match expr { + AstExpr::Literal(Value::String(value)) => Some(value.clone()), + _ => None, + } +} + +fn extract_literal_string(expr: &AstExpr) -> Option { + match expr { + AstExpr::Literal(Value::String(value)) => Some(value.clone()), + AstExpr::Literal(Value::Int32(value)) => Some(value.to_string()), + AstExpr::Literal(Value::Int64(value)) => Some(value.to_string()), + AstExpr::Literal(Value::Boolean(value)) => { + Some(if *value { "true" } else { "false" }.into()) + } + AstExpr::Literal(Value::Float64(value)) => Some(value.to_string()), + _ => None, + } +} + +fn normalize_gin_path(path: &str) -> Option { + let parsed = JsonPath::parse(path).ok()?; + let mut segments = Vec::new(); + if !collect_gin_path_segments(&parsed, &mut segments) || segments.is_empty() { + return None; + } + + let mut normalized = String::new(); + for segment in segments { + if !normalized.is_empty() { + normalized.push('.'); + } + normalized.push_str(&segment); + } + Some(normalized) +} + +fn collect_gin_path_segments(path: &JsonPath, segments: &mut Vec) -> bool { + match path { + JsonPath::Root => true, + JsonPath::Field(parent, field) => { + if !collect_gin_path_segments(parent, segments) { + return false; + } + segments.push(field.clone()); + true + } + JsonPath::Index(parent, index) => { + if !collect_gin_path_segments(parent, segments) { + return false; + } + segments.push(index.to_string()); + true + } + JsonPath::Slice(_, _, _) + | JsonPath::RecursiveField(_, _) + | JsonPath::Wildcard(_) + | JsonPath::Filter(_, _) => false, } } @@ -527,6 +1469,20 @@ pub fn build_execution_context_for_plan( cache: &TableCache, table_name: &str, plan: &LogicalPlan, +) -> ExecutionContext { + build_execution_context_for_plan_with_profile( + cache, + table_name, + plan, + CompilePlanProfile::Default, + ) +} + +pub(crate) fn build_execution_context_for_plan_with_profile( + cache: &TableCache, + table_name: &str, + plan: &LogicalPlan, + profile: CompilePlanProfile, ) -> ExecutionContext { let mut ctx = ExecutionContext::new(); let mut tables = plan.collect_tables(); @@ -538,6 +1494,11 @@ pub fn build_execution_context_for_plan( register_table_context(cache, &mut ctx, &table); } + if let CompilePlanProfile::RootSubset(root_subset_profile) = profile { + register_restricted_relation_intent(cache, &mut ctx, table_name, root_subset_profile); + } + register_plan_costs(cache, &mut ctx, plan); + ctx } @@ -572,7 +1533,12 @@ fn execute_plan_internal( _debug: bool, ) -> ExecutionResult>> { // Build execution context with index info - let ctx = build_execution_context_for_plan(cache, table_name, &plan); + let ctx = build_execution_context_for_plan_with_profile( + cache, + table_name, + &plan, + CompilePlanProfile::Default, + ); // Use unified QueryPlanner for complete optimization pipeline let planner = QueryPlanner::new(ctx); @@ -589,11 +1555,26 @@ fn execute_plan_internal( /// Compiles a logical plan to a physical plan. /// The physical plan can be cached and reused for repeated executions. pub fn compile_plan(cache: &TableCache, table_name: &str, plan: LogicalPlan) -> PhysicalPlan { - // Build execution context with index info - let ctx = build_execution_context_for_plan(cache, table_name, &plan); + compile_plan_with_profile(cache, table_name, plan, CompilePlanProfile::Default) +} - // Use unified QueryPlanner for complete optimization pipeline - let planner = QueryPlanner::new(ctx); +pub(crate) fn compile_plan_with_profile( + cache: &TableCache, + table_name: &str, + plan: LogicalPlan, + profile: CompilePlanProfile, +) -> PhysicalPlan { + // Build execution context with index info + let ctx = build_execution_context_for_plan_with_profile(cache, table_name, &plan, profile); + + // Use a dedicated planner profile when subset refresh wants the declared root table + // to remain the execution driver. + let planner = match profile { + CompilePlanProfile::Default => QueryPlanner::new(ctx), + CompilePlanProfile::RootSubset(_) => { + QueryPlanner::for_profile(ctx, PlannerProfile::RootSubset) + } + }; planner.plan(plan) } @@ -603,7 +1584,16 @@ pub fn compile_cached_plan( table_name: &str, plan: LogicalPlan, ) -> CompiledPhysicalPlan { - let physical_plan = compile_plan(cache, table_name, plan); + compile_cached_plan_with_profile(cache, table_name, plan, CompilePlanProfile::Default) +} + +pub(crate) fn compile_cached_plan_with_profile( + cache: &TableCache, + table_name: &str, + plan: LogicalPlan, + profile: CompilePlanProfile, +) -> CompiledPhysicalPlan { + let physical_plan = compile_plan_with_profile(cache, table_name, plan, profile); let data_source = TableCacheDataSource::new(cache); CompiledPhysicalPlan::new_with_data_source(physical_plan, &data_source) } @@ -666,6 +1656,18 @@ pub fn execute_compiled_physical_plan( runner.execute_with_artifact_row_vec(compiled_plan.physical_plan(), compiled_plan.artifact()) } +#[doc(hidden)] +pub fn execute_compiled_physical_plan_on_table_subset( + cache: &TableCache, + compiled_plan: &CompiledPhysicalPlan, + subset_table: &str, + allowed_row_ids: &HashSet, +) -> ExecutionResult>> { + let data_source = TableSubsetDataSource::try_new(cache, subset_table, allowed_row_ids)?; + let runner = PhysicalPlanRunner::new(&data_source); + runner.execute_with_artifact_row_vec(compiled_plan.physical_plan(), compiled_plan.artifact()) +} + #[doc(hidden)] pub fn execute_physical_plan_with_summary( cache: &TableCache, @@ -713,6 +1715,33 @@ pub fn execute_compiled_physical_plan_with_summary( }) } +#[doc(hidden)] +pub fn execute_compiled_physical_plan_with_summary_on_table_subset( + cache: &TableCache, + compiled_plan: &CompiledPhysicalPlan, + subset_table: &str, + allowed_row_ids: &HashSet, +) -> ExecutionResult { + let data_source = TableSubsetDataSource::try_new(cache, subset_table, allowed_row_ids)?; + let runner = PhysicalPlanRunner::new(&data_source); + let mut rows = Vec::new(); + let mut summary = QueryResultSummaryBuilder::default(); + runner.execute_with_artifact_rows( + compiled_plan.physical_plan(), + compiled_plan.artifact(), + |row| { + summary.push(row.as_ref()); + rows.push(row); + Ok(true) + }, + )?; + + Ok(QueryExecutionOutput { + rows, + summary: summary.finish(), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -890,7 +1919,7 @@ mod tests { .unwrap() .add_primary_key(&["id"], false) .unwrap() - .add_index("idx_metadata_gin", &["metadata"], false) + .add_jsonb_index("idx_metadata_gin", "metadata", &["$.tags", "$.category"]) .unwrap() .build() .unwrap(); @@ -953,6 +1982,72 @@ mod tests { assert!(ctx.find_index("orders", &["user_id"]).is_some()); } + #[test] + fn test_root_subset_profile_registers_effective_row_count_and_intent() { + let cache = create_join_test_cache(); + let plan = LogicalPlan::inner_join( + LogicalPlan::scan("orders"), + LogicalPlan::scan("users"), + AstExpr::eq( + AstExpr::column("orders", "user_id", 1), + AstExpr::column("users", "id", 0), + ), + ); + + let ctx = build_execution_context_for_plan_with_profile( + &cache, + "orders", + &plan, + CompilePlanProfile::RootSubset(RootSubsetPlanningProfile::small()), + ); + + assert_eq!(ctx.base_row_count("orders"), 4096); + assert_eq!(ctx.row_count("orders"), 1024); + assert!(ctx.is_restricted_relation("orders")); + assert!(ctx.is_anchor_relation("orders")); + assert!(ctx.restricted_relation_cbo_enabled()); + } + + #[test] + fn test_choose_root_subset_plan_variant_uses_shared_thresholds() { + assert_eq!(choose_root_subset_plan_variant(64, 10_000), RootSubsetPlanVariant::Small); + assert_eq!( + choose_root_subset_plan_variant(2_500, 10_000), + RootSubsetPlanVariant::Small + ); + assert_eq!( + choose_root_subset_plan_variant(8_193, 10_000), + RootSubsetPlanVariant::Large + ); + assert_eq!( + choose_root_subset_plan_variant(10_000, 30_000), + RootSubsetPlanVariant::Large + ); + } + + #[test] + fn test_execution_context_registers_gin_costs_for_literal_predicates() { + let cache = create_jsonb_test_cache(); + let plan = LogicalPlan::filter( + LogicalPlan::scan("documents"), + AstExpr::jsonb_path_eq( + AstExpr::column("documents", "metadata", 1), + "$.category", + Value::String("tech".into()), + ), + ); + + let ctx = build_execution_context_for_plan(&cache, "documents", &plan); + let gin_index = ctx + .find_gin_index("documents", "metadata") + .expect("expected registered GIN index on metadata column"); + assert!( + ctx.gin_key_value_cost("documents", &gin_index.name, "category", "tech") + .is_some(), + "expected registered GIN posting cost entry" + ); + } + #[test] fn test_compile_plan_prefers_index_join_for_small_outer_fk_join() { let cache = create_join_test_cache(); @@ -1030,6 +2125,34 @@ mod tests { assert_eq!(actual_ids, alloc::vec![1, 3]); } + #[test] + fn test_compiled_plan_table_subset_respects_jsonb_gin_predicate() { + let cache = create_jsonb_test_cache(); + let compiled = compile_cached_plan( + &cache, + "documents", + LogicalPlan::filter( + LogicalPlan::scan("documents"), + AstExpr::jsonb_contains( + AstExpr::column("documents", "metadata", 1), + "$.tags", + Value::String("portable".into()), + ), + ), + ); + + let subset_rows = execute_compiled_physical_plan_with_summary_on_table_subset( + &cache, + &compiled, + "documents", + &HashSet::from([1u64, 2u64]), + ) + .unwrap(); + + let row_ids: Vec = subset_rows.rows.iter().map(|row| row.id()).collect(); + assert_eq!(row_ids, alloc::vec![1]); + } + #[test] fn test_execute_physical_plan_matches_legacy_runner_for_join_project_limit() { let cache = create_join_test_cache(); diff --git a/crates/database/src/reactive_bridge.rs b/crates/database/src/reactive_bridge.rs index 07216ef..056bef3 100644 --- a/crates/database/src/reactive_bridge.rs +++ b/crates/database/src/reactive_bridge.rs @@ -12,20 +12,39 @@ //! Otherwise, falls back to re-query. use crate::binary_protocol::{BinaryEncoder, BinaryResult, SchemaLayout}; -use crate::convert::{gql_response_to_js, row_to_js, value_to_js}; +use crate::convert::{ + gql_response_to_js_with_cache, gql_response_to_js_with_root_list_patch, value_to_js, + GraphqlJsEncodeCache, GraphqlRootListJsCache, +}; +use crate::live_runtime::{ + RowsSnapshotDependencyGraph, RowsSnapshotLookupPrimitive, RowsSnapshotOrderKey, + RowsSnapshotPartialRefreshMetadata, RowsSnapshotPartialRefreshState, + RowsSnapshotRootSubsetMetadata, RowsSnapshotRootSubsetPlan, +}; +use crate::profiling::{ + now_ms, IvmBridgeProfile, IvmBridgeProfiler, SnapshotQueryProfile, SnapshotRefreshMode, +}; +#[cfg(feature = "benchmark")] +use crate::profiling::{GraphqlDeltaProfile, GraphqlSnapshotQueryProfile}; use crate::query_engine::{ - execute_compiled_physical_plan_with_summary, CompiledPhysicalPlan, QueryResultSummary, + choose_root_subset_plan_variant, + execute_compiled_physical_plan_on_table_subset, execute_compiled_physical_plan_with_summary, + CompiledPhysicalPlan, QueryResultSummary, RootSubsetPlanVariant, }; use alloc::boxed::Box; use alloc::rc::Rc; use alloc::string::String; use alloc::vec::Vec; use core::cell::RefCell; +use core::cmp::Ordering; use cynos_core::schema::Table; use cynos_core::{Row, Value}; -use cynos_incremental::{DataflowNode, Delta, MaterializedView, TableId}; +use cynos_incremental::{ + DataflowNode, Delta, MaterializedView, TableId, TraceDeltaBatch, TraceTupleArena, + TraceTupleHandle, +}; use cynos_reactive::ObservableQuery; -use cynos_storage::TableCache; +use cynos_storage::{RowStore, TableCache}; use hashbrown::{HashMap, HashSet}; use wasm_bindgen::prelude::*; @@ -62,9 +81,480 @@ fn query_results_equal( }) } +fn encode_rc_rows_to_binary(rows: &[Rc], binary_layout: &SchemaLayout) -> BinaryResult { + encode_rc_rows_iter_to_binary(rows.iter(), rows.len(), binary_layout) +} + +fn encode_rc_rows_iter_to_binary<'a, I>( + rows: I, + row_count: usize, + binary_layout: &SchemaLayout, +) -> BinaryResult +where + I: IntoIterator>, +{ + let mut encoder = BinaryEncoder::new(binary_layout.clone(), row_count); + encoder.encode_rows_iter(rows); + BinaryResult::new(encoder.finish()) +} + +fn binary_result_to_js_value(result: BinaryResult) -> JsValue { + result.into() +} + +#[derive(Clone)] +struct PartialRefreshCandidateRow { + row: Rc, + root_row_id: u64, +} + +struct SnapshotPartialRefreshRuntime { + metadata: RowsSnapshotPartialRefreshMetadata, + candidate_rows: Vec, +} + +impl SnapshotPartialRefreshRuntime { + fn new(cache: &TableCache, state: RowsSnapshotPartialRefreshState) -> Self { + let candidate_rows = build_partial_refresh_candidate_rows( + cache, + &state.metadata, + &state.initial_candidate_rows, + ) + .expect("partial refresh candidate rows should be resolvable"); + Self { + metadata: state.metadata, + candidate_rows, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum SnapshotRootKey { + Empty, + One(Value), + Many(Vec), +} + +impl SnapshotRootKey { + fn from_values(mut values: Vec) -> Self { + match values.len() { + 0 => Self::Empty, + 1 => Self::One(values.pop().unwrap()), + _ => Self::Many(values), + } + } + + fn from_output_row(row: &Row, indices: &[usize]) -> Option { + let mut values = Vec::with_capacity(indices.len()); + for &index in indices { + values.push(row.get(index)?.clone()); + } + Some(Self::from_values(values)) + } + + fn from_store_row(row: &Row, indices: &[usize]) -> Option { + let mut values = Vec::with_capacity(indices.len()); + for &column_index in indices { + values.push(row.get(column_index)?.clone()); + } + Some(Self::from_values(values)) + } +} + +struct RootSubsetRefreshRuntime { + compiled_plans: crate::live_runtime::RowsSnapshotRootSubsetVariants, + metadata: RowsSnapshotRootSubsetMetadata, + row_id_to_root_key: HashMap, + root_ordinals: HashMap, + next_root_ordinal: usize, + root_rows: HashMap>>, + visible_root_order: Vec, +} + +struct SnapshotRootRefreshOutcome { + changed: bool, + dirty_root_rows: HashSet, +} + +impl RootSubsetRefreshRuntime { + fn new( + cache: &TableCache, + plan: RowsSnapshotRootSubsetPlan, + initial_rows: &[Rc], + ) -> Option { + let RowsSnapshotRootSubsetPlan { + metadata, + compiled_plans, + } = plan; + let store = cache.get_table(&metadata.root_table)?; + + let mut row_id_to_root_key = HashMap::new(); + let mut root_ordinals = HashMap::new(); + let mut next_root_ordinal = 0usize; + store.visit_rows(|row| { + if let Some(root_key) = + SnapshotRootKey::from_store_row(row, &metadata.root_pk_store_indices) + { + row_id_to_root_key.insert(row.id(), root_key.clone()); + if !root_ordinals.contains_key(&root_key) { + root_ordinals.insert(root_key, next_root_ordinal); + next_root_ordinal += 1; + } + } + true + }); + + let mut root_rows = HashMap::new(); + let mut visible_root_order = Vec::new(); + let mut seen_roots = HashSet::new(); + let mut last_root_key: Option = None; + + for row in initial_rows { + let root_key = + SnapshotRootKey::from_output_row(row.as_ref(), &metadata.root_pk_output_indices)?; + if last_root_key.as_ref() != Some(&root_key) && seen_roots.contains(&root_key) { + return None; + } + if seen_roots.insert(root_key.clone()) { + visible_root_order.push(root_key.clone()); + } + root_rows + .entry(root_key.clone()) + .or_insert_with(Vec::new) + .push(row.clone()); + last_root_key = Some(root_key); + } + + Some(Self { + compiled_plans, + metadata, + row_id_to_root_key, + root_ordinals, + next_root_ordinal, + root_rows, + visible_root_order, + }) + } + + fn select_variant( + &self, + cache: &TableCache, + affected_root_ids: &HashSet, + ) -> RootSubsetPlanVariant { + let table_row_count = cache + .get_table(&self.metadata.root_table) + .map(|store| store.len()) + .unwrap_or(0); + choose_root_subset_plan_variant(affected_root_ids.len(), table_row_count) + } + + fn select_compiled_plan<'a>( + &'a self, + cache: &TableCache, + affected_root_ids: &HashSet, + ) -> &'a CompiledPhysicalPlan { + self.compiled_plans + .select(self.select_variant(cache, affected_root_ids)) + } + + fn collect_affected_root_keys( + &mut self, + cache: &TableCache, + affected_root_ids: &HashSet, + refresh_existing: bool, + ) -> Option> { + let store = cache.get_table(&self.metadata.root_table)?; + let mut affected_keys = HashSet::new(); + + for &row_id in affected_root_ids { + if let Some(existing) = self.row_id_to_root_key.get(&row_id).cloned() { + affected_keys.insert(existing); + if !refresh_existing { + continue; + } + } + + if let Some(row) = store.get(row_id) { + let root_key = SnapshotRootKey::from_store_row( + row.as_ref(), + &self.metadata.root_pk_store_indices, + )?; + affected_keys.insert(root_key.clone()); + if !self.root_ordinals.contains_key(&root_key) { + self.root_ordinals + .insert(root_key.clone(), self.next_root_ordinal); + self.next_root_ordinal += 1; + } + self.row_id_to_root_key.insert(row_id, root_key); + } else { + self.row_id_to_root_key.remove(&row_id); + } + } + + Some(affected_keys) + } + + fn apply_subset_rows( + &mut self, + affected_root_keys: &HashSet, + rows: Vec>, + ) -> Option>> { + let mut recomputed_groups: HashMap>> = HashMap::new(); + for row in rows { + let root_key = SnapshotRootKey::from_output_row( + row.as_ref(), + &self.metadata.root_pk_output_indices, + )?; + recomputed_groups + .entry(root_key) + .or_insert_with(Vec::new) + .push(row); + } + + let mut next_visible_root_order = Vec::with_capacity( + self.visible_root_order + .len() + .saturating_add(recomputed_groups.len()), + ); + for root_key in affected_root_keys { + if !recomputed_groups.contains_key(root_key) { + self.root_rows.remove(root_key); + } + } + + for root_key in self.visible_root_order.drain(..) { + if affected_root_keys.contains(&root_key) { + if let Some(group) = recomputed_groups.remove(&root_key) { + self.root_rows.insert(root_key.clone(), group); + next_visible_root_order.push(root_key); + } + continue; + } + + next_visible_root_order.push(root_key); + } + + let mut new_root_keys: Vec = recomputed_groups.keys().cloned().collect(); + new_root_keys.sort_by_key(|root_key| { + self.root_ordinals + .get(root_key) + .copied() + .unwrap_or(usize::MAX) + }); + + for root_key in new_root_keys { + let group = recomputed_groups.remove(&root_key).unwrap_or_default(); + self.root_rows.insert(root_key.clone(), group); + let ordinal = self + .root_ordinals + .get(&root_key) + .copied() + .unwrap_or(usize::MAX); + let insert_at = next_visible_root_order + .iter() + .position(|candidate| { + self.root_ordinals + .get(candidate) + .copied() + .unwrap_or(usize::MAX) + > ordinal + }) + .unwrap_or(next_visible_root_order.len()); + next_visible_root_order.insert(insert_at, root_key); + } + + self.visible_root_order = next_visible_root_order; + + let total_rows = self + .visible_root_order + .iter() + .filter_map(|root_key| self.root_rows.get(root_key).map(Vec::len)) + .sum(); + let mut result = Vec::with_capacity(total_rows); + for root_key in &self.visible_root_order { + if let Some(group) = self.root_rows.get(root_key) { + result.extend(group.iter().cloned()); + } + } + Some(result) + } +} + +fn slice_partial_visible_rows( + candidate_rows: &[PartialRefreshCandidateRow], + metadata: &RowsSnapshotPartialRefreshMetadata, +) -> Vec> { + candidate_rows + .iter() + .skip(metadata.visible_offset) + .take(metadata.visible_limit) + .map(|entry| entry.row.clone()) + .collect() +} + +fn compare_partial_refresh_values( + left: Option<&Value>, + right: Option<&Value>, + order: cynos_query::ast::SortOrder, +) -> Ordering { + let cmp = match (left, right) { + (Some(left), Some(right)) => left.cmp(right), + (None, Some(_)) => Ordering::Less, + (Some(_), None) => Ordering::Greater, + (None, None) => Ordering::Equal, + }; + + match order { + cynos_query::ast::SortOrder::Asc => cmp, + cynos_query::ast::SortOrder::Desc => cmp.reverse(), + } +} + +fn compare_partial_refresh_rows( + left: &PartialRefreshCandidateRow, + right: &PartialRefreshCandidateRow, + order_keys: &[RowsSnapshotOrderKey], +) -> Ordering { + for order_key in order_keys { + let cmp = compare_partial_refresh_values( + left.row.get(order_key.output_index), + right.row.get(order_key.output_index), + order_key.order, + ); + if cmp != Ordering::Equal { + return cmp; + } + } + + left.root_row_id + .cmp(&right.root_row_id) + .then_with(|| left.row.values().cmp(right.row.values())) + .then_with(|| left.row.version().cmp(&right.row.version())) +} + +fn insert_row_ids_by_column_values( + store: &RowStore, + column_index: usize, + lookup: &RowsSnapshotLookupPrimitive, + values: &HashSet, + row_ids: &mut HashSet, +) -> bool { + if values.is_empty() { + return false; + } + + let mut added = false; + match lookup { + RowsSnapshotLookupPrimitive::PrimaryKey => { + for value in values { + store.visit_row_ids_by_pk_values(core::slice::from_ref(value), |row_id| { + added |= row_ids.insert(row_id); + true + }); + } + } + RowsSnapshotLookupPrimitive::SingleColumnIndex { index_name } => { + for value in values { + store.visit_index_row_ids_by_value(index_name, value, |row_id| { + added |= row_ids.insert(row_id); + true + }); + } + } + RowsSnapshotLookupPrimitive::ScanFallback => { + store.visit_rows(|row| { + if row + .get(column_index) + .map(|candidate| values.iter().any(|value| candidate.sql_eq(value))) + .unwrap_or(false) + { + added |= row_ids.insert(row.id()); + } + true + }); + } + } + + added +} + +fn collect_join_values_by_row_ids( + store: &RowStore, + row_ids: &[u64], + column_index: usize, + join_values: &mut HashSet, +) { + join_values.clear(); + store.visit_rows_by_ids(row_ids, |row| { + if let Some(value) = row.get(column_index) { + if !value.is_null() { + join_values.insert(value.clone()); + } + } + true + }); +} + +fn collect_join_values_by_row_ids_and_deltas( + store: &RowStore, + row_ids: &[u64], + deltas: Option<&Vec>>, + column_index: usize, + join_values: &mut HashSet, +) { + collect_join_values_by_row_ids(store, row_ids, column_index, join_values); + let Some(deltas) = deltas else { + return; + }; + + for delta in deltas { + if let Some(value) = delta.data().get(column_index) { + if !value.is_null() { + join_values.insert(value.clone()); + } + } + } +} + +fn resolve_root_row_id_for_output_row( + cache: &TableCache, + metadata: &RowsSnapshotPartialRefreshMetadata, + row: &Rc, +) -> Option { + let store = cache.get_table(&metadata.root_table)?; + let pk_values: Vec = metadata + .root_pk_output_indices + .iter() + .map(|&output_index| row.get(output_index).cloned()) + .collect::>>()?; + if pk_values.iter().any(Value::is_null) { + return None; + } + + store + .get_by_pk_values(&pk_values) + .first() + .map(|root_row| root_row.id()) +} + +fn build_partial_refresh_candidate_rows( + cache: &TableCache, + metadata: &RowsSnapshotPartialRefreshMetadata, + rows: &[Rc], +) -> Option> { + let mut candidate_rows = Vec::with_capacity(rows.len()); + for row in rows { + candidate_rows.push(PartialRefreshCandidateRow { + row: row.clone(), + root_row_id: resolve_root_row_id_for_output_row(cache, metadata, row)?, + }); + } + Some(candidate_rows) +} + #[derive(Default)] struct GraphqlSubscribers { - callbacks: Vec<(usize, Box)>, + callbacks: Vec<(usize, Box)>, keepalive_ids: HashSet, next_sub_id: usize, } @@ -79,7 +569,7 @@ impl GraphqlSubscribers { fn add_callback(&mut self, callback: F) -> usize where - F: Fn(&cynos_gql::GraphqlResponse) + 'static, + F: Fn(&JsValue) + 'static, { let id = self.next_sub_id; self.next_sub_id += 1; @@ -105,9 +595,9 @@ impl GraphqlSubscribers { self.callbacks.len() } - fn emit(&self, response: &cynos_gql::GraphqlResponse) { + fn emit(&self, payload: &JsValue) { for (_, callback) in &self.callbacks { - callback(response); + callback(payload); } } } @@ -135,26 +625,17 @@ fn build_graphql_response_batched( cynos_gql::batch_render::render_graphql_response(cache, catalog, field, plan, state, rows) } -fn build_graphql_response_from_owned_rows( - cache: &TableCache, - catalog: &cynos_gql::GraphqlCatalog, - field: &cynos_gql::bind::BoundRootField, - rows: &[Row], -) -> Result { - let rows: Vec> = rows.iter().cloned().map(Rc::new).collect(); - build_graphql_response(cache, catalog, field, &rows) -} - -fn build_graphql_response_from_owned_rows_batched( +fn build_graphql_response_batched_refs( cache: &TableCache, catalog: &cynos_gql::GraphqlCatalog, field: &cynos_gql::bind::BoundRootField, plan: &cynos_gql::GraphqlBatchPlan, state: &mut cynos_gql::GraphqlBatchState, - rows: &[Row], + rows: &[&Rc], ) -> Result { - let rows: Vec> = rows.iter().cloned().map(Rc::new).collect(); - build_graphql_response_batched(cache, catalog, field, plan, state, &rows) + cynos_gql::batch_render::render_graphql_response_refs( + cache, catalog, field, plan, state, rows, + ) } fn root_field_has_relations(field: &cynos_gql::bind::BoundRootField) -> bool { @@ -186,6 +667,7 @@ fn build_snapshot_batch_invalidation( table_names: &HashMap, changes: &HashMap>, root_changed: bool, + dirty_root_rows: &HashSet, ) -> Result { let mut changed_tables = Vec::with_capacity(changes.len()); let mut dirty_table_rows = HashMap::new(); @@ -201,6 +683,8 @@ fn build_snapshot_batch_invalidation( Ok(cynos_gql::GraphqlInvalidation { root_changed, + dirty_root_rows: dirty_root_rows.clone(), + stable_root_positions: false, changed_tables, dirty_edge_keys: HashMap::new(), dirty_table_rows, @@ -221,6 +705,8 @@ fn build_delta_batch_invalidation( let mut invalidation = cynos_gql::GraphqlInvalidation { root_changed, + dirty_root_rows: HashSet::new(), + stable_root_positions: false, changed_tables: alloc::vec![table_name.clone()], dirty_edge_keys: HashMap::new(), dirty_table_rows: HashMap::from([(table_name.clone(), dirty_row_ids)]), @@ -252,8 +738,60 @@ fn build_delta_batch_invalidation( Ok(invalidation) } -fn graphql_response_to_js_value(response: &cynos_gql::GraphqlResponse) -> JsValue { - gql_response_to_js(response).unwrap_or(JsValue::NULL) +fn output_deltas_preserve_root_positions(output_deltas: &[Delta]) -> bool { + if output_deltas.is_empty() { + return true; + } + + let mut insert_ids = HashSet::new(); + let mut delete_ids = HashSet::new(); + for delta in output_deltas { + if delta.is_insert() { + insert_ids.insert(delta.data.id()); + } else { + delete_ids.insert(delta.data.id()); + } + } + + !insert_ids.is_empty() + && insert_ids.len() == delete_ids.len() + && insert_ids == delete_ids + && output_deltas.len() == insert_ids.len().saturating_mul(2) +} + +fn graphql_response_to_js_value( + response: &cynos_gql::GraphqlResponse, + encode_cache: &mut GraphqlJsEncodeCache, +) -> JsValue { + gql_response_to_js_with_cache(response, encode_cache).unwrap_or(JsValue::NULL) +} + +fn graphql_response_to_js_value_batched( + response: &cynos_gql::GraphqlResponse, + encode_cache: &mut GraphqlJsEncodeCache, + root_list_cache: &mut GraphqlRootListJsCache, + patch: Option<&cynos_gql::GraphqlRootListPatch>, +) -> JsValue { + gql_response_to_js_with_root_list_patch(response, encode_cache, root_list_cache, patch) + .unwrap_or(JsValue::NULL) +} + +fn batch_response_changed(state: &cynos_gql::GraphqlBatchState) -> Option { + match state.last_root_patch() { + Some(cynos_gql::GraphqlRootListPatch::StablePositions(positions)) => { + Some(!positions.is_empty()) + } + Some(cynos_gql::GraphqlRootListPatch::Splice { + removed_positions, + inserted_positions, + updated_positions, + }) => Some( + !removed_positions.is_empty() + || !inserted_positions.is_empty() + || !updated_positions.is_empty(), + ), + None => None, + } } /// A re-query based observable that re-executes the query on each change. @@ -266,10 +804,16 @@ pub struct ReQueryObservable { compiled_plan: CompiledPhysicalPlan, /// Reference to the table cache cache: Rc>, + /// Table ID -> table name bindings for dependency-aware invalidation. + dependency_table_names: HashMap, /// Current result set result: Vec>, /// Summary of the current result set for fast equality checks result_summary: QueryResultSummary, + /// Optional runtime state for candidate-window partial requery. + partial_refresh: Option, + /// Optional runtime state for root-subset requery on no-blocking snapshot queries. + root_subset_refresh: Option, /// Subscription callbacks subscriptions: Vec<(usize, Box]) + 'static>)>, /// Next subscription ID @@ -282,23 +826,47 @@ impl ReQueryObservable { compiled_plan: CompiledPhysicalPlan, cache: Rc>, initial_result: Vec>, + dependency_table_bindings: Vec<(TableId, String)>, ) -> Self { let result_summary = QueryResultSummary::from_rows(&initial_result); - Self::new_with_summary(compiled_plan, cache, initial_result, result_summary) + Self::new_with_summary( + compiled_plan, + cache, + initial_result, + result_summary, + dependency_table_bindings, + None, + None, + ) } #[doc(hidden)] - pub fn new_with_summary( + pub(crate) fn new_with_summary( compiled_plan: CompiledPhysicalPlan, cache: Rc>, initial_result: Vec>, result_summary: QueryResultSummary, + dependency_table_bindings: Vec<(TableId, String)>, + partial_refresh: Option, + root_subset_refresh: Option, ) -> Self { + let (partial_refresh, root_subset_refresh) = { + let cache_ref = cache.borrow(); + let partial_refresh = + partial_refresh.map(|state| SnapshotPartialRefreshRuntime::new(&cache_ref, state)); + let root_subset_refresh = root_subset_refresh.and_then(|metadata| { + RootSubsetRefreshRuntime::new(&cache_ref, metadata, &initial_result) + }); + (partial_refresh, root_subset_refresh) + }; Self { compiled_plan, cache, + dependency_table_names: dependency_table_bindings.into_iter().collect(), result: initial_result, result_summary, + partial_refresh, + root_subset_refresh, subscriptions: Vec::new(), next_sub_id: 0, } @@ -343,133 +911,573 @@ impl ReQueryObservable { /// Only notifies subscribers if the result actually changed. /// Skips re-query entirely if there are no subscribers. /// - /// `changed_ids` contains the row IDs that were modified. - /// For simple single-table pipelines this enables a row-local fast path; - /// all other plans fall back to deterministic full-result comparison. - pub fn on_change(&mut self, changed_ids: &HashSet) { + /// `changes` contains row IDs grouped by the table that changed. + /// For simple single-table pipelines this enables a row-local fast path + /// only when the underlying patch table changed; all other plans fall back + /// to deterministic full-result comparison. + pub fn on_change(&mut self, changes: &HashMap>) { + let _ = self.on_change_profiled_inner(changes, None); + } + + #[allow(dead_code)] + pub(crate) fn on_change_profiled( + &mut self, + changes: &HashMap>, + ) -> SnapshotQueryProfile { + self.on_change_profiled_inner(changes, None) + } + + pub(crate) fn on_change_with_deltas_profiled( + &mut self, + changes: &HashMap>, + delta_changes: &HashMap>>, + ) -> SnapshotQueryProfile { + self.on_change_profiled_inner(changes, Some(delta_changes)) + } + + fn on_change_profiled_inner( + &mut self, + changes: &HashMap>, + delta_changes: Option<&HashMap>>>, + ) -> SnapshotQueryProfile { + let started_at = now_ms(); + let mut profile = SnapshotQueryProfile::default(); + // Skip re-query if no subscribers - major optimization for unused observables if self.subscriptions.is_empty() { - return; + return profile; } - if let Some(changed_rows) = - collect_changed_rows(&self.cache, &self.compiled_plan, changed_ids) - { - match self - .compiled_plan - .apply_reactive_patch(&mut self.result, &changed_rows) + if let Some(changed_ids) = self.patch_table_changed_ids(changes) { + if let Some(changed_rows) = + collect_changed_rows(&self.cache, &self.compiled_plan, changed_ids) { - Some(true) => { - self.result_summary = QueryResultSummary::from_rows(&self.result); - for (_, callback) in &self.subscriptions { - callback(&self.result); + profile.reactive_patch_attempted = true; + let patch_started_at = now_ms(); + match self + .compiled_plan + .apply_reactive_patch(&mut self.result, &changed_rows) + { + Some(true) => { + profile.reactive_patch_hit = true; + profile.reactive_patch_ms = now_ms() - patch_started_at; + self.result_summary = QueryResultSummary::from_rows(&self.result); + let callback_started_at = now_ms(); + for (_, callback) in &self.subscriptions { + callback(&self.result); + } + profile.callback_ms += now_ms() - callback_started_at; + profile.total_ms = now_ms() - started_at; + return profile; } - return; + Some(false) => { + profile.reactive_patch_ms = now_ms() - patch_started_at; + profile.total_ms = now_ms() - started_at; + return profile; + } + None => {} } - Some(false) => return, - None => {} + profile.reactive_patch_ms = now_ms() - patch_started_at; + } + } + + if let Some(changed) = + self.try_partial_refresh_profiled(changes, delta_changes, &mut profile) + { + if changed { + let callback_started_at = now_ms(); + for (_, callback) in &self.subscriptions { + callback(&self.result); + } + profile.callback_ms += now_ms() - callback_started_at; } + profile.total_ms = now_ms() - started_at; + return profile; + } + + if let Some(changed) = + self.try_root_subset_refresh_profiled(changes, delta_changes, &mut profile) + { + if changed { + let callback_started_at = now_ms(); + for (_, callback) in &self.subscriptions { + callback(&self.result); + } + profile.callback_ms += now_ms() - callback_started_at; + } + profile.total_ms = now_ms() - started_at; + return profile; } // Re-execute the cached compiled plan (no optimization or lowering overhead) - let cache = self.cache.borrow(); + profile.refresh_mode = SnapshotRefreshMode::FullRequery; + let full_requery_started_at = now_ms(); + let output = { + let cache = self.cache.borrow(); + execute_compiled_physical_plan_with_summary(&cache, &self.compiled_plan) + }; + profile.full_requery_ms = now_ms() - full_requery_started_at; - match execute_compiled_physical_plan_with_summary(&cache, &self.compiled_plan) { + match output { Ok(output) => { - // Only notify if result changed - if !query_results_equal( - &self.result_summary, - &output.summary, - &self.result, - &output.rows, + if self.apply_full_requery_output_profiled( + output.rows, + output.summary, + &mut profile.full_requery_compare_ms, ) { - self.result = output.rows; - self.result_summary = output.summary; // Notify all subscribers + let callback_started_at = now_ms(); for (_, callback) in &self.subscriptions { callback(&self.result); } + profile.callback_ms += now_ms() - callback_started_at; } } Err(_) => { // Query execution failed, keep old result } } - } -} -pub struct GraphqlSubscriptionObservable { - compiled_plan: CompiledPhysicalPlan, - cache: Rc>, - catalog: cynos_gql::GraphqlCatalog, - field: cynos_gql::bind::BoundRootField, - batch_plan: Option, - batch_state: cynos_gql::GraphqlBatchState, - dependency_table_names: HashMap, - root_table_ids: HashSet, - root_rows: Vec>, - root_summary: QueryResultSummary, - response: Option, - response_dirty: bool, - subscribers: GraphqlSubscribers, -} + profile.total_ms = now_ms() - started_at; + profile + } -impl GraphqlSubscriptionObservable { - pub fn new( - compiled_plan: CompiledPhysicalPlan, - cache: Rc>, - catalog: cynos_gql::GraphqlCatalog, - field: cynos_gql::bind::BoundRootField, - dependency_table_bindings: Vec<(TableId, String)>, - root_table_ids: HashSet, - initial_rows: Vec>, - initial_summary: QueryResultSummary, - ) -> Self { - Self { - compiled_plan, - cache, - batch_plan: cynos_gql::compile_batch_plan(&catalog, &field) - .ok() - .filter(|plan| plan.has_relations()), - batch_state: cynos_gql::GraphqlBatchState::default(), - dependency_table_names: dependency_table_bindings.into_iter().collect(), - catalog, - field, - root_table_ids, - root_rows: initial_rows, - root_summary: initial_summary, - response: None, - response_dirty: true, - subscribers: GraphqlSubscribers::default(), + fn patch_table_changed_ids<'a>( + &self, + changes: &'a HashMap>, + ) -> Option<&'a HashSet> { + let patch_table = self.compiled_plan.reactive_patch_table()?; + if changes.len() != 1 { + return None; } - } - pub fn attach_keepalive(&mut self) -> usize { - self.subscribers.add_keepalive() + let (&table_id, changed_ids) = changes.iter().next()?; + let table_name = self.dependency_table_names.get(&table_id)?; + if table_name == patch_table { + Some(changed_ids) + } else { + None + } } - pub fn response_js_value(&mut self) -> JsValue { - if self.response.is_some() && !self.response_dirty { - return graphql_response_to_js_value(self.response.as_ref().unwrap()); + fn apply_full_requery_output_profiled( + &mut self, + rows: Vec>, + summary: QueryResultSummary, + compare_ms: &mut f64, + ) -> bool { + if let Some(metadata) = self + .partial_refresh + .as_ref() + .map(|partial_refresh| partial_refresh.metadata.clone()) + { + let candidate_rows = { + let cache = self.cache.borrow(); + build_partial_refresh_candidate_rows(&cache, &metadata, &rows) + }; + let Some(candidate_rows) = candidate_rows else { + return false; + }; + let visible_rows = slice_partial_visible_rows(&candidate_rows, &metadata); + let visible_summary = QueryResultSummary::from_rows(&visible_rows); + let compare_started_at = now_ms(); + let changed = !query_results_equal( + &self.result_summary, + &visible_summary, + &self.result, + &visible_rows, + ); + *compare_ms += now_ms() - compare_started_at; + + if let Some(partial_refresh) = &mut self.partial_refresh { + partial_refresh.candidate_rows = candidate_rows; + } + self.result = visible_rows; + self.result_summary = visible_summary; + return changed; } - if self.subscribers.callback_count() == 0 { - return self.render_response_js_value(); + let compare_started_at = now_ms(); + if query_results_equal(&self.result_summary, &summary, &self.result, &rows) { + *compare_ms += now_ms() - compare_started_at; + return false; } + *compare_ms += now_ms() - compare_started_at; - match self.current_response() { - Some(response) => graphql_response_to_js_value(response), - None => JsValue::NULL, - } + self.result = rows; + self.result_summary = summary; + true } - pub fn subscribe( + fn try_partial_refresh_profiled( &mut self, - callback: F, - ) -> usize { - self.subscribers.add_callback(callback) - } - + changes: &HashMap>, + delta_changes: Option<&HashMap>>>, + profile: &mut SnapshotQueryProfile, + ) -> Option { + profile.partial_refresh_attempted = self.partial_refresh.is_some(); + let collect_started_at = now_ms(); + let (affected_root_ids, affected_candidate_rows, partial_metadata, current_candidate_rows) = { + let partial_refresh = self.partial_refresh.as_ref()?; + let affected_root_ids = Self::collect_affected_root_row_ids( + &self.cache, + changes, + delta_changes, + &partial_refresh.metadata.dependency_graph, + )?; + if affected_root_ids.is_empty() { + return Some(false); + } + + let affected_candidate_rows = partial_refresh + .candidate_rows + .iter() + .filter(|entry| affected_root_ids.contains(&entry.root_row_id)) + .count(); + ( + affected_root_ids, + affected_candidate_rows, + partial_refresh.metadata.clone(), + partial_refresh.candidate_rows.clone(), + ) + }; + profile.partial_refresh_collect_ms += now_ms() - collect_started_at; + if affected_candidate_rows > partial_metadata.overscan { + return None; + } + + let requery_started_at = now_ms(); + let recomputed_rows = { + let cache = self.cache.borrow(); + let rows = execute_compiled_physical_plan_on_table_subset( + &cache, + &self.compiled_plan, + &partial_metadata.root_table, + &affected_root_ids, + ) + .ok()?; + build_partial_refresh_candidate_rows(&cache, &partial_metadata, &rows)? + }; + profile.partial_refresh_requery_ms += now_ms() - requery_started_at; + + let apply_started_at = now_ms(); + let unaffected = current_candidate_rows + .iter() + .filter(|entry| !affected_root_ids.contains(&entry.root_row_id)) + .cloned(); + let mut merged_rows: Vec = + unaffected.chain(recomputed_rows.into_iter()).collect(); + merged_rows.sort_by(|left, right| { + compare_partial_refresh_rows(left, right, &partial_metadata.order_keys) + }); + merged_rows.truncate(partial_metadata.candidate_limit); + let min_shadow_window = partial_metadata + .visible_offset + .saturating_add(partial_metadata.visible_limit) + .saturating_add(partial_metadata.overscan); + if merged_rows.len() < min_shadow_window + && current_candidate_rows.len() >= min_shadow_window + { + return None; + } + + let visible_rows = slice_partial_visible_rows(&merged_rows, &partial_metadata); + let visible_summary = QueryResultSummary::from_rows(&visible_rows); + let compare_started_at = now_ms(); + let changed = !query_results_equal( + &self.result_summary, + &visible_summary, + &self.result, + &visible_rows, + ); + profile.partial_refresh_compare_ms += now_ms() - compare_started_at; + + if let Some(partial_refresh) = &mut self.partial_refresh { + partial_refresh.candidate_rows = merged_rows; + } + self.result = visible_rows; + self.result_summary = visible_summary; + profile.partial_refresh_hit = true; + profile.refresh_mode = SnapshotRefreshMode::CandidateWindowPartialRefresh; + profile.partial_refresh_apply_ms += now_ms() - apply_started_at; + Some(changed) + } + + fn try_root_subset_refresh_profiled( + &mut self, + changes: &HashMap>, + delta_changes: Option<&HashMap>>>, + profile: &mut SnapshotQueryProfile, + ) -> Option { + profile.root_subset_attempted = self.root_subset_refresh.is_some(); + let collect_started_at = now_ms(); + let (affected_root_ids, affected_root_keys) = { + let root_subset_refresh = self.root_subset_refresh.as_mut()?; + let affected_root_ids = Self::collect_affected_root_row_ids( + &self.cache, + changes, + delta_changes, + &root_subset_refresh.metadata.dependency_graph, + )?; + if affected_root_ids.is_empty() { + return Some(false); + } + + let cache = self.cache.borrow(); + let refresh_existing_root_keys = + changes.contains_key(&root_subset_refresh.metadata.dependency_graph.root_table_id); + let affected_root_keys = root_subset_refresh.collect_affected_root_keys( + &cache, + &affected_root_ids, + refresh_existing_root_keys, + )?; + (affected_root_ids, affected_root_keys) + }; + profile.root_subset_collect_ms += now_ms() - collect_started_at; + + if affected_root_keys.is_empty() { + return Some(false); + } + + let root_table = self + .root_subset_refresh + .as_ref() + .map(|runtime| runtime.metadata.root_table.clone())?; + let requery_started_at = now_ms(); + let rows = { + let cache = self.cache.borrow(); + let subset_plan = self + .root_subset_refresh + .as_ref() + .map(|runtime| runtime.select_compiled_plan(&cache, &affected_root_ids))?; + execute_compiled_physical_plan_on_table_subset( + &cache, + subset_plan, + &root_table, + &affected_root_ids, + ) + .ok()? + }; + profile.root_subset_requery_ms += now_ms() - requery_started_at; + + let apply_started_at = now_ms(); + let rows = { + let root_subset_refresh = self.root_subset_refresh.as_mut()?; + root_subset_refresh.apply_subset_rows(&affected_root_keys, rows)? + }; + let summary = QueryResultSummary::from_rows(&rows); + let compare_started_at = now_ms(); + let changed = !query_results_equal(&self.result_summary, &summary, &self.result, &rows); + profile.root_subset_compare_ms += now_ms() - compare_started_at; + self.result = rows; + self.result_summary = summary; + profile.root_subset_apply_ms += now_ms() - apply_started_at; + profile.root_subset_hit = true; + profile.refresh_mode = SnapshotRefreshMode::RootSubsetRefresh; + Some(changed) + } + + fn collect_affected_root_row_ids( + cache_ref: &Rc>, + changes: &HashMap>, + delta_changes: Option<&HashMap>>>, + dependency_graph: &RowsSnapshotDependencyGraph, + ) -> Option> { + let cache = cache_ref.borrow(); + let mut dirty_rows_by_table: HashMap> = HashMap::new(); + for (table_id, row_ids) in changes { + if dependency_graph.table_name(*table_id).is_none() { + continue; + } + dirty_rows_by_table + .entry(*table_id) + .or_insert_with(HashSet::new) + .extend(row_ids.iter().copied()); + } + + let mut queue: Vec = dirty_rows_by_table.keys().copied().collect(); + let mut queued_tables: HashSet = queue.iter().copied().collect(); + let mut dirty_row_ids = Vec::new(); + let mut join_values = HashSet::new(); + + while let Some(table_id) = queue.pop() { + queued_tables.remove(&table_id); + let dirty_row_set = dirty_rows_by_table.get(&table_id)?; + if dirty_row_set.is_empty() { + continue; + } + + dirty_row_ids.clear(); + dirty_row_ids.extend(dirty_row_set.iter().copied()); + + let Some(table_name) = dependency_graph.table_name(table_id) else { + continue; + }; + let store = cache.get_table(table_name)?; + let table_deltas = delta_changes.and_then(|changes| changes.get(&table_id)); + if table_id != dependency_graph.root_table_id + && store.count_existing_rows_by_ids(&dirty_row_ids) != dirty_row_ids.len() + && table_deltas.is_none_or(Vec::is_empty) + { + return None; + } + + for edge in dependency_graph.edges_from(table_id) { + collect_join_values_by_row_ids_and_deltas( + store, + &dirty_row_ids, + table_deltas, + edge.source_column_index, + &mut join_values, + ); + if join_values.is_empty() { + continue; + } + + let target_store = cache.get_table(&edge.target_table)?; + let target_entry = dirty_rows_by_table + .entry(edge.target_table_id) + .or_insert_with(HashSet::new); + let added = insert_row_ids_by_column_values( + target_store, + edge.target_column_index, + &edge.lookup, + &join_values, + target_entry, + ); + if added && !queued_tables.contains(&edge.target_table_id) { + queued_tables.insert(edge.target_table_id); + queue.push(edge.target_table_id); + } + } + } + + Some( + dirty_rows_by_table + .remove(&dependency_graph.root_table_id) + .unwrap_or_default(), + ) + } +} + +pub struct GraphqlSubscriptionObservable { + compiled_plan: CompiledPhysicalPlan, + cache: Rc>, + catalog: cynos_gql::GraphqlCatalog, + field: cynos_gql::bind::BoundRootField, + batch_plan: Option, + batch_state: cynos_gql::GraphqlBatchState, + dependency_table_names: HashMap, + root_table_ids: HashSet, + root_rows: Vec>, + root_summary: QueryResultSummary, + root_subset_refresh: Option, + response: Option, + response_js: Option, + response_encode_cache: GraphqlJsEncodeCache, + response_root_list_js_cache: GraphqlRootListJsCache, + response_dirty: bool, + subscribers: GraphqlSubscribers, +} + +impl GraphqlSubscriptionObservable { + pub(crate) fn new( + compiled_plan: CompiledPhysicalPlan, + cache: Rc>, + catalog: cynos_gql::GraphqlCatalog, + field: cynos_gql::bind::BoundRootField, + dependency_table_bindings: Vec<(TableId, String)>, + root_subset_refresh: Option, + root_table_ids: HashSet, + initial_rows: Vec>, + initial_summary: QueryResultSummary, + ) -> Self { + let root_subset_refresh = { + let cache_ref = cache.borrow(); + root_subset_refresh + .and_then(|plan| RootSubsetRefreshRuntime::new(&cache_ref, plan, &initial_rows)) + }; + Self { + compiled_plan, + cache, + batch_plan: cynos_gql::compile_batch_plan(&catalog, &field) + .ok() + .filter(|plan| plan.has_relations()), + batch_state: cynos_gql::GraphqlBatchState::default(), + dependency_table_names: dependency_table_bindings.into_iter().collect(), + catalog, + field, + root_table_ids, + root_rows: initial_rows, + root_summary: initial_summary, + root_subset_refresh, + response: None, + response_js: None, + response_encode_cache: GraphqlJsEncodeCache::default(), + response_root_list_js_cache: GraphqlRootListJsCache::default(), + response_dirty: true, + subscribers: GraphqlSubscribers::default(), + } + } + + pub fn attach_keepalive(&mut self) -> usize { + self.subscribers.add_keepalive() + } + + pub fn response_js_value(&mut self) -> JsValue { + if self.response.is_some() && !self.response_dirty { + if let Some(payload) = &self.response_js { + return payload.clone(); + } + + let payload = match self.batch_plan.as_ref() { + Some(_) => graphql_response_to_js_value_batched( + self.response.as_ref().unwrap(), + &mut self.response_encode_cache, + &mut self.response_root_list_js_cache, + self.batch_state.last_root_patch(), + ), + None => graphql_response_to_js_value( + self.response.as_ref().unwrap(), + &mut self.response_encode_cache, + ), + }; + self.response_js = Some(payload.clone()); + return payload; + } + + if self.subscribers.callback_count() == 0 { + return self.render_response_js_value(); + } + + if self.current_response().is_none() { + return JsValue::NULL; + } + + if let Some(payload) = &self.response_js { + payload.clone() + } else { + let payload = match self.batch_plan.as_ref() { + Some(_) => graphql_response_to_js_value_batched( + self.response.as_ref().unwrap(), + &mut self.response_encode_cache, + &mut self.response_root_list_js_cache, + self.batch_state.last_root_patch(), + ), + None => graphql_response_to_js_value( + self.response.as_ref().unwrap(), + &mut self.response_encode_cache, + ), + }; + self.response_js = Some(payload.clone()); + payload + } + } + + pub fn subscribe(&mut self, callback: F) -> usize { + self.subscribers.add_callback(callback) + } + pub fn unsubscribe(&mut self, id: usize) -> bool { self.subscribers.remove(id) } @@ -483,6 +1491,54 @@ impl GraphqlSubscriptionObservable { } pub fn on_change(&mut self, changes: &HashMap>) { + self.on_change_inner( + changes, + None, + #[cfg(feature = "benchmark")] + None, + ); + } + + #[cfg(feature = "benchmark")] + pub(crate) fn on_change_profiled( + &mut self, + changes: &HashMap>, + ) -> GraphqlSnapshotQueryProfile { + let mut profile = GraphqlSnapshotQueryProfile::default(); + self.on_change_inner(changes, None, Some(&mut profile)); + profile + } + + pub(crate) fn on_change_with_deltas( + &mut self, + changes: &HashMap>, + delta_changes: &HashMap>>, + ) { + self.on_change_inner( + changes, + Some(delta_changes), + #[cfg(feature = "benchmark")] + None, + ); + } + + #[cfg(feature = "benchmark")] + pub(crate) fn on_change_with_deltas_profiled( + &mut self, + changes: &HashMap>, + delta_changes: &HashMap>>, + ) -> GraphqlSnapshotQueryProfile { + let mut profile = GraphqlSnapshotQueryProfile::default(); + self.on_change_inner(changes, Some(delta_changes), Some(&mut profile)); + profile + } + + fn on_change_inner( + &mut self, + changes: &HashMap>, + delta_changes: Option<&HashMap>>>, + #[cfg(feature = "benchmark")] mut profile: Option<&mut GraphqlSnapshotQueryProfile>, + ) { if self.subscribers.total_count() == 0 { return; } @@ -497,12 +1553,23 @@ impl GraphqlSubscriptionObservable { } } + let should_refresh_roots = !root_changed_ids.is_empty() || self.root_subset_refresh.is_some(); let mut root_changed = false; - if !root_changed_ids.is_empty() { - root_changed = match self.refresh_root_rows(&root_changed_ids) { - Some(changed) => changed, + let mut dirty_root_rows = HashSet::new(); + if should_refresh_roots { + #[cfg(feature = "benchmark")] + let refresh_started_at = now_ms(); + let refresh_outcome = + match self.refresh_root_rows(changes, delta_changes, &root_changed_ids) { + Some(outcome) => outcome, None => return, }; + root_changed = refresh_outcome.changed; + dirty_root_rows = refresh_outcome.dirty_root_rows; + #[cfg(feature = "benchmark")] + if let Some(profile) = profile.as_deref_mut() { + profile.root_refresh_ms += now_ms() - refresh_started_at; + } } if !root_changed && !saw_nested_change { @@ -510,48 +1577,100 @@ impl GraphqlSubscriptionObservable { } if let Some(plan) = self.batch_plan.as_ref() { + #[cfg(feature = "benchmark")] + let invalidation_started_at = now_ms(); match build_snapshot_batch_invalidation( &self.dependency_table_names, changes, root_changed, + &dirty_root_rows, ) { Ok(invalidation) => self.batch_state.apply_invalidation(plan, &invalidation), Err(()) => { self.batch_state = cynos_gql::GraphqlBatchState::default(); } } + #[cfg(feature = "benchmark")] + if let Some(profile) = profile.as_deref_mut() { + profile.batch_invalidation_ms += now_ms() - invalidation_started_at; + } } self.response_dirty = true; if self.subscribers.callback_count() == 0 { return; } + #[cfg(feature = "benchmark")] + let render_started_at = now_ms(); if let Some(changed) = self.materialize_response_if_dirty() { + #[cfg(feature = "benchmark")] + if let Some(profile) = profile.as_deref_mut() { + profile.render_ms += now_ms() - render_started_at; + } if changed { - if let Some(response) = self.response.as_ref() { - self.subscribers.emit(response); + if self.response.is_some() { + #[cfg(feature = "benchmark")] + let encode_started_at = now_ms(); + let payload = self.response_js_value(); + #[cfg(feature = "benchmark")] + if let Some(profile) = profile.as_deref_mut() { + profile.encode_ms += now_ms() - encode_started_at; + } + #[cfg(feature = "benchmark")] + let emit_started_at = now_ms(); + self.subscribers.emit(&payload); + #[cfg(feature = "benchmark")] + if let Some(profile) = profile.as_deref_mut() { + profile.emit_ms += now_ms() - emit_started_at; + } } } + } else { + #[cfg(feature = "benchmark")] + if let Some(profile) = profile.as_deref_mut() { + profile.render_ms += now_ms() - render_started_at; + } } } - fn refresh_root_rows(&mut self, changed_ids: &HashSet) -> Option { - if let Some(changed_rows) = - collect_changed_rows(&self.cache, &self.compiled_plan, changed_ids) - { - match self - .compiled_plan - .apply_reactive_patch(&mut self.root_rows, &changed_rows) + fn refresh_root_rows( + &mut self, + changes: &HashMap>, + delta_changes: Option<&HashMap>>>, + changed_ids: &HashSet, + ) -> Option { + let only_root_table_changes = !changes.is_empty() + && changes.keys().all(|table_id| self.root_table_ids.contains(table_id)); + if !changed_ids.is_empty() && only_root_table_changes { + if let Some(changed_rows) = + collect_changed_rows(&self.cache, &self.compiled_plan, changed_ids) { - Some(true) => { - self.root_summary = QueryResultSummary::from_rows(&self.root_rows); - return Some(true); + match self + .compiled_plan + .apply_reactive_patch(&mut self.root_rows, &changed_rows) + { + Some(true) => { + self.root_summary = QueryResultSummary::from_rows(&self.root_rows); + return Some(SnapshotRootRefreshOutcome { + changed: true, + dirty_root_rows: changed_ids.clone(), + }); + } + Some(false) => { + return Some(SnapshotRootRefreshOutcome { + changed: false, + dirty_root_rows: HashSet::new(), + }); + } + None => {} } - Some(false) => return Some(false), - None => {} } } + if let Some(outcome) = self.try_root_subset_refresh(changes, delta_changes) { + return Some(outcome); + } + let cache = self.cache.borrow(); let output = execute_compiled_physical_plan_with_summary(&cache, &self.compiled_plan).ok()?; @@ -561,12 +1680,89 @@ impl GraphqlSubscriptionObservable { &self.root_rows, &output.rows, ) { - return Some(false); + return Some(SnapshotRootRefreshOutcome { + changed: false, + dirty_root_rows: HashSet::new(), + }); } self.root_rows = output.rows; self.root_summary = output.summary; - Some(true) + Some(SnapshotRootRefreshOutcome { + changed: true, + dirty_root_rows: changed_ids.clone(), + }) + } + + fn try_root_subset_refresh( + &mut self, + changes: &HashMap>, + delta_changes: Option<&HashMap>>>, + ) -> Option { + let (affected_root_ids, affected_root_keys) = { + let root_subset_refresh = self.root_subset_refresh.as_mut()?; + let affected_root_ids = ReQueryObservable::collect_affected_root_row_ids( + &self.cache, + changes, + delta_changes, + &root_subset_refresh.metadata.dependency_graph, + )?; + if affected_root_ids.is_empty() { + return Some(SnapshotRootRefreshOutcome { + changed: false, + dirty_root_rows: HashSet::new(), + }); + } + + let cache = self.cache.borrow(); + let refresh_existing_root_keys = + changes.contains_key(&root_subset_refresh.metadata.dependency_graph.root_table_id); + let affected_root_keys = root_subset_refresh.collect_affected_root_keys( + &cache, + &affected_root_ids, + refresh_existing_root_keys, + )?; + (affected_root_ids, affected_root_keys) + }; + + if affected_root_keys.is_empty() { + return Some(SnapshotRootRefreshOutcome { + changed: false, + dirty_root_rows: HashSet::new(), + }); + } + + let root_table = self + .root_subset_refresh + .as_ref() + .map(|runtime| runtime.metadata.root_table.clone())?; + let rows = { + let cache = self.cache.borrow(); + let subset_plan = self + .root_subset_refresh + .as_ref() + .map(|runtime| runtime.select_compiled_plan(&cache, &affected_root_ids))?; + execute_compiled_physical_plan_on_table_subset( + &cache, + subset_plan, + &root_table, + &affected_root_ids, + ) + .ok()? + }; + + let rows = { + let root_subset_refresh = self.root_subset_refresh.as_mut()?; + root_subset_refresh.apply_subset_rows(&affected_root_keys, rows)? + }; + let summary = QueryResultSummary::from_rows(&rows); + let changed = !query_results_equal(&self.root_summary, &summary, &self.root_rows, &rows); + self.root_rows = rows; + self.root_summary = summary; + Some(SnapshotRootRefreshOutcome { + changed, + dirty_root_rows: affected_root_ids, + }) } fn materialize_response_if_dirty(&mut self) -> Option { @@ -589,12 +1785,20 @@ impl GraphqlSubscriptionObservable { build_graphql_response(&cache, &self.catalog, &self.field, &self.root_rows).ok()? } }; - let changed = self - .response - .as_ref() - .map_or(true, |current| *current != response); + let changed = match self.batch_plan.as_ref() { + Some(_) => batch_response_changed(&self.batch_state).unwrap_or_else(|| { + self.response + .as_ref() + .map_or(true, |current| *current != response) + }), + None => self + .response + .as_ref() + .map_or(true, |current| *current != response), + }; if changed { self.response = Some(response); + self.response_js = None; } self.response_dirty = false; Some(changed) @@ -619,7 +1823,15 @@ impl GraphqlSubscriptionObservable { None => build_graphql_response(&cache, &self.catalog, &self.field, &self.root_rows), }; match response { - Ok(response) => graphql_response_to_js_value(&response), + Ok(response) => match self.batch_plan.as_ref() { + Some(_) => graphql_response_to_js_value_batched( + &response, + &mut self.response_encode_cache, + &mut self.response_root_list_js_cache, + self.batch_state.last_root_patch(), + ), + None => graphql_response_to_js_value(&response, &mut self.response_encode_cache), + }, Err(_) => JsValue::NULL, } } @@ -635,6 +1847,9 @@ pub struct GraphqlDeltaObservable { dependency_table_names: HashMap, has_nested_relations: bool, response: Option, + response_js: Option, + response_encode_cache: GraphqlJsEncodeCache, + response_root_list_js_cache: GraphqlRootListJsCache, response_dirty: bool, subscribers: GraphqlSubscribers, } @@ -660,54 +1875,223 @@ impl GraphqlDeltaObservable { has_nested_relations: root_field_has_relations(&field), field, response: None, + response_js: None, + response_encode_cache: GraphqlJsEncodeCache::default(), + response_root_list_js_cache: GraphqlRootListJsCache::default(), response_dirty: true, subscribers: GraphqlSubscribers::default(), } } - pub fn attach_keepalive(&mut self) -> usize { - self.subscribers.add_keepalive() - } - - pub fn response_js_value(&mut self) -> JsValue { - if self.response.is_some() && !self.response_dirty { - return graphql_response_to_js_value(self.response.as_ref().unwrap()); - } - - if self.subscribers.callback_count() == 0 { - return self.render_response_js_value(); - } - - match self.current_response() { - Some(response) => graphql_response_to_js_value(response), - None => JsValue::NULL, + pub fn new_with_sources( + dataflow: DataflowNode, + cache: Rc>, + catalog: cynos_gql::GraphqlCatalog, + field: cynos_gql::bind::BoundRootField, + dependency_table_bindings: Vec<(TableId, String)>, + initial_rows: Vec, + source_rows: &HashMap>, + ) -> Self { + Self { + view: MaterializedView::with_sources(dataflow, initial_rows, source_rows), + cache, + batch_plan: cynos_gql::compile_batch_plan(&catalog, &field) + .ok() + .filter(|plan| plan.has_relations()), + batch_state: cynos_gql::GraphqlBatchState::default(), + dependency_table_names: dependency_table_bindings.into_iter().collect(), + catalog, + has_nested_relations: root_field_has_relations(&field), + field, + response: None, + response_js: None, + response_encode_cache: GraphqlJsEncodeCache::default(), + response_root_list_js_cache: GraphqlRootListJsCache::default(), + response_dirty: true, + subscribers: GraphqlSubscribers::default(), } } - pub fn dependencies(&self) -> &[TableId] { - self.view.dependencies() - } - - pub fn subscribe( - &mut self, - callback: F, - ) -> usize { - self.subscribers.add_callback(callback) - } - - pub fn unsubscribe(&mut self, id: usize) -> bool { - self.subscribers.remove(id) - } - - pub fn subscription_count(&self) -> usize { - self.subscribers.total_count() + pub fn new_with_compiled_loader( + dataflow: DataflowNode, + compiled_ivm_plan: cynos_incremental::CompiledIvmPlan, + compiled_bootstrap_plan: cynos_incremental::CompiledBootstrapPlan, + cache: Rc>, + catalog: cynos_gql::GraphqlCatalog, + field: cynos_gql::bind::BoundRootField, + dependency_table_bindings: Vec<(TableId, String)>, + initial_rows: Vec>, + load_source_rows: F, + ) -> Self + where + F: FnMut(TableId) -> Vec>, + { + Self { + view: MaterializedView::with_compiled_loader_and_bootstrap( + dataflow, + compiled_ivm_plan, + compiled_bootstrap_plan, + initial_rows, + load_source_rows, + ), + cache, + batch_plan: cynos_gql::compile_batch_plan(&catalog, &field) + .ok() + .filter(|plan| plan.has_relations()), + batch_state: cynos_gql::GraphqlBatchState::default(), + dependency_table_names: dependency_table_bindings.into_iter().collect(), + catalog, + has_nested_relations: root_field_has_relations(&field), + field, + response: None, + response_js: None, + response_encode_cache: GraphqlJsEncodeCache::default(), + response_root_list_js_cache: GraphqlRootListJsCache::default(), + response_dirty: true, + subscribers: GraphqlSubscribers::default(), + } } - pub fn listener_count(&self) -> usize { - self.subscribers.callback_count() + pub fn new_with_compiled_source_visitor( + dataflow: DataflowNode, + compiled_ivm_plan: cynos_incremental::CompiledIvmPlan, + compiled_bootstrap_plan: cynos_incremental::CompiledBootstrapPlan, + cache: Rc>, + catalog: cynos_gql::GraphqlCatalog, + field: cynos_gql::bind::BoundRootField, + dependency_table_bindings: Vec<(TableId, String)>, + initial_rows: Vec>, + visit_source_rows: F, + ) -> Self + where + F: FnMut(TableId, usize, &mut dyn FnMut(Rc)), + { + Self { + view: MaterializedView::with_compiled_source_visitor_and_bootstrap( + dataflow, + compiled_ivm_plan, + compiled_bootstrap_plan, + initial_rows, + visit_source_rows, + ), + cache, + batch_plan: cynos_gql::compile_batch_plan(&catalog, &field) + .ok() + .filter(|plan| plan.has_relations()), + batch_state: cynos_gql::GraphqlBatchState::default(), + dependency_table_names: dependency_table_bindings.into_iter().collect(), + catalog, + has_nested_relations: root_field_has_relations(&field), + field, + response: None, + response_js: None, + response_encode_cache: GraphqlJsEncodeCache::default(), + response_root_list_js_cache: GraphqlRootListJsCache::default(), + response_dirty: true, + subscribers: GraphqlSubscribers::default(), + } + } + + pub fn attach_keepalive(&mut self) -> usize { + self.subscribers.add_keepalive() + } + + pub fn response_js_value(&mut self) -> JsValue { + if self.response.is_some() && !self.response_dirty { + if let Some(payload) = &self.response_js { + return payload.clone(); + } + + let payload = match self.batch_plan.as_ref() { + Some(_) => graphql_response_to_js_value_batched( + self.response.as_ref().unwrap(), + &mut self.response_encode_cache, + &mut self.response_root_list_js_cache, + self.batch_state.last_root_patch(), + ), + None => graphql_response_to_js_value( + self.response.as_ref().unwrap(), + &mut self.response_encode_cache, + ), + }; + self.response_js = Some(payload.clone()); + return payload; + } + + if self.subscribers.callback_count() == 0 { + return self.render_response_js_value(); + } + + if self.current_response().is_none() { + return JsValue::NULL; + } + + if let Some(payload) = &self.response_js { + payload.clone() + } else { + let payload = match self.batch_plan.as_ref() { + Some(_) => graphql_response_to_js_value_batched( + self.response.as_ref().unwrap(), + &mut self.response_encode_cache, + &mut self.response_root_list_js_cache, + self.batch_state.last_root_patch(), + ), + None => graphql_response_to_js_value( + self.response.as_ref().unwrap(), + &mut self.response_encode_cache, + ), + }; + self.response_js = Some(payload.clone()); + payload + } + } + + pub fn dependencies(&self) -> &[TableId] { + self.view.dependencies() + } + + pub fn subscribe(&mut self, callback: F) -> usize { + self.subscribers.add_callback(callback) + } + + pub fn unsubscribe(&mut self, id: usize) -> bool { + self.subscribers.remove(id) + } + + pub fn subscription_count(&self) -> usize { + self.subscribers.total_count() + } + + pub fn listener_count(&self) -> usize { + self.subscribers.callback_count() } pub fn on_table_change(&mut self, table_id: TableId, deltas: Vec>) { + self.on_table_change_inner( + table_id, + deltas, + #[cfg(feature = "benchmark")] + None, + ); + } + + #[cfg(feature = "benchmark")] + pub(crate) fn on_table_change_profiled( + &mut self, + table_id: TableId, + deltas: Vec>, + ) -> GraphqlDeltaProfile { + let mut profile = GraphqlDeltaProfile::default(); + self.on_table_change_inner(table_id, deltas, Some(&mut profile)); + profile + } + + fn on_table_change_inner( + &mut self, + table_id: TableId, + deltas: Vec>, + #[cfg(feature = "benchmark")] mut profile: Option<&mut GraphqlDeltaProfile>, + ) { if self.subscribers.total_count() == 0 { return; } @@ -721,15 +2105,31 @@ impl GraphqlDeltaObservable { false, ) }); + #[cfg(feature = "benchmark")] + let view_started_at = now_ms(); let output_deltas = self.view.on_table_change(table_id, deltas); + #[cfg(feature = "benchmark")] + if let Some(profile) = profile.as_deref_mut() { + profile.view_update_ms += now_ms() - view_started_at; + } if output_deltas.is_empty() && !self.has_nested_relations { return; } if let Some(plan) = self.batch_plan.as_ref() { + #[cfg(feature = "benchmark")] + let invalidation_started_at = now_ms(); match batch_invalidation { Some(Ok(mut invalidation)) => { invalidation.root_changed = !output_deltas.is_empty(); + if invalidation.root_changed { + invalidation.dirty_root_rows = output_deltas + .iter() + .map(|delta| delta.data.id()) + .collect(); + invalidation.stable_root_positions = + output_deltas_preserve_root_positions(&output_deltas); + } self.batch_state.apply_invalidation(plan, &invalidation); } Some(Err(())) => { @@ -737,18 +2137,46 @@ impl GraphqlDeltaObservable { } None => {} } + #[cfg(feature = "benchmark")] + if let Some(profile) = profile.as_deref_mut() { + profile.invalidation_ms += now_ms() - invalidation_started_at; + } } self.response_dirty = true; if self.subscribers.callback_count() == 0 { return; } + #[cfg(feature = "benchmark")] + let render_started_at = now_ms(); if let Some(changed) = self.materialize_response_if_dirty() { + #[cfg(feature = "benchmark")] + if let Some(profile) = profile.as_deref_mut() { + profile.render_ms += now_ms() - render_started_at; + } if changed { - if let Some(response) = self.response.as_ref() { - self.subscribers.emit(response); + if self.response.is_some() { + #[cfg(feature = "benchmark")] + let encode_started_at = now_ms(); + let payload = self.response_js_value(); + #[cfg(feature = "benchmark")] + if let Some(profile) = profile.as_deref_mut() { + profile.encode_ms += now_ms() - encode_started_at; + } + #[cfg(feature = "benchmark")] + let emit_started_at = now_ms(); + self.subscribers.emit(&payload); + #[cfg(feature = "benchmark")] + if let Some(profile) = profile.as_deref_mut() { + profile.emit_ms += now_ms() - emit_started_at; + } } } + } else { + #[cfg(feature = "benchmark")] + if let Some(profile) = profile.as_deref_mut() { + profile.render_ms += now_ms() - render_started_at; + } } } @@ -757,29 +2185,39 @@ impl GraphqlDeltaObservable { return Some(false); } - let rows = self.view.result(); let cache = self.cache.borrow(); let response = match self.batch_plan.as_ref() { - Some(plan) => build_graphql_response_from_owned_rows_batched( - &cache, - &self.catalog, - &self.field, - plan, - &mut self.batch_state, - &rows, - ) - .ok()?, + Some(plan) => { + let rows = self.view.result_row_refs().collect::>(); + build_graphql_response_batched_refs( + &cache, + &self.catalog, + &self.field, + plan, + &mut self.batch_state, + &rows, + ) + .ok()? + } None => { - build_graphql_response_from_owned_rows(&cache, &self.catalog, &self.field, &rows) - .ok()? + let rows = self.view.result_rc(); + build_graphql_response(&cache, &self.catalog, &self.field, &rows).ok()? } }; - let changed = self - .response - .as_ref() - .map_or(true, |current| *current != response); + let changed = match self.batch_plan.as_ref() { + Some(_) => batch_response_changed(&self.batch_state).unwrap_or_else(|| { + self.response + .as_ref() + .map_or(true, |current| *current != response) + }), + None => self + .response + .as_ref() + .map_or(true, |current| *current != response), + }; if changed { self.response = Some(response); + self.response_js = None; } self.response_dirty = false; Some(changed) @@ -791,23 +2229,34 @@ impl GraphqlDeltaObservable { } fn render_response_js_value(&mut self) -> JsValue { - let rows = self.view.result(); let cache = self.cache.borrow(); let response = match self.batch_plan.as_ref() { - Some(plan) => build_graphql_response_from_owned_rows_batched( - &cache, - &self.catalog, - &self.field, - plan, - &mut self.batch_state, - &rows, - ), + Some(plan) => { + let rows = self.view.result_row_refs().collect::>(); + build_graphql_response_batched_refs( + &cache, + &self.catalog, + &self.field, + plan, + &mut self.batch_state, + &rows, + ) + } None => { - build_graphql_response_from_owned_rows(&cache, &self.catalog, &self.field, &rows) + let rows = self.view.result_rc(); + build_graphql_response(&cache, &self.catalog, &self.field, &rows) } }; match response { - Ok(response) => graphql_response_to_js_value(&response), + Ok(response) => match self.batch_plan.as_ref() { + Some(_) => graphql_response_to_js_value_batched( + &response, + &mut self.response_encode_cache, + &mut self.response_root_list_js_cache, + self.batch_state.last_root_patch(), + ), + None => graphql_response_to_js_value(&response, &mut self.response_encode_cache), + }, Err(_) => JsValue::NULL, } } @@ -834,7 +2283,7 @@ impl GraphqlSubscriptionInner { } } - fn subscribe(&self, callback: F) -> usize { + fn subscribe(&self, callback: F) -> usize { match self { Self::Snapshot(inner) => inner.borrow_mut().subscribe(callback), Self::Delta(inner) => inner.borrow_mut().subscribe(callback), @@ -933,15 +2382,18 @@ impl JsObservableQuery { let schema = self.schema.clone(); let projected_columns = self.projected_columns.clone(); let aggregate_columns = self.aggregate_columns.clone(); + let materializer = if let Some(ref cols) = aggregate_columns { + JsSnapshotRowsMaterializer::projected(cols.clone()) + } else if let Some(ref cols) = projected_columns { + JsSnapshotRowsMaterializer::projected(cols.clone()) + } else { + JsSnapshotRowsMaterializer::full(schema.clone()) + }; + let materialization_cache = Rc::new(RefCell::new(JsSnapshotRowsCache::default())); let sub_id = self.inner.borrow_mut().subscribe(move |rows| { - let current_data = if let Some(ref cols) = aggregate_columns { - projected_rows_to_js_array(rows, cols) - } else if let Some(ref cols) = projected_columns { - projected_rows_to_js_array(rows, cols) - } else { - rows_to_js_array(rows, &schema) - }; + let current_data = + materializer.materialize(rows, &mut materialization_cache.borrow_mut()); callback.call1(&JsValue::NULL, ¤t_data).ok(); }); @@ -959,6 +2411,37 @@ impl JsObservableQuery { unsubscribe.into_js_value().unchecked_into() } + /// Subscribes to query changes using binary snapshots. + /// + /// The callback receives a `BinaryResult` for the complete current result set. + /// This avoids per-update JS object materialization inside the WASM bridge. + /// Call `getSchemaLayout()` once and decode with `ResultSet` on the JS side. + /// + /// It is called whenever data changes (not immediately - use getResultBinary for initial data). + /// Returns an unsubscribe function. + #[wasm_bindgen(js_name = subscribeBinary)] + pub fn subscribe_binary(&mut self, callback: js_sys::Function) -> js_sys::Function { + let binary_layout = self.binary_layout.clone(); + + let sub_id = self.inner.borrow_mut().subscribe(move |rows| { + let current_data = + binary_result_to_js_value(encode_rc_rows_to_binary(rows, &binary_layout)); + callback.call1(&JsValue::NULL, ¤t_data).ok(); + }); + + let inner_unsub = self.inner.clone(); + let called = Rc::new(RefCell::new(false)); + let called_c = called.clone(); + let unsubscribe = Closure::wrap(Box::new(move || { + let mut c = called_c.borrow_mut(); + if !*c { + *c = true; + inner_unsub.borrow_mut().unsubscribe(sub_id); + } + }) as Box); + unsubscribe.into_js_value().unchecked_into() + } + /// Returns the current result as a JavaScript array. #[wasm_bindgen(js_name = getResult)] pub fn get_result(&self) -> JsValue { @@ -976,10 +2459,7 @@ impl JsObservableQuery { #[wasm_bindgen(js_name = getResultBinary)] pub fn get_result_binary(&self) -> BinaryResult { let inner = self.inner.borrow(); - let rows = inner.result(); - let mut encoder = BinaryEncoder::new(self.binary_layout.clone(), rows.len()); - encoder.encode_rows(rows); - BinaryResult::new(encoder.finish()) + encode_rc_rows_to_binary(inner.result(), &self.binary_layout) } /// Returns the schema layout for decoding binary results. @@ -1012,13 +2492,14 @@ impl JsObservableQuery { #[wasm_bindgen] pub struct JsIvmObservableQuery { inner: Rc>, - schema: Table, - /// Optional projected column names. - projected_columns: Option>, /// Pre-computed binary layout for getResultBinary(). binary_layout: SchemaLayout, - /// Optional aggregate column names. - aggregate_columns: Option>, + /// Pre-built row materializer with cached property keys. + materializer: JsSnapshotRowsMaterializer, + /// Snapshot cache reused across getResult() calls. + materialization_cache: Rc>, + /// Shared sink for per-flush bridge profiling. + bridge_profiler: Rc>, } impl JsIvmObservableQuery { @@ -1026,28 +2507,32 @@ impl JsIvmObservableQuery { inner: Rc>, schema: Table, binary_layout: SchemaLayout, + bridge_profiler: Rc>, ) -> Self { + let materializer = JsSnapshotRowsMaterializer::full(schema.clone()); Self { inner, - schema, - projected_columns: None, binary_layout, - aggregate_columns: None, + materializer, + materialization_cache: Rc::new(RefCell::new(JsSnapshotRowsCache::default())), + bridge_profiler, } } pub(crate) fn new_with_projection( inner: Rc>, - schema: Table, + _schema: Table, projected_columns: Vec, binary_layout: SchemaLayout, + bridge_profiler: Rc>, ) -> Self { + let materializer = JsSnapshotRowsMaterializer::projected(projected_columns.clone()); Self { inner, - schema, - projected_columns: Some(projected_columns), binary_layout, - aggregate_columns: None, + materializer, + materialization_cache: Rc::new(RefCell::new(JsSnapshotRowsCache::default())), + bridge_profiler, } } } @@ -1063,36 +2548,45 @@ impl JsIvmObservableQuery { /// Use `getResult()` to get the initial full result before subscribing. /// Returns an unsubscribe function. pub fn subscribe(&mut self, callback: js_sys::Function) -> js_sys::Function { - let schema = self.schema.clone(); - let projected_columns = self.projected_columns.clone(); - let aggregate_columns = self.aggregate_columns.clone(); - - let sub_id = self.inner.borrow_mut().subscribe(move |change_set| { - let delta_obj = js_sys::Object::new(); - - // Serialize only added rows - let added = if let Some(ref cols) = aggregate_columns { - ivm_rows_to_js_array(&change_set.added, cols) - } else if let Some(ref cols) = projected_columns { - ivm_rows_to_js_array(&change_set.added, cols) - } else { - ivm_full_rows_to_js_array(&change_set.added, &schema) - }; - - // Serialize only removed rows - let removed = if let Some(ref cols) = aggregate_columns { - ivm_rows_to_js_array(&change_set.removed, cols) - } else if let Some(ref cols) = projected_columns { - ivm_rows_to_js_array(&change_set.removed, cols) - } else { - ivm_full_rows_to_js_array(&change_set.removed, &schema) - }; - - js_sys::Reflect::set(&delta_obj, &JsValue::from_str("added"), &added).ok(); - js_sys::Reflect::set(&delta_obj, &JsValue::from_str("removed"), &removed).ok(); - - callback.call1(&JsValue::NULL, &delta_obj).ok(); - }); + let materializer = self.materializer.clone(); + let bridge_profiler = self.bridge_profiler.clone(); + let added_key = JsValue::from_str("added"); + let removed_key = JsValue::from_str("removed"); + let materialization_cache = self.materialization_cache.clone(); + + let sub_id = self + .inner + .borrow_mut() + .subscribe_trace_batches(move |batch| { + let total_started_at = now_ms(); + let (delta_obj, serialize_added_ms, serialize_removed_ms, assemble_delta_ms) = + trace_delta_batch_to_js_delta( + batch, + &materializer, + &mut materialization_cache.borrow_mut(), + &added_key, + &removed_key, + ); + let added_row_count = batch.insert_count(); + let removed_row_count = batch.delete_count(); + + let callback_started_at = now_ms(); + callback.call1(&JsValue::NULL, &delta_obj).ok(); + let callback_call_ms = now_ms() - callback_started_at; + + bridge_profiler + .borrow_mut() + .record_sample(&IvmBridgeProfile { + callback_count: 1, + added_row_count, + removed_row_count, + serialize_added_ms, + serialize_removed_ms, + assemble_delta_ms, + callback_call_ms, + total_ms: now_ms() - total_started_at, + }); + }); let inner_unsub = self.inner.clone(); let called = Rc::new(RefCell::new(false)); @@ -1111,25 +2605,18 @@ impl JsIvmObservableQuery { #[wasm_bindgen(js_name = getResult)] pub fn get_result(&self) -> JsValue { let inner = self.inner.borrow(); - let rows = inner.result(); - if let Some(ref cols) = self.aggregate_columns { - ivm_rows_to_js_array(&rows, cols) - } else if let Some(ref cols) = self.projected_columns { - ivm_rows_to_js_array(&rows, cols) - } else { - ivm_full_rows_to_js_array(&rows, &self.schema) - } + self.materializer.materialize_from_iter( + inner.result_row_refs(), + inner.len(), + &mut self.materialization_cache.borrow_mut(), + ) } /// Returns the current result as a binary buffer for zero-copy access. #[wasm_bindgen(js_name = getResultBinary)] pub fn get_result_binary(&self) -> BinaryResult { let inner = self.inner.borrow(); - let rows = inner.result(); - let rc_rows: Vec> = rows.into_iter().map(Rc::new).collect(); - let mut encoder = BinaryEncoder::new(self.binary_layout.clone(), rc_rows.len()); - encoder.encode_rows(&rc_rows); - BinaryResult::new(encoder.finish()) + encode_rc_rows_iter_to_binary(inner.result_row_refs(), inner.len(), &self.binary_layout) } /// Returns the schema layout for decoding binary results. @@ -1157,36 +2644,39 @@ impl JsIvmObservableQuery { } } -/// Converts IVM rows (owned Row, not Rc) to a JavaScript array using projected columns. -fn ivm_rows_to_js_array(rows: &[Row], column_names: &[String]) -> JsValue { - let arr = js_sys::Array::new_with_length(rows.len() as u32); - for (i, row) in rows.iter().enumerate() { - let obj = js_sys::Object::new(); - for (col_idx, col_name) in column_names.iter().enumerate() { - if let Some(value) = row.get(col_idx) { - let js_val = value_to_js(value); - js_sys::Reflect::set(&obj, &JsValue::from_str(col_name), &js_val).ok(); - } +fn trace_delta_batch_to_js_delta( + batch: &TraceDeltaBatch, + materializer: &JsSnapshotRowsMaterializer, + cache: &mut JsSnapshotRowsCache, + added_key: &JsValue, + removed_key: &JsValue, +) -> (JsValue, f64, f64, f64) { + let serialize_started_at = now_ms(); + let added = js_sys::Array::new_with_length(batch.insert_count() as u32); + let removed = js_sys::Array::new_with_length(batch.delete_count() as u32); + let mut added_index = 0u32; + let mut removed_index = 0u32; + + for delta in batch.deltas() { + let js_row = cache.materialize_trace_handle(materializer, batch.arena(), &delta.data); + if delta.is_insert() { + added.set(added_index, js_row); + added_index += 1; + } else if delta.is_delete() { + removed.set(removed_index, js_row); + removed_index += 1; + cache.remove_row(batch.arena().row_id(&delta.data)); } - arr.set(i as u32, obj.into()); } - arr.into() -} -/// Converts IVM rows (owned Row, not Rc) to a JavaScript array using full schema. -fn ivm_full_rows_to_js_array(rows: &[Row], schema: &Table) -> JsValue { - let arr = js_sys::Array::new_with_length(rows.len() as u32); - for (i, row) in rows.iter().enumerate() { - let obj = js_sys::Object::new(); - for col in schema.columns() { - if let Some(value) = row.get(col.index()) { - let js_val = value_to_js(value); - js_sys::Reflect::set(&obj, &JsValue::from_str(col.name()), &js_val).ok(); - } - } - arr.set(i as u32, obj.into()); - } - arr.into() + let serialize_delta_ms = now_ms() - serialize_started_at; + let assemble_started_at = now_ms(); + let delta_obj = js_sys::Object::new(); + js_sys::Reflect::set(&delta_obj, added_key, &added).ok(); + js_sys::Reflect::set(&delta_obj, removed_key, &removed).ok(); + let assemble_delta_ms = now_ms() - assemble_started_at; + + (delta_obj.into(), serialize_delta_ms, 0.0, assemble_delta_ms) } /// JavaScript-friendly GraphQL subscription wrapper. @@ -1236,9 +2726,8 @@ impl JsGraphqlSubscription { pub fn subscribe(&self, callback: js_sys::Function) -> js_sys::Function { let inner = self.inner.clone(); let initial_callback = callback.clone(); - let sub_id = inner.subscribe(move |response| { - let payload = graphql_response_to_js_value(response); - callback.call1(&JsValue::NULL, &payload).ok(); + let sub_id = inner.subscribe(move |payload| { + callback.call1(&JsValue::NULL, payload).ok(); }); let initial = inner.response_js_value(); initial_callback.call1(&JsValue::NULL, &initial).ok(); @@ -1301,24 +2790,24 @@ impl JsChangesStream { let schema = self.schema.clone(); let inner = self.inner.clone(); let projected_columns = self.projected_columns.clone(); - - // Emit initial value immediately - let initial_data = if let Some(ref cols) = projected_columns { - projected_rows_to_js_array(inner.borrow().result(), cols) + let materializer = if let Some(ref cols) = projected_columns { + JsSnapshotRowsMaterializer::projected(cols.clone()) } else { - rows_to_js_array(inner.borrow().result(), &schema) + JsSnapshotRowsMaterializer::full(schema.clone()) }; + let materialization_cache = Rc::new(RefCell::new(JsSnapshotRowsCache::default())); + + // Emit initial value immediately + let initial_data = materializer.materialize( + inner.borrow().result(), + &mut materialization_cache.borrow_mut(), + ); callback.call1(&JsValue::NULL, &initial_data).ok(); // Subscribe to subsequent changes - let schema_clone = schema.clone(); - let projected_columns_clone = projected_columns.clone(); let sub_id = inner.borrow_mut().subscribe(move |rows| { - let current_data = if let Some(ref cols) = projected_columns_clone { - projected_rows_to_js_array(rows, cols) - } else { - rows_to_js_array(rows, &schema_clone) - }; + let current_data = + materializer.materialize(rows, &mut materialization_cache.borrow_mut()); callback.call1(&JsValue::NULL, ¤t_data).ok(); }); @@ -1335,6 +2824,43 @@ impl JsChangesStream { unsubscribe.into_js_value().unchecked_into() } + /// Subscribes to the changes stream using binary snapshots. + /// + /// The callback receives a `BinaryResult` for the full current result set. + /// It is called immediately with the initial data, and again whenever data changes. + /// Use `getSchemaLayout()` once and decode with `ResultSet` on the JS side. + /// + /// Returns an unsubscribe function. + #[wasm_bindgen(js_name = subscribeBinary)] + pub fn subscribe_binary(&self, callback: js_sys::Function) -> js_sys::Function { + let inner = self.inner.clone(); + let binary_layout = self.binary_layout.clone(); + + let initial_data = binary_result_to_js_value(encode_rc_rows_to_binary( + inner.borrow().result(), + &binary_layout, + )); + callback.call1(&JsValue::NULL, &initial_data).ok(); + + let binary_layout_clone = binary_layout.clone(); + let sub_id = inner.borrow_mut().subscribe(move |rows| { + let current_data = + binary_result_to_js_value(encode_rc_rows_to_binary(rows, &binary_layout_clone)); + callback.call1(&JsValue::NULL, ¤t_data).ok(); + }); + + let called = Rc::new(RefCell::new(false)); + let called_c = called.clone(); + let unsubscribe = Closure::wrap(Box::new(move || { + let mut c = called_c.borrow_mut(); + if !*c { + *c = true; + inner.borrow_mut().unsubscribe(sub_id); + } + }) as Box); + unsubscribe.into_js_value().unchecked_into() + } + /// Returns the current result. #[wasm_bindgen(js_name = getResult)] pub fn get_result(&self) -> JsValue { @@ -1350,10 +2876,7 @@ impl JsChangesStream { #[wasm_bindgen(js_name = getResultBinary)] pub fn get_result_binary(&self) -> BinaryResult { let inner = self.inner.borrow(); - let rows = inner.result(); - let mut encoder = BinaryEncoder::new(self.binary_layout.clone(), rows.len()); - encoder.encode_rows(rows); - BinaryResult::new(encoder.finish()) + encode_rc_rows_to_binary(inner.result(), &self.binary_layout) } /// Returns the schema layout for decoding binary results. @@ -1363,41 +2886,231 @@ impl JsChangesStream { } } +struct CachedSnapshotRowJs { + version: u64, + epoch: u64, + value: JsValue, +} + +#[derive(Default)] +struct JsSnapshotRowsCache { + rows: HashMap, + jsonb_cache: HashMap, JsValue>, + epoch: u64, +} + +#[derive(Clone)] +enum JsSnapshotRowsMaterializer { + Full { property_keys: Vec }, + Projected { property_keys: Vec }, +} + +impl JsSnapshotRowsMaterializer { + fn full(schema: Table) -> Self { + let property_keys = schema + .columns() + .iter() + .map(|column| JsValue::from_str(column.name())) + .collect(); + Self::Full { property_keys } + } + + fn projected(column_names: Vec) -> Self { + let property_keys = column_names + .iter() + .map(|column_name| JsValue::from_str(column_name)) + .collect(); + Self::Projected { property_keys } + } + + fn materialize(&self, rows: &[Rc], cache: &mut JsSnapshotRowsCache) -> JsValue { + self.materialize_from_iter(rows.iter(), rows.len(), cache) + } + + fn materialize_from_iter<'a, I>( + &self, + rows: I, + row_count: usize, + cache: &mut JsSnapshotRowsCache, + ) -> JsValue + where + I: IntoIterator>, + { + if cache.epoch == u64::MAX { + cache.rows.clear(); + cache.epoch = 1; + } else { + cache.epoch += 1; + if cache.epoch == 0 { + cache.epoch = 1; + } + } + let epoch = cache.epoch; + + let arr = js_sys::Array::new_with_length(row_count as u32); + for (index, row) in rows.into_iter().enumerate() { + let js_row = self.materialize_row(row.as_ref(), cache, epoch); + arr.set(index as u32, js_row); + } + + cache.rows.retain(|_, entry| entry.epoch == epoch); + arr.into() + } + + fn materialize_row(&self, row: &Row, cache: &mut JsSnapshotRowsCache, epoch: u64) -> JsValue { + if let Some(entry) = cache.rows.get_mut(&row.id()) { + if entry.version == row.version() { + entry.epoch = epoch; + return entry.value.clone(); + } + } + + let value = self.build_row_object(row, &mut cache.jsonb_cache); + cache.rows.insert( + row.id(), + CachedSnapshotRowJs { + version: row.version(), + epoch, + value: value.clone(), + }, + ); + value + } + + fn build_row_object(&self, row: &Row, jsonb_cache: &mut HashMap, JsValue>) -> JsValue { + match self { + Self::Full { property_keys } | Self::Projected { property_keys } => { + row_to_js_with_keys(row, property_keys, jsonb_cache) + } + } + } + + fn build_trace_handle_object( + &self, + arena: &TraceTupleArena, + handle: &TraceTupleHandle, + jsonb_cache: &mut HashMap, JsValue>, + ) -> JsValue { + match self { + Self::Full { property_keys } | Self::Projected { property_keys } => { + trace_handle_to_js_with_keys(arena, handle, property_keys, jsonb_cache) + } + } + } +} + +impl JsSnapshotRowsCache { + fn materialize_trace_handle( + &mut self, + materializer: &JsSnapshotRowsMaterializer, + arena: &TraceTupleArena, + handle: &TraceTupleHandle, + ) -> JsValue { + let row_id = arena.row_id(handle); + let version = arena.version(handle); + + if let Some(entry) = self.rows.get_mut(&row_id) { + if entry.version == version { + return entry.value.clone(); + } + } + + let value = materializer.build_trace_handle_object(arena, handle, &mut self.jsonb_cache); + self.rows.insert( + row_id, + CachedSnapshotRowJs { + version, + epoch: self.epoch, + value: value.clone(), + }, + ); + value + } + + fn remove_row(&mut self, row_id: u64) { + self.rows.remove(&row_id); + } +} + /// Converts rows to a JavaScript array. fn rows_to_js_array(rows: &[Rc], schema: &Table) -> JsValue { - let arr = js_sys::Array::new_with_length(rows.len() as u32); - for (i, row) in rows.iter().enumerate() { - arr.set(i as u32, row_to_js(row, schema)); - } - arr.into() + JsSnapshotRowsMaterializer::full(schema.clone()) + .materialize(rows, &mut JsSnapshotRowsCache::default()) } /// Converts projected rows to a JavaScript array. /// Only includes the specified columns in the output. fn projected_rows_to_js_array(rows: &[Rc], column_names: &[String]) -> JsValue { - let arr = js_sys::Array::new_with_length(rows.len() as u32); - for (i, row) in rows.iter().enumerate() { - let obj = js_sys::Object::new(); - for (col_idx, col_name) in column_names.iter().enumerate() { - if let Some(value) = row.get(col_idx) { - let js_val = value_to_js(value); - js_sys::Reflect::set(&obj, &JsValue::from_str(col_name), &js_val).ok(); + JsSnapshotRowsMaterializer::projected(column_names.to_vec()) + .materialize(rows, &mut JsSnapshotRowsCache::default()) +} + +fn row_to_js_with_keys( + row: &Row, + property_keys: &[JsValue], + jsonb_cache: &mut HashMap, JsValue>, +) -> JsValue { + let obj = js_sys::Object::new(); + + for (i, property_key) in property_keys.iter().enumerate() { + if let Some(value) = row.get(i) { + let js_val = value_to_js_cached(value, jsonb_cache); + js_sys::Reflect::set(&obj, property_key, &js_val).ok(); + } + } + + obj.into() +} + +fn trace_handle_to_js_with_keys( + arena: &TraceTupleArena, + handle: &TraceTupleHandle, + property_keys: &[JsValue], + jsonb_cache: &mut HashMap, JsValue>, +) -> JsValue { + let obj = js_sys::Object::new(); + + for (i, property_key) in property_keys.iter().enumerate() { + if let Some(value) = arena.value_at(handle, i) { + let js_val = value_to_js_cached(&value, jsonb_cache); + js_sys::Reflect::set(&obj, property_key, &js_val).ok(); + } + } + + obj.into() +} + +fn value_to_js_cached(value: &Value, jsonb_cache: &mut HashMap, JsValue>) -> JsValue { + match value { + Value::Jsonb(jsonb) => { + if let Some(cached) = jsonb_cache.get(jsonb.0.as_slice()) { + return cached.clone(); } + + let parsed = value_to_js(value); + jsonb_cache.insert(jsonb.0.clone(), parsed.clone()); + parsed } - arr.set(i as u32, obj.into()); + _ => value_to_js(value), } - arr.into() } #[cfg(test)] mod tests { use super::*; - use crate::live_runtime::LiveRegistry; + use crate::live_runtime::{ + LiveRegistry, RowsSnapshotDependencyGraph, RowsSnapshotDirectedJoinEdge, + RowsSnapshotLookupPrimitive, RowsSnapshotPartialRefreshMetadata, + RowsSnapshotRootSubsetMetadata, RowsSnapshotRootSubsetPlan, + }; + use crate::query_engine::QueryResultSummary; + use crate::query_engine::{compile_cached_plan, execute_compiled_physical_plan_with_summary}; use cynos_core::schema::TableBuilder; use cynos_core::{DataType, Value}; - use cynos_query::ast::Expr; + use cynos_query::ast::{Expr, SortOrder}; use cynos_query::executor::{InMemoryDataSource, PhysicalPlanRunner}; - use cynos_query::planner::PhysicalPlan; + use cynos_query::planner::{LogicalPlan, PhysicalPlan}; + use cynos_storage::TableCache; use wasm_bindgen_test::*; wasm_bindgen_test_configure!(run_in_browser); @@ -1428,6 +3141,489 @@ mod tests { ) } + fn partial_refresh_dependency_graph() -> RowsSnapshotDependencyGraph { + RowsSnapshotDependencyGraph { + root_table_id: 1, + table_names: HashMap::from([ + (1, "issues".into()), + (2, "projects".into()), + (3, "counters".into()), + ]), + edges_by_source: HashMap::from([ + ( + 1, + alloc::vec![RowsSnapshotDirectedJoinEdge { + source_column_index: 1, + target_table_id: 2, + target_table: "projects".into(), + target_column_index: 0, + lookup: RowsSnapshotLookupPrimitive::PrimaryKey, + }], + ), + ( + 2, + alloc::vec![ + RowsSnapshotDirectedJoinEdge { + source_column_index: 0, + target_table_id: 1, + target_table: "issues".into(), + target_column_index: 1, + lookup: RowsSnapshotLookupPrimitive::SingleColumnIndex { + index_name: "idx_issues_project_id".into(), + }, + }, + RowsSnapshotDirectedJoinEdge { + source_column_index: 0, + target_table_id: 3, + target_table: "counters".into(), + target_column_index: 0, + lookup: RowsSnapshotLookupPrimitive::PrimaryKey, + }, + ], + ), + ( + 3, + alloc::vec![RowsSnapshotDirectedJoinEdge { + source_column_index: 0, + target_table_id: 2, + target_table: "projects".into(), + target_column_index: 0, + lookup: RowsSnapshotLookupPrimitive::PrimaryKey, + }], + ), + ]), + } + } + + fn root_subset_dependency_graph() -> RowsSnapshotDependencyGraph { + RowsSnapshotDependencyGraph { + root_table_id: 1, + table_names: HashMap::from([ + (1, "issues".into()), + (2, "projects".into()), + (3, "counters".into()), + ]), + edges_by_source: HashMap::from([ + ( + 1, + alloc::vec![RowsSnapshotDirectedJoinEdge { + source_column_index: 1, + target_table_id: 2, + target_table: "projects".into(), + target_column_index: 0, + lookup: RowsSnapshotLookupPrimitive::PrimaryKey, + }], + ), + ( + 2, + alloc::vec![ + RowsSnapshotDirectedJoinEdge { + source_column_index: 0, + target_table_id: 1, + target_table: "issues".into(), + target_column_index: 1, + lookup: RowsSnapshotLookupPrimitive::ScanFallback, + }, + RowsSnapshotDirectedJoinEdge { + source_column_index: 0, + target_table_id: 3, + target_table: "counters".into(), + target_column_index: 0, + lookup: RowsSnapshotLookupPrimitive::PrimaryKey, + }, + ], + ), + ( + 3, + alloc::vec![RowsSnapshotDirectedJoinEdge { + source_column_index: 0, + target_table_id: 2, + target_table: "projects".into(), + target_column_index: 0, + lookup: RowsSnapshotLookupPrimitive::PrimaryKey, + }], + ), + ]), + } + } + + fn patch_test_observable() -> ReQueryObservable { + let users = TableBuilder::new("users") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("age", DataType::Int32) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .build() + .unwrap(); + let orders = TableBuilder::new("orders") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("amount", DataType::Int64) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .build() + .unwrap(); + + let mut cache = TableCache::new(); + cache.create_table(users).unwrap(); + cache.create_table(orders).unwrap(); + cache + .get_table_mut("users") + .unwrap() + .insert(Row::new(1, alloc::vec![Value::Int64(1), Value::Int32(42)])) + .unwrap(); + cache + .get_table_mut("orders") + .unwrap() + .insert(Row::new(1, alloc::vec![Value::Int64(1), Value::Int64(100)])) + .unwrap(); + + let cache = Rc::new(RefCell::new(cache)); + let compiled_plan = CompiledPhysicalPlan::new(PhysicalPlan::filter( + PhysicalPlan::table_scan("users"), + Expr::gt( + Expr::column("users", "age", 1), + Expr::literal(Value::Int32(30)), + ), + )); + let initial_output = { + let cache_ref = cache.borrow(); + execute_compiled_physical_plan_with_summary(&cache_ref, &compiled_plan).unwrap() + }; + + ReQueryObservable::new_with_summary( + compiled_plan, + cache, + initial_output.rows, + initial_output.summary, + alloc::vec![(1, "users".into()), (2, "orders".into())], + None, + None, + ) + } + + fn partial_refresh_test_observable() -> (Rc>, ReQueryObservable) { + let issues = TableBuilder::new("issues") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("project_id", DataType::Int64) + .unwrap() + .add_column("updated_at", DataType::Int64) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .add_index("idx_issues_project_id", &["project_id"], false) + .unwrap() + .build() + .unwrap(); + let projects = TableBuilder::new("projects") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("state", DataType::String) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .build() + .unwrap(); + let counters = TableBuilder::new("counters") + .unwrap() + .add_column("project_id", DataType::Int64) + .unwrap() + .add_column("open_count", DataType::Int64) + .unwrap() + .add_primary_key(&["project_id"], false) + .unwrap() + .build() + .unwrap(); + + let mut cache = TableCache::new(); + cache.create_table(issues).unwrap(); + cache.create_table(projects).unwrap(); + cache.create_table(counters).unwrap(); + + { + let store = cache.get_table_mut("issues").unwrap(); + for (id, updated_at) in [(1u64, 500i64), (2, 400), (3, 300), (4, 200), (5, 100)] { + store + .insert(Row::new( + id, + alloc::vec![ + Value::Int64(id as i64), + Value::Int64(id as i64), + Value::Int64(updated_at), + ], + )) + .unwrap(); + } + } + + { + let store = cache.get_table_mut("projects").unwrap(); + for id in 1..=5u64 { + store + .insert(Row::new( + id, + alloc::vec![Value::Int64(id as i64), Value::String("active".into()),], + )) + .unwrap(); + } + } + + { + let store = cache.get_table_mut("counters").unwrap(); + for (project_id, open_count) in [(1u64, 10i64), (2, 20), (3, 30), (4, 40), (5, 50)] { + store + .insert(Row::new( + project_id, + alloc::vec![Value::Int64(project_id as i64), Value::Int64(open_count)], + )) + .unwrap(); + } + } + + let cache = Rc::new(RefCell::new(cache)); + let logical_plan = LogicalPlan::project( + LogicalPlan::limit( + LogicalPlan::sort( + LogicalPlan::filter( + LogicalPlan::left_join( + LogicalPlan::left_join( + LogicalPlan::scan("issues"), + LogicalPlan::scan("projects"), + Expr::eq( + Expr::column("issues", "project_id", 1), + Expr::column("projects", "id", 0), + ), + ), + LogicalPlan::scan("counters"), + Expr::eq( + Expr::column("projects", "id", 0), + Expr::column("counters", "project_id", 0), + ), + ), + Expr::eq( + Expr::column("projects", "state", 1), + Expr::literal(Value::String("active".into())), + ), + ), + alloc::vec![(Expr::column("issues", "updated_at", 2), SortOrder::Desc)], + ), + 4, + 0, + ), + alloc::vec![ + Expr::column("issues", "id", 0), + Expr::column("issues", "updated_at", 2), + Expr::column("projects", "state", 1), + Expr::column("counters", "open_count", 1), + ], + ); + let compiled_plan = { + let cache_ref = cache.borrow(); + compile_cached_plan(&cache_ref, "issues", logical_plan.clone()) + }; + let initial_output = { + let cache_ref = cache.borrow(); + execute_compiled_physical_plan_with_summary(&cache_ref, &compiled_plan).unwrap() + }; + let visible_rows: Vec> = initial_output.rows.iter().take(2).cloned().collect(); + let visible_summary = QueryResultSummary::from_rows(&visible_rows); + let observable = ReQueryObservable::new_with_summary( + compiled_plan, + cache.clone(), + visible_rows, + visible_summary, + alloc::vec![ + (1, "issues".into()), + (2, "projects".into()), + (3, "counters".into()), + ], + Some(RowsSnapshotPartialRefreshState { + metadata: RowsSnapshotPartialRefreshMetadata { + root_table: "issues".into(), + root_pk_output_indices: alloc::vec![0], + order_keys: alloc::vec![RowsSnapshotOrderKey { + output_index: 1, + order: SortOrder::Desc, + }], + visible_offset: 0, + visible_limit: 2, + overscan: 1, + candidate_limit: 4, + dependency_graph: partial_refresh_dependency_graph(), + }, + initial_candidate_rows: initial_output.rows, + }), + None, + ); + (cache, observable) + } + + fn root_subset_test_observable() -> (Rc>, ReQueryObservable) { + let issues = TableBuilder::new("issues") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("project_id", DataType::Int64) + .unwrap() + .add_column("updated_at", DataType::Int64) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .build() + .unwrap(); + let projects = TableBuilder::new("projects") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("state", DataType::String) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .build() + .unwrap(); + let counters = TableBuilder::new("counters") + .unwrap() + .add_column("project_id", DataType::Int64) + .unwrap() + .add_column("open_count", DataType::Int64) + .unwrap() + .add_primary_key(&["project_id"], false) + .unwrap() + .build() + .unwrap(); + + let mut cache = TableCache::new(); + cache.create_table(issues).unwrap(); + cache.create_table(projects).unwrap(); + cache.create_table(counters).unwrap(); + + { + let store = cache.get_table_mut("issues").unwrap(); + for (id, updated_at) in [(1u64, 500i64), (2, 400), (3, 300), (4, 200), (5, 100)] { + store + .insert(Row::new( + id, + alloc::vec![ + Value::Int64(id as i64), + Value::Int64(id as i64), + Value::Int64(updated_at), + ], + )) + .unwrap(); + } + } + + { + let store = cache.get_table_mut("projects").unwrap(); + for id in 1..=5u64 { + store + .insert(Row::new( + id, + alloc::vec![Value::Int64(id as i64), Value::String("active".into())], + )) + .unwrap(); + } + } + + { + let store = cache.get_table_mut("counters").unwrap(); + for (project_id, open_count) in [(1u64, 10i64), (2, 20), (3, 30), (4, 40), (5, 50)] { + store + .insert(Row::new( + project_id, + alloc::vec![Value::Int64(project_id as i64), Value::Int64(open_count)], + )) + .unwrap(); + } + } + + let cache = Rc::new(RefCell::new(cache)); + let logical_plan = LogicalPlan::project( + LogicalPlan::filter( + LogicalPlan::left_join( + LogicalPlan::left_join( + LogicalPlan::scan("issues"), + LogicalPlan::scan("projects"), + Expr::eq( + Expr::column("issues", "project_id", 1), + Expr::column("projects", "id", 0), + ), + ), + LogicalPlan::scan("counters"), + Expr::eq( + Expr::column("projects", "id", 0), + Expr::column("counters", "project_id", 0), + ), + ), + Expr::eq( + Expr::column("projects", "state", 1), + Expr::literal(Value::String("active".into())), + ), + ), + alloc::vec![ + Expr::column("issues", "id", 0), + Expr::column("issues", "updated_at", 2), + Expr::column("projects", "state", 1), + Expr::column("counters", "open_count", 1), + ], + ); + let compiled_plan = { + let cache_ref = cache.borrow(); + compile_cached_plan(&cache_ref, "issues", logical_plan.clone()) + }; + let root_subset_plan = { + let cache_ref = cache.borrow(); + compile_cached_plan(&cache_ref, "issues", logical_plan) + }; + let initial_output = { + let cache_ref = cache.borrow(); + execute_compiled_physical_plan_with_summary(&cache_ref, &compiled_plan).unwrap() + }; + + let observable = ReQueryObservable::new_with_summary( + compiled_plan, + cache.clone(), + initial_output.rows, + initial_output.summary, + alloc::vec![ + (1, "issues".into()), + (2, "projects".into()), + (3, "counters".into()), + ], + None, + Some(RowsSnapshotRootSubsetPlan { + metadata: RowsSnapshotRootSubsetMetadata { + root_table: "issues".into(), + root_pk_store_indices: alloc::vec![0], + root_pk_output_indices: alloc::vec![0], + dependency_graph: root_subset_dependency_graph(), + }, + compiled_plans: crate::live_runtime::RowsSnapshotRootSubsetVariants { + small: root_subset_plan.clone(), + large: root_subset_plan, + }, + }), + ); + (cache, observable) + } + + fn visible_issue_ids(observable: &ReQueryObservable) -> Vec { + observable + .result() + .iter() + .filter_map(|row| row.get(0)) + .filter_map(Value::as_i64) + .collect() + } + #[wasm_bindgen_test] fn test_live_registry_new() { let registry = LiveRegistry::new(); @@ -1447,6 +3643,169 @@ mod tests { assert_eq!(arr.length(), 2); } + #[test] + fn test_patch_table_changed_ids_accepts_exact_patch_table_changes() { + let observable = patch_test_observable(); + let changes = HashMap::from([(1, HashSet::from([1u64]))]); + + let changed_ids = observable + .patch_table_changed_ids(&changes) + .expect("patch table change should be eligible for reactive patching"); + assert!(changed_ids.contains(&1)); + } + + #[test] + fn test_patch_table_changed_ids_rejects_unrelated_or_mixed_table_changes() { + let observable = patch_test_observable(); + + let unrelated = HashMap::from([(2, HashSet::from([1u64]))]); + assert!( + observable.patch_table_changed_ids(&unrelated).is_none(), + "unrelated table changes must not be routed into the patch fast-path", + ); + + let mixed = HashMap::from([(1, HashSet::from([1u64])), (2, HashSet::from([1u64]))]); + assert!( + observable.patch_table_changed_ids(&mixed).is_none(), + "mixed-table changes must fall back to full requery so we keep table provenance intact", + ); + } + + #[test] + fn test_partial_refresh_propagates_multi_hop_join_changes() { + let (cache, mut observable) = partial_refresh_test_observable(); + observable.subscribe(|_| {}); + + { + let mut cache_ref = cache.borrow_mut(); + let store = cache_ref.get_table_mut("counters").unwrap(); + let old_row = store.get(1).unwrap(); + store + .update( + 1, + Row::new_with_version( + 1, + old_row.version().wrapping_add(1), + alloc::vec![Value::Int64(1), Value::Int64(99)], + ), + ) + .unwrap(); + } + + observable.on_change(&HashMap::from([(3, HashSet::from([1u64]))])); + + let first_row = observable.result().first().expect("visible row"); + assert_eq!(first_row.get(0), Some(&Value::Int64(1))); + assert_eq!(first_row.get(3), Some(&Value::Int64(99))); + } + + #[test] + fn test_partial_refresh_falls_back_when_shadow_window_is_depleted() { + let (cache, mut observable) = partial_refresh_test_observable(); + observable.subscribe(|_| {}); + + { + let mut cache_ref = cache.borrow_mut(); + let store = cache_ref.get_table_mut("projects").unwrap(); + let old_row = store.get(1).unwrap(); + store + .update( + 1, + Row::new_with_version( + 1, + old_row.version().wrapping_add(1), + alloc::vec![Value::Int64(1), Value::String("paused".into())], + ), + ) + .unwrap(); + } + observable.on_change(&HashMap::from([(2, HashSet::from([1u64]))])); + assert_eq!(visible_issue_ids(&observable), alloc::vec![2, 3]); + + { + let mut cache_ref = cache.borrow_mut(); + let store = cache_ref.get_table_mut("projects").unwrap(); + let old_row = store.get(2).unwrap(); + store + .update( + 2, + Row::new_with_version( + 2, + old_row.version().wrapping_add(1), + alloc::vec![Value::Int64(2), Value::String("paused".into())], + ), + ) + .unwrap(); + } + observable.on_change(&HashMap::from([(2, HashSet::from([2u64]))])); + + assert_eq!(visible_issue_ids(&observable), alloc::vec![3, 4]); + let partial_refresh = observable + .partial_refresh + .as_ref() + .expect("partial refresh runtime"); + let candidate_issue_ids: Vec = partial_refresh + .candidate_rows + .iter() + .filter_map(|entry| entry.row.get(0)) + .filter_map(Value::as_i64) + .collect(); + assert_eq!(candidate_issue_ids, alloc::vec![3, 4, 5]); + } + + #[test] + fn test_root_subset_refresh_updates_multi_hop_join_without_full_requery() { + let (cache, mut observable) = root_subset_test_observable(); + observable.subscribe(|_| {}); + + { + let mut cache_ref = cache.borrow_mut(); + let store = cache_ref.get_table_mut("counters").unwrap(); + let old_row = store.get(3).unwrap(); + store + .update( + 3, + Row::new_with_version( + 3, + old_row.version().wrapping_add(1), + alloc::vec![Value::Int64(3), Value::Int64(300)], + ), + ) + .unwrap(); + } + + let profile = observable.on_change_profiled(&HashMap::from([(3, HashSet::from([3u64]))])); + + assert_eq!(profile.refresh_mode, SnapshotRefreshMode::RootSubsetRefresh); + assert!(profile.root_subset_hit); + let updated_row = observable + .result() + .iter() + .find(|row| row.get(0) == Some(&Value::Int64(3))) + .expect("issue 3 should still be visible"); + assert_eq!(updated_row.get(3), Some(&Value::Int64(300))); + } + + #[test] + fn test_root_subset_refresh_keeps_fast_path_for_deleted_intermediate_rows() { + let (cache, mut observable) = root_subset_test_observable(); + observable.subscribe(|_| {}); + + let project_delete = { + let mut cache_ref = cache.borrow_mut(); + let store = cache_ref.get_table_mut("projects").unwrap(); + store.delete_with_delta(3).unwrap() + }; + + let changes = HashMap::from([(2, HashSet::from([3u64]))]); + let deltas = HashMap::from([(2, alloc::vec![project_delete])]); + let profile = observable.on_change_with_deltas_profiled(&changes, &deltas); + + assert_eq!(profile.refresh_mode, SnapshotRefreshMode::RootSubsetRefresh); + assert!(profile.root_subset_hit); + assert_eq!(visible_issue_ids(&observable), alloc::vec![1, 2, 4, 5]); + } + #[test] fn test_projection_query_preserves_version_and_live_query_detects_update() { let plan = PhysicalPlan::project( diff --git a/crates/database/src/table.rs b/crates/database/src/table.rs index 7db36df..09edbc6 100644 --- a/crates/database/src/table.rs +++ b/crates/database/src/table.rs @@ -8,6 +8,7 @@ use alloc::string::{String, ToString}; use alloc::vec::Vec; use cynos_core::schema::{Table, TableBuilder}; use cynos_core::DataType; +use cynos_jsonb::JsonPath; use wasm_bindgen::prelude::*; /// Column options for table creation. @@ -105,6 +106,7 @@ struct IndexDef { name: String, columns: Vec, unique: bool, + gin_paths: Option>, } #[derive(Clone, Debug)] @@ -188,6 +190,7 @@ impl JsTableBuilder { name: name.to_string(), columns: cols, unique: false, + gin_paths: None, }); self } @@ -207,20 +210,29 @@ impl JsTableBuilder { name: name.to_string(), columns: cols, unique: true, + gin_paths: None, }); self } /// Adds a JSONB index for specific paths. #[wasm_bindgen(js_name = jsonbIndex)] - pub fn jsonb_index(mut self, column: &str, _paths: &JsValue) -> Self { - // JSONB indices are handled specially - for now just create a regular index - // The actual JSONB indexing is done at the storage layer + pub fn jsonb_index(mut self, column: &str, paths: &JsValue) -> Self { + let gin_paths = if paths.is_null() || paths.is_undefined() { + None + } else { + let normalized = Self::parse_jsonb_index_paths(paths); + if normalized.is_empty() { + return self; + } + Some(normalized) + }; let name = alloc::format!("idx_jsonb_{}", column); self.indices.push(IndexDef { name, columns: alloc::vec![column.to_string()], unique: false, + gin_paths, }); self } @@ -274,9 +286,16 @@ impl JsTableBuilder { // Add indices for idx in &self.indices { let col_refs: Vec<&str> = idx.columns.iter().map(|s| s.as_str()).collect(); - builder = builder - .add_index(&idx.name, &col_refs, idx.unique) - .map_err(|e| JsValue::from_str(&alloc::format!("{:?}", e)))?; + builder = if let Some(paths) = &idx.gin_paths { + let path_refs: Vec<&str> = paths.iter().map(|path| path.as_str()).collect(); + builder + .add_jsonb_index(&idx.name, &idx.columns[0], &path_refs) + .map_err(|e| JsValue::from_str(&alloc::format!("{:?}", e)))? + } else { + builder + .add_index(&idx.name, &col_refs, idx.unique) + .map_err(|e| JsValue::from_str(&alloc::format!("{:?}", e)))? + }; } // Add foreign keys @@ -305,6 +324,64 @@ impl JsTableBuilder { } } +impl JsTableBuilder { + fn parse_jsonb_index_paths(paths: &JsValue) -> Vec { + if let Some(arr) = paths.dyn_ref::() { + arr.iter() + .filter_map(|value| value.as_string()) + .filter_map(|path| Self::normalize_jsonb_index_path(&path)) + .collect() + } else if let Some(path) = paths.as_string() { + Self::normalize_jsonb_index_path(&path) + .map(|normalized| alloc::vec![normalized]) + .unwrap_or_default() + } else { + Vec::new() + } + } + + fn normalize_jsonb_index_path(path: &str) -> Option { + let parsed = JsonPath::parse(path).ok()?; + let mut segments = Vec::new(); + if !Self::collect_jsonb_index_segments(&parsed, &mut segments) || segments.is_empty() { + return None; + } + + let mut normalized = String::new(); + for segment in segments { + if !normalized.is_empty() { + normalized.push('.'); + } + normalized.push_str(&segment); + } + Some(normalized) + } + + fn collect_jsonb_index_segments(path: &JsonPath, segments: &mut Vec) -> bool { + match path { + JsonPath::Root => true, + JsonPath::Field(parent, field) => { + if !Self::collect_jsonb_index_segments(parent, segments) { + return false; + } + segments.push(field.clone()); + true + } + JsonPath::Index(parent, index) => { + if !Self::collect_jsonb_index_segments(parent, segments) { + return false; + } + segments.push(index.to_string()); + true + } + JsonPath::Slice(_, _, _) + | JsonPath::RecursiveField(_, _) + | JsonPath::Wildcard(_) + | JsonPath::Filter(_, _) => false, + } + } +} + /// JavaScript-friendly table reference. #[wasm_bindgen] pub struct JsTable { diff --git a/crates/database/src/transaction.rs b/crates/database/src/transaction.rs index f725916..5d5b2c7 100644 --- a/crates/database/src/transaction.rs +++ b/crates/database/src/transaction.rs @@ -3,18 +3,33 @@ //! This module provides transaction support with commit and rollback capabilities. use crate::convert::{js_array_to_rows, js_to_value}; -use crate::expr::Expr; +use crate::expr::{ComparisonOp, Expr, ExprInner}; use crate::live_runtime::LiveRegistry; +use crate::profiling::now_ms; use crate::query_builder::evaluate_predicate; use alloc::rc::Rc; use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::cell::RefCell; +use cynos_core::schema::{IndexType, Table}; use cynos_core::{reserve_row_ids, Row}; +use cynos_incremental::Delta; +use cynos_index::KeyRange; use cynos_reactive::TableId; -use cynos_storage::{TableCache, Transaction, TransactionState}; -use hashbrown::HashSet; +use cynos_storage::{RowStore, TableCache, Transaction, TransactionState}; +use hashbrown::{HashMap, HashSet}; use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; + +#[derive(Clone, Debug, Default)] +pub(crate) struct CommitProfile { + pub storage_commit_ms: f64, + pub registry_flush_ms: f64, + pub total_commit_ms: f64, + pub changed_table_count: usize, + pub changed_row_count: usize, + pub delta_row_count: usize, +} /// JavaScript-friendly transaction wrapper. #[wasm_bindgen] @@ -22,9 +37,12 @@ pub struct JsTransaction { cache: Rc>, query_registry: Rc>, table_id_map: Rc>>, + last_commit_profile: Rc>>, inner: Option, - /// Pending changes: (table_id, changed_row_ids) - pending_changes: Vec<(TableId, HashSet)>, + /// Pending changes grouped by table so one commit triggers one live flush per table. + pending_changes: HashMap>, + /// Pending row deltas grouped by table for trace()/delta-backed subscriptions. + pending_deltas: HashMap>>, } impl JsTransaction { @@ -32,15 +50,164 @@ impl JsTransaction { cache: Rc>, query_registry: Rc>, table_id_map: Rc>>, + last_commit_profile: Rc>>, ) -> Self { Self { cache, query_registry, table_id_map, + last_commit_profile, inner: Some(Transaction::begin()), - pending_changes: Vec::new(), + pending_changes: HashMap::new(), + pending_deltas: HashMap::new(), + } + } + + fn record_pending_change( + &mut self, + table_id: TableId, + changed_ids: HashSet, + deltas: Vec>, + ) { + if changed_ids.is_empty() { + return; + } + + self.pending_changes + .entry(table_id) + .or_insert_with(HashSet::new) + .extend(changed_ids); + + if !deltas.is_empty() { + self.pending_deltas + .entry(table_id) + .or_insert_with(Vec::new) + .extend(deltas); } } + + fn collect_candidate_rows( + store: &RowStore, + schema: &Table, + predicate: Option<&Expr>, + ) -> Result, JsValue> { + let Some(predicate) = predicate else { + return Ok(store.scan().map(|rc| (*rc).clone()).collect()); + }; + + if let Some(rows) = Self::lookup_rows_for_predicate(store, schema, predicate)? { + return Ok(rows); + } + + Ok(store + .scan() + .filter(|row| evaluate_predicate(predicate, &**row, schema)) + .map(|rc| (*rc).clone()) + .collect()) + } + + fn lookup_rows_for_predicate( + store: &RowStore, + schema: &Table, + predicate: &Expr, + ) -> Result>, JsValue> { + let mut equalities = HashMap::::new(); + if !Self::collect_point_equalities(schema, predicate, &mut equalities)? { + return Ok(None); + } + + if !store.pk_columns().is_empty() + && store + .pk_columns() + .iter() + .all(|idx| equalities.contains_key(idx)) + { + let pk_values: Vec<_> = store + .pk_columns() + .iter() + .filter_map(|idx| equalities.get(idx).cloned()) + .collect(); + let rows = store + .get_by_pk_values(&pk_values) + .into_iter() + .filter(|row| evaluate_predicate(predicate, &**row, schema)) + .map(|row| (*row).clone()) + .collect(); + return Ok(Some(rows)); + } + + if equalities.len() != 1 { + return Ok(None); + } + + let Some((&column_idx, value)) = equalities.iter().next() else { + return Ok(None); + }; + let Some(index_name) = Self::find_single_column_index(schema, column_idx) else { + return Ok(None); + }; + let rows = store + .index_scan(&index_name, Some(&KeyRange::only(value.clone()))) + .into_iter() + .filter(|row| evaluate_predicate(predicate, &**row, schema)) + .map(|row| (*row).clone()) + .collect(); + Ok(Some(rows)) + } + + fn collect_point_equalities( + schema: &Table, + predicate: &Expr, + equalities: &mut HashMap, + ) -> Result { + match predicate.inner() { + ExprInner::And { left, right } => { + Ok(Self::collect_point_equalities(schema, left, equalities)? + && Self::collect_point_equalities(schema, right, equalities)?) + } + ExprInner::Comparison { column, op, value } if *op == ComparisonOp::Eq => { + if value.is_object() { + return Ok(false); + } + + if let Some(table_name) = column.table_name() { + if table_name != schema.name() { + return Ok(false); + } + } + + let Some(schema_column) = schema.get_column(&column.name()) else { + return Ok(false); + }; + let literal = js_to_value(value, schema_column.data_type())?; + match equalities.get(&schema_column.index()) { + Some(existing) if existing != &literal => Ok(false), + Some(_) => Ok(true), + None => { + equalities.insert(schema_column.index(), literal); + Ok(true) + } + } + } + _ => Ok(false), + } + } + + fn find_single_column_index(schema: &Table, column_idx: usize) -> Option { + schema + .indices() + .iter() + .find(|index| { + index.get_index_type() != IndexType::Gin + && index.columns().len() == 1 + && index + .columns() + .first() + .and_then(|col| schema.get_column_index(&col.name)) + == Some(column_idx) + }) + .map(|index| index.name().to_string()) + } } #[wasm_bindgen] @@ -70,17 +237,22 @@ impl JsTransaction { // Collect inserted row IDs let mut inserted_ids = HashSet::new(); + let mut deltas = Vec::with_capacity(rows.len()); // Insert through transaction for row in rows { inserted_ids.insert(row.id()); + deltas.push(Delta::insert(row.clone())); tx.insert(&mut *cache, table, row) .map_err(|e| JsValue::from_str(&alloc::format!("{:?}", e)))?; } + drop(cache); + // Store pending changes - if let Some(table_id) = self.table_id_map.borrow().get(table).copied() { - self.pending_changes.push((table_id, inserted_ids)); + let table_id = self.table_id_map.borrow().get(table).copied(); + if let Some(table_id) = table_id { + self.record_pending_change(table_id, inserted_ids, deltas); } Ok(()) @@ -120,20 +292,11 @@ impl JsTransaction { } // Find rows to update - let rows_to_update: Vec = store - .scan() - .filter(|row| { - if let Some(ref pred) = predicate { - evaluate_predicate(pred, &**row, &schema) - } else { - true - } - }) - .map(|rc| (*rc).clone()) - .collect(); + let rows_to_update = Self::collect_candidate_rows(store, &schema, predicate.as_ref())?; let mut updated_ids = HashSet::new(); let mut update_count = 0; + let mut deltas = Vec::with_capacity(rows_to_update.len() * 2); for old_row in rows_to_update { let mut new_values = old_row.values().to_vec(); @@ -153,6 +316,8 @@ impl JsTransaction { let new_row = Row::new_with_version(old_row.id(), new_version, new_values); updated_ids.insert(old_row.id()); + deltas.push(Delta::delete(old_row.clone())); + deltas.push(Delta::insert(new_row.clone())); tx.update(&mut *cache, table, old_row.id(), new_row) .map_err(|e| JsValue::from_str(&alloc::format!("{:?}", e)))?; @@ -160,8 +325,11 @@ impl JsTransaction { update_count += 1; } - if let Some(table_id) = self.table_id_map.borrow().get(table).copied() { - self.pending_changes.push((table_id, updated_ids)); + drop(cache); + + let table_id = self.table_id_map.borrow().get(table).copied(); + if let Some(table_id) = table_id { + self.record_pending_change(table_id, updated_ids, deltas); } Ok(update_count) @@ -182,29 +350,24 @@ impl JsTransaction { let schema = store.schema().clone(); // Find rows to delete - let rows_to_delete: Vec = store - .scan() - .filter(|row| { - if let Some(ref pred) = predicate { - evaluate_predicate(pred, &**row, &schema) - } else { - true - } - }) - .map(|rc| (*rc).clone()) - .collect(); + let rows_to_delete = Self::collect_candidate_rows(store, &schema, predicate.as_ref())?; let delete_count = rows_to_delete.len(); let mut deleted_ids = HashSet::new(); + let mut deltas = Vec::with_capacity(delete_count); for row in rows_to_delete { deleted_ids.insert(row.id()); + deltas.push(Delta::delete(row.clone())); tx.delete(&mut *cache, table, row.id()) .map_err(|e| JsValue::from_str(&alloc::format!("{:?}", e)))?; } - if let Some(table_id) = self.table_id_map.borrow().get(table).copied() { - self.pending_changes.push((table_id, deleted_ids)); + drop(cache); + + let table_id = self.table_id_map.borrow().get(table).copied(); + if let Some(table_id) = table_id { + self.record_pending_change(table_id, deleted_ids, deltas); } Ok(delete_count) @@ -217,15 +380,44 @@ impl JsTransaction { .take() .ok_or_else(|| JsValue::from_str("Transaction already completed"))?; + let storage_started_at = now_ms(); tx.commit() .map_err(|e| JsValue::from_str(&alloc::format!("{:?}", e)))?; + let storage_commit_ms = now_ms() - storage_started_at; // Notify query registry of all changes - for (table_id, changed_ids) in self.pending_changes.drain(..) { - self.query_registry - .borrow_mut() - .on_table_change(table_id, &changed_ids); + let notify_started_at = now_ms(); + let mut changed_table_count = 0usize; + let mut changed_row_count = 0usize; + let mut delta_row_count = 0usize; + { + let mut registry = self.query_registry.borrow_mut(); + for (table_id, changed_ids) in self.pending_changes.drain() { + changed_table_count += 1; + changed_row_count += changed_ids.len(); + let deltas = self.pending_deltas.remove(&table_id).unwrap_or_default(); + delta_row_count += deltas.len(); + if deltas.is_empty() { + registry.on_table_change(table_id, &changed_ids); + } else { + registry.on_table_change_delta(table_id, deltas, &changed_ids); + } + } + + if registry.has_pending_changes() { + registry.flush(); + } } + let registry_flush_ms = now_ms() - notify_started_at; + + *self.last_commit_profile.borrow_mut() = Some(CommitProfile { + storage_commit_ms, + registry_flush_ms, + total_commit_ms: storage_commit_ms + registry_flush_ms, + changed_table_count, + changed_row_count, + delta_row_count, + }); Ok(()) } @@ -241,11 +433,15 @@ impl JsTransaction { tx.rollback(&mut *cache) .map_err(|e| JsValue::from_str(&alloc::format!("{:?}", e)))?; + self.pending_deltas.clear(); + // Notify Live Query of rollback changes (data was restored) - for (table_id, changed_ids) in self.pending_changes.drain(..) { - self.query_registry - .borrow_mut() - .on_table_change(table_id, &changed_ids); + let mut registry = self.query_registry.borrow_mut(); + for (table_id, changed_ids) in self.pending_changes.drain() { + registry.on_table_change(table_id, &changed_ids); + } + if registry.has_pending_changes() { + registry.flush(); } Ok(()) diff --git a/crates/gql/src/batch_render.rs b/crates/gql/src/batch_render.rs index ea3101d..eab5963 100644 --- a/crates/gql/src/batch_render.rs +++ b/crates/gql/src/batch_render.rs @@ -21,9 +21,27 @@ use crate::render_plan::{ }; use crate::response::{GraphqlResponse, ResponseField, ResponseValue}; +trait RowRenderRef { + fn row_rc(&self) -> &Rc; +} + +impl RowRenderRef for Rc { + fn row_rc(&self) -> &Rc { + self + } +} + +impl RowRenderRef for &Rc { + fn row_rc(&self) -> &Rc { + self + } +} + #[derive(Clone, Debug, Default)] pub struct GraphqlInvalidation { pub root_changed: bool, + pub dirty_root_rows: HashSet, + pub stable_root_positions: bool, pub changed_tables: Vec, pub dirty_edge_keys: HashMap>, pub dirty_table_rows: HashMap>, @@ -54,6 +72,28 @@ pub struct GraphqlBatchState { node_row_index: HashMap>>, edge_bucket_cache: HashMap>>>, edge_parent_membership: HashMap>>, + root_list_cache: Option, + dirty_root_rows: HashSet, + root_list_requires_full_rebuild: bool, + last_root_patch: Option, +} + +#[derive(Clone, Debug)] +struct RootListCacheEntry { + row_keys: Vec, + row_positions: HashMap, + items: Rc<[ResponseValue]>, + list_value: ResponseValue, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GraphqlRootListPatch { + StablePositions(Vec), + Splice { + removed_positions: Vec, + inserted_positions: Vec, + updated_positions: Vec, + }, } impl GraphqlBatchState { @@ -67,7 +107,20 @@ impl GraphqlBatchState { let mut seen = HashSet::new(); if invalidation.root_changed { - self.collect_node_rows(plan.root_node(), &mut pending); + self.dirty_root_rows + .extend(invalidation.dirty_root_rows.iter().copied()); + if !invalidation.stable_root_positions { + self.root_list_requires_full_rebuild = true; + } + if invalidation.dirty_root_rows.is_empty() { + self.collect_node_rows(plan.root_node(), &mut pending); + } else if invalidation.dirty_table_rows.is_empty() + && invalidation.dirty_edge_keys.is_empty() + { + for row_id in &invalidation.dirty_root_rows { + self.collect_row_id_entries(plan.root_node(), *row_id, &mut pending); + } + } } for (table_name, row_ids) in &invalidation.dirty_table_rows { @@ -115,6 +168,9 @@ impl GraphqlBatchState { if !seen.insert(row_key) { continue; } + if row_key.node_id == plan.root_node() { + self.dirty_root_rows.insert(row_key.row_id); + } let parents = self.parent_rows_for_row(plan, row_key); self.remove_row_entry(row_key); pending.extend(parents); @@ -255,6 +311,49 @@ impl GraphqlBatchState { self.node_row_index.remove(&row_key.node_id); } } + + if self + .root_list_cache + .as_ref() + .is_some_and(|cache| cache.row_positions.contains_key(&row_key.row_id)) + { + self.dirty_root_rows.insert(row_key.row_id); + } + } + + fn clear_root_list_cache(&mut self) { + self.root_list_cache = None; + self.dirty_root_rows.clear(); + self.root_list_requires_full_rebuild = false; + self.last_root_patch = None; + } + + fn update_root_list_cache( + &mut self, + row_keys: Vec, + items: Vec, + ) -> ResponseValue { + let row_positions = row_keys + .iter() + .enumerate() + .map(|(index, row_key)| (row_key.row_id, index)) + .collect(); + let items: Rc<[ResponseValue]> = items.into(); + let list_value = ResponseValue::list_shared(items.clone()); + self.root_list_cache = Some(RootListCacheEntry { + row_keys, + row_positions, + items, + list_value: list_value.clone(), + }); + self.dirty_root_rows.clear(); + self.root_list_requires_full_rebuild = false; + self.last_root_patch = None; + list_value + } + + pub fn last_root_patch(&self) -> Option<&GraphqlRootListPatch> { + self.last_root_patch.as_ref() } } @@ -265,6 +364,28 @@ pub fn render_graphql_response( plan: &GraphqlBatchPlan, state: &mut GraphqlBatchState, rows: &[Rc], +) -> GqlResult { + render_graphql_response_impl(cache, catalog, field, plan, state, rows) +} + +pub fn render_graphql_response_refs( + cache: &TableCache, + catalog: &GraphqlCatalog, + field: &BoundRootField, + plan: &GraphqlBatchPlan, + state: &mut GraphqlBatchState, + rows: &[&Rc], +) -> GqlResult { + render_graphql_response_impl(cache, catalog, field, plan, state, rows) +} + +fn render_graphql_response_impl( + cache: &TableCache, + catalog: &GraphqlCatalog, + field: &BoundRootField, + plan: &GraphqlBatchPlan, + state: &mut GraphqlBatchState, + rows: &[R], ) -> GqlResult { let field = render_root_field(cache, catalog, field, plan, state, rows)?; Ok(GraphqlResponse::new(ResponseValue::object(alloc::vec![ @@ -272,36 +393,35 @@ pub fn render_graphql_response( ]))) } -fn render_root_field( +fn render_root_field( cache: &TableCache, catalog: &GraphqlCatalog, field: &BoundRootField, plan: &GraphqlBatchPlan, state: &mut GraphqlBatchState, - rows: &[Rc], + rows: &[R], ) -> GqlResult { let value = match &field.kind { BoundRootFieldKind::Collection { .. } | BoundRootFieldKind::Insert { .. } | BoundRootFieldKind::Update { .. } - | BoundRootFieldKind::Delete { .. } => ResponseValue::list(render_node_list( - cache, - catalog, - plan, - state, - plan.root_node(), - rows, - )?), + | BoundRootFieldKind::Delete { .. } => { + render_root_node_list(cache, catalog, plan, state, plan.root_node(), rows)? + } BoundRootFieldKind::ByPk { .. } => match rows.first() { Some(row) => { - prefetch_node_edges( - cache, - catalog, - plan, - state, - plan.root_node(), - core::slice::from_ref(row), - )?; + let row = row.row_rc(); + if !row_is_cached(state, plan.root_node(), row) { + let singleton = [row]; + prefetch_node_edges_with_children( + cache, + catalog, + plan, + state, + plan.root_node(), + &singleton, + )?; + } render_node_object(cache, catalog, plan, state, plan.root_node(), row)? } None => ResponseValue::Null, @@ -317,24 +437,293 @@ fn render_root_field( Ok(ResponseField::new(field.response_key.clone(), value)) } -fn render_node_list( +fn render_root_node_list( cache: &TableCache, catalog: &GraphqlCatalog, plan: &GraphqlBatchPlan, state: &mut GraphqlBatchState, node_id: NodeId, - rows: &[Rc], + rows: &[R], +) -> GqlResult { + if rows.is_empty() { + state.clear_root_list_cache(); + return Ok(ResponseValue::list(Vec::new())); + } + + if let Some(list_value) = try_render_root_node_list_cached( + cache, catalog, plan, state, node_id, rows, + )? { + return Ok(list_value); + } + + let items = render_node_list(cache, catalog, plan, state, node_id, rows)?; + let row_keys = rows + .iter() + .map(|row| RowCacheKey::new(node_id, row.row_rc())) + .collect(); + Ok(state.update_root_list_cache(row_keys, items)) +} + +fn try_render_root_node_list_cached( + cache: &TableCache, + catalog: &GraphqlCatalog, + plan: &GraphqlBatchPlan, + state: &mut GraphqlBatchState, + node_id: NodeId, + rows: &[R], +) -> GqlResult> { + let Some(mut cached) = state.root_list_cache.take() else { + return Ok(None); + }; + + if !state.root_list_requires_full_rebuild { + if cached.row_keys.len() == rows.len() { + if state.dirty_root_rows.is_empty() { + let list_value = cached.list_value.clone(); + state.root_list_cache = Some(cached); + return Ok(Some(list_value)); + } + + let dirty_positions = state + .dirty_root_rows + .iter() + .map(|row_id| { + cached + .row_positions + .get(row_id) + .copied() + .ok_or_else(|| { + GqlError::new(GqlErrorKind::Execution, "missing root row position") + }) + }) + .collect::>>(); + + match dirty_positions { + Ok(dirty_positions) => { + let mut row_keys = cached.row_keys.clone(); + let mut items = cached.items.as_ref().to_vec(); + let mut changed = false; + let mut applied_positions = Vec::with_capacity(dirty_positions.len()); + + for position in dirty_positions { + let row = rows + .get(position) + .map(RowRenderRef::row_rc) + .ok_or_else(|| { + GqlError::new( + GqlErrorKind::Execution, + "root row position out of bounds", + ) + })?; + if row.id() != row_keys[position].row_id { + state.root_list_requires_full_rebuild = true; + break; + } + if !row_is_cached(state, node_id, row) { + let singleton = [row]; + prefetch_node_edges_with_children( + cache, + catalog, + plan, + state, + node_id, + &singleton, + )?; + } + let rendered = + render_node_object(cache, catalog, plan, state, node_id, row)?; + if items[position] != rendered { + items[position] = rendered; + changed = true; + } + row_keys[position] = RowCacheKey::new(node_id, row); + applied_positions.push(position); + } + + if !state.root_list_requires_full_rebuild { + if changed { + let list_value = state.update_root_list_cache(row_keys, items); + state.last_root_patch = + Some(GraphqlRootListPatch::StablePositions(applied_positions)); + return Ok(Some(list_value)); + } + cached.row_keys = row_keys; + cached.row_positions = cached + .row_keys + .iter() + .enumerate() + .map(|(index, row_key)| (row_key.row_id, index)) + .collect(); + state.dirty_root_rows.clear(); + state.last_root_patch = + Some(GraphqlRootListPatch::StablePositions(Vec::new())); + let list_value = cached.list_value.clone(); + state.root_list_cache = Some(cached); + return Ok(Some(list_value)); + } + } + Err(_) => { + state.root_list_requires_full_rebuild = true; + } + } + } else { + state.root_list_requires_full_rebuild = true; + } + } + + if let Some((row_keys, items, patch)) = + try_render_root_node_list_splice(cache, catalog, plan, state, node_id, rows, &cached)? + { + let list_value = state.update_root_list_cache(row_keys, items); + state.last_root_patch = Some(patch); + return Ok(Some(list_value)); + } + + Ok(None) +} + +fn try_render_root_node_list_splice( + cache: &TableCache, + catalog: &GraphqlCatalog, + plan: &GraphqlBatchPlan, + state: &mut GraphqlBatchState, + node_id: NodeId, + rows: &[R], + cached: &RootListCacheEntry, +) -> GqlResult, Vec, GraphqlRootListPatch)>> { + let row_keys = rows + .iter() + .map(|row| RowCacheKey::new(node_id, row.row_rc())) + .collect::>(); + let new_row_positions = row_keys + .iter() + .enumerate() + .map(|(index, row_key)| (row_key.row_id, index)) + .collect::>(); + + let mut removed_positions = cached + .row_keys + .iter() + .enumerate() + .filter_map(|(index, row_key)| { + (!new_row_positions.contains_key(&row_key.row_id)).then_some(index) + }) + .collect::>(); + removed_positions.sort_unstable_by(|left, right| right.cmp(left)); + + let inserted_positions = row_keys + .iter() + .enumerate() + .filter_map(|(index, row_key)| { + (!cached.row_positions.contains_key(&row_key.row_id)).then_some(index) + }) + .collect::>(); + + let mut last_old_position = None; + for row_key in &row_keys { + let Some(old_position) = cached.row_positions.get(&row_key.row_id).copied() else { + continue; + }; + if last_old_position.is_some_and(|previous| old_position < previous) { + return Ok(None); + } + last_old_position = Some(old_position); + } + + let mut positions_needing_render = inserted_positions.clone(); + let mut updated_positions = Vec::new(); + for (position, row_key) in row_keys.iter().enumerate() { + let Some(old_position) = cached.row_positions.get(&row_key.row_id).copied() else { + continue; + }; + if cached.row_keys[old_position] != *row_key || state.dirty_root_rows.contains(&row_key.row_id) + { + positions_needing_render.push(position); + } + } + positions_needing_render.sort_unstable(); + positions_needing_render.dedup(); + + let uncached_rows = positions_needing_render + .iter() + .filter_map(|position| rows.get(*position).map(RowRenderRef::row_rc)) + .filter(|row| !row_is_cached(state, node_id, row)) + .collect::>(); + if !uncached_rows.is_empty() { + prefetch_node_edges_with_children(cache, catalog, plan, state, node_id, &uncached_rows)?; + } + + let positions_needing_render = positions_needing_render + .into_iter() + .collect::>(); + let mut items = Vec::with_capacity(rows.len()); + for (position, row_key) in row_keys.iter().enumerate() { + let old_position = cached.row_positions.get(&row_key.row_id).copied(); + if !positions_needing_render.contains(&position) { + if let Some(old_position) = old_position { + items.push(cached.items[old_position].clone()); + continue; + } + } + + let row = rows + .get(position) + .map(RowRenderRef::row_rc) + .ok_or_else(|| GqlError::new(GqlErrorKind::Execution, "root row position out of bounds"))?; + let rendered = render_node_object(cache, catalog, plan, state, node_id, row)?; + if let Some(old_position) = old_position { + if cached.items[old_position] == rendered { + items.push(cached.items[old_position].clone()); + } else { + updated_positions.push(position); + items.push(rendered); + } + } else { + items.push(rendered); + } + } + + Ok(Some(( + row_keys, + items, + GraphqlRootListPatch::Splice { + removed_positions, + inserted_positions, + updated_positions, + }, + ))) +} + +fn render_node_list( + cache: &TableCache, + catalog: &GraphqlCatalog, + plan: &GraphqlBatchPlan, + state: &mut GraphqlBatchState, + node_id: NodeId, + rows: &[R], ) -> GqlResult> { if rows.is_empty() { return Ok(Vec::new()); } - prefetch_node_edges(cache, catalog, plan, state, node_id, rows)?; + let uncached_rows = rows + .iter() + .map(RowRenderRef::row_rc) + .filter(|row| !row_is_cached(state, node_id, row)) + .collect::>(); + if !uncached_rows.is_empty() { + prefetch_node_edges_with_children(cache, catalog, plan, state, node_id, &uncached_rows)?; + } let mut values = Vec::with_capacity(rows.len()); for row in rows { values.push(render_node_object( - cache, catalog, plan, state, node_id, row, + cache, + catalog, + plan, + state, + node_id, + row.row_rc(), )?); } Ok(values) @@ -410,20 +799,27 @@ fn render_forward_relation( match child_row { Some(child_row) => { - prefetch_node_edges( - cache, - catalog, - plan, - state, - edge.child_node, - core::slice::from_ref(&child_row), - )?; + if !row_is_cached(state, edge.child_node, &child_row) { + let singleton = [&child_row]; + prefetch_node_edges_with_children( + cache, + catalog, + plan, + state, + edge.child_node, + &singleton, + )?; + } render_node_object(cache, catalog, plan, state, edge.child_node, &child_row) } None => Ok(ResponseValue::Null), } } +fn row_is_cached(state: &GraphqlBatchState, node_id: NodeId, row: &Rc) -> bool { + state.row_cache.contains_key(&RowCacheKey::new(node_id, row)) +} + fn render_reverse_relation( cache: &TableCache, catalog: &GraphqlCatalog, @@ -459,7 +855,7 @@ fn prefetch_node_edges( plan: &GraphqlBatchPlan, state: &mut GraphqlBatchState, node_id: NodeId, - rows: &[Rc], + rows: &[&Rc], ) -> GqlResult<()> { if rows.is_empty() { return Ok(()); @@ -506,7 +902,64 @@ fn prefetch_node_edges( Ok(()) } -fn collect_edge_keys(edge: &RelationEdgePlan, rows: &[Rc]) -> HashSet { +fn prefetch_node_edges_with_children( + cache: &TableCache, + catalog: &GraphqlCatalog, + plan: &GraphqlBatchPlan, + state: &mut GraphqlBatchState, + node_id: NodeId, + rows: &[&Rc], +) -> GqlResult<()> { + prefetch_node_edges(cache, catalog, plan, state, node_id, rows)?; + + let mut child_rows_by_node = HashMap::>>::new(); + let mut seen_child_rows = HashSet::::new(); + + for field in &plan.node(node_id).fields { + let edge_id = match field.kind { + RenderFieldKind::ForwardRelation { edge_id } + | RenderFieldKind::ReverseRelation { edge_id } => edge_id, + RenderFieldKind::Typename { .. } | RenderFieldKind::Column { .. } => continue, + }; + + let edge = plan.edge(edge_id); + let keys = collect_edge_keys(edge, rows); + if keys.is_empty() { + continue; + } + + let Some(edge_cache) = state.edge_bucket_cache.get(&edge_id) else { + continue; + }; + for key in keys { + let Some(child_rows) = edge_cache.get(&key) else { + continue; + }; + for child_row in child_rows { + if row_is_cached(state, edge.child_node, child_row) { + continue; + } + let child_row_key = RowCacheKey::new(edge.child_node, child_row); + if !seen_child_rows.insert(child_row_key) { + continue; + } + child_rows_by_node + .entry(edge.child_node) + .or_insert_with(Vec::new) + .push(child_row.clone()); + } + } + } + + for (child_node, child_rows) in child_rows_by_node { + let child_row_refs = child_rows.iter().collect::>(); + prefetch_node_edges(cache, catalog, plan, state, child_node, &child_row_refs)?; + } + + Ok(()) +} + +fn collect_edge_keys(edge: &RelationEdgePlan, rows: &[&Rc]) -> HashSet { let mut keys = HashSet::new(); for row in rows { let value = match edge.kind { @@ -533,11 +986,21 @@ fn fetch_edge_buckets( } let mut buckets = match edge.strategy { - RelationFetchStrategy::PlannerBatch => planner_batch_fetch(cache, catalog, edge, keys) - .or_else(|_| scan_and_bucket_fetch(cache, edge, keys)), - RelationFetchStrategy::IndexedProbeBatch => indexed_probe_fetch(cache, edge, keys) - .or_else(|_| planner_batch_fetch(cache, catalog, edge, keys)) - .or_else(|_| scan_and_bucket_fetch(cache, edge, keys)), + RelationFetchStrategy::PlannerBatch => match planner_batch_fetch(cache, catalog, edge, keys) + { + Ok(buckets) => Ok(buckets), + Err(error) if edge_query_uses_relations(edge) => Err(error), + Err(_) => scan_and_bucket_fetch(cache, edge, keys), + }, + RelationFetchStrategy::IndexedProbeBatch => match indexed_probe_fetch(cache, edge, keys) { + Ok(buckets) => Ok(buckets), + Err(error) if edge_query_uses_relations(edge) => Err(error), + Err(_) => match planner_batch_fetch(cache, catalog, edge, keys) { + Ok(buckets) => Ok(buckets), + Err(error) if edge_query_uses_relations(edge) => Err(error), + Err(_) => scan_and_bucket_fetch(cache, edge, keys), + }, + }, RelationFetchStrategy::ScanAndBucket => scan_and_bucket_fetch(cache, edge, keys), }?; @@ -547,6 +1010,23 @@ fn fetch_edge_buckets( Ok(buckets) } +fn edge_query_uses_relations(edge: &RelationEdgePlan) -> bool { + edge.query + .as_ref() + .and_then(|query| query.filter.as_ref()) + .is_some_and(filter_uses_relations) +} + +fn filter_uses_relations(filter: &BoundFilter) -> bool { + match filter { + BoundFilter::And(filters) | BoundFilter::Or(filters) => { + filters.iter().any(filter_uses_relations) + } + BoundFilter::Column(_) => false, + BoundFilter::Relation(_) => true, + } +} + fn planner_batch_fetch( cache: &TableCache, catalog: &GraphqlCatalog, @@ -561,13 +1041,10 @@ fn planner_batch_fetch( ) })?; let query = build_batch_query(table, edge, keys)?; - let plan = build_table_query_plan(table_name, table, &query)?; + let plan = build_table_query_plan(catalog, table_name, table, &query)?; let rows = execute_logical_plan(cache, table_name, plan)?; - let mut buckets = bucket_rows(rows, edge_target_column_index(edge)); - if let Some(query) = edge.query.as_ref() { - apply_bucket_window(&mut buckets, query); - } + let buckets = bucket_rows_for_query(rows, edge_target_column_index(edge), edge.query.as_ref()); Ok(buckets) } @@ -617,6 +1094,12 @@ fn indexed_probe_fetch( "reverse indexed probe fetch requires a bound collection query", ) })?; + if query.filter.as_ref().is_some_and(filter_uses_relations) { + return Err(GqlError::new( + GqlErrorKind::Unsupported, + "reverse indexed probe fetch cannot evaluate relation filters without planner support", + )); + } let index_name = if store.schema().get_index(&edge.relation.fk_name).is_some() { Some(edge.relation.fk_name.as_str()) } else { @@ -630,13 +1113,19 @@ fn indexed_probe_fetch( }; for key in keys { - let rows = fetch_rows_by_known_index_or_scan( + let rows = fetch_rows_by_known_index_or_scan_windowed( store, index_name, &edge.relation.child_column, key, + (query.filter.is_none() && query.order_by.is_empty()).then_some(query), ); - buckets.insert(key.clone(), apply_collection_query(rows, query)); + let rows = if query.filter.is_none() && query.order_by.is_empty() { + rows + } else { + apply_collection_query(rows, query) + }; + buckets.insert(key.clone(), rows); } } } @@ -669,6 +1158,12 @@ fn scan_and_bucket_fetch( } if let Some(query) = edge.query.as_ref() { + if query.filter.as_ref().is_some_and(filter_uses_relations) { + return Err(GqlError::new( + GqlErrorKind::Unsupported, + "scan-and-bucket fetch cannot evaluate relation filters without planner support", + )); + } for rows in buckets.values_mut() { let materialized = apply_collection_query(core::mem::take(rows), query); *rows = materialized; @@ -735,23 +1230,36 @@ fn relation_key_filter( })) } -fn apply_bucket_window(buckets: &mut HashMap>>, query: &BoundCollectionQuery) { - if query.limit.is_none() && query.offset == 0 { - return; - } - - for rows in buckets.values_mut() { - let start = core::cmp::min(query.offset, rows.len()); - let end = match query.limit { - Some(limit) => start.saturating_add(limit).min(rows.len()), - None => rows.len(), +fn bucket_rows(rows: Vec>, key_column_index: usize) -> HashMap>> { + let mut buckets: HashMap>> = HashMap::new(); + for row in rows { + let Some(key) = row.get(key_column_index).cloned() else { + continue; }; - *rows = rows[start..end].to_vec(); + if key.is_null() { + continue; + } + buckets.entry(key).or_insert_with(Vec::new).push(row); } + buckets } -fn bucket_rows(rows: Vec>, key_column_index: usize) -> HashMap>> { +fn bucket_rows_for_query( + rows: Vec>, + key_column_index: usize, + query: Option<&BoundCollectionQuery>, +) -> HashMap>> { + let Some(query) = query else { + return bucket_rows(rows, key_column_index); + }; + if query.limit.is_none() && query.offset == 0 { + return bucket_rows(rows, key_column_index); + } + let mut buckets: HashMap>> = HashMap::new(); + let mut seen_per_bucket: HashMap = HashMap::new(); + let window_end = query.limit.map(|limit| query.offset.saturating_add(limit)); + for row in rows { let Some(key) = row.get(key_column_index).cloned() else { continue; @@ -759,8 +1267,15 @@ fn bucket_rows(rows: Vec>, key_column_index: usize) -> HashMap= query.offset && window_end.is_none_or(|end| *seen < end); + *seen += 1; + if keep { + buckets.entry(key).or_insert_with(Vec::new).push(row); + } } + buckets } @@ -801,6 +1316,56 @@ fn fetch_rows_by_known_index_or_scan( .collect() } +fn fetch_rows_by_known_index_or_scan_windowed( + store: &RowStore, + index_name: &str, + column_name: &str, + value: &Value, + windowed_query: Option<&BoundCollectionQuery>, +) -> Vec> { + if let Some(query) = windowed_query { + if store.schema().get_index(index_name).is_some() { + return store.index_scan_with_limit_offset( + index_name, + Some(&KeyRange::only(value.clone())), + query.limit, + query.offset, + ); + } + + let Some(column_index) = store.schema().get_column_index(column_name) else { + return Vec::new(); + }; + let mut rows = Vec::new(); + let mut skipped = 0usize; + let mut emitted = 0usize; + store.visit_rows(|row| { + let matches = row + .get(column_index) + .map(|candidate| candidate.sql_eq(value)) + .unwrap_or(false); + if !matches { + return true; + } + if skipped < query.offset { + skipped += 1; + return true; + } + if let Some(limit) = query.limit { + if emitted >= limit { + return false; + } + } + rows.push(row.clone()); + emitted += 1; + true + }); + return rows; + } + + fetch_rows_by_known_index_or_scan(store, index_name, column_name, value) +} + fn find_single_column_index_name<'a>(store: &'a RowStore, column_name: &str) -> Option<&'a str> { store .schema() @@ -851,6 +1416,8 @@ mod tests { Some("posts"), ) .unwrap() + .add_index("idx_posts_author_id", &["author_id"], false) + .unwrap() .build() .unwrap(); let comments = TableBuilder::new("comments") @@ -872,6 +1439,8 @@ mod tests { Some("comments"), ) .unwrap() + .add_index("idx_comments_post_id", &["post_id"], false) + .unwrap() .build() .unwrap(); @@ -1011,6 +1580,20 @@ mod tests { (field, plan, rows, state) } + fn root_list_ptr(value: &ResponseValue) -> *const [ResponseValue] { + match value { + ResponseValue::List(items) => Rc::as_ptr(items), + other => panic!("expected list value, found {other:?}"), + } + } + + fn object_ptr(value: &ResponseValue) -> *const [ResponseField] { + match value { + ResponseValue::Object(fields) => Rc::as_ptr(fields), + other => panic!("expected object value, found {other:?}"), + } + } + #[test] fn batch_renderer_matches_recursive_execution_for_reverse_relation_order_limit() { let cache = build_cache(); @@ -1023,6 +1606,38 @@ mod tests { assert_eq!(actual, expected); } + #[test] + fn batch_plan_uses_indexed_probe_for_windowed_reverse_relation_without_order() { + let cache = build_cache(); + let catalog = GraphqlCatalog::from_table_cache(&cache); + let prepared = PreparedQuery::parse( + "{ users(orderBy: [{ field: ID, direction: ASC }]) { id posts(limit: 1, offset: 1) { id title } } }", + ) + .unwrap(); + let bound = prepared.bind(&catalog, None).unwrap(); + let field = bound.fields.into_iter().next().unwrap(); + let plan = crate::compile_batch_plan(&catalog, &field).unwrap(); + let posts_edge = plan + .edges() + .iter() + .find(|edge| edge.direct_table == "posts") + .expect("posts edge"); + + assert_eq!(posts_edge.strategy, RelationFetchStrategy::IndexedProbeBatch); + } + + #[test] + fn batch_renderer_matches_recursive_execution_for_reverse_relation_limit_offset_without_order() { + let cache = build_cache(); + let catalog = GraphqlCatalog::from_table_cache(&cache); + let query = "{ users(orderBy: [{ field: ID, direction: ASC }]) { id name posts(limit: 1, offset: 1) { id title } } }"; + + let expected = execute_query(&cache, &catalog, query, None, None).unwrap(); + let actual = execute_with_batch(&cache, &catalog, query); + + assert_eq!(actual, expected); + } + #[test] fn batch_renderer_matches_recursive_execution_for_multilevel_relations() { let cache = build_cache(); @@ -1053,6 +1668,8 @@ mod tests { &plan, &GraphqlInvalidation { root_changed: false, + dirty_root_rows: HashSet::new(), + stable_root_positions: false, changed_tables: alloc::vec!["comments".into()], dirty_edge_keys: HashMap::from([( comments_edge_id, @@ -1099,6 +1716,8 @@ mod tests { &plan, &GraphqlInvalidation { root_changed: false, + dirty_root_rows: HashSet::new(), + stable_root_positions: false, changed_tables: alloc::vec!["users".into()], dirty_edge_keys: HashMap::from([( author_edge_id, @@ -1127,4 +1746,144 @@ mod tests { assert!(cached_user_1); assert!(!cached_user_2); } + + #[test] + fn batch_invalidation_targets_changed_root_rows_without_flushing_unrelated_roots() { + let cache = build_cache(); + let catalog = GraphqlCatalog::from_table_cache(&cache); + let query = "{ posts(orderBy: [{ field: ID, direction: ASC }]) { id title author { id name } } }"; + + let (_field, plan, rows, mut state) = prepare_batch_execution(&cache, &catalog, query); + let root_node = plan.root_node(); + let root_post_10 = RowCacheKey::new(root_node, &rows[0]); + let root_post_11 = RowCacheKey::new(root_node, &rows[1]); + let root_post_12 = RowCacheKey::new(root_node, &rows[2]); + + state.apply_invalidation( + &plan, + &GraphqlInvalidation { + root_changed: true, + dirty_root_rows: HashSet::from([11_u64]), + stable_root_positions: false, + changed_tables: Vec::new(), + dirty_edge_keys: HashMap::new(), + dirty_table_rows: HashMap::new(), + }, + ); + + assert!(state.row_cache.contains_key(&root_post_10)); + assert!(!state.row_cache.contains_key(&root_post_11)); + assert!(state.row_cache.contains_key(&root_post_12)); + } + + #[test] + fn batch_renderer_reuses_root_list_when_stable_root_update_keeps_response_equal() { + let cache = build_cache(); + let catalog = GraphqlCatalog::from_table_cache(&cache); + let query = "{ posts(orderBy: [{ field: ID, direction: ASC }]) { id title author { id name } } }"; + + let (field, plan, rows, mut state) = prepare_batch_execution(&cache, &catalog, query); + let initial_list_ptr = root_list_ptr(&state.root_list_cache.as_ref().unwrap().list_value); + + let mut updated_rows = rows.clone(); + updated_rows[1] = Rc::new(Row::new_with_version( + rows[1].id(), + rows[1].version() + 1, + rows[1].values().to_vec(), + )); + + state.apply_invalidation( + &plan, + &GraphqlInvalidation { + root_changed: true, + dirty_root_rows: HashSet::from([rows[1].id()]), + stable_root_positions: true, + changed_tables: Vec::new(), + dirty_edge_keys: HashMap::new(), + dirty_table_rows: HashMap::new(), + }, + ); + + let response = + render_graphql_response(&cache, &catalog, &field, &plan, &mut state, &updated_rows) + .unwrap(); + let rerendered_list_ptr = root_list_ptr(&state.root_list_cache.as_ref().unwrap().list_value); + + assert_eq!(response, execute_with_batch(&cache, &catalog, query)); + assert_eq!(rerendered_list_ptr, initial_list_ptr); + } + + #[test] + fn batch_renderer_only_rebuilds_changed_root_object_when_positions_stay_stable() { + let cache = build_cache(); + let catalog = GraphqlCatalog::from_table_cache(&cache); + let query = "{ posts(orderBy: [{ field: ID, direction: ASC }]) { id title author { id name } } }"; + + let (field, plan, rows, mut state) = prepare_batch_execution(&cache, &catalog, query); + let initial_items = state.root_list_cache.as_ref().unwrap().items.clone(); + let initial_first_ptr = object_ptr(&initial_items[0]); + let initial_second_ptr = object_ptr(&initial_items[1]); + let initial_third_ptr = object_ptr(&initial_items[2]); + + let mut updated_values = rows[1].values().to_vec(); + updated_values[2] = Value::String("second+".into()); + let mut updated_rows = rows.clone(); + updated_rows[1] = Rc::new(Row::new_with_version( + rows[1].id(), + rows[1].version() + 1, + updated_values, + )); + + state.apply_invalidation( + &plan, + &GraphqlInvalidation { + root_changed: true, + dirty_root_rows: HashSet::from([rows[1].id()]), + stable_root_positions: true, + changed_tables: Vec::new(), + dirty_edge_keys: HashMap::new(), + dirty_table_rows: HashMap::new(), + }, + ); + + render_graphql_response(&cache, &catalog, &field, &plan, &mut state, &updated_rows).unwrap(); + let rerendered_items = &state.root_list_cache.as_ref().unwrap().items; + + assert_eq!(object_ptr(&rerendered_items[0]), initial_first_ptr); + assert_ne!(object_ptr(&rerendered_items[1]), initial_second_ptr); + assert_eq!(object_ptr(&rerendered_items[2]), initial_third_ptr); + } + + #[test] + fn batch_renderer_reports_splice_patch_for_root_membership_change() { + let cache = build_cache(); + let catalog = GraphqlCatalog::from_table_cache(&cache); + let query = "{ posts(orderBy: [{ field: ID, direction: ASC }]) { id title author { id name } } }"; + + let (field, plan, rows, mut state) = prepare_batch_execution(&cache, &catalog, query); + let updated_rows = alloc::vec![rows[0].clone(), rows[2].clone()]; + + state.apply_invalidation( + &plan, + &GraphqlInvalidation { + root_changed: true, + dirty_root_rows: HashSet::from([rows[1].id()]), + stable_root_positions: false, + changed_tables: Vec::new(), + dirty_edge_keys: HashMap::new(), + dirty_table_rows: HashMap::new(), + }, + ); + + render_graphql_response(&cache, &catalog, &field, &plan, &mut state, &updated_rows).unwrap(); + + assert_eq!( + state.last_root_patch(), + Some(&GraphqlRootListPatch::Splice { + removed_positions: alloc::vec![1], + inserted_positions: Vec::new(), + updated_positions: Vec::new(), + }) + ); + } } diff --git a/crates/gql/src/bind.rs b/crates/gql/src/bind.rs index 83b47f2..8a649b7 100644 --- a/crates/gql/src/bind.rs +++ b/crates/gql/src/bind.rs @@ -1,3 +1,4 @@ +use alloc::boxed::Box; use alloc::collections::BTreeMap; use alloc::format; use alloc::string::{String, ToString}; @@ -118,6 +119,7 @@ pub enum BoundFilter { And(Vec), Or(Vec), Column(ColumnPredicate), + Relation(RelationPredicate), } #[derive(Clone, Debug)] @@ -127,6 +129,13 @@ pub struct ColumnPredicate { pub ops: Vec, } +#[derive(Clone, Debug)] +pub struct RelationPredicate { + pub relation: RelationMeta, + pub target_table: String, + pub filter: Box, +} + #[derive(Clone, Debug)] pub enum PredicateOp { IsNull(bool), @@ -169,6 +178,10 @@ pub fn is_delta_capable_root_field(field: &BoundRootField) -> bool { query.order_by.is_empty() && query.limit.is_none() && query.offset == 0 + && query + .filter + .as_ref() + .map_or(true, is_delta_capable_filter) && is_delta_capable_selection(selection) } BoundRootFieldKind::ByPk { selection, .. } => is_delta_capable_selection(selection), @@ -188,16 +201,40 @@ fn is_delta_capable_field(field: &BoundField) -> bool { BoundField::Typename { .. } | BoundField::Column { .. } => true, BoundField::ForwardRelation { selection, .. } => is_delta_capable_selection(selection), BoundField::ReverseRelation { + relation, query, selection, .. - } => { - query.order_by.is_empty() - && query.limit.is_none() - && query.offset == 0 - && is_delta_capable_selection(selection) + } => reverse_relation_query_is_delta_capable(relation, query) + && is_delta_capable_selection(selection), + } +} + +fn is_delta_capable_filter(filter: &BoundFilter) -> bool { + match filter { + BoundFilter::And(filters) | BoundFilter::Or(filters) => { + filters.iter().all(is_delta_capable_filter) } + BoundFilter::Column(_) => true, + BoundFilter::Relation(predicate) => is_delta_capable_filter(predicate.filter.as_ref()), } } +fn reverse_relation_query_is_delta_capable( + relation: &RelationMeta, + query: &BoundCollectionQuery, +) -> bool { + query.order_by.is_empty() + && query.offset == 0 + && query + .filter + .as_ref() + .map_or(true, is_delta_capable_filter) + && match query.limit { + None => true, + Some(1) => relation.child_column_unique, + Some(_) => false, + } +} + fn collect_root_field_dependency_tables( field: &BoundRootField, tables: &mut hashbrown::HashSet, @@ -230,6 +267,14 @@ fn collect_root_field_dependency_tables( .. } => { tables.insert(table_name.clone()); + if let BoundRootFieldKind::Collection { query, .. } + | BoundRootFieldKind::Update { query, .. } + | BoundRootFieldKind::Delete { query, .. } = &field.kind + { + if let Some(filter) = &query.filter { + collect_filter_dependency_tables(filter, tables); + } + } collect_selection_dependency_tables(selection, tables); } } @@ -251,17 +296,39 @@ fn collect_selection_dependency_tables( collect_selection_dependency_tables(selection, tables); } BoundField::ReverseRelation { + query, relation, selection, .. } => { tables.insert(relation.child_table.clone()); + if let Some(filter) = &query.filter { + collect_filter_dependency_tables(filter, tables); + } collect_selection_dependency_tables(selection, tables); } } } } +fn collect_filter_dependency_tables( + filter: &BoundFilter, + tables: &mut hashbrown::HashSet, +) { + match filter { + BoundFilter::And(filters) | BoundFilter::Or(filters) => { + for filter in filters { + collect_filter_dependency_tables(filter, tables); + } + } + BoundFilter::Column(_) => {} + BoundFilter::Relation(predicate) => { + tables.insert(predicate.target_table.clone()); + collect_filter_dependency_tables(predicate.filter.as_ref(), tables); + } + } +} + pub fn bind_document( document: &Document, catalog: &GraphqlCatalog, @@ -357,7 +424,7 @@ fn bind_operation( let kind = match root_field.kind { RootFieldKind::List => BoundRootFieldKind::Collection { table_name: root_field.table_name.clone(), - query: bind_collection_arguments(field, table, variables)?, + query: bind_collection_arguments(field, table, catalog, variables)?, selection: bind_required_selection_set(field, table, catalog, variables)?, }, RootFieldKind::ByPk => BoundRootFieldKind::ByPk { @@ -374,14 +441,20 @@ fn bind_operation( let arguments = materialize_argument_map(field, variables)?; BoundRootFieldKind::Update { table_name: root_field.table_name.clone(), - query: bind_collection_arguments_from_map(field, table, &arguments, &["set"])?, + query: bind_collection_arguments_from_map( + field, + table, + catalog, + &arguments, + &["set"], + )?, assignments: bind_assignments_from_map(field, table, &arguments)?, selection: bind_required_selection_set(field, table, catalog, variables)?, } } RootFieldKind::Delete => BoundRootFieldKind::Delete { table_name: root_field.table_name.clone(), - query: bind_collection_arguments(field, table, variables)?, + query: bind_collection_arguments(field, table, catalog, variables)?, selection: bind_required_selection_set(field, table, catalog, variables)?, }, }; @@ -477,7 +550,7 @@ fn bind_selection_set( ) })?; let nested = bind_required_selection_set(field, target_table, catalog, variables)?; - let query = bind_collection_arguments(field, target_table, variables)?; + let query = bind_collection_arguments(field, target_table, catalog, variables)?; fields.push(BoundField::ReverseRelation { response_key: field.response_key().to_string(), relation: relation.clone(), @@ -493,15 +566,17 @@ fn bind_selection_set( fn bind_collection_arguments( field: &Field, table: &TableMeta, + catalog: &GraphqlCatalog, variables: &VariableValues, ) -> GqlResult { let arguments = materialize_argument_map(field, variables)?; - bind_collection_arguments_from_map(field, table, &arguments, &[]) + bind_collection_arguments_from_map(field, table, catalog, &arguments, &[]) } fn bind_collection_arguments_from_map( field: &Field, table: &TableMeta, + catalog: &GraphqlCatalog, arguments: &BTreeMap, extra_allowed: &[&str], ) -> GqlResult { @@ -511,7 +586,7 @@ fn bind_collection_arguments_from_map( let filter = arguments .get("where") - .map(|value| bind_where(value, table)) + .map(|value| bind_where(value, table, catalog)) .transpose()? .flatten(); let order_by = arguments @@ -810,28 +885,75 @@ fn materialize_input_value( } } -fn bind_where(value: &InputValue, table: &TableMeta) -> GqlResult> { +fn bind_where( + value: &InputValue, + table: &TableMeta, + catalog: &GraphqlCatalog, +) -> GqlResult> { let fields = expect_object(value, "where")?; let mut predicates = Vec::new(); for field in fields { match field.name.as_str() { - "AND" => predicates.push(bind_logical_filter(&field.value, table, true)?), - "OR" => predicates.push(bind_logical_filter(&field.value, table, false)?), - column_name => { - let column = table.column(column_name).ok_or_else(|| { + "AND" => predicates.push(bind_logical_filter(&field.value, table, catalog, true)?), + "OR" => predicates.push(bind_logical_filter(&field.value, table, catalog, false)?), + field_name => { + let table_field = table.field(field_name).ok_or_else(|| { GqlError::new( GqlErrorKind::Validation, format!( - "unknown filter column `{}` on `{}`", - column_name, table.graphql_name + "unknown filter field `{}` on `{}`", + field_name, table.graphql_name ), ) })?; - predicates.push(BoundFilter::Column(bind_column_predicate( - column, - &field.value, - )?)); + match table_field { + TableFieldMeta::Column(column) => { + predicates.push(BoundFilter::Column(bind_column_predicate( + column, + &field.value, + )?)); + } + TableFieldMeta::ForwardRelation(relation) => { + let target_table = catalog.table(&relation.parent_table).ok_or_else(|| { + GqlError::new( + GqlErrorKind::Binding, + format!("table `{}` is not available", relation.parent_table), + ) + })?; + predicates.push(bind_single_relation_predicate( + relation, + target_table, + &field.value, + catalog, + field_name, + )?); + } + TableFieldMeta::ReverseRelation(relation) => { + if !relation.child_column_unique { + return Err(GqlError::new( + GqlErrorKind::Unsupported, + format!( + "multi-row reverse relation filter `{}` on `{}` is not supported yet", + field_name, table.graphql_name + ), + )); + } + let target_table = catalog.table(&relation.child_table).ok_or_else(|| { + GqlError::new( + GqlErrorKind::Binding, + format!("table `{}` is not available", relation.child_table), + ) + })?; + predicates.push(bind_single_relation_predicate( + relation, + target_table, + &field.value, + catalog, + field_name, + )?); + } + } } } } @@ -843,11 +965,32 @@ fn bind_where(value: &InputValue, table: &TableMeta) -> GqlResult GqlResult { +fn bind_single_relation_predicate( + relation: &RelationMeta, + target_table: &TableMeta, + value: &InputValue, + catalog: &GraphqlCatalog, + field_name: &str, +) -> GqlResult { + let filter = bind_where(value, target_table, catalog)?.unwrap_or(BoundFilter::And(Vec::new())); + let _ = field_name; + Ok(BoundFilter::Relation(RelationPredicate { + relation: relation.clone(), + target_table: target_table.table_name.clone(), + filter: Box::new(filter), + })) +} + +fn bind_logical_filter( + value: &InputValue, + table: &TableMeta, + catalog: &GraphqlCatalog, + and: bool, +) -> GqlResult { let values = expect_list(value, if and { "AND" } else { "OR" })?; let mut predicates = Vec::with_capacity(values.len()); for value in values { - let predicate = bind_where(value, table)?.ok_or_else(|| { + let predicate = bind_where(value, table, catalog)?.ok_or_else(|| { GqlError::new( GqlErrorKind::Validation, if and { diff --git a/crates/gql/src/catalog.rs b/crates/gql/src/catalog.rs index a80a658..3752ca3 100644 --- a/crates/gql/src/catalog.rs +++ b/crates/gql/src/catalog.rs @@ -246,6 +246,7 @@ pub struct RelationMeta { pub child_table: String, pub child_column: String, pub child_column_index: usize, + pub child_column_unique: bool, pub parent_table: String, pub parent_column: String, pub parent_column_index: usize, @@ -334,6 +335,7 @@ fn build_table_meta( child_table: fk.child_table.clone(), child_column: fk.child_column.clone(), child_column_index, + child_column_unique: is_single_column_unique_key(table, &fk.child_column), parent_table: fk.parent_table.clone(), parent_column: fk.parent_column.clone(), parent_column_index, @@ -363,6 +365,7 @@ fn build_table_meta( child_table: fk.child_table.clone(), child_column: fk.child_column.clone(), child_column_index, + child_column_unique: is_single_column_unique_key(child_table, &fk.child_column), parent_table: fk.parent_table.clone(), parent_column: fk.parent_column.clone(), parent_column_index, @@ -397,6 +400,16 @@ fn build_table_meta( } } +fn is_single_column_unique_key(table: &Table, column_name: &str) -> bool { + table.indices().iter().any(|index| { + index.is_unique() + && index.columns().len() == 1 + && index.columns()[0].name == column_name + }) || table.primary_key().is_some_and(|primary_key| { + primary_key.columns().len() == 1 && primary_key.columns()[0].name == column_name + }) +} + fn build_reverse_relations(tables: &[Table]) -> BTreeMap> { let mut map: BTreeMap> = BTreeMap::new(); for table in tables { diff --git a/crates/gql/src/execute.rs b/crates/gql/src/execute.rs index d48e2fd..b164bc2 100644 --- a/crates/gql/src/execute.rs +++ b/crates/gql/src/execute.rs @@ -402,9 +402,12 @@ fn select_collection_rows( ) })?; - match build_table_query_plan(table_name, table, query) { + match build_table_query_plan(catalog, table_name, table, query) { Ok(plan) => execute_logical_plan(cache, table_name, plan), Err(error) if error.kind() == GqlErrorKind::Unsupported => { + if query.filter.as_ref().is_some_and(filter_uses_relations) { + return Err(error); + } select_collection_rows_fallback(cache, table_name, query) } Err(error) => Err(error), @@ -658,11 +661,22 @@ fn compare_rows(left: &Row, right: &Row, order_by: &[crate::bind::OrderSpec]) -> left.id().cmp(&right.id()) } +fn filter_uses_relations(filter: &BoundFilter) -> bool { + match filter { + BoundFilter::And(filters) | BoundFilter::Or(filters) => { + filters.iter().any(filter_uses_relations) + } + BoundFilter::Column(_) => false, + BoundFilter::Relation(_) => true, + } +} + fn matches_filter(row: &Row, filter: &BoundFilter) -> bool { match filter { BoundFilter::And(filters) => filters.iter().all(|filter| matches_filter(row, filter)), BoundFilter::Or(filters) => filters.iter().any(|filter| matches_filter(row, filter)), BoundFilter::Column(predicate) => matches_column_predicate(row, predicate), + BoundFilter::Relation(_) => false, } } diff --git a/crates/gql/src/lib.rs b/crates/gql/src/lib.rs index 3ee0908..34ba197 100644 --- a/crates/gql/src/lib.rs +++ b/crates/gql/src/lib.rs @@ -17,7 +17,7 @@ pub mod response; pub mod schema; pub use ast::{Document, InputValue, OperationDefinition, OperationType, SelectionSet}; -pub use batch_render::{GraphqlBatchState, GraphqlInvalidation}; +pub use batch_render::{GraphqlBatchState, GraphqlInvalidation, GraphqlRootListPatch}; pub use bind::{BoundOperation, VariableValues}; pub use cache::SchemaCache; pub use catalog::GraphqlCatalog; diff --git a/crates/gql/src/plan.rs b/crates/gql/src/plan.rs index 8749086..e34166c 100644 --- a/crates/gql/src/plan.rs +++ b/crates/gql/src/plan.rs @@ -1,4 +1,5 @@ use alloc::boxed::Box; +use alloc::collections::BTreeMap; use alloc::format; use alloc::rc::Rc; use alloc::string::{String, ToString}; @@ -18,7 +19,7 @@ use crate::bind::{ BoundCollectionQuery, BoundFilter, BoundRootField, BoundRootFieldKind, JsonPredicate, PredicateOp, }; -use crate::catalog::{GraphqlCatalog, TableMeta}; +use crate::catalog::{GraphqlCatalog, RelationMeta, TableMeta}; use crate::error::{GqlError, GqlErrorKind, GqlResult}; #[derive(Clone, Debug)] @@ -27,6 +28,95 @@ pub struct RootFieldPlan { pub logical_plan: LogicalPlan, } +struct RelationLoweringState<'a> { + root_table_name: &'a str, + plan: LogicalPlan, + joins_by_path: BTreeMap, String>, + paths_by_table: BTreeMap>, +} + +impl<'a> RelationLoweringState<'a> { + fn new(root_table_name: &'a str, root_table: &'a TableMeta, _catalog: &'a GraphqlCatalog) -> Self { + let root_path = Vec::new(); + let mut paths_by_table = BTreeMap::new(); + paths_by_table.insert(root_table.table_name.clone(), root_path.clone()); + Self { + root_table_name, + plan: LogicalPlan::Scan { + table: root_table_name.to_string(), + }, + joins_by_path: BTreeMap::new(), + paths_by_table, + } + } + + fn into_plan(self) -> LogicalPlan { + self.plan + } + + fn has_joins(&self) -> bool { + !self.joins_by_path.is_empty() + } + + fn ensure_join( + &mut self, + current_table_name: &str, + current_table: &TableMeta, + relation: &RelationMeta, + target_table: &TableMeta, + path: Vec, + ) -> GqlResult { + if let Some(existing) = self.joins_by_path.get(&path) { + return Ok(existing.clone()); + } + + if target_table.table_name == current_table.table_name || target_table.table_name == self.root_table_name + { + return Err(GqlError::new( + GqlErrorKind::Unsupported, + format!( + "relation filter path `{}` requires a self-join or alias, which is not supported yet", + render_relation_path(&path) + ), + )); + } + + if let Some(existing_path) = self.paths_by_table.get(&target_table.table_name) { + if existing_path != &path { + return Err(GqlError::new( + GqlErrorKind::Unsupported, + format!( + "relation filter path `{}` reuses table `{}` through a second alias-less path, which is not supported yet", + render_relation_path(&path), + target_table.table_name + ), + )); + } + return Ok(target_table.table_name.clone()); + } + + let condition = build_relation_join_condition( + current_table_name, + current_table, + relation, + &target_table.table_name, + target_table, + )?; + let left = core::mem::replace(&mut self.plan, LogicalPlan::Empty); + self.plan = LogicalPlan::left_join( + left, + LogicalPlan::Scan { + table: target_table.table_name.clone(), + }, + condition, + ); + self.joins_by_path.insert(path.clone(), target_table.table_name.clone()); + self.paths_by_table + .insert(target_table.table_name.clone(), path); + Ok(target_table.table_name.clone()) + } +} + pub fn build_root_field_plan( catalog: &GraphqlCatalog, field: &BoundRootField, @@ -43,7 +133,7 @@ pub fn build_root_field_plan( })?; Ok(RootFieldPlan { table_name: table_name.clone(), - logical_plan: build_table_query_plan(table_name, table, query)?, + logical_plan: build_table_query_plan(catalog, table_name, table, query)?, }) } BoundRootFieldKind::ByPk { @@ -70,19 +160,35 @@ pub fn build_root_field_plan( } pub(crate) fn build_table_query_plan( + catalog: &GraphqlCatalog, table_name: &str, table: &TableMeta, query: &BoundCollectionQuery, ) -> GqlResult { - let mut plan = LogicalPlan::Scan { - table: table_name.to_string(), + let mut lowering = RelationLoweringState::new(table_name, table, catalog); + let filter_expr = if let Some(filter) = &query.filter { + Some(build_filter_expr( + catalog, + table_name, + table, + filter, + &mut lowering, + &[], + )?) + } else { + None }; + let has_joins = lowering.has_joins(); + let mut plan = lowering.into_plan(); - if let Some(filter) = &query.filter { + if let Some(predicate) = filter_expr { plan = LogicalPlan::Filter { input: Box::new(plan), - predicate: build_filter_expr(table_name, table, filter)?, + predicate, }; + if has_joins { + plan = build_root_projection(table_name, table, plan); + } } if !query.order_by.is_empty() { @@ -186,22 +292,39 @@ fn build_by_pk_plan( } fn build_filter_expr( + catalog: &GraphqlCatalog, table_name: &str, table: &TableMeta, filter: &BoundFilter, + lowering: &mut RelationLoweringState<'_>, + current_path: &[String], ) -> GqlResult { match filter { BoundFilter::And(filters) => { let mut expressions = Vec::with_capacity(filters.len()); for filter in filters { - expressions.push(build_filter_expr(table_name, table, filter)?); + expressions.push(build_filter_expr( + catalog, + table_name, + table, + filter, + lowering, + current_path, + )?); } Ok(and_all(expressions)) } BoundFilter::Or(filters) => { let mut expressions = Vec::with_capacity(filters.len()); for filter in filters { - expressions.push(build_filter_expr(table_name, table, filter)?); + expressions.push(build_filter_expr( + catalog, + table_name, + table, + filter, + lowering, + current_path, + )?); } Ok(or_all(expressions)) } @@ -224,9 +347,145 @@ fn build_filter_expr( } Ok(and_all(expressions)) } + BoundFilter::Relation(predicate) => { + let target_table = catalog.table(&predicate.target_table).ok_or_else(|| { + GqlError::new( + GqlErrorKind::Binding, + format!("table `{}` is not available", predicate.target_table), + ) + })?; + let mut path = current_path.to_vec(); + path.push(predicate.relation.name.clone()); + let joined_table_name = + lowering.ensure_join(table_name, table, &predicate.relation, target_table, path.clone())?; + let nested = build_filter_expr( + catalog, + &joined_table_name, + target_table, + predicate.filter.as_ref(), + lowering, + &path, + )?; + Ok(AstExpr::and( + relation_exists_expr(&joined_table_name, target_table)?, + nested, + )) + } } } +fn build_root_projection(table_name: &str, table: &TableMeta, input: LogicalPlan) -> LogicalPlan { + let columns = table + .columns() + .iter() + .map(|column| AstExpr::column(table_name, &column.name, column.index)) + .collect(); + LogicalPlan::Project { + input: Box::new(input), + columns, + } +} + +fn relation_exists_expr(table_name: &str, table: &TableMeta) -> GqlResult { + let column = table + .primary_key() + .and_then(|pk| pk.columns.first()) + .or_else(|| table.columns().iter().find(|column| !column.nullable)) + .ok_or_else(|| { + GqlError::new( + GqlErrorKind::Unsupported, + format!( + "relation filter target `{}` must expose a primary key or a non-null column", + table.table_name + ), + ) + })?; + Ok(AstExpr::is_not_null(AstExpr::column( + table_name, + &column.name, + column.index, + ))) +} + +fn build_relation_join_condition( + current_table_name: &str, + current_table: &TableMeta, + relation: &RelationMeta, + target_table_name: &str, + target_table: &TableMeta, +) -> GqlResult { + if current_table.table_name == relation.child_table && target_table.table_name == relation.parent_table + { + let left = current_table.column_by_index(relation.child_column_index).ok_or_else(|| { + GqlError::new( + GqlErrorKind::Binding, + format!( + "column index {} was not found on `{}`", + relation.child_column_index, + current_table.table_name + ), + ) + })?; + let right = target_table.column_by_index(relation.parent_column_index).ok_or_else(|| { + GqlError::new( + GqlErrorKind::Binding, + format!( + "column index {} was not found on `{}`", + relation.parent_column_index, + target_table.table_name + ), + ) + })?; + return Ok(AstExpr::eq( + AstExpr::column(current_table_name, &left.name, left.index), + AstExpr::column(target_table_name, &right.name, right.index), + )); + } + + if current_table.table_name == relation.parent_table && target_table.table_name == relation.child_table + { + let left = current_table.column_by_index(relation.parent_column_index).ok_or_else(|| { + GqlError::new( + GqlErrorKind::Binding, + format!( + "column index {} was not found on `{}`", + relation.parent_column_index, + current_table.table_name + ), + ) + })?; + let right = target_table.column_by_index(relation.child_column_index).ok_or_else(|| { + GqlError::new( + GqlErrorKind::Binding, + format!( + "column index {} was not found on `{}`", + relation.child_column_index, + target_table.table_name + ), + ) + })?; + return Ok(AstExpr::eq( + AstExpr::column(current_table_name, &left.name, left.index), + AstExpr::column(target_table_name, &right.name, right.index), + )); + } + + Err(GqlError::new( + GqlErrorKind::Binding, + format!( + "relation `{}` does not connect `{}` to `{}`", + relation.name, current_table.table_name, target_table.table_name + ), + )) +} + +fn render_relation_path(path: &[String]) -> String { + if path.is_empty() { + return "".to_string(); + } + path.join(".") +} + fn build_predicate_expr(column_expr: AstExpr, op: &PredicateOp) -> GqlResult { match op { PredicateOp::IsNull(true) => Ok(AstExpr::is_null(column_expr)), @@ -503,12 +762,17 @@ impl<'a> DataSource for TableCacheDataSource<'a> { table: &str, index: &str, pairs: &[(&str, &str)], + match_all: bool, ) -> ExecutionResult>> { let store = self .cache .get_table(table) .ok_or_else(|| ExecutionError::TableNotFound(table.into()))?; - Ok(store.gin_index_get_by_key_values_all(index, pairs)) + Ok(if match_all { + store.gin_index_get_by_key_values_all(index, pairs) + } else { + store.gin_index_get_by_key_values_any(index, pairs) + }) } } @@ -623,6 +887,81 @@ mod tests { (cache, catalog, field) } + fn build_relation_filter_cache() -> TableCache { + let mut cache = build_cache(); + let profiles = TableBuilder::new("profiles") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("user_id", DataType::Int64) + .unwrap() + .add_column("bio", DataType::String) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .add_index("idx_profiles_user_id", &["user_id"], true) + .unwrap() + .add_foreign_key_with_graphql_names( + "fk_profiles_user", + "user_id", + "users", + "id", + Some("user"), + Some("profile"), + ) + .unwrap() + .build() + .unwrap(); + + cache.create_table(profiles).unwrap(); + cache + .get_table_mut("profiles") + .unwrap() + .insert(Row::new( + 10, + alloc::vec![ + Value::Int64(10), + Value::Int64(1), + Value::String("Alpha".into()), + ], + )) + .unwrap(); + cache + .get_table_mut("profiles") + .unwrap() + .insert(Row::new( + 11, + alloc::vec![ + Value::Int64(11), + Value::Int64(2), + Value::String("Builder".into()), + ], + )) + .unwrap(); + cache + } + + fn logical_plan_contains_join(plan: &LogicalPlan) -> bool { + match plan { + LogicalPlan::Join { .. } => true, + LogicalPlan::Filter { input, .. } + | LogicalPlan::Project { input, .. } + | LogicalPlan::Aggregate { input, .. } + | LogicalPlan::Sort { input, .. } + | LogicalPlan::Limit { input, .. } => logical_plan_contains_join(input), + LogicalPlan::CrossProduct { left, right } | LogicalPlan::Union { left, right, .. } => { + logical_plan_contains_join(left) || logical_plan_contains_join(right) + } + LogicalPlan::Scan { .. } + | LogicalPlan::IndexScan { .. } + | LogicalPlan::IndexGet { .. } + | LogicalPlan::IndexInGet { .. } + | LogicalPlan::GinIndexScan { .. } + | LogicalPlan::GinIndexScanMulti { .. } + | LogicalPlan::Empty => false, + } + } + #[test] fn root_where_eq_lowers_to_index_get() { let (cache, catalog, field) = @@ -672,6 +1011,26 @@ mod tests { assert_eq!(rows[0].get(0), Some(&Value::Int64(2))); } + #[test] + fn unique_reverse_relation_filter_lowers_to_join_and_executes() { + let cache = build_relation_filter_cache(); + let catalog = GraphqlCatalog::from_table_cache(&cache); + let prepared = PreparedQuery::parse( + "{ users(where: { profile: { bio: { eq: \"Builder\" } } }) { id name } }", + ) + .unwrap(); + let bound = prepared.bind(&catalog, None).unwrap(); + let field = bound.fields.first().unwrap(); + + let plan = build_root_field_plan(&catalog, field).unwrap(); + assert!(logical_plan_contains_join(&plan.logical_plan)); + + let rows = execute_logical_plan(&cache, &plan.table_name, plan.logical_plan).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].get(0), Some(&Value::Int64(2))); + assert_eq!(rows[0].get(1), Some(&Value::String("Bob".into()))); + } + #[test] fn directive_pruning_keeps_remaining_root_field_on_planner_path() { let cache = build_cache(); diff --git a/crates/gql/src/query.rs b/crates/gql/src/query.rs index 52621dc..ee61a7c 100644 --- a/crates/gql/src/query.rs +++ b/crates/gql/src/query.rs @@ -178,14 +178,14 @@ mod tests { fn object_fields(value: &ResponseValue) -> &[ResponseField] { match value { - ResponseValue::Object(fields) => fields, + ResponseValue::Object(fields) => fields.as_ref(), other => panic!("expected object, got {other:?}"), } } fn list_items(value: &ResponseValue) -> &[ResponseValue] { match value { - ResponseValue::List(items) => items, + ResponseValue::List(items) => items.as_ref(), other => panic!("expected list, got {other:?}"), } } @@ -193,13 +193,13 @@ mod tests { fn field<'a>(fields: &'a [ResponseField], name: &str) -> &'a ResponseValue { fields .iter() - .find(|field| field.name == name) + .find(|field| field.name.as_ref() == name) .map(|field| &field.value) .unwrap_or_else(|| panic!("missing field `{name}`")) } fn has_field(fields: &[ResponseField], name: &str) -> bool { - fields.iter().any(|field| field.name == name) + fields.iter().any(|field| field.name.as_ref() == name) } fn int64(value: &ResponseValue) -> i64 { diff --git a/crates/gql/src/render_plan.rs b/crates/gql/src/render_plan.rs index 31fdbaf..5f8e63a 100644 --- a/crates/gql/src/render_plan.rs +++ b/crates/gql/src/render_plan.rs @@ -5,7 +5,8 @@ use alloc::vec::Vec; use hashbrown::{HashMap, HashSet}; use crate::bind::{ - BoundCollectionQuery, BoundField, BoundRootField, BoundRootFieldKind, BoundSelectionSet, + BoundCollectionQuery, BoundField, BoundFilter, BoundRootField, BoundRootFieldKind, + BoundSelectionSet, }; use crate::catalog::{GraphqlCatalog, RelationMeta, TableMeta}; use crate::error::{GqlError, GqlErrorKind, GqlResult}; @@ -314,19 +315,69 @@ fn choose_forward_strategy( } fn choose_reverse_strategy(query: &BoundCollectionQuery) -> RelationFetchStrategy { - if query.filter.is_none() - && query.order_by.is_empty() - && query.limit.is_none() - && query.offset == 0 - { + if query.order_by.is_empty() && query.filter.as_ref().is_none_or(|filter| !uses_relation_filter(filter)) { RelationFetchStrategy::IndexedProbeBatch } else { RelationFetchStrategy::PlannerBatch } } +fn uses_relation_filter(filter: &BoundFilter) -> bool { + match filter { + BoundFilter::And(filters) | BoundFilter::Or(filters) => { + filters.iter().any(uses_relation_filter) + } + BoundFilter::Column(_) => false, + BoundFilter::Relation(_) => true, + } +} + fn is_single_column_primary_key(table: &TableMeta, column_name: &str) -> bool { table .primary_key() .is_some_and(|pk| pk.columns.len() == 1 && pk.columns[0].name == column_name) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::bind::{ColumnPredicate, PredicateOp}; + use cynos_core::{DataType, Value}; + + #[test] + fn reverse_strategy_uses_indexed_probe_for_windowed_non_relational_queries() { + let query = BoundCollectionQuery { + filter: Some(BoundFilter::Column(ColumnPredicate { + column_index: 1, + data_type: DataType::Int64, + ops: vec![PredicateOp::Eq(Value::Int64(1))], + })), + order_by: Vec::new(), + limit: Some(5), + offset: 2, + }; + + assert_eq!( + choose_reverse_strategy(&query), + RelationFetchStrategy::IndexedProbeBatch + ); + } + + #[test] + fn reverse_strategy_keeps_planner_batch_for_ordered_queries() { + let query = BoundCollectionQuery { + filter: None, + order_by: vec![crate::bind::OrderSpec { + column_index: 0, + descending: true, + }], + limit: Some(1), + offset: 0, + }; + + assert_eq!( + choose_reverse_strategy(&query), + RelationFetchStrategy::PlannerBatch + ); + } +} diff --git a/crates/gql/src/response.rs b/crates/gql/src/response.rs index 80003ed..6145655 100644 --- a/crates/gql/src/response.rs +++ b/crates/gql/src/response.rs @@ -1,8 +1,9 @@ +use alloc::rc::Rc; use alloc::string::String; use alloc::vec::Vec; use cynos_core::Value; -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] pub struct GraphqlResponse { pub data: ResponseValue, } @@ -13,35 +14,68 @@ impl GraphqlResponse { } } -#[derive(Clone, Debug, PartialEq)] +impl PartialEq for GraphqlResponse { + fn eq(&self, other: &Self) -> bool { + self.data == other.data + } +} + +#[derive(Clone, Debug)] pub enum ResponseValue { Null, Scalar(Value), - Object(Vec), - List(Vec), + Object(Rc<[ResponseField]>), + List(Rc<[ResponseValue]>), } impl ResponseValue { pub fn object(fields: Vec) -> Self { - Self::Object(fields) + Self::Object(fields.into()) } pub fn list(items: Vec) -> Self { + Self::List(items.into()) + } + + pub fn list_shared(items: Rc<[ResponseValue]>) -> Self { Self::List(items) } } -#[derive(Clone, Debug, PartialEq)] +impl PartialEq for ResponseValue { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Null, Self::Null) => true, + (Self::Scalar(left), Self::Scalar(right)) => left == right, + (Self::Object(left), Self::Object(right)) => { + Rc::ptr_eq(left, right) || left.as_ref() == right.as_ref() + } + (Self::List(left), Self::List(right)) => { + Rc::ptr_eq(left, right) || left.as_ref() == right.as_ref() + } + _ => false, + } + } +} + +#[derive(Clone, Debug)] pub struct ResponseField { - pub name: String, + pub name: Rc, pub value: ResponseValue, } impl ResponseField { pub fn new(name: impl Into, value: ResponseValue) -> Self { Self { - name: name.into(), + name: Rc::::from(name.into()), value, } } } + +impl PartialEq for ResponseField { + fn eq(&self, other: &Self) -> bool { + (Rc::ptr_eq(&self.name, &other.name) || self.name.as_ref() == other.name.as_ref()) + && self.value == other.value + } +} diff --git a/crates/gql/src/schema.rs b/crates/gql/src/schema.rs index 3cf9da6..4d64d9e 100644 --- a/crates/gql/src/schema.rs +++ b/crates/gql/src/schema.rs @@ -1,16 +1,14 @@ use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::{String, ToString}; use alloc::vec; use alloc::vec::Vec; -use cynos_core::schema::{ForeignKey, Table}; use cynos_core::DataType; use cynos_storage::TableCache; use hashbrown::HashSet; -use crate::catalog::table_type_name; +use crate::catalog::{GraphqlCatalog, TableFieldMeta, TableMeta}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct GraphqlSchema { @@ -114,26 +112,14 @@ enum ScalarFilterKind { impl GraphqlSchema { pub fn from_table_cache(cache: &TableCache) -> Self { - let table_names = cache.table_names(); - let mut tables = Vec::with_capacity(table_names.len()); - let mut type_names = BTreeMap::new(); - - for table_name in table_names { - if let Some(store) = cache.get_table(table_name) { - let table = store.schema(); - type_names.insert(table.name().to_string(), table_type_name(table.name())); - tables.push(table.clone()); - } - } - - let reverse_relations = build_reverse_relations(&tables); + let catalog = GraphqlCatalog::from_table_cache(cache); let mut scalar_names = HashSet::new(); scalar_names.insert("Long".to_string()); scalar_names.insert("DateTime".to_string()); scalar_names.insert("Bytes".to_string()); scalar_names.insert("JSON".to_string()); - let mut objects = Vec::with_capacity(tables.len()); + let mut objects = Vec::with_capacity(catalog.tables().len()); let mut input_objects = Vec::new(); let mut enums = vec![EnumTypeDef { name: "SortDirection".to_string(), @@ -141,24 +127,24 @@ impl GraphqlSchema { }]; let scalar_filter_defs = scalar_filter_definitions(); - for table in &tables { - objects.push(build_object_type(table, &type_names, &reverse_relations)); - enums.push(build_order_enum(table, &type_names)); - input_objects.push(build_where_input(table)); - input_objects.push(build_order_input(table, &type_names)); - input_objects.push(build_insert_input(table, &type_names)); - input_objects.push(build_patch_input(table, &type_names)); + for table in catalog.tables() { + objects.push(build_object_type(table, &catalog)); + enums.push(build_order_enum(table)); + input_objects.push(build_where_input(table, &catalog)); + input_objects.push(build_order_input(table)); + input_objects.push(build_insert_input(table)); + input_objects.push(build_patch_input(table)); - if let Some(pk_input) = build_pk_input(table, &type_names) { + if let Some(pk_input) = build_pk_input(table) { input_objects.push(pk_input); } } input_objects.extend(scalar_filter_defs); - let query_fields = build_query_fields(&tables, &type_names); - let mutation_fields = build_mutation_fields(&tables, &type_names); - let subscription_fields = build_subscription_fields(&tables, &type_names); + let query_fields = build_query_fields(catalog.tables()); + let mutation_fields = build_mutation_fields(catalog.tables()); + let subscription_fields = build_subscription_fields(catalog.tables()); let mut scalars: Vec = scalar_names .into_iter() @@ -266,83 +252,50 @@ fn render_object(out: &mut String, object: &ObjectTypeDef) { out.push_str("}\n\n"); } -fn build_object_type( - table: &Table, - type_names: &BTreeMap, - reverse_relations: &BTreeMap>, -) -> ObjectTypeDef { +fn build_object_type(table: &TableMeta, catalog: &GraphqlCatalog) -> ObjectTypeDef { let mut fields = Vec::new(); - let mut used_names = HashSet::new(); - - for column in table.columns() { - let field_name = column.name().to_string(); - used_names.insert(field_name.clone()); - fields.push(FieldDef { - name: field_name, - args: Vec::new(), - ty: graphql_type_for_column(column.data_type(), !column.is_nullable()), - }); - } - for fk in table.constraints().get_foreign_keys() { - let parent_type = type_names - .get(&fk.parent_table) - .cloned() - .unwrap_or_else(|| table_type_name(&fk.parent_table)); - let mut name = fk - .graphql_forward_field() - .map(ToString::to_string) - .unwrap_or_else(|| fk.parent_table.clone()); - if used_names.contains(&name) { - name = format!("{}_rel", name); - } - used_names.insert(name.clone()); - fields.push(FieldDef { - name, - args: Vec::new(), - ty: TypeRef::named(parent_type, false), - }); - } - - if let Some(reverse) = reverse_relations.get(table.name()) { - for fk in reverse { - let child_type = type_names - .get(&fk.child_table) - .cloned() - .unwrap_or_else(|| table_type_name(&fk.child_table)); - let mut name = fk - .graphql_reverse_field() - .map(ToString::to_string) - .unwrap_or_else(|| fk.child_table.clone()); - if used_names.contains(&name) { - name = format!("{}_rel", name); + for field in table.fields() { + match field { + TableFieldMeta::Column(column) => fields.push(FieldDef { + name: column.name.clone(), + args: Vec::new(), + ty: graphql_type_for_column(column.data_type, !column.nullable), + }), + TableFieldMeta::ForwardRelation(relation) => { + let target = catalog + .table(&relation.parent_table) + .expect("forward relation target must exist"); + fields.push(FieldDef { + name: relation.name.clone(), + args: Vec::new(), + ty: TypeRef::named(target.graphql_name.clone(), false), + }); + } + TableFieldMeta::ReverseRelation(relation) => { + let target = catalog + .table(&relation.child_table) + .expect("reverse relation target must exist"); + fields.push(FieldDef { + name: relation.name.clone(), + args: collection_arguments(&target.graphql_name), + ty: TypeRef::list(TypeRef::named(target.graphql_name.clone(), true), true), + }); } - used_names.insert(name.clone()); - fields.push(FieldDef { - name, - args: collection_arguments(&child_type), - ty: TypeRef::list(TypeRef::named(child_type, true), true), - }); } } ObjectTypeDef { - name: type_names - .get(table.name()) - .cloned() - .unwrap_or_else(|| table_type_name(table.name())), + name: table.graphql_name.clone(), fields, } } -fn build_query_fields(tables: &[Table], type_names: &BTreeMap) -> Vec { +fn build_query_fields(tables: &[TableMeta]) -> Vec { let mut fields = Vec::new(); for table in tables { - let table_name = table.name().to_string(); - let type_name = type_names - .get(&table_name) - .cloned() - .unwrap_or_else(|| table_type_name(&table_name)); + let table_name = table.table_name.clone(); + let type_name = table.graphql_name.clone(); fields.push(FieldDef { name: table_name.clone(), args: collection_arguments(&type_name), @@ -363,14 +316,10 @@ fn build_query_fields(tables: &[Table], type_names: &BTreeMap) - fields } -fn build_mutation_fields(tables: &[Table], type_names: &BTreeMap) -> Vec { +fn build_mutation_fields(tables: &[TableMeta]) -> Vec { let mut fields = Vec::new(); for table in tables { - let table_name = table.name().to_string(); - let type_name = type_names - .get(&table_name) - .cloned() - .unwrap_or_else(|| table_type_name(&table_name)); + let type_name = table.graphql_name.clone(); fields.push(FieldDef { name: format!("insert{}", type_name), @@ -407,11 +356,8 @@ fn build_mutation_fields(tables: &[Table], type_names: &BTreeMap fields } -fn build_subscription_fields( - tables: &[Table], - type_names: &BTreeMap, -) -> Vec { - build_query_fields(tables, type_names) +fn build_subscription_fields(tables: &[TableMeta]) -> Vec { + build_query_fields(tables) } fn collection_arguments(type_name: &str) -> Vec { @@ -438,8 +384,8 @@ fn collection_arguments(type_name: &str) -> Vec { ] } -fn build_where_input(table: &Table) -> InputObjectTypeDef { - let type_name = table_type_name(table.name()); +fn build_where_input(table: &TableMeta, catalog: &GraphqlCatalog) -> InputObjectTypeDef { + let type_name = table.graphql_name.clone(); let mut fields = vec![ InputValueDef { name: "AND".to_string(), @@ -457,12 +403,35 @@ fn build_where_input(table: &Table) -> InputObjectTypeDef { }, ]; - for column in table.columns() { - let filter_name = scalar_filter_name(column.data_type()); - fields.push(InputValueDef { - name: column.name().to_string(), - ty: TypeRef::named(filter_name, false), - }); + for field in table.fields() { + match field { + TableFieldMeta::Column(column) => { + let filter_name = scalar_filter_name(column.data_type); + fields.push(InputValueDef { + name: column.name.clone(), + ty: TypeRef::named(filter_name, false), + }); + } + TableFieldMeta::ForwardRelation(relation) => { + let target = catalog + .table(&relation.parent_table) + .expect("forward relation target must exist"); + fields.push(InputValueDef { + name: relation.name.clone(), + ty: TypeRef::named(format!("{}WhereInput", target.graphql_name), false), + }); + } + TableFieldMeta::ReverseRelation(relation) if relation.child_column_unique => { + let target = catalog + .table(&relation.child_table) + .expect("reverse relation target must exist"); + fields.push(InputValueDef { + name: relation.name.clone(), + ty: TypeRef::named(format!("{}WhereInput", target.graphql_name), false), + }); + } + TableFieldMeta::ReverseRelation(_) => {} + } } InputObjectTypeDef { @@ -471,28 +440,19 @@ fn build_where_input(table: &Table) -> InputObjectTypeDef { } } -fn build_order_enum(table: &Table, type_names: &BTreeMap) -> EnumTypeDef { +fn build_order_enum(table: &TableMeta) -> EnumTypeDef { EnumTypeDef { - name: format!( - "{}OrderField", - type_names - .get(table.name()) - .cloned() - .unwrap_or_else(|| table_type_name(table.name())) - ), + name: format!("{}OrderField", table.graphql_name), values: table .columns() .iter() - .map(|column| to_graphql_enum_value(column.name())) + .map(|column| to_graphql_enum_value(&column.name)) .collect(), } } -fn build_order_input(table: &Table, type_names: &BTreeMap) -> InputObjectTypeDef { - let type_name = type_names - .get(table.name()) - .cloned() - .unwrap_or_else(|| table_type_name(table.name())); +fn build_order_input(table: &TableMeta) -> InputObjectTypeDef { + let type_name = table.graphql_name.clone(); InputObjectTypeDef { name: format!("{}OrderByInput", type_name), fields: vec![ @@ -508,86 +468,52 @@ fn build_order_input(table: &Table, type_names: &BTreeMap) -> In } } -fn build_pk_input( - table: &Table, - type_names: &BTreeMap, -) -> Option { +fn build_pk_input(table: &TableMeta) -> Option { let pk = table.primary_key()?; - let mut fields = Vec::new(); - for column in pk.columns() { - if let Some(table_column) = table.get_column(&column.name) { - fields.push(InputValueDef { - name: column.name.clone(), - ty: graphql_type_for_column(table_column.data_type(), true), - }); - } - } + let fields = pk + .columns + .iter() + .map(|column| InputValueDef { + name: column.name.clone(), + ty: graphql_type_for_column(column.data_type, true), + }) + .collect(); Some(InputObjectTypeDef { - name: format!( - "{}PkInput", - type_names - .get(table.name()) - .cloned() - .unwrap_or_else(|| table_type_name(table.name())) - ), + name: format!("{}PkInput", table.graphql_name), fields, }) } -fn build_insert_input(table: &Table, type_names: &BTreeMap) -> InputObjectTypeDef { +fn build_insert_input(table: &TableMeta) -> InputObjectTypeDef { let fields = table .columns() .iter() .map(|column| InputValueDef { - name: column.name().to_string(), - ty: graphql_type_for_column(column.data_type(), !column.is_nullable()), + name: column.name.clone(), + ty: graphql_type_for_column(column.data_type, !column.nullable), }) .collect(); InputObjectTypeDef { - name: format!( - "{}InsertInput", - type_names - .get(table.name()) - .cloned() - .unwrap_or_else(|| table_type_name(table.name())) - ), + name: format!("{}InsertInput", table.graphql_name), fields, } } -fn build_patch_input(table: &Table, type_names: &BTreeMap) -> InputObjectTypeDef { +fn build_patch_input(table: &TableMeta) -> InputObjectTypeDef { let fields = table .columns() .iter() .map(|column| InputValueDef { - name: column.name().to_string(), - ty: graphql_type_for_column(column.data_type(), false), + name: column.name.clone(), + ty: graphql_type_for_column(column.data_type, false), }) .collect(); InputObjectTypeDef { - name: format!( - "{}PatchInput", - type_names - .get(table.name()) - .cloned() - .unwrap_or_else(|| table_type_name(table.name())) - ), + name: format!("{}PatchInput", table.graphql_name), fields, } } -fn build_reverse_relations(tables: &[Table]) -> BTreeMap> { - let mut map: BTreeMap> = BTreeMap::new(); - for table in tables { - for fk in table.constraints().get_foreign_keys() { - map.entry(fk.parent_table.clone()) - .or_default() - .push(fk.clone()); - } - } - map -} - fn scalar_filter_definitions() -> Vec { vec![ scalar_filter_definition("BooleanFilterInput", ScalarFilterKind::Boolean), @@ -790,6 +716,35 @@ mod tests { cache } + fn build_unique_reverse_cache() -> TableCache { + let mut cache = build_cache(); + let profiles = TableBuilder::new("profiles") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("user_id", DataType::Int64) + .unwrap() + .add_column("bio", DataType::String) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .add_index("idx_profiles_user_id", &["user_id"], true) + .unwrap() + .add_foreign_key_with_graphql_names( + "fk_profiles_user", + "user_id", + "users", + "id", + Some("user"), + Some("profile"), + ) + .unwrap() + .build() + .unwrap(); + cache.create_table(profiles).unwrap(); + cache + } + #[test] fn schema_includes_query_mutation_and_subscription_roots() { let cache = build_cache(); @@ -817,4 +772,14 @@ mod tests { assert!(sdl.contains("input UsersInsertInput")); assert!(sdl.contains("input UsersPatchInput")); } + + #[test] + fn schema_includes_single_valued_relation_filters_in_where_inputs() { + let cache = build_unique_reverse_cache(); + let sdl = render_schema_sdl(&cache); + + assert!(sdl.contains("input OrdersWhereInput")); + assert!(sdl.contains("user: UsersWhereInput") || sdl.contains("users: UsersWhereInput")); + assert!(sdl.contains("profile: ProfilesWhereInput")); + } } diff --git a/crates/incremental/src/dataflow/mod.rs b/crates/incremental/src/dataflow/mod.rs index 170b2de..c67ff4e 100644 --- a/crates/incremental/src/dataflow/mod.rs +++ b/crates/incremental/src/dataflow/mod.rs @@ -7,4 +7,6 @@ mod graph; pub mod node; pub use graph::{DataflowGraph, NodeId}; -pub use node::{AggregateType, ColumnId, DataflowNode, JoinType, KeyExtractorFn, TableId}; +pub use node::{ + AggregateType, ColumnId, DataflowNode, JoinKeySpec, JoinType, KeyExtractorFn, TableId, +}; diff --git a/crates/incremental/src/dataflow/node.rs b/crates/incremental/src/dataflow/node.rs index 3749006..98787ac 100644 --- a/crates/incremental/src/dataflow/node.rs +++ b/crates/incremental/src/dataflow/node.rs @@ -3,9 +3,10 @@ //! Based on DBSP (Database Stream Processing) theory, each node represents //! a lifted relational operator that processes Z-set deltas incrementally. +use crate::trace::{TraceTupleArena, TraceTupleHandle}; use alloc::boxed::Box; use alloc::vec::Vec; -use cynos_core::Row; +use cynos_core::{Row, Value}; /// Type alias for table identifier. pub type TableId = u32; @@ -16,11 +17,41 @@ pub type ColumnId = usize; /// Predicate for filtering rows. pub type PredicateFn = Box bool + Send + Sync>; +/// Handle-aware predicate for trace tuple fast paths. +pub type TracePredicateFn = Box bool + Send + Sync>; + /// Mapper function for transforming rows. pub type MapperFn = Box Row + Send + Sync>; +/// Handle-aware mapper for trace tuple fast paths. +pub type TraceMapperFn = Box Row + Send + Sync>; + /// Key extractor function for joins. -pub type KeyExtractorFn = Box Vec + Send + Sync>; +pub type KeyExtractorFn = Box Vec + Send + Sync>; + +/// Join key extraction strategy. +/// +/// `Columns` is the fast path used for standard equi-joins compiled from SQL. +/// `Dynamic` is the fallback for arbitrary key extractors. +pub enum JoinKeySpec { + Columns(Vec), + Constant(Vec), + Dynamic(KeyExtractorFn), +} + +impl JoinKeySpec { + #[inline] + pub fn extract_owned(&self, row: &Row) -> Vec { + match self { + Self::Columns(indices) => indices + .iter() + .map(|&index| row.get(index).cloned().unwrap_or(Value::Null)) + .collect(), + Self::Constant(values) => values.clone(), + Self::Dynamic(extractor) => extractor(row), + } + } +} /// Aggregate function types. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -54,6 +85,7 @@ pub enum DataflowNode { Filter { input: Box, predicate: PredicateFn, + trace_predicate: Option, }, /// Project operation - selects specific columns @@ -66,6 +98,7 @@ pub enum DataflowNode { Map { input: Box, mapper: MapperFn, + trace_mapper: Option, }, /// Join operation - combines two inputs. @@ -74,9 +107,11 @@ pub enum DataflowNode { Join { left: Box, right: Box, - left_key: KeyExtractorFn, - right_key: KeyExtractorFn, + left_key: JoinKeySpec, + right_key: JoinKeySpec, join_type: JoinType, + left_width: usize, + right_width: usize, }, /// Aggregate operation - computes aggregates per group. @@ -103,6 +138,7 @@ impl DataflowNode { DataflowNode::Filter { input: Box::new(input), predicate: Box::new(predicate), + trace_predicate: None, } } @@ -122,6 +158,7 @@ impl DataflowNode { DataflowNode::Map { input: Box::new(input), mapper: Box::new(mapper), + trace_mapper: None, } } @@ -135,9 +172,11 @@ impl DataflowNode { DataflowNode::Join { left: Box::new(left), right: Box::new(right), - left_key, - right_key, + left_key: JoinKeySpec::Dynamic(left_key), + right_key: JoinKeySpec::Dynamic(right_key), join_type: JoinType::Inner, + left_width: 0, + right_width: 0, } } @@ -152,9 +191,11 @@ impl DataflowNode { DataflowNode::Join { left: Box::new(left), right: Box::new(right), - left_key, - right_key, + left_key: JoinKeySpec::Dynamic(left_key), + right_key: JoinKeySpec::Dynamic(right_key), join_type, + left_width: 0, + right_width: 0, } } diff --git a/crates/incremental/src/lib.rs b/crates/incremental/src/lib.rs index 610a114..07c785f 100644 --- a/crates/incremental/src/lib.rs +++ b/crates/incremental/src/lib.rs @@ -50,16 +50,20 @@ pub mod dataflow; pub mod delta; pub mod materialize; pub mod operators; +pub mod trace; pub use collection::{ConsolidatedCollection, DiffCollection}; pub use dataflow::{ - AggregateType, ColumnId, DataflowGraph, DataflowNode, JoinType, KeyExtractorFn, NodeId, TableId, + AggregateType, ColumnId, DataflowGraph, DataflowNode, JoinKeySpec, JoinType, KeyExtractorFn, + NodeId, TableId, }; pub use delta::{Delta, DeltaBatch, DeltaBatchExt}; pub use materialize::{ - AggregateState, GroupAggregateState, JoinState, MaterializedView, MaterializedViewBuilder, + AggregateState, BootstrapExecutionProfile, CompiledBootstrapPlan, CompiledIvmPlan, + GroupAggregateState, JoinState, MaterializedView, MaterializedViewBuilder, TraceUpdateProfile, }; pub use operators::{ filter_incremental, map_incremental, project_incremental, IncrementalAvg, IncrementalCount, IncrementalHashJoin, IncrementalMax, IncrementalMin, IncrementalSum, }; +pub use trace::{TraceDeltaBatch, TraceTupleArena, TraceTupleHandle, VisibleResultStore}; diff --git a/crates/incremental/src/materialize.rs b/crates/incremental/src/materialize.rs index 8901cca..407f18a 100644 --- a/crates/incremental/src/materialize.rs +++ b/crates/incremental/src/materialize.rs @@ -5,40 +5,209 @@ //! the current result and propagates deltas through the dataflow graph. use crate::dataflow::node::JoinType; -use crate::dataflow::{AggregateType, ColumnId, DataflowNode, TableId}; +use crate::dataflow::{AggregateType, ColumnId, DataflowNode, JoinKeySpec, TableId}; use crate::delta::Delta; -use crate::operators::{filter_incremental, map_incremental, project_incremental}; +use crate::trace::{TraceDeltaBatch, TraceTupleArena, TraceTupleHandle, VisibleResultStore}; +use alloc::boxed::Box; use alloc::collections::BTreeMap; +use alloc::rc::Rc; +use alloc::vec; use alloc::vec::Vec; -use cynos_core::{Row, RowId, Value}; +use core::mem; +use cynos_core::{aggregate_group_row_id, Row, RowId, Value}; use hashbrown::HashMap; // --------------------------------------------------------------------------- // JoinState — supports Inner, Left, Right, Full Outer joins via DBSP // --------------------------------------------------------------------------- +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum JoinKey { + Empty, + One(Value), + Many(Vec), +} + +impl JoinKey { + fn from_vec(mut values: Vec) -> Self { + match values.len() { + 0 => Self::Empty, + 1 => Self::One(values.pop().unwrap()), + _ => Self::Many(values), + } + } +} + +#[derive(Clone, Debug)] +struct JoinSlot { + row: TraceTupleHandle, + key: JoinKey, + bucket_pos: usize, + match_count: usize, +} + +#[derive(Default)] +struct JoinSideState { + buckets: HashMap>, + row_to_slot: HashMap, + slots: Vec>, + free_list: Vec, + col_count: usize, +} + +impl JoinSideState { + fn with_col_count(col_count: usize) -> Self { + Self { + col_count, + ..Self::default() + } + } + + #[inline] + fn ensure_col_count(&mut self, row: &TraceTupleHandle) { + if self.col_count == 0 { + self.col_count = row.len(); + } + } + + fn insert(&mut self, row: TraceTupleHandle, key: JoinKey, match_count: usize) -> usize { + self.ensure_col_count(&row); + let row_id = row.row_id(); + let bucket = self.buckets.entry(key.clone()).or_default(); + let bucket_pos = bucket.len(); + + let slot = JoinSlot { + row, + key, + bucket_pos, + match_count, + }; + + let slot_id = if let Some(slot_id) = self.free_list.pop() { + self.slots[slot_id] = Some(slot); + slot_id + } else { + self.slots.push(Some(slot)); + self.slots.len() - 1 + }; + + bucket.push(slot_id); + self.row_to_slot.insert(row_id, slot_id); + slot_id + } + + fn remove_by_row_id(&mut self, row_id: RowId) -> Option { + let slot_id = self.row_to_slot.remove(&row_id)?; + let (bucket_key, expected_pos) = { + let slot = self.slots.get(slot_id)?.as_ref()?; + (slot.key.clone(), slot.bucket_pos) + }; + + let mut bucket_should_remove = false; + let mut actual_pos = None; + if let Some(bucket) = self.buckets.get_mut(&bucket_key) { + actual_pos = if expected_pos < bucket.len() + && bucket.get(expected_pos).copied() == Some(slot_id) + { + Some(expected_pos) + } else { + repair_bucket_positions(bucket, &mut self.slots, &bucket_key); + bucket.iter().position(|candidate| *candidate == slot_id) + }; + } + + let slot = self.slots.get_mut(slot_id)?.take()?; + + if let Some(bucket) = self.buckets.get_mut(&bucket_key) { + let remove_pos = actual_pos.unwrap_or_else(|| { + repair_bucket_positions(bucket, &mut self.slots, &bucket_key); + bucket + .iter() + .position(|candidate| *candidate == slot_id) + .unwrap_or(bucket.len()) + }); + if remove_pos >= bucket.len() { + bucket_should_remove = bucket.is_empty(); + } else { + let swapped = bucket.swap_remove(remove_pos); + if swapped != slot_id { + if let Some(moved_slot) = self.slots.get_mut(swapped).and_then(Option::as_mut) { + moved_slot.bucket_pos = remove_pos; + } else { + repair_bucket_positions(bucket, &mut self.slots, &bucket_key); + } + } + bucket_should_remove = bucket.is_empty(); + } + } + + if bucket_should_remove { + self.buckets.remove(&bucket_key); + } + + self.free_list.push(slot_id); + Some(slot) + } + + fn collect_match_ids(&self, key: &JoinKey, out: &mut Vec) { + out.clear(); + if let Some(bucket) = self.buckets.get(key) { + out.extend(bucket.iter().copied()); + } + } + + #[inline] + fn slot(&self, slot_id: usize) -> &JoinSlot { + self.slots[slot_id] + .as_ref() + .expect("join slot should exist while referenced") + } + + #[inline] + fn slot_mut(&mut self, slot_id: usize) -> &mut JoinSlot { + self.slots[slot_id] + .as_mut() + .expect("join slot should exist while referenced") + } + + #[inline] + fn len(&self) -> usize { + self.row_to_slot.len() + } +} + +fn repair_bucket_positions(bucket: &mut Vec, slots: &mut [Option], key: &JoinKey) { + bucket.retain(|slot_id| { + slots + .get(*slot_id) + .and_then(Option::as_ref) + .map(|slot| slot.key == *key) + .unwrap_or(false) + }); + bucket.sort_unstable(); + bucket.dedup(); + + for (bucket_pos, slot_id) in bucket.iter().copied().enumerate() { + if let Some(slot) = slots.get_mut(slot_id).and_then(Option::as_mut) { + slot.bucket_pos = bucket_pos; + } + } +} + /// State for incremental join operations. -/// Maintains indexes for both sides and match counts for outer join support. +/// Maintains per-side slot arenas and key buckets for O(1) delete bookkeeping. pub struct JoinState { - pub left_index: HashMap, Vec>, - pub right_index: HashMap, Vec>, - /// For outer joins: count of right matches per left row id - left_match_count: HashMap, - /// For outer joins: count of left matches per right row id - right_match_count: HashMap, - right_col_count: usize, - left_col_count: usize, + left: JoinSideState, + right: JoinSideState, + match_scratch: Vec, } impl JoinState { pub fn new() -> Self { Self { - left_index: HashMap::new(), - right_index: HashMap::new(), - left_match_count: HashMap::new(), - right_match_count: HashMap::new(), - right_col_count: 0, - left_col_count: 0, + left: JoinSideState::default(), + right: JoinSideState::default(), + match_scratch: Vec::new(), } } @@ -46,256 +215,455 @@ impl JoinState { /// Required for outer joins to correctly pad NULL columns. pub fn with_col_counts(left_col_count: usize, right_col_count: usize) -> Self { Self { - left_index: HashMap::new(), - right_index: HashMap::new(), - left_match_count: HashMap::new(), - right_match_count: HashMap::new(), - right_col_count, - left_col_count, + left: JoinSideState::with_col_count(left_col_count), + right: JoinSideState::with_col_count(right_col_count), + match_scratch: Vec::new(), } } - /// Handles a left-side insertion. Returns inner join results. pub fn on_left_insert(&mut self, row: Row, key: Vec) -> Vec { - let mut output = Vec::new(); - if self.left_col_count == 0 { - self.left_col_count = row.len(); - } - if let Some(right_rows) = self.right_index.get(&key) { - for r in right_rows { - output.push(merge_rows(&row, r)); - } - } - self.left_index.entry(key).or_default().push(row); - output + let arena = TraceTupleArena; + let mut deltas = Vec::new(); + self.process_left_insert( + arena.owned(row), + JoinKey::from_vec(key), + JoinType::Inner, + &arena, + &mut deltas, + ); + deltas + .into_iter() + .filter(|delta| delta.is_insert()) + .map(|delta| arena.materialize_row(&delta.data)) + .collect() } - /// Handles a left-side deletion. Returns inner join results to remove. - pub fn on_left_delete(&mut self, row: &Row, key: Vec) -> Vec { - let mut output = Vec::new(); - if let Some(right_rows) = self.right_index.get(&key) { - for r in right_rows { - output.push(merge_rows(row, r)); - } - } - if let Some(left_rows) = self.left_index.get_mut(&key) { - left_rows.retain(|l| l.id() != row.id()); - if left_rows.is_empty() { - self.left_index.remove(&key); - } - } - output + pub fn on_left_delete(&mut self, row: &Row, _key: Vec) -> Vec { + let arena = TraceTupleArena; + let mut deltas = Vec::new(); + self.process_left_delete(row.id(), JoinType::Inner, &arena, &mut deltas); + deltas + .into_iter() + .filter(|delta| delta.is_delete()) + .map(|delta| arena.materialize_row(&delta.data)) + .collect() } - /// Handles a right-side insertion. Returns inner join results. pub fn on_right_insert(&mut self, row: Row, key: Vec) -> Vec { - let mut output = Vec::new(); - if self.right_col_count == 0 { - self.right_col_count = row.len(); + let arena = TraceTupleArena; + let mut deltas = Vec::new(); + self.process_right_insert( + arena.owned(row), + JoinKey::from_vec(key), + JoinType::Inner, + &arena, + &mut deltas, + ); + deltas + .into_iter() + .filter(|delta| delta.is_insert()) + .map(|delta| arena.materialize_row(&delta.data)) + .collect() + } + + pub fn on_right_delete(&mut self, row: &Row, _key: Vec) -> Vec { + let arena = TraceTupleArena; + let mut deltas = Vec::new(); + self.process_right_delete(row.id(), JoinType::Inner, &arena, &mut deltas); + deltas + .into_iter() + .filter(|delta| delta.is_delete()) + .map(|delta| arena.materialize_row(&delta.data)) + .collect() + } + + pub fn left_count(&self) -> usize { + self.left.len() + } + + pub fn right_count(&self) -> usize { + self.right.len() + } + + fn process_left_insert( + &mut self, + row: TraceTupleHandle, + key: JoinKey, + join_type: JoinType, + arena: &TraceTupleArena, + output: &mut Vec>, + ) { + self.right.collect_match_ids(&key, &mut self.match_scratch); + let match_count = self.match_scratch.len(); + let slot_id = self.left.insert(row, key, match_count); + + if match_count == 0 { + if emits_unmatched_left(join_type) { + let left_row = &self.left.slot(slot_id).row; + output.push(Delta::insert( + arena.null_pad_right(left_row.clone(), self.right.col_count), + )); + } + return; } - if let Some(left_rows) = self.left_index.get(&key) { - for l in left_rows { - output.push(merge_rows(l, &row)); + + for right_id in self.match_scratch.iter().copied() { + let emit_unmatched_delete = { + let left_row = &self.left.slot(slot_id).row; + let right_slot = self.right.slot(right_id); + output.push(Delta::insert(arena.concat( + left_row.clone(), + right_slot.row.clone(), + self.left.col_count, + ))); + emits_unmatched_right(join_type) && right_slot.match_count == 0 + }; + + if emit_unmatched_delete { + let right_row = &self.right.slot(right_id).row; + output.push(Delta::delete( + arena.null_pad_left(right_row.clone(), self.left.col_count), + )); } + + self.right.slot_mut(right_id).match_count += 1; } - self.right_index.entry(key).or_default().push(row); - output } - /// Handles a right-side deletion. Returns inner join results to remove. - pub fn on_right_delete(&mut self, row: &Row, key: Vec) -> Vec { - let mut output = Vec::new(); - if let Some(left_rows) = self.left_index.get(&key) { - for l in left_rows { - output.push(merge_rows(l, row)); + fn process_left_delete( + &mut self, + row_id: RowId, + join_type: JoinType, + arena: &TraceTupleArena, + output: &mut Vec>, + ) { + let Some(slot) = self.left.remove_by_row_id(row_id) else { + return; + }; + + if slot.match_count == 0 { + if emits_unmatched_left(join_type) { + output.push(Delta::delete( + arena.null_pad_right(slot.row.clone(), self.right.col_count), + )); } + return; } - if let Some(right_rows) = self.right_index.get_mut(&key) { - right_rows.retain(|r| r.id() != row.id()); - if right_rows.is_empty() { - self.right_index.remove(&key); + + self.right + .collect_match_ids(&slot.key, &mut self.match_scratch); + for right_id in self.match_scratch.iter().copied() { + let emit_unmatched_insert = { + let right_slot = self.right.slot(right_id); + output.push(Delta::delete(arena.concat( + slot.row.clone(), + right_slot.row.clone(), + self.left.col_count, + ))); + emits_unmatched_right(join_type) && right_slot.match_count == 1 + }; + + { + let right_slot = self.right.slot_mut(right_id); + right_slot.match_count = right_slot.match_count.saturating_sub(1); + } + + if emit_unmatched_insert { + let right_row = &self.right.slot(right_id).row; + output.push(Delta::insert( + arena.null_pad_left(right_row.clone(), self.left.col_count), + )); } } - output } - pub fn left_count(&self) -> usize { - self.left_index.values().map(|v| v.len()).sum() - } + fn process_left_update_same_key( + &mut self, + old_row: &TraceTupleHandle, + new_row: TraceTupleHandle, + key: JoinKey, + join_type: JoinType, + arena: &TraceTupleArena, + output: &mut Vec>, + ) { + let Some(&slot_id) = self.left.row_to_slot.get(&old_row.row_id()) else { + self.process_left_insert(new_row, key, join_type, arena, output); + return; + }; - pub fn right_count(&self) -> usize { - self.right_index.values().map(|v| v.len()).sum() - } + let match_count = self.left.slot(slot_id).match_count; + if match_count == 0 { + if emits_unmatched_left(join_type) { + output.push(Delta::delete( + arena.null_pad_right(old_row.clone(), self.right.col_count), + )); + output.push(Delta::insert( + arena.null_pad_right(new_row.clone(), self.right.col_count), + )); + } + self.left.slot_mut(slot_id).row = new_row; + return; + } + + self.right.collect_match_ids(&key, &mut self.match_scratch); + for right_id in self.match_scratch.iter().copied() { + let right_row = &self.right.slot(right_id).row; + output.push(Delta::delete(arena.concat( + old_row.clone(), + right_row.clone(), + self.left.col_count, + ))); + output.push(Delta::insert(arena.concat( + new_row.clone(), + right_row.clone(), + self.left.col_count, + ))); + } - // --- Outer join helpers --- + self.left.slot_mut(slot_id).row = new_row; + } - /// Process a left insert for outer join. Returns deltas including antijoin transitions. - fn on_left_insert_outer( + fn process_right_insert( &mut self, - row: Row, - key: Vec, + row: TraceTupleHandle, + key: JoinKey, join_type: JoinType, - ) -> Vec> { - let mut output = Vec::new(); - if self.left_col_count == 0 { - self.left_col_count = row.len(); - } - let right_matches = self.right_index.get(&key).map(|v| v.len()).unwrap_or(0); - - if right_matches > 0 { - // Has matches → emit inner join results - for r in self.right_index.get(&key).unwrap() { - output.push(Delta::insert(merge_rows(&row, r))); - // Track right match count for all outer join types - // (needed for correct delete handling) - let rc = self.right_match_count.entry(r.id()).or_insert(0); - if matches!(join_type, JoinType::RightOuter | JoinType::FullOuter) && *rc == 0 { - output.push(Delta::delete(merge_rows_null_left(r, self.left_col_count))); - } - *rc += 1; - } - // Always track left match count so we can handle left deletes - self.left_match_count.insert(row.id(), right_matches); - } else if matches!(join_type, JoinType::LeftOuter | JoinType::FullOuter) { - // No matches → emit antijoin row (left + NULLs) - output.push(Delta::insert(merge_rows_null_right( - &row, - self.right_col_count, - ))); - self.left_match_count.insert(row.id(), 0); + arena: &TraceTupleArena, + output: &mut Vec>, + ) { + self.left.collect_match_ids(&key, &mut self.match_scratch); + let match_count = self.match_scratch.len(); + let slot_id = self.right.insert(row, key, match_count); + + if match_count == 0 { + if emits_unmatched_right(join_type) { + let right_row = &self.right.slot(slot_id).row; + output.push(Delta::insert( + arena.null_pad_left(right_row.clone(), self.left.col_count), + )); + } + return; } - self.left_index.entry(key).or_default().push(row); - output + for left_id in self.match_scratch.iter().copied() { + let emit_unmatched_delete = { + let left_slot = self.left.slot(left_id); + let right_row = &self.right.slot(slot_id).row; + output.push(Delta::insert(arena.concat( + left_slot.row.clone(), + right_row.clone(), + self.left.col_count, + ))); + emits_unmatched_left(join_type) && left_slot.match_count == 0 + }; + + if emit_unmatched_delete { + let left_row = &self.left.slot(left_id).row; + output.push(Delta::delete( + arena.null_pad_right(left_row.clone(), self.right.col_count), + )); + } + + self.left.slot_mut(left_id).match_count += 1; + } } - /// Process a left delete for outer join. - fn on_left_delete_outer( + fn process_right_delete( &mut self, - row: &Row, - key: Vec, + row_id: RowId, join_type: JoinType, - ) -> Vec> { - let mut output = Vec::new(); - let match_count = self.left_match_count.remove(&row.id()).unwrap_or(0); - - if match_count > 0 { - // Had matches → remove inner join results - if let Some(right_rows) = self.right_index.get(&key) { - for r in right_rows { - output.push(Delta::delete(merge_rows(row, r))); - // Always decrement right match count - if let Some(rc) = self.right_match_count.get_mut(&r.id()) { - *rc = rc.saturating_sub(1); - if matches!(join_type, JoinType::RightOuter | JoinType::FullOuter) - && *rc == 0 - { - output - .push(Delta::insert(merge_rows_null_left(r, self.left_col_count))); - } - } - } + arena: &TraceTupleArena, + output: &mut Vec>, + ) { + let Some(slot) = self.right.remove_by_row_id(row_id) else { + return; + }; + + if slot.match_count == 0 { + if emits_unmatched_right(join_type) { + output.push(Delta::delete( + arena.null_pad_left(slot.row.clone(), self.left.col_count), + )); } - } else if matches!(join_type, JoinType::LeftOuter | JoinType::FullOuter) { - // Was unmatched → remove antijoin row - output.push(Delta::delete(merge_rows_null_right( - row, - self.right_col_count, - ))); + return; } - if let Some(left_rows) = self.left_index.get_mut(&key) { - left_rows.retain(|l| l.id() != row.id()); - if left_rows.is_empty() { - self.left_index.remove(&key); + self.left + .collect_match_ids(&slot.key, &mut self.match_scratch); + for left_id in self.match_scratch.iter().copied() { + let emit_unmatched_insert = { + let left_slot = self.left.slot(left_id); + output.push(Delta::delete(arena.concat( + left_slot.row.clone(), + slot.row.clone(), + self.left.col_count, + ))); + emits_unmatched_left(join_type) && left_slot.match_count == 1 + }; + + { + let left_slot = self.left.slot_mut(left_id); + left_slot.match_count = left_slot.match_count.saturating_sub(1); + } + + if emit_unmatched_insert { + let left_row = &self.left.slot(left_id).row; + output.push(Delta::insert( + arena.null_pad_right(left_row.clone(), self.right.col_count), + )); } } - output } - /// Process a right insert for outer join. - fn on_right_insert_outer( + fn process_right_update_same_key( &mut self, - row: Row, - key: Vec, + old_row: &TraceTupleHandle, + new_row: TraceTupleHandle, + key: JoinKey, join_type: JoinType, - ) -> Vec> { - let mut output = Vec::new(); - let left_matches = self.left_index.get(&key).map(|v| v.len()).unwrap_or(0); - - if self.right_col_count == 0 { - self.right_col_count = row.len(); - } - - if left_matches > 0 { - for l in self.left_index.get(&key).unwrap() { - output.push(Delta::insert(merge_rows(l, &row))); - // Track left match count for all outer join types - let lc = self.left_match_count.entry(l.id()).or_insert(0); - if matches!(join_type, JoinType::LeftOuter | JoinType::FullOuter) && *lc == 0 { - output.push(Delta::delete(merge_rows_null_right( - l, - self.right_col_count, - ))); - } - *lc += 1; - } - // Always track right match count so we can handle right deletes - self.right_match_count.insert(row.id(), left_matches); - } else if matches!(join_type, JoinType::RightOuter | JoinType::FullOuter) { - output.push(Delta::insert(merge_rows_null_left( - &row, - self.left_col_count, + arena: &TraceTupleArena, + output: &mut Vec>, + ) { + let Some(&slot_id) = self.right.row_to_slot.get(&old_row.row_id()) else { + self.process_right_insert(new_row, key, join_type, arena, output); + return; + }; + + let match_count = self.right.slot(slot_id).match_count; + if match_count == 0 { + if emits_unmatched_right(join_type) { + output.push(Delta::delete( + arena.null_pad_left(old_row.clone(), self.left.col_count), + )); + output.push(Delta::insert( + arena.null_pad_left(new_row.clone(), self.left.col_count), + )); + } + self.right.slot_mut(slot_id).row = new_row; + return; + } + + self.left.collect_match_ids(&key, &mut self.match_scratch); + for left_id in self.match_scratch.iter().copied() { + let left_row = &self.left.slot(left_id).row; + output.push(Delta::delete(arena.concat( + left_row.clone(), + old_row.clone(), + self.left.col_count, + ))); + output.push(Delta::insert(arena.concat( + left_row.clone(), + new_row.clone(), + self.left.col_count, ))); - self.right_match_count.insert(row.id(), 0); } - self.right_index.entry(key).or_default().push(row); - output + self.right.slot_mut(slot_id).row = new_row; + } + + fn on_left_insert_outer( + &mut self, + row: TraceTupleHandle, + key: JoinKey, + join_type: JoinType, + arena: &TraceTupleArena, + output: &mut Vec>, + ) { + self.process_left_insert(row, key, join_type, arena, output); + } + + fn on_left_delete_outer( + &mut self, + row: &TraceTupleHandle, + _key: JoinKey, + join_type: JoinType, + arena: &TraceTupleArena, + output: &mut Vec>, + ) { + self.process_left_delete(row.row_id(), join_type, arena, output); + } + + fn on_right_insert_outer( + &mut self, + row: TraceTupleHandle, + key: JoinKey, + join_type: JoinType, + arena: &TraceTupleArena, + output: &mut Vec>, + ) { + self.process_right_insert(row, key, join_type, arena, output); } - /// Process a right delete for outer join. fn on_right_delete_outer( &mut self, - row: &Row, - key: Vec, + row: &TraceTupleHandle, + _key: JoinKey, join_type: JoinType, - ) -> Vec> { - let mut output = Vec::new(); - let match_count = self.right_match_count.remove(&row.id()).unwrap_or(0); - - if match_count > 0 { - if let Some(left_rows) = self.left_index.get(&key) { - for l in left_rows { - output.push(Delta::delete(merge_rows(l, row))); - // Always decrement left match count - if let Some(lc) = self.left_match_count.get_mut(&l.id()) { - *lc = lc.saturating_sub(1); - if matches!(join_type, JoinType::LeftOuter | JoinType::FullOuter) - && *lc == 0 - { - output.push(Delta::insert(merge_rows_null_right( - l, - self.right_col_count, - ))); - } - } + arena: &TraceTupleArena, + output: &mut Vec>, + ) { + self.process_right_delete(row.row_id(), join_type, arena, output); + } + + fn finalize_bootstrap_match_counts(&mut self) { + for slot in self.left.slots.iter_mut().filter_map(Option::as_mut) { + slot.match_count = 0; + } + for slot in self.right.slots.iter_mut().filter_map(Option::as_mut) { + slot.match_count = 0; + } + + let bucket_pairs = self + .left + .buckets + .iter() + .filter_map(|(key, left_bucket)| { + self.right + .buckets + .get(key) + .map(|right_bucket| (left_bucket.clone(), right_bucket.clone())) + }) + .collect::>(); + + for (left_bucket, right_bucket) in bucket_pairs { + let right_matches = right_bucket.len(); + let left_matches = left_bucket.len(); + for left_id in left_bucket { + self.left.slot_mut(left_id).match_count = right_matches; + } + + for right_id in right_bucket { + self.right.slot_mut(right_id).match_count = left_matches; + } + } + } + + fn emit_bootstrap_rows(&self, join_type: JoinType, arena: &TraceTupleArena, emit: &mut F) + where + F: FnMut(TraceTupleHandle) + ?Sized, + { + for left_slot in self.left.slots.iter().filter_map(Option::as_ref) { + if let Some(right_bucket) = self.right.buckets.get(&left_slot.key) { + for &right_id in right_bucket { + let right_slot = self.right.slot(right_id); + emit(arena.concat( + left_slot.row.clone(), + right_slot.row.clone(), + self.left.col_count, + )); } + } else if emits_unmatched_left(join_type) { + emit(arena.null_pad_right(left_slot.row.clone(), self.right.col_count)); } - } else if matches!(join_type, JoinType::RightOuter | JoinType::FullOuter) { - output.push(Delta::delete(merge_rows_null_left( - row, - self.left_col_count, - ))); } - if let Some(right_rows) = self.right_index.get_mut(&key) { - right_rows.retain(|r| r.id() != row.id()); - if right_rows.is_empty() { - self.right_index.remove(&key); + if emits_unmatched_right(join_type) { + for right_slot in self.right.slots.iter().filter_map(Option::as_ref) { + if right_slot.match_count == 0 { + emit(arena.null_pad_left(right_slot.row.clone(), self.left.col_count)); + } } } - output } } @@ -305,27 +673,53 @@ impl Default for JoinState { } } -/// Merges two rows into a single joined row. +#[allow(dead_code)] +/// Test/compat helper: materializes a full joined row from two concrete rows. fn merge_rows(left: &Row, right: &Row) -> Row { - let mut values = left.values().to_vec(); + let mut values = Vec::with_capacity(left.len().saturating_add(right.len())); + values.extend(left.values().iter().cloned()); values.extend(right.values().iter().cloned()); - Row::new(left.id(), values) + Row::new_with_version( + cynos_core::join_row_id(left.id(), right.id()), + left.version().wrapping_add(right.version()), + values, + ) } -/// Merges a left row with NULL padding for right columns (left outer antijoin). +#[allow(dead_code)] +/// Test/compat helper: materializes a left row with NULLs on the right side. fn merge_rows_null_right(left: &Row, right_col_count: usize) -> Row { - let mut values = left.values().to_vec(); - for _ in 0..right_col_count { - values.push(Value::Null); - } - Row::new(left.id(), values) + let mut values = Vec::with_capacity(left.len().saturating_add(right_col_count)); + values.extend(left.values().iter().cloned()); + values.resize(values.len().saturating_add(right_col_count), Value::Null); + Row::new_with_version( + cynos_core::left_join_null_row_id(left.id()), + left.version(), + values, + ) } -/// Merges a right row with NULL padding for left columns (right outer antijoin). +#[allow(dead_code)] +/// Test/compat helper: materializes a right row with NULLs on the left side. fn merge_rows_null_left(right: &Row, left_col_count: usize) -> Row { - let mut values: Vec = (0..left_col_count).map(|_| Value::Null).collect(); + let mut values = Vec::with_capacity(left_col_count.saturating_add(right.len())); + values.resize(left_col_count, Value::Null); values.extend(right.values().iter().cloned()); - Row::new(right.id(), values) + Row::new_with_version( + cynos_core::right_join_null_row_id(right.id()), + right.version(), + values, + ) +} + +#[inline] +fn emits_unmatched_left(join_type: JoinType) -> bool { + matches!(join_type, JoinType::LeftOuter | JoinType::FullOuter) +} + +#[inline] +fn emits_unmatched_right(join_type: JoinType) -> bool { + matches!(join_type, JoinType::RightOuter | JoinType::FullOuter) } // --------------------------------------------------------------------------- @@ -438,9 +832,6 @@ pub struct GroupAggregateState { group_by: Vec, /// Track the last emitted row ID per group key, so deletes use the correct ID last_row_ids: HashMap, RowId>, - /// Monotonic counter for generating unique aggregate output row IDs. - /// Uses a high base (0xA660...) to avoid collision with real row IDs. - next_row_id: RowId, } impl GroupAggregateState { @@ -450,7 +841,6 @@ impl GroupAggregateState { functions, group_by, last_row_ids: HashMap::new(), - next_row_id: 0xA660_0000_0000_0000, } } @@ -525,47 +915,2049 @@ impl GroupAggregateState { output } - /// Build an output row from group key + aggregate values. - fn build_output_row(&mut self, key: &[Value]) -> Row { - let states = self.groups.get(key).unwrap(); - let mut values: Vec = key.to_vec(); - for state in states { - values.push(state.get_value()); + pub fn process_trace_deltas( + &mut self, + arena: &TraceTupleArena, + deltas: &[Delta], + ) -> Vec> { + let mut grouped: HashMap, Vec<(&TraceTupleHandle, i32)>> = HashMap::new(); + for delta in deltas { + let key = self + .group_by + .iter() + .map(|&col| arena.value_at(&delta.data, col).unwrap_or(Value::Null)) + .collect::>(); + grouped + .entry(key) + .or_default() + .push((&delta.data, delta.diff)); } - let id = self.next_row_id; - self.next_row_id += 1; - Row::new(id, values) - } -} -fn extract_numeric(value: &Value) -> f64 { - match value { - Value::Int32(v) => *v as f64, - Value::Int64(v) => *v as f64, - Value::Float64(v) => *v, - _ => 0.0, - } -} + let mut output = Vec::new(); + for (key, rows) in grouped { + let existed = self.groups.contains_key(&key); + let old_row = if existed { + Some(self.build_output_row(&key)) + } else { + None + }; -// --------------------------------------------------------------------------- -// MaterializedView — the core DBSP dataflow executor -// --------------------------------------------------------------------------- + let states = self.groups.entry(key.clone()).or_insert_with(|| { + self.functions + .iter() + .map(|(_, agg_type)| AggregateState::new(*agg_type)) + .collect() + }); -/// A materialized view that maintains query results incrementally. -pub struct MaterializedView { - dataflow: DataflowNode, - result_map: HashMap, - dependencies: Vec, - join_states: HashMap, - aggregate_states: HashMap, -} + for (handle, diff) in &rows { + for (index, (col, _)) in self.functions.iter().enumerate() { + let value = arena.value_at(handle, *col).unwrap_or(Value::Null); + states[index].apply(&value, *diff); + } + } + + let is_empty = states.iter().all(|state| state.is_empty()); + + if let Some(&old_id) = self.last_row_ids.get(&key) { + if let Some(old) = old_row { + let mut old_with_id = old; + old_with_id.set_id(old_id); + output.push(Delta::delete(arena.owned(old_with_id))); + } + } + + if !is_empty { + let new_row = self.build_output_row(&key); + self.last_row_ids.insert(key.clone(), new_row.id()); + output.push(Delta::insert(arena.owned(new_row))); + } else { + self.groups.remove(&key); + self.last_row_ids.remove(&key); + } + } + + output + } + + fn apply_trace_bootstrap_handle(&mut self, arena: &TraceTupleArena, handle: &TraceTupleHandle) { + let key = self + .group_by + .iter() + .map(|&col| arena.value_at(handle, col).unwrap_or(Value::Null)) + .collect::>(); + + let states = self.groups.entry(key).or_insert_with(|| { + self.functions + .iter() + .map(|(_, agg_type)| AggregateState::new(*agg_type)) + .collect() + }); + + for (index, (col, _)) in self.functions.iter().enumerate() { + let value = arena.value_at(handle, *col).unwrap_or(Value::Null); + states[index].apply(&value, 1); + } + } + + fn emit_bootstrap_rows(&mut self, arena: &TraceTupleArena, emit: &mut F) + where + F: FnMut(TraceTupleHandle) + ?Sized, + { + let group_keys = self.groups.keys().cloned().collect::>(); + for key in group_keys { + let row = self.build_output_row(&key); + self.last_row_ids.insert(key, row.id()); + emit(arena.owned(row)); + } + } + + /// Build an output row from group key + aggregate values. + fn build_output_row(&mut self, key: &[Value]) -> Row { + let states = self.groups.get(key).unwrap(); + let mut values: Vec = key.to_vec(); + for state in states { + values.push(state.get_value()); + } + Row::new(aggregate_group_row_id(key), values) + } +} + +#[inline] +fn left_child_state_id(state_id: usize) -> usize { + state_id.saturating_mul(2).saturating_add(1) +} + +#[inline] +fn right_child_state_id(state_id: usize) -> usize { + state_id.saturating_mul(2).saturating_add(2) +} + +fn extract_join_key_handle( + spec: &crate::dataflow::JoinKeySpec, + arena: &TraceTupleArena, + handle: &TraceTupleHandle, +) -> JoinKey { + match spec { + crate::dataflow::JoinKeySpec::Columns(indices) => match indices.as_slice() { + [] => JoinKey::Empty, + [index] => JoinKey::One(arena.value_at(handle, *index).unwrap_or(Value::Null)), + _ => JoinKey::Many( + indices + .iter() + .map(|&index| arena.value_at(handle, index).unwrap_or(Value::Null)) + .collect(), + ), + }, + crate::dataflow::JoinKeySpec::Constant(values) => JoinKey::from_vec(values.clone()), + crate::dataflow::JoinKeySpec::Dynamic(extractor) => { + JoinKey::from_vec(extractor(&arena.materialize_rc(handle))) + } + } +} + +#[allow(dead_code)] +#[derive(Clone)] +struct CompiledIvmNode { + sources: Box<[TableId]>, + kind: CompiledIvmNodeKind, +} + +pub struct CompiledIvmPlan { + node: CompiledIvmNode, + program: CompiledTraceProgram, +} + +type TraceSlotId = usize; + +struct CompiledTraceProgram { + instructions: Box<[TraceInstruction]>, + slot_count: usize, + root_slot: TraceSlotId, + scratch_slots: Vec>>, +} + +#[derive(Clone)] +enum TraceInstruction { + Source { + table_id: TableId, + output_slot: TraceSlotId, + }, + Unary { + input_slot: TraceSlotId, + output_slot: TraceSlotId, + block: UnaryFusionBlock, + }, + Join { + node_index: usize, + state_id: usize, + left_width: usize, + right_width: usize, + left_slot: TraceSlotId, + right_slot: TraceSlotId, + output_slot: TraceSlotId, + }, + Aggregate { + state_id: usize, + input_slot: TraceSlotId, + output_slot: TraceSlotId, + group_by: Vec, + functions: Vec<(ColumnId, AggregateType)>, + }, +} + +#[derive(Clone)] +struct UnaryFusionBlock { + ops: Vec, + accepts_append: bool, +} + +#[derive(Clone)] +enum TraceUnaryOp { + Filter { + node_index: usize, + tuple_native: bool, + }, + Project { + columns: Rc<[usize]>, + }, + Map { + node_index: usize, + tuple_native: bool, + }, +} + +enum TraceRuntimeNode<'a> { + Filter { + predicate: &'a (dyn Fn(&Row) -> bool + Send + Sync), + trace_predicate: + Option<&'a (dyn Fn(&TraceTupleArena, &TraceTupleHandle) -> bool + Send + Sync)>, + }, + Map { + mapper: &'a (dyn Fn(&Row) -> Row + Send + Sync), + trace_mapper: + Option<&'a (dyn Fn(&TraceTupleArena, &TraceTupleHandle) -> Row + Send + Sync)>, + }, + Join { + left_key: &'a JoinKeySpec, + right_key: &'a JoinKeySpec, + join_type: JoinType, + }, + Marker, +} + +#[derive(Clone, Debug, Default)] +pub struct TraceUpdateProfile { + pub source_dispatch_ms: f64, + pub unary_execute_ms: f64, + pub join_execute_ms: f64, + pub aggregate_execute_ms: f64, + pub result_apply_ms: f64, +} + +#[derive(Clone, Debug, Default)] +pub struct BootstrapExecutionProfile { + pub filter_bootstrap_ms: f64, + pub project_bootstrap_ms: f64, + pub map_bootstrap_ms: f64, + pub join_bootstrap_ms: f64, + pub aggregate_bootstrap_ms: f64, + pub root_sink_ms: f64, +} + +#[allow(dead_code)] +#[derive(Clone)] +pub struct CompiledBootstrapPlan { + legacy_node: CompiledBootstrapNode, + program: CompiledBootstrapProgram, +} + +#[derive(Clone)] +struct CompiledBootstrapProgram { + instructions: Box<[BootstrapInstruction]>, + slot_count: usize, +} + +type BootstrapSlotId = usize; + +#[derive(Clone)] +enum BootstrapInstruction { + Source { + node_index: usize, + source_index: usize, + output_slot: BootstrapSlotId, + }, + Filter { + node_index: usize, + input_slot: BootstrapSlotId, + output_slot: BootstrapSlotId, + covered_source_index: Option, + }, + Project { + input_slot: BootstrapSlotId, + output_slot: BootstrapSlotId, + columns: Rc<[usize]>, + }, + Map { + node_index: usize, + input_slot: BootstrapSlotId, + output_slot: BootstrapSlotId, + }, + Join { + node_index: usize, + state_id: usize, + left_width: usize, + right_width: usize, + left_slot: BootstrapSlotId, + right_slot: BootstrapSlotId, + output_slot: Option, + }, + Aggregate { + state_id: usize, + input_slot: BootstrapSlotId, + output_slot: Option, + group_by: Vec, + functions: Vec<(ColumnId, AggregateType)>, + }, +} + +struct BootstrapCompileOutput { + output_slot: Option, + direct_source_index: Option, +} + +enum BootstrapRuntimeNode<'a> { + Source { + table_id: TableId, + }, + Filter { + predicate: &'a (dyn Fn(&Row) -> bool + Send + Sync), + trace_predicate: + Option<&'a (dyn Fn(&TraceTupleArena, &TraceTupleHandle) -> bool + Send + Sync)>, + }, + Map { + mapper: &'a (dyn Fn(&Row) -> Row + Send + Sync), + trace_mapper: + Option<&'a (dyn Fn(&TraceTupleArena, &TraceTupleHandle) -> Row + Send + Sync)>, + }, + Join { + left_key: &'a JoinKeySpec, + right_key: &'a JoinKeySpec, + join_type: JoinType, + }, +} + +#[allow(dead_code)] +#[derive(Clone)] +struct CompiledBootstrapNode { + kind: CompiledBootstrapNodeKind, +} + +#[allow(dead_code)] +#[derive(Clone)] +enum CompiledBootstrapNodeKind { + Source { + source_index: usize, + }, + Filter { + input: Box, + }, + Project { + input: Box, + columns: Rc<[usize]>, + }, + Map { + input: Box, + }, + Join { + state_id: usize, + left_width: usize, + right_width: usize, + left: Box, + right: Box, + }, + Aggregate { + state_id: usize, + input: Box, + }, +} + +#[allow(dead_code)] +#[derive(Clone)] +enum CompiledIvmNodeKind { + Source, + Filter { + input: Box, + }, + Project { + input: Box, + columns: Rc<[usize]>, + }, + Map { + input: Box, + }, + Join { + state_id: usize, + left: Box, + right: Box, + }, + Aggregate { + state_id: usize, + input: Box, + }, +} + +impl CompiledIvmPlan { + pub fn compile(node: &DataflowNode) -> Self { + let mut next_trace_node_index = 0usize; + let mut next_trace_slot = 0usize; + let mut trace_instructions = Vec::new(); + let root_slot = compile_trace_program_node( + node, + 0, + &mut next_trace_node_index, + &mut next_trace_slot, + &mut trace_instructions, + ); + Self { + node: compile_ivm_node(node, 0), + program: CompiledTraceProgram { + instructions: trace_instructions.into_boxed_slice(), + slot_count: next_trace_slot, + root_slot, + scratch_slots: vec![Vec::new(); next_trace_slot], + }, + } + } + + #[inline] + pub fn sources(&self) -> &[TableId] { + &self.node.sources + } + + #[inline] + pub fn depends_on(&self, table_id: TableId) -> bool { + self.node.sources.binary_search(&table_id).is_ok() + } +} + +impl UnaryFusionBlock { + fn new(op: TraceUnaryOp) -> Self { + let accepts_append = op.can_append(); + Self { + ops: vec![op], + accepts_append, + } + } + + fn push(&mut self, op: TraceUnaryOp) { + self.accepts_append = op.can_append(); + self.ops.push(op); + } +} + +impl TraceUnaryOp { + #[inline] + fn can_append(&self) -> bool { + match self { + Self::Project { .. } => true, + Self::Filter { tuple_native, .. } | Self::Map { tuple_native, .. } => *tuple_native, + } + } +} + +impl CompiledBootstrapPlan { + pub fn compile(node: &DataflowNode) -> Self { + let mut next_source_index = 0usize; + let legacy_node = compile_bootstrap_node(node, 0, &mut next_source_index); + let mut program_source_index = 0usize; + let mut next_node_index = 0usize; + let mut next_slot = 0usize; + let mut instructions = Vec::new(); + compile_bootstrap_program_node( + node, + false, + 0, + &mut next_node_index, + &mut program_source_index, + &mut next_slot, + &mut instructions, + ); + Self { + legacy_node, + program: CompiledBootstrapProgram { + instructions: instructions.into_boxed_slice(), + slot_count: next_slot, + }, + } + } +} + +#[allow(dead_code)] +impl CompiledIvmNode { + #[inline] + fn sources(&self) -> &[TableId] { + &self.sources + } + + #[inline] + fn depends_on(&self, table_id: TableId) -> bool { + self.sources.binary_search(&table_id).is_ok() + } +} + +fn compile_ivm_node(node: &DataflowNode, state_id: usize) -> CompiledIvmNode { + match node { + DataflowNode::Source { table_id } => CompiledIvmNode { + sources: alloc::vec![*table_id].into_boxed_slice(), + kind: CompiledIvmNodeKind::Source, + }, + DataflowNode::Filter { input, .. } => { + let input = compile_ivm_node(input, state_id); + CompiledIvmNode { + sources: input.sources.clone(), + kind: CompiledIvmNodeKind::Filter { + input: Box::new(input), + }, + } + } + DataflowNode::Project { input, columns } => { + let input = compile_ivm_node(input, state_id); + CompiledIvmNode { + sources: input.sources.clone(), + kind: CompiledIvmNodeKind::Project { + input: Box::new(input), + columns: Rc::<[usize]>::from(columns.clone().into_boxed_slice()), + }, + } + } + DataflowNode::Map { input, .. } => { + let input = compile_ivm_node(input, state_id); + CompiledIvmNode { + sources: input.sources.clone(), + kind: CompiledIvmNodeKind::Map { + input: Box::new(input), + }, + } + } + DataflowNode::Join { left, right, .. } => { + let left = compile_ivm_node(left, left_child_state_id(state_id)); + let right = compile_ivm_node(right, right_child_state_id(state_id)); + CompiledIvmNode { + sources: merge_compiled_sources(left.sources(), right.sources()), + kind: CompiledIvmNodeKind::Join { + state_id, + left: Box::new(left), + right: Box::new(right), + }, + } + } + DataflowNode::Aggregate { input, .. } => { + let input = compile_ivm_node(input, left_child_state_id(state_id)); + CompiledIvmNode { + sources: input.sources.clone(), + kind: CompiledIvmNodeKind::Aggregate { + state_id, + input: Box::new(input), + }, + } + } + } +} + +fn alloc_trace_slot(next_slot: &mut usize) -> TraceSlotId { + let slot = *next_slot; + *next_slot = next_slot.saturating_add(1); + slot +} + +fn append_trace_unary_op( + input_slot: TraceSlotId, + next_slot: &mut usize, + instructions: &mut Vec, + op: TraceUnaryOp, +) -> TraceSlotId { + if let Some(TraceInstruction::Unary { + output_slot, block, .. + }) = instructions.last_mut() + { + if *output_slot == input_slot && block.accepts_append { + block.push(op); + return *output_slot; + } + } + + let output_slot = alloc_trace_slot(next_slot); + instructions.push(TraceInstruction::Unary { + input_slot, + output_slot, + block: UnaryFusionBlock::new(op), + }); + output_slot +} + +fn compile_trace_program_node( + node: &DataflowNode, + state_id: usize, + next_node_index: &mut usize, + next_slot: &mut usize, + instructions: &mut Vec, +) -> TraceSlotId { + let node_index = *next_node_index; + *next_node_index = next_node_index.saturating_add(1); + + match node { + DataflowNode::Source { table_id } => { + let output_slot = alloc_trace_slot(next_slot); + instructions.push(TraceInstruction::Source { + table_id: *table_id, + output_slot, + }); + output_slot + } + DataflowNode::Filter { + input, + trace_predicate, + .. + } => { + let input_slot = compile_trace_program_node( + input, + state_id, + next_node_index, + next_slot, + instructions, + ); + append_trace_unary_op( + input_slot, + next_slot, + instructions, + TraceUnaryOp::Filter { + node_index, + tuple_native: trace_predicate.is_some(), + }, + ) + } + DataflowNode::Project { input, columns } => { + let input_slot = compile_trace_program_node( + input, + state_id, + next_node_index, + next_slot, + instructions, + ); + append_trace_unary_op( + input_slot, + next_slot, + instructions, + TraceUnaryOp::Project { + columns: Rc::<[usize]>::from(columns.clone().into_boxed_slice()), + }, + ) + } + DataflowNode::Map { + input, + trace_mapper, + .. + } => { + let input_slot = compile_trace_program_node( + input, + state_id, + next_node_index, + next_slot, + instructions, + ); + append_trace_unary_op( + input_slot, + next_slot, + instructions, + TraceUnaryOp::Map { + node_index, + tuple_native: trace_mapper.is_some(), + }, + ) + } + DataflowNode::Join { + left, + right, + left_width, + right_width, + .. + } => { + let left_slot = compile_trace_program_node( + left, + left_child_state_id(state_id), + next_node_index, + next_slot, + instructions, + ); + let right_slot = compile_trace_program_node( + right, + right_child_state_id(state_id), + next_node_index, + next_slot, + instructions, + ); + let output_slot = alloc_trace_slot(next_slot); + instructions.push(TraceInstruction::Join { + node_index, + state_id, + left_width: *left_width, + right_width: *right_width, + left_slot, + right_slot, + output_slot, + }); + output_slot + } + DataflowNode::Aggregate { + input, + group_by, + functions, + } => { + let input_slot = compile_trace_program_node( + input, + left_child_state_id(state_id), + next_node_index, + next_slot, + instructions, + ); + let output_slot = alloc_trace_slot(next_slot); + instructions.push(TraceInstruction::Aggregate { + state_id, + input_slot, + output_slot, + group_by: group_by.clone(), + functions: functions.clone(), + }); + output_slot + } + } +} + +fn compile_bootstrap_node( + node: &DataflowNode, + state_id: usize, + next_source_index: &mut usize, +) -> CompiledBootstrapNode { + let kind = match node { + DataflowNode::Source { .. } => { + let source_index = *next_source_index; + *next_source_index = next_source_index.saturating_add(1); + CompiledBootstrapNodeKind::Source { source_index } + } + DataflowNode::Filter { input, .. } => CompiledBootstrapNodeKind::Filter { + input: Box::new(compile_bootstrap_node(input, state_id, next_source_index)), + }, + DataflowNode::Project { input, columns } => CompiledBootstrapNodeKind::Project { + input: Box::new(compile_bootstrap_node(input, state_id, next_source_index)), + columns: Rc::<[usize]>::from(columns.clone().into_boxed_slice()), + }, + DataflowNode::Map { input, .. } => CompiledBootstrapNodeKind::Map { + input: Box::new(compile_bootstrap_node(input, state_id, next_source_index)), + }, + DataflowNode::Join { + left, + right, + left_width, + right_width, + .. + } => CompiledBootstrapNodeKind::Join { + state_id, + left_width: *left_width, + right_width: *right_width, + left: Box::new(compile_bootstrap_node( + left, + left_child_state_id(state_id), + next_source_index, + )), + right: Box::new(compile_bootstrap_node( + right, + right_child_state_id(state_id), + next_source_index, + )), + }, + DataflowNode::Aggregate { input, .. } => CompiledBootstrapNodeKind::Aggregate { + state_id, + input: Box::new(compile_bootstrap_node( + input, + left_child_state_id(state_id), + next_source_index, + )), + }, + }; + CompiledBootstrapNode { kind } +} + +fn alloc_bootstrap_slot(next_slot: &mut usize) -> BootstrapSlotId { + let slot = *next_slot; + *next_slot = next_slot.saturating_add(1); + slot +} + +fn compile_bootstrap_program_node( + node: &DataflowNode, + emit_to_parent: bool, + state_id: usize, + next_node_index: &mut usize, + next_source_index: &mut usize, + next_slot: &mut usize, + instructions: &mut Vec, +) -> BootstrapCompileOutput { + let node_index = *next_node_index; + *next_node_index = next_node_index.saturating_add(1); + + match node { + DataflowNode::Source { .. } => { + let source_index = *next_source_index; + *next_source_index = next_source_index.saturating_add(1); + if !emit_to_parent { + return BootstrapCompileOutput { + output_slot: None, + direct_source_index: Some(source_index), + }; + } + + let output_slot = alloc_bootstrap_slot(next_slot); + instructions.push(BootstrapInstruction::Source { + node_index, + source_index, + output_slot, + }); + BootstrapCompileOutput { + output_slot: Some(output_slot), + direct_source_index: Some(source_index), + } + } + DataflowNode::Filter { input, .. } => { + if !emit_to_parent { + compile_bootstrap_program_node( + input, + false, + state_id, + next_node_index, + next_source_index, + next_slot, + instructions, + ); + return BootstrapCompileOutput { + output_slot: None, + direct_source_index: None, + }; + } + + let input = compile_bootstrap_program_node( + input, + true, + state_id, + next_node_index, + next_source_index, + next_slot, + instructions, + ); + let Some(input_slot) = input.output_slot else { + return BootstrapCompileOutput { + output_slot: None, + direct_source_index: None, + }; + }; + let output_slot = alloc_bootstrap_slot(next_slot); + instructions.push(BootstrapInstruction::Filter { + node_index, + input_slot, + output_slot, + covered_source_index: input.direct_source_index, + }); + BootstrapCompileOutput { + output_slot: Some(output_slot), + direct_source_index: None, + } + } + DataflowNode::Project { input, columns } => { + if !emit_to_parent { + compile_bootstrap_program_node( + input, + false, + state_id, + next_node_index, + next_source_index, + next_slot, + instructions, + ); + return BootstrapCompileOutput { + output_slot: None, + direct_source_index: None, + }; + } + + let input = compile_bootstrap_program_node( + input, + true, + state_id, + next_node_index, + next_source_index, + next_slot, + instructions, + ); + let Some(input_slot) = input.output_slot else { + return BootstrapCompileOutput { + output_slot: None, + direct_source_index: None, + }; + }; + let output_slot = alloc_bootstrap_slot(next_slot); + instructions.push(BootstrapInstruction::Project { + input_slot, + output_slot, + columns: Rc::<[usize]>::from(columns.clone().into_boxed_slice()), + }); + BootstrapCompileOutput { + output_slot: Some(output_slot), + direct_source_index: None, + } + } + DataflowNode::Map { input, .. } => { + if !emit_to_parent { + compile_bootstrap_program_node( + input, + false, + state_id, + next_node_index, + next_source_index, + next_slot, + instructions, + ); + return BootstrapCompileOutput { + output_slot: None, + direct_source_index: None, + }; + } + + let input = compile_bootstrap_program_node( + input, + true, + state_id, + next_node_index, + next_source_index, + next_slot, + instructions, + ); + let Some(input_slot) = input.output_slot else { + return BootstrapCompileOutput { + output_slot: None, + direct_source_index: None, + }; + }; + let output_slot = alloc_bootstrap_slot(next_slot); + instructions.push(BootstrapInstruction::Map { + node_index, + input_slot, + output_slot, + }); + BootstrapCompileOutput { + output_slot: Some(output_slot), + direct_source_index: None, + } + } + DataflowNode::Join { + left, + right, + left_width, + right_width, + .. + } => { + let left = compile_bootstrap_program_node( + left, + true, + left_child_state_id(state_id), + next_node_index, + next_source_index, + next_slot, + instructions, + ); + let Some(left_slot) = left.output_slot else { + return BootstrapCompileOutput { + output_slot: None, + direct_source_index: None, + }; + }; + let right = compile_bootstrap_program_node( + right, + true, + right_child_state_id(state_id), + next_node_index, + next_source_index, + next_slot, + instructions, + ); + let Some(right_slot) = right.output_slot else { + return BootstrapCompileOutput { + output_slot: None, + direct_source_index: None, + }; + }; + let output_slot = emit_to_parent.then(|| alloc_bootstrap_slot(next_slot)); + instructions.push(BootstrapInstruction::Join { + node_index, + state_id, + left_width: *left_width, + right_width: *right_width, + left_slot, + right_slot, + output_slot, + }); + BootstrapCompileOutput { + output_slot, + direct_source_index: None, + } + } + DataflowNode::Aggregate { + input, + group_by, + functions, + } => { + let input = compile_bootstrap_program_node( + input, + true, + left_child_state_id(state_id), + next_node_index, + next_source_index, + next_slot, + instructions, + ); + let Some(input_slot) = input.output_slot else { + return BootstrapCompileOutput { + output_slot: None, + direct_source_index: None, + }; + }; + let output_slot = emit_to_parent.then(|| alloc_bootstrap_slot(next_slot)); + instructions.push(BootstrapInstruction::Aggregate { + state_id, + input_slot, + output_slot, + group_by: group_by.clone(), + functions: functions.clone(), + }); + BootstrapCompileOutput { + output_slot, + direct_source_index: None, + } + } + } +} + +fn collect_bootstrap_runtime_nodes<'a>( + node: &'a DataflowNode, + runtime_nodes: &mut Vec>, +) { + match node { + DataflowNode::Source { table_id } => { + runtime_nodes.push(BootstrapRuntimeNode::Source { + table_id: *table_id, + }); + } + DataflowNode::Filter { + input, + predicate, + trace_predicate, + } => { + runtime_nodes.push(BootstrapRuntimeNode::Filter { + predicate: predicate.as_ref(), + trace_predicate: trace_predicate.as_deref(), + }); + collect_bootstrap_runtime_nodes(input, runtime_nodes); + } + DataflowNode::Project { input, .. } => { + runtime_nodes.push(BootstrapRuntimeNode::Source { table_id: u32::MAX }); + collect_bootstrap_runtime_nodes(input, runtime_nodes); + } + DataflowNode::Map { + input, + mapper, + trace_mapper, + } => { + runtime_nodes.push(BootstrapRuntimeNode::Map { + mapper: mapper.as_ref(), + trace_mapper: trace_mapper.as_deref(), + }); + collect_bootstrap_runtime_nodes(input, runtime_nodes); + } + DataflowNode::Join { + left, + right, + left_key, + right_key, + join_type, + .. + } => { + runtime_nodes.push(BootstrapRuntimeNode::Join { + left_key, + right_key, + join_type: *join_type, + }); + collect_bootstrap_runtime_nodes(left, runtime_nodes); + collect_bootstrap_runtime_nodes(right, runtime_nodes); + } + DataflowNode::Aggregate { input, .. } => { + runtime_nodes.push(BootstrapRuntimeNode::Source { table_id: u32::MAX }); + collect_bootstrap_runtime_nodes(input, runtime_nodes); + } + } +} + +fn merge_compiled_sources(left: &[TableId], right: &[TableId]) -> Box<[TableId]> { + let mut merged = Vec::with_capacity(left.len().saturating_add(right.len())); + let mut left_index = 0usize; + let mut right_index = 0usize; + + while left_index < left.len() && right_index < right.len() { + match left[left_index].cmp(&right[right_index]) { + core::cmp::Ordering::Less => { + merged.push(left[left_index]); + left_index += 1; + } + core::cmp::Ordering::Greater => { + merged.push(right[right_index]); + right_index += 1; + } + core::cmp::Ordering::Equal => { + merged.push(left[left_index]); + left_index += 1; + right_index += 1; + } + } + } + + merged.extend_from_slice(&left[left_index..]); + merged.extend_from_slice(&right[right_index..]); + merged.into_boxed_slice() +} + +fn collect_trace_runtime_nodes<'a>( + node: &'a DataflowNode, + runtime_nodes: &mut Vec>, +) { + match node { + DataflowNode::Source { .. } => runtime_nodes.push(TraceRuntimeNode::Marker), + DataflowNode::Filter { + input, + predicate, + trace_predicate, + } => { + runtime_nodes.push(TraceRuntimeNode::Filter { + predicate: predicate.as_ref(), + trace_predicate: trace_predicate.as_deref(), + }); + collect_trace_runtime_nodes(input, runtime_nodes); + } + DataflowNode::Project { input, .. } => { + runtime_nodes.push(TraceRuntimeNode::Marker); + collect_trace_runtime_nodes(input, runtime_nodes); + } + DataflowNode::Map { + input, + mapper, + trace_mapper, + } => { + runtime_nodes.push(TraceRuntimeNode::Map { + mapper: mapper.as_ref(), + trace_mapper: trace_mapper.as_deref(), + }); + collect_trace_runtime_nodes(input, runtime_nodes); + } + DataflowNode::Join { + left, + right, + left_key, + right_key, + join_type, + .. + } => { + runtime_nodes.push(TraceRuntimeNode::Join { + left_key, + right_key, + join_type: *join_type, + }); + collect_trace_runtime_nodes(left, runtime_nodes); + collect_trace_runtime_nodes(right, runtime_nodes); + } + DataflowNode::Aggregate { input, .. } => { + runtime_nodes.push(TraceRuntimeNode::Marker); + collect_trace_runtime_nodes(input, runtime_nodes); + } + } +} + +fn keep_trace_handle( + arena: &TraceTupleArena, + handle: &TraceTupleHandle, + predicate: &(dyn Fn(&Row) -> bool + Send + Sync), + trace_predicate: Option<&(dyn Fn(&TraceTupleArena, &TraceTupleHandle) -> bool + Send + Sync)>, +) -> bool { + if let Some(trace_predicate) = trace_predicate.filter(|_| !arena.has_materialized_row(handle)) { + trace_predicate(arena, handle) + } else { + let row = arena.materialize_rc(handle); + predicate(&row) + } +} + +fn map_trace_handle( + arena: &TraceTupleArena, + handle: &TraceTupleHandle, + mapper: &(dyn Fn(&Row) -> Row + Send + Sync), + trace_mapper: Option<&(dyn Fn(&TraceTupleArena, &TraceTupleHandle) -> Row + Send + Sync)>, +) -> Row { + if let Some(trace_mapper) = trace_mapper.filter(|_| !arena.has_materialized_row(handle)) { + trace_mapper(arena, handle) + } else { + let row = arena.materialize_rc(handle); + mapper(&row) + } +} + +fn execute_unary_fusion_block( + block: &UnaryFusionBlock, + runtime_nodes: &[TraceRuntimeNode<'_>], + arena: &TraceTupleArena, + input: &mut Vec>, + output: &mut Vec>, +) { + output.clear(); + output.reserve(input.len()); + + for delta in input.drain(..) { + let mut handle = delta.data; + let diff = delta.diff; + let mut keep = true; + + for op in block.ops.iter() { + match op { + TraceUnaryOp::Filter { node_index, .. } => { + let (predicate, trace_predicate) = match runtime_nodes.get(*node_index) { + Some(TraceRuntimeNode::Filter { + predicate, + trace_predicate, + }) => (*predicate, *trace_predicate), + _ => unreachable!("trace filter op must map to filter runtime node"), + }; + if !keep_trace_handle(arena, &handle, predicate, trace_predicate) { + keep = false; + break; + } + } + TraceUnaryOp::Project { columns } => { + handle = arena.project(handle, columns.clone()); + } + TraceUnaryOp::Map { node_index, .. } => { + let (mapper, trace_mapper) = match runtime_nodes.get(*node_index) { + Some(TraceRuntimeNode::Map { + mapper, + trace_mapper, + }) => (*mapper, *trace_mapper), + _ => unreachable!("trace map op must map to map runtime node"), + }; + let row_id = handle.row_id(); + let version = handle.version(); + let mut mapped = map_trace_handle(arena, &handle, mapper, trace_mapper); + mapped.set_id(row_id); + mapped.set_version(version); + handle = arena.owned(mapped); + } + } + } + + if keep { + output.push(Delta::new(handle, diff)); + } + } +} + +fn recycle_slot_buffer( + slots: &mut [Vec>], + slot_id: TraceSlotId, + mut buffer: Vec>, +) { + buffer.clear(); + mem::swap(&mut slots[slot_id], &mut buffer); +} + +fn execute_compiled_trace_program( + dataflow: &DataflowNode, + program: &mut CompiledTraceProgram, + join_states: &mut HashMap, + aggregate_states: &mut HashMap, + source_table: TableId, + deltas: &TraceDeltaBatch, + now_fn: Option f64>, +) -> (TraceDeltaBatch, TraceUpdateProfile) { + let arena = deltas.arena().clone(); + let mut runtime_nodes = Vec::new(); + collect_trace_runtime_nodes(dataflow, &mut runtime_nodes); + let slots = &mut program.scratch_slots; + debug_assert_eq!(slots.len(), program.slot_count); + let mut profile = TraceUpdateProfile::default(); + + for instruction in program.instructions.iter() { + match instruction { + TraceInstruction::Source { + table_id, + output_slot, + } => { + let output = &mut slots[*output_slot]; + output.clear(); + if *table_id != source_table { + continue; + } + output.reserve(deltas.deltas().len()); + timed_block(now_fn, &mut profile.source_dispatch_ms, || { + output.extend(deltas.deltas().iter().cloned()); + }); + } + TraceInstruction::Unary { + input_slot, + output_slot, + block, + } => { + let mut input = Vec::new(); + mem::swap(&mut slots[*input_slot], &mut input); + timed_block(now_fn, &mut profile.unary_execute_ms, || { + execute_unary_fusion_block( + block, + &runtime_nodes, + &arena, + &mut input, + &mut slots[*output_slot], + ); + }); + recycle_slot_buffer(slots, *input_slot, input); + } + TraceInstruction::Join { + node_index, + state_id, + left_width, + right_width, + left_slot, + right_slot, + output_slot, + } => { + let (left_key, right_key, join_type) = match runtime_nodes.get(*node_index) { + Some(TraceRuntimeNode::Join { + left_key, + right_key, + join_type, + }) => (*left_key, *right_key, *join_type), + _ => unreachable!("trace join instruction must map to join runtime node"), + }; + let mut left_input = Vec::new(); + let mut right_input = Vec::new(); + mem::swap(&mut slots[*left_slot], &mut left_input); + mem::swap(&mut slots[*right_slot], &mut right_input); + let output = &mut slots[*output_slot]; + output.clear(); + timed_block(now_fn, &mut profile.join_execute_ms, || { + process_join_trace_deltas( + join_states, + *state_id, + *left_width, + *right_width, + left_key, + right_key, + join_type, + &arena, + &left_input, + &right_input, + output, + ); + }); + recycle_slot_buffer(slots, *left_slot, left_input); + recycle_slot_buffer(slots, *right_slot, right_input); + } + TraceInstruction::Aggregate { + state_id, + input_slot, + output_slot, + group_by, + functions, + } => { + let mut input = Vec::new(); + mem::swap(&mut slots[*input_slot], &mut input); + let output = &mut slots[*output_slot]; + output.clear(); + timed_block(now_fn, &mut profile.aggregate_execute_ms, || { + process_aggregate_trace_deltas( + aggregate_states, + *state_id, + group_by, + functions, + &arena, + &input, + output, + ); + }); + recycle_slot_buffer(slots, *input_slot, input); + } + } + } + + let mut output = Vec::new(); + mem::swap(&mut slots[program.root_slot], &mut output); + (TraceDeltaBatch::new(arena, output), profile) +} + +fn process_left_join_trace_deltas( + join_state: &mut JoinState, + left_key: &JoinKeySpec, + join_type: JoinType, + arena: &TraceTupleArena, + deltas: &[Delta], + output: &mut Vec>, +) { + for action in normalize_join_side_deltas(deltas, left_key, arena) { + match action { + NormalizedJoinSideAction::SameKeyUpdate(update) => { + let SameKeyTraceUpdate { + old_row, + new_row, + key, + } = update; + join_state + .process_left_update_same_key(&old_row, new_row, key, join_type, arena, output); + } + NormalizedJoinSideAction::Delta(delta) => { + let key = extract_join_key_handle(left_key, arena, &delta.data); + if join_type == JoinType::Inner { + if delta.is_insert() { + join_state.process_left_insert( + delta.data, + key, + JoinType::Inner, + arena, + output, + ); + } else if delta.is_delete() { + join_state.process_left_delete( + delta.data.row_id(), + JoinType::Inner, + arena, + output, + ); + } + } else if delta.is_insert() { + join_state.on_left_insert_outer(delta.data, key, join_type, arena, output); + } else if delta.is_delete() { + join_state.on_left_delete_outer(&delta.data, key, join_type, arena, output); + } + } + } + } +} + +struct SameKeyTraceUpdate { + old_row: TraceTupleHandle, + new_row: TraceTupleHandle, + key: JoinKey, +} + +enum NormalizedJoinSideAction { + SameKeyUpdate(SameKeyTraceUpdate), + Delta(Delta), +} + +fn normalize_join_side_deltas( + deltas: &[Delta], + key_spec: &JoinKeySpec, + arena: &TraceTupleArena, +) -> Vec { + let mut pending_deletes = HashMap::::new(); + let mut updates_by_delete = HashMap::::new(); + let mut skip_indices = HashMap::::new(); + + for (index, delta) in deltas.iter().enumerate() { + if delta.is_delete() { + pending_deletes.insert(delta.data.row_id(), index); + continue; + } + if !delta.is_insert() { + continue; + } + + let Some(delete_index) = pending_deletes.remove(&delta.data.row_id()) else { + continue; + }; + let old_delta = &deltas[delete_index]; + let old_key = extract_join_key_handle(key_spec, arena, &old_delta.data); + let new_key = extract_join_key_handle(key_spec, arena, &delta.data); + if old_key != new_key { + continue; + } + updates_by_delete.insert( + delete_index, + SameKeyTraceUpdate { + old_row: old_delta.data.clone(), + new_row: delta.data.clone(), + key: old_key, + }, + ); + skip_indices.insert(index, ()); + } + + let mut normalized = Vec::with_capacity(deltas.len()); + for (index, delta) in deltas.iter().enumerate() { + if let Some(update) = updates_by_delete.remove(&index) { + normalized.push(NormalizedJoinSideAction::SameKeyUpdate(update)); + continue; + } + if skip_indices.contains_key(&index) { + continue; + } + normalized.push(NormalizedJoinSideAction::Delta(delta.clone())); + } + normalized +} + +fn process_right_join_trace_deltas( + join_state: &mut JoinState, + right_key: &JoinKeySpec, + join_type: JoinType, + arena: &TraceTupleArena, + deltas: &[Delta], + output: &mut Vec>, +) { + for action in normalize_join_side_deltas(deltas, right_key, arena) { + match action { + NormalizedJoinSideAction::SameKeyUpdate(update) => { + let SameKeyTraceUpdate { + old_row, + new_row, + key, + } = update; + join_state.process_right_update_same_key( + &old_row, new_row, key, join_type, arena, output, + ); + } + NormalizedJoinSideAction::Delta(delta) => { + let key = extract_join_key_handle(right_key, arena, &delta.data); + if join_type == JoinType::Inner { + if delta.is_insert() { + join_state.process_right_insert( + delta.data, + key, + JoinType::Inner, + arena, + output, + ); + } else if delta.is_delete() { + join_state.process_right_delete( + delta.data.row_id(), + JoinType::Inner, + arena, + output, + ); + } + } else if delta.is_insert() { + join_state.on_right_insert_outer(delta.data, key, join_type, arena, output); + } else if delta.is_delete() { + join_state.on_right_delete_outer(&delta.data, key, join_type, arena, output); + } + } + } + } +} + +fn process_join_trace_deltas( + join_states: &mut HashMap, + state_id: usize, + left_width: usize, + right_width: usize, + left_key: &JoinKeySpec, + right_key: &JoinKeySpec, + join_type: JoinType, + arena: &TraceTupleArena, + left_deltas: &[Delta], + right_deltas: &[Delta], + output: &mut Vec>, +) { + if left_deltas.is_empty() && right_deltas.is_empty() { + output.clear(); + return; + } + + let join_state = join_states + .entry(state_id) + .or_insert_with(|| JoinState::with_col_counts(left_width, right_width)); + output.clear(); + + if !left_deltas.is_empty() { + process_left_join_trace_deltas(join_state, left_key, join_type, arena, left_deltas, output); + } + if !right_deltas.is_empty() { + process_right_join_trace_deltas( + join_state, + right_key, + join_type, + arena, + right_deltas, + output, + ); + } +} + +fn process_aggregate_trace_deltas( + aggregate_states: &mut HashMap, + state_id: usize, + group_by: &[ColumnId], + functions: &[(ColumnId, AggregateType)], + arena: &TraceTupleArena, + input_deltas: &[Delta], + output: &mut Vec>, +) { + if input_deltas.is_empty() { + output.clear(); + return; + } + + let agg_state = aggregate_states + .entry(state_id) + .or_insert_with(|| GroupAggregateState::new(group_by.to_vec(), functions.to_vec())); + *output = agg_state.process_trace_deltas(arena, input_deltas); +} + +#[allow(dead_code)] +fn bootstrap_node_stream_with_source_visitor( + node: &DataflowNode, + meta: &CompiledBootstrapNode, + emit_to_parent: bool, + visit_source_rows: &mut F, + join_states: &mut HashMap, + aggregate_states: &mut HashMap, + emit: &mut dyn FnMut(TraceTupleHandle), +) where + F: FnMut(TableId, usize, &mut dyn FnMut(Rc)), +{ + let arena = TraceTupleArena; + match (node, &meta.kind) { + (DataflowNode::Source { table_id }, CompiledBootstrapNodeKind::Source { source_index }) => { + if !emit_to_parent { + return; + } + let mut emit_source_row = |row: Rc| emit(arena.base_rc(row)); + visit_source_rows(*table_id, *source_index, &mut emit_source_row); + } + + ( + DataflowNode::Filter { + input, + predicate, + trace_predicate, + }, + CompiledBootstrapNodeKind::Filter { input: input_meta }, + ) => bootstrap_node_stream_with_source_visitor( + input, + input_meta, + emit_to_parent, + visit_source_rows, + join_states, + aggregate_states, + &mut |handle| { + let keep = if let Some(trace_predicate) = trace_predicate + .as_ref() + .filter(|_| !arena.has_materialized_row(&handle)) + { + trace_predicate(&arena, &handle) + } else { + let row = arena.materialize_rc(&handle); + predicate(&row) + }; + if keep { + emit(handle); + } + }, + ), + + ( + DataflowNode::Project { input, .. }, + CompiledBootstrapNodeKind::Project { + input: input_meta, + columns, + }, + ) => { + bootstrap_node_stream_with_source_visitor( + input, + input_meta, + emit_to_parent, + visit_source_rows, + join_states, + aggregate_states, + &mut |handle| emit(arena.project(handle, columns.clone())), + ); + } + + ( + DataflowNode::Map { + input, + mapper, + trace_mapper, + }, + CompiledBootstrapNodeKind::Map { input: input_meta }, + ) => bootstrap_node_stream_with_source_visitor( + input, + input_meta, + emit_to_parent, + visit_source_rows, + join_states, + aggregate_states, + &mut |handle| { + let mut mapped = if let Some(trace_mapper) = trace_mapper + .as_ref() + .filter(|_| !arena.has_materialized_row(&handle)) + { + trace_mapper(&arena, &handle) + } else { + let row = arena.materialize_rc(&handle); + mapper(&row) + }; + mapped.set_id(handle.row_id()); + mapped.set_version(handle.version()); + emit(arena.owned(mapped)); + }, + ), + + ( + DataflowNode::Join { + left, + right, + left_key, + right_key, + join_type, + .. + }, + CompiledBootstrapNodeKind::Join { + state_id, + left_width, + right_width, + left: left_meta, + right: right_meta, + }, + ) => { + let mut join_state = JoinState::with_col_counts(*left_width, *right_width); + + bootstrap_node_stream_with_source_visitor( + left, + left_meta, + true, + visit_source_rows, + join_states, + aggregate_states, + &mut |handle| { + let key = extract_join_key_handle(left_key, &arena, &handle); + join_state.left.insert(handle, key, 0); + }, + ); + + bootstrap_node_stream_with_source_visitor( + right, + right_meta, + true, + visit_source_rows, + join_states, + aggregate_states, + &mut |handle| { + let key = extract_join_key_handle(right_key, &arena, &handle); + join_state.right.insert(handle, key, 0); + }, + ); + + join_state.finalize_bootstrap_match_counts(); + if emit_to_parent { + join_state.emit_bootstrap_rows(*join_type, &arena, emit); + } + join_states.insert(*state_id, join_state); + } + + ( + DataflowNode::Aggregate { + input, + group_by, + functions, + }, + CompiledBootstrapNodeKind::Aggregate { + state_id, + input: input_meta, + }, + ) => { + let mut aggregate_state = GroupAggregateState::new(group_by.clone(), functions.clone()); + bootstrap_node_stream_with_source_visitor( + input, + input_meta, + true, + visit_source_rows, + join_states, + aggregate_states, + &mut |handle| aggregate_state.apply_trace_bootstrap_handle(&arena, &handle), + ); + if emit_to_parent { + aggregate_state.emit_bootstrap_rows(&arena, emit); + } + aggregate_states.insert(*state_id, aggregate_state); + } + + _ => unreachable!("compiled bootstrap metadata must mirror the dataflow shape"), + } +} + +fn timed_block(now_fn: Option f64>, total_ms: &mut f64, run: impl FnOnce()) { + if let Some(now_fn) = now_fn { + let started_at = now_fn(); + run(); + *total_ms += now_fn() - started_at; + } else { + run(); + } +} + +fn execute_compiled_bootstrap_program_with_source_visitor( + dataflow: &DataflowNode, + plan: &CompiledBootstrapPlan, + visit_source_rows: &mut F, + source_filter_coverage: Option<&[bool]>, + join_states: &mut HashMap, + aggregate_states: &mut HashMap, + now_fn: Option f64>, +) -> BootstrapExecutionProfile +where + F: FnMut(TableId, usize, &mut dyn FnMut(Rc)), +{ + let arena = TraceTupleArena; + let mut runtime_nodes = Vec::new(); + collect_bootstrap_runtime_nodes(dataflow, &mut runtime_nodes); + let mut slots = vec![Vec::::new(); plan.program.slot_count]; + let mut profile = BootstrapExecutionProfile::default(); + + for instruction in plan.program.instructions.iter() { + match instruction { + BootstrapInstruction::Source { + node_index, + source_index, + output_slot, + } => { + let table_id = match runtime_nodes.get(*node_index) { + Some(BootstrapRuntimeNode::Source { table_id }) => *table_id, + _ => { + unreachable!("bootstrap source instruction must map to source runtime node") + } + }; + let slot = &mut slots[*output_slot]; + slot.clear(); + let mut emit_source_row = |row: Rc| slot.push(arena.base_rc(row)); + visit_source_rows(table_id, *source_index, &mut emit_source_row); + } + BootstrapInstruction::Filter { + node_index, + input_slot, + output_slot, + covered_source_index, + } => { + let (predicate, trace_predicate) = match runtime_nodes.get(*node_index) { + Some(BootstrapRuntimeNode::Filter { + predicate, + trace_predicate, + }) => (*predicate, *trace_predicate), + _ => { + unreachable!("bootstrap filter instruction must map to filter runtime node") + } + }; + let input: Vec = mem::take(&mut slots[*input_slot]); + let output = &mut slots[*output_slot]; + output.clear(); + output.reserve(input.len()); + let skip_filter = covered_source_index + .and_then(|source_index| { + source_filter_coverage + .and_then(|coverages| coverages.get(source_index)) + .copied() + }) + .unwrap_or(false); + if skip_filter { + output.extend(input); + continue; + } + timed_block(now_fn, &mut profile.filter_bootstrap_ms, || { + for handle in input { + let keep = if let Some(trace_predicate) = + trace_predicate.filter(|_| !arena.has_materialized_row(&handle)) + { + trace_predicate(&arena, &handle) + } else { + let row = arena.materialize_rc(&handle); + predicate(&row) + }; + if keep { + output.push(handle); + } + } + }); + } + BootstrapInstruction::Project { + input_slot, + output_slot, + columns, + } => { + let input: Vec = mem::take(&mut slots[*input_slot]); + let output = &mut slots[*output_slot]; + output.clear(); + output.reserve(input.len()); + timed_block(now_fn, &mut profile.project_bootstrap_ms, || { + for handle in input { + output.push(arena.project(handle, columns.clone())); + } + }); + } + BootstrapInstruction::Map { + node_index, + input_slot, + output_slot, + } => { + let (mapper, trace_mapper) = match runtime_nodes.get(*node_index) { + Some(BootstrapRuntimeNode::Map { + mapper, + trace_mapper, + }) => (*mapper, *trace_mapper), + _ => unreachable!("bootstrap map instruction must map to map runtime node"), + }; + let input: Vec = mem::take(&mut slots[*input_slot]); + let output = &mut slots[*output_slot]; + output.clear(); + output.reserve(input.len()); + timed_block(now_fn, &mut profile.map_bootstrap_ms, || { + for handle in input { + let mut mapped = if let Some(trace_mapper) = + trace_mapper.filter(|_| !arena.has_materialized_row(&handle)) + { + trace_mapper(&arena, &handle) + } else { + let row = arena.materialize_rc(&handle); + mapper(&row) + }; + mapped.set_id(handle.row_id()); + mapped.set_version(handle.version()); + output.push(arena.owned(mapped)); + } + }); + } + BootstrapInstruction::Join { + node_index, + state_id, + left_width, + right_width, + left_slot, + right_slot, + output_slot, + } => { + let (left_key, right_key, join_type) = match runtime_nodes.get(*node_index) { + Some(BootstrapRuntimeNode::Join { + left_key, + right_key, + join_type, + }) => (*left_key, *right_key, *join_type), + _ => unreachable!("bootstrap join instruction must map to join runtime node"), + }; + let left_input = mem::take(&mut slots[*left_slot]); + let right_input = mem::take(&mut slots[*right_slot]); + let mut join_state = JoinState::with_col_counts(*left_width, *right_width); + timed_block(now_fn, &mut profile.join_bootstrap_ms, || { + for handle in left_input { + let key = extract_join_key_handle(left_key, &arena, &handle); + join_state.left.insert(handle, key, 0); + } + for handle in right_input { + let key = extract_join_key_handle(right_key, &arena, &handle); + join_state.right.insert(handle, key, 0); + } + join_state.finalize_bootstrap_match_counts(); + }); + + if let Some(output_slot) = output_slot { + let output = &mut slots[*output_slot]; + output.clear(); + timed_block(now_fn, &mut profile.join_bootstrap_ms, || { + join_state.emit_bootstrap_rows(join_type, &arena, &mut |handle| { + output.push(handle); + }); + }); + } else { + // Root join is the visible sink in trace bootstrap; visible rows are already installed. + timed_block(now_fn, &mut profile.root_sink_ms, || {}); + } + + join_states.insert(*state_id, join_state); + } + BootstrapInstruction::Aggregate { + state_id, + input_slot, + output_slot, + group_by, + functions, + } => { + let input = mem::take(&mut slots[*input_slot]); + let mut aggregate_state = + GroupAggregateState::new(group_by.clone(), functions.clone()); + timed_block(now_fn, &mut profile.aggregate_bootstrap_ms, || { + for handle in &input { + aggregate_state.apply_trace_bootstrap_handle(&arena, handle); + } + }); + + if let Some(output_slot) = output_slot { + let output = &mut slots[*output_slot]; + output.clear(); + timed_block(now_fn, &mut profile.aggregate_bootstrap_ms, || { + aggregate_state.emit_bootstrap_rows(&arena, &mut |handle| { + output.push(handle); + }); + }); + } else { + timed_block(now_fn, &mut profile.root_sink_ms, || {}); + } + + aggregate_states.insert(*state_id, aggregate_state); + } + } + } + + profile +} + +fn extract_numeric(value: &Value) -> f64 { + match value { + Value::Int32(v) => *v as f64, + Value::Int64(v) => *v as f64, + Value::Float64(v) => *v, + _ => 0.0, + } +} + +// --------------------------------------------------------------------------- +// MaterializedView — the core DBSP dataflow executor +// --------------------------------------------------------------------------- + +/// A materialized view that maintains query results incrementally. +pub struct MaterializedView { + dataflow: DataflowNode, + compiled_plan: CompiledIvmPlan, + visible_rows: VisibleResultStore, + dependencies: Vec, + join_states: HashMap, + aggregate_states: HashMap, +} impl MaterializedView { pub fn new(dataflow: DataflowNode) -> Self { - let dependencies = dataflow.collect_sources(); + let compiled_plan = CompiledIvmPlan::compile(&dataflow); + let dependencies = compiled_plan.sources().to_vec(); Self { dataflow, - result_map: HashMap::new(), + compiled_plan, + visible_rows: VisibleResultStore::default(), dependencies, join_states: HashMap::new(), aggregate_states: HashMap::new(), @@ -573,20 +2965,248 @@ impl MaterializedView { } pub fn with_initial(dataflow: DataflowNode, initial: Vec) -> Self { - let dependencies = dataflow.collect_sources(); - let mut result_map = HashMap::with_capacity(initial.len()); - for row in initial { - result_map.insert(row.id(), row); - } + let compiled_plan = CompiledIvmPlan::compile(&dataflow); + Self::with_compiled_initial(dataflow, compiled_plan, initial) + } + + pub fn with_compiled_initial( + dataflow: DataflowNode, + compiled_plan: CompiledIvmPlan, + initial: Vec, + ) -> Self { + let compiled_bootstrap_plan = CompiledBootstrapPlan::compile(&dataflow); + Self::with_compiled_initial_and_bootstrap( + dataflow, + compiled_plan, + compiled_bootstrap_plan, + initial.into_iter().map(Rc::new).collect(), + ) + } + + pub fn with_compiled_initial_rc( + dataflow: DataflowNode, + compiled_plan: CompiledIvmPlan, + initial: Vec>, + ) -> Self { + let compiled_bootstrap_plan = CompiledBootstrapPlan::compile(&dataflow); + Self::with_compiled_initial_and_bootstrap( + dataflow, + compiled_plan, + compiled_bootstrap_plan, + initial, + ) + } + + pub fn with_compiled_initial_and_bootstrap( + dataflow: DataflowNode, + compiled_plan: CompiledIvmPlan, + _compiled_bootstrap_plan: CompiledBootstrapPlan, + initial: Vec>, + ) -> Self { + let dependencies = compiled_plan.sources().to_vec(); Self { dataflow, - result_map, + compiled_plan, + visible_rows: VisibleResultStore::from_rc_rows(initial), dependencies, join_states: HashMap::new(), aggregate_states: HashMap::new(), } } + pub fn with_sources( + dataflow: DataflowNode, + initial: Vec, + source_rows: &HashMap>, + ) -> Self { + let compiled_plan = CompiledIvmPlan::compile(&dataflow); + let compiled_bootstrap_plan = CompiledBootstrapPlan::compile(&dataflow); + Self::with_compiled_sources_and_bootstrap( + dataflow, + compiled_plan, + compiled_bootstrap_plan, + initial.into_iter().map(Rc::new).collect(), + source_rows, + ) + } + + pub fn with_compiled_sources( + dataflow: DataflowNode, + compiled_plan: CompiledIvmPlan, + initial: Vec, + source_rows: &HashMap>, + ) -> Self { + let compiled_bootstrap_plan = CompiledBootstrapPlan::compile(&dataflow); + Self::with_compiled_sources_and_bootstrap( + dataflow, + compiled_plan, + compiled_bootstrap_plan, + initial.into_iter().map(Rc::new).collect(), + source_rows, + ) + } + + pub fn with_compiled_sources_and_bootstrap( + dataflow: DataflowNode, + compiled_plan: CompiledIvmPlan, + compiled_bootstrap_plan: CompiledBootstrapPlan, + initial: Vec>, + source_rows: &HashMap>, + ) -> Self { + let dependencies = compiled_plan.sources().to_vec(); + + let mut join_states = HashMap::new(); + let mut aggregate_states = HashMap::new(); + execute_compiled_bootstrap_program_with_source_visitor( + &dataflow, + &compiled_bootstrap_plan, + &mut |table_id, _source_index, emit| { + if let Some(rows) = source_rows.get(&table_id) { + for row in rows { + emit(Rc::new(row.clone())); + } + } + }, + None, + &mut join_states, + &mut aggregate_states, + None, + ); + + Self { + dataflow, + compiled_plan, + visible_rows: VisibleResultStore::from_rc_rows(initial), + dependencies, + join_states, + aggregate_states, + } + } + + pub fn with_compiled_loader( + dataflow: DataflowNode, + compiled_plan: CompiledIvmPlan, + initial: Vec>, + load_source_rows: F, + ) -> Self + where + F: FnMut(TableId) -> Vec>, + { + let compiled_bootstrap_plan = CompiledBootstrapPlan::compile(&dataflow); + Self::with_compiled_loader_and_bootstrap( + dataflow, + compiled_plan, + compiled_bootstrap_plan, + initial, + load_source_rows, + ) + } + + pub fn with_compiled_loader_and_bootstrap( + dataflow: DataflowNode, + compiled_plan: CompiledIvmPlan, + compiled_bootstrap_plan: CompiledBootstrapPlan, + initial: Vec>, + mut load_source_rows: F, + ) -> Self + where + F: FnMut(TableId) -> Vec>, + { + Self::with_compiled_source_visitor_and_bootstrap( + dataflow, + compiled_plan, + compiled_bootstrap_plan, + initial, + move |table_id, _source_index, emit| { + for row in load_source_rows(table_id) { + emit(row); + } + }, + ) + } + + pub fn with_compiled_source_visitor_and_bootstrap( + dataflow: DataflowNode, + compiled_plan: CompiledIvmPlan, + compiled_bootstrap_plan: CompiledBootstrapPlan, + initial: Vec>, + visit_source_rows: F, + ) -> Self + where + F: FnMut(TableId, usize, &mut dyn FnMut(Rc)), + { + Self::with_compiled_source_visitor_and_bootstrap_profiled( + dataflow, + compiled_plan, + compiled_bootstrap_plan, + initial, + visit_source_rows, + None, + ) + .0 + } + + pub fn with_compiled_source_visitor_and_bootstrap_profiled( + dataflow: DataflowNode, + compiled_plan: CompiledIvmPlan, + compiled_bootstrap_plan: CompiledBootstrapPlan, + initial: Vec>, + visit_source_rows: F, + now_fn: Option f64>, + ) -> (Self, BootstrapExecutionProfile) + where + F: FnMut(TableId, usize, &mut dyn FnMut(Rc)), + { + Self::with_compiled_source_visitor_and_bootstrap_profiled_with_filter_coverage( + dataflow, + compiled_plan, + compiled_bootstrap_plan, + initial, + visit_source_rows, + None, + now_fn, + ) + } + + pub fn with_compiled_source_visitor_and_bootstrap_profiled_with_filter_coverage( + dataflow: DataflowNode, + compiled_plan: CompiledIvmPlan, + compiled_bootstrap_plan: CompiledBootstrapPlan, + initial: Vec>, + mut visit_source_rows: F, + source_filter_coverage: Option>, + now_fn: Option f64>, + ) -> (Self, BootstrapExecutionProfile) + where + F: FnMut(TableId, usize, &mut dyn FnMut(Rc)), + { + let dependencies = compiled_plan.sources().to_vec(); + + let mut join_states = HashMap::new(); + let mut aggregate_states = HashMap::new(); + let bootstrap_profile = execute_compiled_bootstrap_program_with_source_visitor( + &dataflow, + &compiled_bootstrap_plan, + &mut visit_source_rows, + source_filter_coverage.as_deref(), + &mut join_states, + &mut aggregate_states, + now_fn, + ); + + ( + Self { + dataflow, + compiled_plan, + visible_rows: VisibleResultStore::from_rc_rows(initial), + dependencies, + join_states, + aggregate_states, + }, + bootstrap_profile, + ) + } + pub fn initialize_join_state( &mut self, left_rows: &[Row], @@ -595,37 +3215,46 @@ impl MaterializedView { right_key_fn: impl Fn(&Row) -> Vec, ) { let join_state = self.join_states.entry(0).or_insert_with(JoinState::new); + let arena = TraceTupleArena; for row in left_rows { - let key = left_key_fn(row); - join_state - .left_index - .entry(key) - .or_default() - .push(row.clone()); + join_state.left.insert( + arena.owned(row.clone()), + JoinKey::from_vec(left_key_fn(row)), + 0, + ); } for row in right_rows { - let key = right_key_fn(row); - join_state - .right_index - .entry(key) - .or_default() - .push(row.clone()); + join_state.right.insert( + arena.owned(row.clone()), + JoinKey::from_vec(right_key_fn(row)), + 0, + ); } } #[inline] pub fn result(&self) -> Vec { - self.result_map.values().cloned().collect() + self.visible_rows.rows() + } + + #[inline] + pub fn result_rc(&self) -> Vec> { + self.visible_rows.rc_rows() + } + + #[inline] + pub fn result_row_refs(&self) -> impl Iterator> + '_ { + self.visible_rows.row_refs() } #[inline] pub fn len(&self) -> usize { - self.result_map.len() + self.visible_rows.len() } #[inline] pub fn is_empty(&self) -> bool { - self.result_map.is_empty() + self.visible_rows.is_empty() } #[inline] @@ -634,7 +3263,7 @@ impl MaterializedView { } pub fn depends_on(&self, table_id: TableId) -> bool { - self.dependencies.contains(&table_id) + self.compiled_plan.depends_on(table_id) } /// Handles changes to a source table. @@ -644,235 +3273,288 @@ impl MaterializedView { table_id: TableId, deltas: Vec>, ) -> Vec> { + self.on_table_change_batch(table_id, deltas) + .materialize_rows() + } + + pub fn on_table_change_batch( + &mut self, + table_id: TableId, + deltas: Vec>, + ) -> TraceDeltaBatch { + self.on_table_change_batch_profiled(table_id, deltas, None) + .0 + } + + pub fn on_table_change_batch_profiled( + &mut self, + table_id: TableId, + deltas: Vec>, + now_fn: Option f64>, + ) -> (TraceDeltaBatch, TraceUpdateProfile) { if !self.depends_on(table_id) { - return Vec::new(); + return (TraceDeltaBatch::empty(), TraceUpdateProfile::default()); } - // Split borrows: immutable borrow of dataflow, mutable borrows of states - let output_deltas = propagate_deltas( + let input_batch = TraceDeltaBatch::from_row_deltas(deltas); + let (output_deltas, mut profile) = execute_compiled_trace_program( &self.dataflow, + &mut self.compiled_plan.program, &mut self.join_states, &mut self.aggregate_states, table_id, - deltas, - 0, - 0, - ) - .0; + &input_batch, + now_fn, + ); - // Apply output deltas to result - for delta in &output_deltas { - if delta.is_insert() { - self.result_map.insert(delta.data.id(), delta.data.clone()); - } else if delta.is_delete() { - self.result_map.remove(&delta.data.id()); - } - } + timed_block(now_fn, &mut profile.result_apply_ms, || { + self.visible_rows.apply(&output_deltas); + }); - output_deltas + (output_deltas, profile) } pub fn clear(&mut self) { - self.result_map.clear(); + self.visible_rows.clear(); } pub fn set_result(&mut self, rows: Vec) { - self.result_map.clear(); - for row in rows { - self.result_map.insert(row.id(), row); - } + self.visible_rows.replace_rows(rows); + } + + pub fn set_result_rc(&mut self, rows: Vec>) { + self.visible_rows.replace_rc_rows(rows); } } -/// Propagates deltas through a dataflow node. -/// This is a free function to allow split borrows: immutable dataflow + mutable states. -/// Returns (output_deltas, next_join_id, next_agg_id). -fn propagate_deltas( +/// Propagates trace deltas through a dataflow node without eagerly materializing rows. +#[allow(dead_code)] +fn propagate_trace_deltas( node: &DataflowNode, + meta: &CompiledIvmNode, join_states: &mut HashMap, aggregate_states: &mut HashMap, source_table: TableId, - deltas: Vec>, - join_id: usize, - agg_id: usize, -) -> (Vec>, usize, usize) { - match node { - DataflowNode::Source { table_id } => { + deltas: &TraceDeltaBatch, +) -> TraceDeltaBatch { + let arena = deltas.arena().clone(); + match (node, &meta.kind) { + (DataflowNode::Source { table_id }, CompiledIvmNodeKind::Source) => { if *table_id == source_table { - (deltas, join_id, agg_id) + TraceDeltaBatch::new(arena, deltas.deltas().to_vec()) } else { - (Vec::new(), join_id, agg_id) + TraceDeltaBatch::empty() } } - DataflowNode::Filter { input, predicate } => { - let (input_deltas, jid, aid) = propagate_deltas( + ( + DataflowNode::Filter { + input, + predicate, + trace_predicate, + }, + CompiledIvmNodeKind::Filter { input: input_meta }, + ) => { + let input_deltas = propagate_trace_deltas( input, + input_meta, join_states, aggregate_states, source_table, deltas, - join_id, - agg_id, ); - ( - filter_incremental(&input_deltas, |row| predicate(row)), - jid, - aid, - ) + if input_deltas.is_empty() { + return input_deltas; + } + let mut filtered = Vec::with_capacity(input_deltas.deltas().len()); + for delta in input_deltas.deltas() { + if keep_trace_handle( + &arena, + &delta.data, + predicate.as_ref(), + trace_predicate.as_deref(), + ) { + filtered.push(delta.clone()); + } + } + TraceDeltaBatch::new(arena, filtered) } - DataflowNode::Project { input, columns } => { - let (input_deltas, jid, aid) = propagate_deltas( + ( + DataflowNode::Project { input, .. }, + CompiledIvmNodeKind::Project { + input: input_meta, + columns, + }, + ) => { + let input_deltas = propagate_trace_deltas( input, + input_meta, join_states, aggregate_states, source_table, deltas, - join_id, - agg_id, ); - (project_incremental(&input_deltas, columns), jid, aid) + if input_deltas.is_empty() { + return input_deltas; + } + let projected = input_deltas + .deltas() + .iter() + .map(|delta| { + Delta::new( + arena.project(delta.data.clone(), columns.clone()), + delta.diff, + ) + }) + .collect(); + TraceDeltaBatch::new(arena, projected) } - DataflowNode::Map { input, mapper } => { - let (input_deltas, jid, aid) = propagate_deltas( + ( + DataflowNode::Map { input, + mapper, + trace_mapper, + }, + CompiledIvmNodeKind::Map { input: input_meta }, + ) => { + let input_deltas = propagate_trace_deltas( + input, + input_meta, join_states, aggregate_states, source_table, deltas, - join_id, - agg_id, ); - (map_incremental(&input_deltas, |row| mapper(row)), jid, aid) + if input_deltas.is_empty() { + return input_deltas; + } + let mapped = input_deltas + .deltas() + .iter() + .map(|delta| { + let mut mapped = map_trace_handle( + &arena, + &delta.data, + mapper.as_ref(), + trace_mapper.as_deref(), + ); + mapped.set_id(delta.data.row_id()); + mapped.set_version(delta.data.version()); + Delta::new(arena.owned(mapped), delta.diff) + }) + .collect(); + TraceDeltaBatch::new(arena, mapped) } - DataflowNode::Join { - left, - right, - left_key, - right_key, - join_type, - } => { - let current_join_id = join_id; - if !join_states.contains_key(¤t_join_id) { - join_states.insert(current_join_id, JoinState::new()); + ( + DataflowNode::Join { + left, + right, + left_key, + right_key, + join_type, + left_width, + right_width, + }, + CompiledIvmNodeKind::Join { + state_id: current_join_id, + left: left_meta, + right: right_meta, + }, + ) => { + if !join_states.contains_key(current_join_id) { + join_states.insert( + *current_join_id, + JoinState::with_col_counts(*left_width, *right_width), + ); } - let left_sources = left.collect_sources(); - let right_sources = right.collect_sources(); - let is_left_side = left_sources.contains(&source_table); - let is_right_side = right_sources.contains(&source_table); + let is_left_side = left_meta.depends_on(source_table); + let is_right_side = right_meta.depends_on(source_table); let jt = *join_type; let mut output_deltas = Vec::new(); - - if is_left_side { - let (left_deltas, _, _) = propagate_deltas( + let left_deltas = if is_left_side { + propagate_trace_deltas( left, + left_meta, join_states, aggregate_states, source_table, - deltas.clone(), - current_join_id + 1, - agg_id, - ); - - let join_state = join_states.get_mut(¤t_join_id).unwrap(); - for delta in left_deltas { - let key = left_key(&delta.data); - if jt == JoinType::Inner { - // Fast path for inner join - if delta.is_insert() { - for row in join_state.on_left_insert(delta.data, key) { - output_deltas.push(Delta::insert(row)); - } - } else if delta.is_delete() { - for row in join_state.on_left_delete(&delta.data, key) { - output_deltas.push(Delta::delete(row)); - } - } - } else if delta.is_insert() { - output_deltas.extend(join_state.on_left_insert_outer(delta.data, key, jt)); - } else if delta.is_delete() { - output_deltas.extend(join_state.on_left_delete_outer(&delta.data, key, jt)); - } - } - } - - if is_right_side { - let (right_deltas, _, _) = propagate_deltas( + deltas, + ) + } else { + TraceDeltaBatch::empty() + }; + let right_deltas = if is_right_side { + propagate_trace_deltas( right, + right_meta, join_states, aggregate_states, source_table, deltas, - current_join_id + 1, - agg_id, - ); - - let join_state = join_states.get_mut(¤t_join_id).unwrap(); - for delta in right_deltas { - let key = right_key(&delta.data); - if jt == JoinType::Inner { - if delta.is_insert() { - for row in join_state.on_right_insert(delta.data, key) { - output_deltas.push(Delta::insert(row)); - } - } else if delta.is_delete() { - for row in join_state.on_right_delete(&delta.data, key) { - output_deltas.push(Delta::delete(row)); - } - } - } else if delta.is_insert() { - output_deltas.extend(join_state.on_right_insert_outer(delta.data, key, jt)); - } else if delta.is_delete() { - output_deltas.extend(join_state.on_right_delete_outer( - &delta.data, - key, - jt, - )); - } - } - } + ) + } else { + TraceDeltaBatch::empty() + }; + process_join_trace_deltas( + join_states, + *current_join_id, + *left_width, + *right_width, + left_key, + right_key, + jt, + &arena, + left_deltas.deltas(), + right_deltas.deltas(), + &mut output_deltas, + ); - (output_deltas, current_join_id + 1, agg_id) + TraceDeltaBatch::new(arena, output_deltas) } - DataflowNode::Aggregate { - input, - group_by, - functions, - } => { - let current_agg_id = agg_id; - let (input_deltas, jid, _) = propagate_deltas( + ( + DataflowNode::Aggregate { + input, + group_by, + functions, + }, + CompiledIvmNodeKind::Aggregate { + state_id: current_agg_id, + input: input_meta, + }, + ) => { + let input_deltas = propagate_trace_deltas( input, + input_meta, join_states, aggregate_states, source_table, deltas, - join_id, - current_agg_id + 1, ); if input_deltas.is_empty() { - return (Vec::new(), jid, current_agg_id + 1); + return input_deltas; } - // Get or create aggregate state - if !aggregate_states.contains_key(¤t_agg_id) { - aggregate_states.insert( - current_agg_id, - GroupAggregateState::new(group_by.clone(), functions.clone()), - ); - } - - let agg_state = aggregate_states.get_mut(¤t_agg_id).unwrap(); - let output = agg_state.process_deltas(&input_deltas); - - (output, jid, current_agg_id + 1) + let mut output = Vec::new(); + process_aggregate_trace_deltas( + aggregate_states, + *current_agg_id, + group_by, + functions, + &arena, + input_deltas.deltas(), + &mut output, + ); + TraceDeltaBatch::new(arena.clone(), output) } + + _ => unreachable!("compiled IVM metadata must mirror the dataflow shape"), } } @@ -920,7 +3602,9 @@ impl MaterializedViewBuilder { #[cfg(test)] mod tests { use super::*; + use crate::dataflow::JoinKeySpec; use alloc::boxed::Box; + use alloc::rc::Rc; use alloc::vec; use cynos_core::Value; @@ -987,19 +3671,297 @@ mod tests { assert!(view.is_empty()); } - fn make_employee(id: u64, name_hash: i64, dept_id: i64) -> Row { - Row::new( - id, - vec![ - Value::Int64(id as i64), - Value::Int64(name_hash), - Value::Int64(dept_id), - ], - ) + fn make_employee(id: u64, name_hash: i64, dept_id: i64) -> Row { + Row::new( + id, + vec![ + Value::Int64(id as i64), + Value::Int64(name_hash), + Value::Int64(dept_id), + ], + ) + } + + fn make_department(id: u64, name_hash: i64) -> Row { + Row::new(id, vec![Value::Int64(id as i64), Value::Int64(name_hash)]) + } + + fn make_employee_department_inner_join() -> DataflowNode { + DataflowNode::Join { + left: Box::new(DataflowNode::source(1)), + right: Box::new(DataflowNode::source(2)), + left_key: JoinKeySpec::Columns(vec![2]), + right_key: JoinKeySpec::Columns(vec![0]), + join_type: JoinType::Inner, + left_width: 3, + right_width: 2, + } + } + + fn make_employee_department_left_outer_join() -> DataflowNode { + DataflowNode::Join { + left: Box::new(DataflowNode::source(1)), + right: Box::new(DataflowNode::source(2)), + left_key: JoinKeySpec::Columns(vec![2]), + right_key: JoinKeySpec::Columns(vec![0]), + join_type: JoinType::LeftOuter, + left_width: 3, + right_width: 2, + } + } + + fn make_sum_aggregate() -> DataflowNode { + DataflowNode::Aggregate { + input: Box::new(DataflowNode::source(1)), + group_by: vec![0], + functions: vec![(1, AggregateType::Sum)], + } + } + + fn make_self_join() -> DataflowNode { + DataflowNode::Join { + left: Box::new(DataflowNode::source(1)), + right: Box::new(DataflowNode::source(1)), + left_key: JoinKeySpec::Columns(vec![1]), + right_key: JoinKeySpec::Columns(vec![0]), + join_type: JoinType::Inner, + left_width: 2, + right_width: 2, + } + } + + fn normalize_rows(rows: &[Row]) -> Vec { + let mut normalized: Vec<_> = rows + .iter() + .map(|row| alloc::format!("{:?}:{}:{}", row.values(), row.id(), row.version())) + .collect(); + normalized.sort(); + normalized + } + + fn normalize_deltas(deltas: &[Delta]) -> Vec { + let mut normalized: Vec<_> = deltas + .iter() + .map(|delta| { + alloc::format!( + "{}:{:?}:{}:{}", + if delta.is_insert() { + "insert" + } else { + "delete" + }, + delta.data.values(), + delta.data.id(), + delta.data.version() + ) + }) + .collect(); + normalized.sort(); + normalized + } + + fn bootstrap_view_with_legacy_executor( + dataflow: DataflowNode, + initial: Vec, + source_rows: &HashMap<(TableId, usize), Vec>, + ) -> MaterializedView { + let compiled_plan = CompiledIvmPlan::compile(&dataflow); + let compiled_bootstrap_plan = CompiledBootstrapPlan::compile(&dataflow); + let dependencies = compiled_plan.sources().to_vec(); + let mut join_states = HashMap::new(); + let mut aggregate_states = HashMap::new(); + bootstrap_node_stream_with_source_visitor( + &dataflow, + &compiled_bootstrap_plan.legacy_node, + false, + &mut |table_id, source_index, emit| { + if let Some(rows) = source_rows.get(&(table_id, source_index)) { + for row in rows { + emit(Rc::new(row.clone())); + } + } + }, + &mut join_states, + &mut aggregate_states, + &mut |_handle| {}, + ); + MaterializedView { + dataflow, + compiled_plan, + visible_rows: VisibleResultStore::from_rc_rows( + initial.into_iter().map(Rc::new).collect(), + ), + dependencies, + join_states, + aggregate_states, + } + } + + fn bootstrap_view_with_compiled_executor( + dataflow: DataflowNode, + initial: Vec, + source_rows: &HashMap<(TableId, usize), Vec>, + ) -> MaterializedView { + bootstrap_view_with_compiled_executor_with_filter_coverage( + dataflow, + initial, + source_rows, + None, + ) + } + + fn bootstrap_view_with_compiled_executor_with_filter_coverage( + dataflow: DataflowNode, + initial: Vec, + source_rows: &HashMap<(TableId, usize), Vec>, + source_filter_coverage: Option>, + ) -> MaterializedView { + let compiled_plan = CompiledIvmPlan::compile(&dataflow); + let compiled_bootstrap_plan = CompiledBootstrapPlan::compile(&dataflow); + MaterializedView::with_compiled_source_visitor_and_bootstrap_profiled_with_filter_coverage( + dataflow, + compiled_plan, + compiled_bootstrap_plan, + initial.into_iter().map(Rc::new).collect(), + |table_id, source_index, emit| { + if let Some(rows) = source_rows.get(&(table_id, source_index)) { + for row in rows { + emit(Rc::new(row.clone())); + } + } + }, + source_filter_coverage, + None, + ) + .0 + } + + fn unary_block_op_counts(plan: &CompiledIvmPlan) -> Vec { + plan.program + .instructions + .iter() + .filter_map(|instruction| match instruction { + TraceInstruction::Unary { block, .. } => Some(block.ops.len()), + _ => None, + }) + .collect() + } + + fn apply_legacy_update( + view: &mut MaterializedView, + table_id: TableId, + deltas: Vec>, + ) -> Vec> { + if !view.depends_on(table_id) { + return Vec::new(); + } + let input_batch = TraceDeltaBatch::from_row_deltas(deltas); + let output = propagate_trace_deltas( + &view.dataflow, + &view.compiled_plan.node, + &mut view.join_states, + &mut view.aggregate_states, + table_id, + &input_batch, + ); + view.visible_rows.apply(&output); + output.materialize_rows() + } + + fn make_trace_unary_fusion_dataflow() -> DataflowNode { + DataflowNode::Map { + input: Box::new(DataflowNode::Project { + input: Box::new(DataflowNode::Filter { + input: Box::new(DataflowNode::source(1)), + predicate: Box::new(|row| { + row.get(1) + .and_then(Value::as_i64) + .map(|value| value >= 18) + .unwrap_or(false) + }), + trace_predicate: Some(Box::new(|arena, handle| { + arena + .value_at(handle, 1) + .and_then(|value| value.as_i64()) + .map(|value| value >= 18) + .unwrap_or(false) + })), + }), + columns: vec![0, 1], + }), + mapper: Box::new(|row| { + Row::new(row.id(), vec![row.get(0).cloned().unwrap_or(Value::Null)]) + }), + trace_mapper: Some(Box::new(|arena, handle| { + Row::new( + arena.row_id(handle), + vec![arena.value_at(handle, 0).unwrap_or(Value::Null)], + ) + })), + } } - fn make_department(id: u64, name_hash: i64) -> Row { - Row::new(id, vec![Value::Int64(id as i64), Value::Int64(name_hash)]) + fn make_trace_filter_project_chain() -> DataflowNode { + DataflowNode::Project { + input: Box::new(DataflowNode::Project { + input: Box::new(DataflowNode::Filter { + input: Box::new(DataflowNode::Filter { + input: Box::new(DataflowNode::source(1)), + predicate: Box::new(|row| { + row.get(1) + .and_then(Value::as_i64) + .map(|value| value >= 18) + .unwrap_or(false) + }), + trace_predicate: Some(Box::new(|arena, handle| { + arena + .value_at(handle, 1) + .and_then(|value| value.as_i64()) + .map(|value| value >= 18) + .unwrap_or(false) + })), + }), + predicate: Box::new(|row| { + row.get(1) + .and_then(Value::as_i64) + .map(|value| value % 2 == 0) + .unwrap_or(false) + }), + trace_predicate: Some(Box::new(|arena, handle| { + arena + .value_at(handle, 1) + .and_then(|value| value.as_i64()) + .map(|value| value % 2 == 0) + .unwrap_or(false) + })), + }), + columns: vec![0, 1], + }), + columns: vec![1], + } + } + + fn make_dynamic_map_barrier_chain() -> DataflowNode { + DataflowNode::Project { + input: Box::new(DataflowNode::Map { + input: Box::new(DataflowNode::Project { + input: Box::new(DataflowNode::source(1)), + columns: vec![0, 1], + }), + mapper: Box::new(|row| { + Row::new( + row.id(), + vec![ + row.get(0).cloned().unwrap_or(Value::Null), + row.get(1).cloned().unwrap_or(Value::Null), + Value::Int64(row.len() as i64), + ], + ) + }), + trace_mapper: None, + }), + columns: vec![2], + } } #[test] @@ -1007,9 +3969,11 @@ mod tests { let dataflow = DataflowNode::Join { left: Box::new(DataflowNode::source(1)), right: Box::new(DataflowNode::source(2)), - left_key: Box::new(|row| vec![row.get(2).cloned().unwrap_or(Value::Null)]), - right_key: Box::new(|row| vec![row.get(0).cloned().unwrap_or(Value::Null)]), + left_key: JoinKeySpec::Columns(vec![2]), + right_key: JoinKeySpec::Columns(vec![0]), join_type: JoinType::Inner, + left_width: 3, + right_width: 2, }; let mut view = MaterializedView::new(dataflow); @@ -1024,9 +3988,11 @@ mod tests { let dataflow = DataflowNode::Join { left: Box::new(DataflowNode::source(1)), right: Box::new(DataflowNode::source(2)), - left_key: Box::new(|row| vec![row.get(2).cloned().unwrap_or(Value::Null)]), - right_key: Box::new(|row| vec![row.get(0).cloned().unwrap_or(Value::Null)]), + left_key: JoinKeySpec::Columns(vec![2]), + right_key: JoinKeySpec::Columns(vec![0]), join_type: JoinType::LeftOuter, + left_width: 3, + right_width: 2, }; let mut view = MaterializedView::new(dataflow); @@ -1051,9 +4017,11 @@ mod tests { let dataflow = DataflowNode::Join { left: Box::new(DataflowNode::source(1)), right: Box::new(DataflowNode::source(2)), - left_key: Box::new(|row| vec![row.get(2).cloned().unwrap_or(Value::Null)]), - right_key: Box::new(|row| vec![row.get(0).cloned().unwrap_or(Value::Null)]), + left_key: JoinKeySpec::Columns(vec![2]), + right_key: JoinKeySpec::Columns(vec![0]), join_type: JoinType::LeftOuter, + left_width: 3, + right_width: 2, }; let mut view = MaterializedView::new(dataflow); @@ -1075,6 +4043,45 @@ mod tests { assert_eq!(inserts[0].data.get(3), Some(&Value::Null)); } + #[test] + fn test_left_outer_join_same_key_update_does_not_emit_unmatched_transition() { + let dataflow = DataflowNode::Join { + left: Box::new(DataflowNode::source(1)), + right: Box::new(DataflowNode::source(2)), + left_key: JoinKeySpec::Columns(vec![2]), + right_key: JoinKeySpec::Columns(vec![0]), + join_type: JoinType::LeftOuter, + left_width: 3, + right_width: 2, + }; + let mut view = MaterializedView::new(dataflow); + + let employee = make_employee(1, 200, 10); + let department_v1 = make_department(10, 100); + let department_v2 = Row::new_with_version(10, 1, vec![Value::Int64(10), Value::Int64(101)]); + + view.on_table_change(2, vec![Delta::insert(department_v1.clone())]); + view.on_table_change(1, vec![Delta::insert(employee.clone())]); + + let output = view.on_table_change( + 2, + vec![ + Delta::delete(department_v1.clone()), + Delta::insert(department_v2.clone()), + ], + ); + + let inserts: Vec<_> = output.iter().filter(|delta| delta.is_insert()).collect(); + let deletes: Vec<_> = output.iter().filter(|delta| delta.is_delete()).collect(); + assert_eq!(inserts.len(), 1); + assert_eq!(deletes.len(), 1); + assert_eq!(inserts[0].data.get(0), Some(&Value::Int64(1))); + assert_eq!(inserts[0].data.get(3), Some(&Value::Int64(10))); + assert_eq!(inserts[0].data.get(4), Some(&Value::Int64(101))); + assert_eq!(deletes[0].data.get(4), Some(&Value::Int64(100))); + assert_eq!(view.len(), 1); + } + #[test] fn test_aggregate_count_sum() { // GROUP BY column 0, COUNT(*) and SUM(column 1) @@ -1151,6 +4158,490 @@ mod tests { assert_eq!(view.len(), 1); } + #[test] + fn test_materialized_view_with_sources_bootstraps_inner_join_state() { + let employee = make_employee(1, 200, 10); + let department = make_department(10, 100); + let initial = vec![merge_rows(&employee, &department)]; + let dataflow = DataflowNode::Join { + left: Box::new(DataflowNode::source(1)), + right: Box::new(DataflowNode::source(2)), + left_key: JoinKeySpec::Columns(vec![2]), + right_key: JoinKeySpec::Columns(vec![0]), + join_type: JoinType::Inner, + left_width: 3, + right_width: 2, + }; + + let mut source_rows = HashMap::new(); + source_rows.insert(1, vec![employee]); + source_rows.insert(2, vec![department]); + + let mut view = MaterializedView::with_sources(dataflow, initial, &source_rows); + assert_eq!(view.len(), 1); + + let output = view.on_table_change(1, vec![Delta::insert(make_employee(2, 201, 10))]); + assert_eq!(output.len(), 1); + assert_eq!(view.len(), 2); + } + + #[test] + fn test_materialized_view_with_sources_bootstraps_left_outer_join_widths() { + let employee = make_employee(1, 200, 99); + let initial = vec![merge_rows_null_right(&employee, 2)]; + let dataflow = DataflowNode::Join { + left: Box::new(DataflowNode::source(1)), + right: Box::new(DataflowNode::source(2)), + left_key: JoinKeySpec::Columns(vec![2]), + right_key: JoinKeySpec::Columns(vec![0]), + join_type: JoinType::LeftOuter, + left_width: 3, + right_width: 2, + }; + + let mut source_rows = HashMap::new(); + source_rows.insert(1, vec![employee]); + source_rows.insert(2, Vec::new()); + + let mut view = MaterializedView::with_sources(dataflow, initial, &source_rows); + let output = view.on_table_change(2, vec![Delta::insert(make_department(99, 300))]); + + let inserts: Vec<_> = output.iter().filter(|delta| delta.is_insert()).collect(); + let deletes: Vec<_> = output.iter().filter(|delta| delta.is_delete()).collect(); + assert_eq!(inserts.len(), 1); + assert_eq!(deletes.len(), 1); + assert_eq!(inserts[0].data.len(), 5); + assert_eq!(view.len(), 1); + } + + #[test] + fn test_materialized_view_with_sources_bootstraps_aggregate_state() { + let dataflow = DataflowNode::Aggregate { + input: Box::new(DataflowNode::source(1)), + group_by: vec![0], + functions: vec![(1, AggregateType::Sum)], + }; + + let mut source_rows = HashMap::new(); + source_rows.insert( + 1, + vec![ + Row::new(1, vec![Value::Int64(1), Value::Int64(10)]), + Row::new(2, vec![Value::Int64(1), Value::Int64(20)]), + ], + ); + + let initial = vec![Row::new( + aggregate_group_row_id(&[Value::Int64(1)]), + vec![Value::Int64(1), Value::Float64(30.0)], + )]; + let mut view = MaterializedView::with_sources(dataflow, initial, &source_rows); + let output = view.on_table_change( + 1, + vec![Delta::insert(Row::new( + 3, + vec![Value::Int64(1), Value::Int64(5)], + ))], + ); + + let inserts: Vec<_> = output.iter().filter(|delta| delta.is_insert()).collect(); + assert_eq!(inserts.len(), 1); + assert_eq!(inserts[0].data.get(1), Some(&Value::Float64(35.0))); + assert_eq!(view.result()[0].get(1), Some(&Value::Float64(35.0))); + } + + #[test] + fn test_compiled_bootstrap_skip_covered_source_filter_matches_reference_aggregate() { + let dataflow = DataflowNode::Aggregate { + input: Box::new(DataflowNode::Filter { + input: Box::new(DataflowNode::source(1)), + predicate: Box::new(|row| { + row.get(1) + .and_then(Value::as_i64) + .map(|value| value > 10) + .unwrap_or(false) + }), + trace_predicate: None, + }), + group_by: vec![0], + functions: vec![(1, AggregateType::Sum)], + }; + + let initial = vec![Row::new( + aggregate_group_row_id(&[Value::Int64(1)]), + vec![Value::Int64(1), Value::Float64(50.0)], + )]; + let mut source_rows = HashMap::new(); + source_rows.insert( + (1, 0), + vec![ + Row::new(1, vec![Value::Int64(1), Value::Int64(20)]), + Row::new(2, vec![Value::Int64(1), Value::Int64(30)]), + ], + ); + + let mut reference_view = + bootstrap_view_with_compiled_executor(dataflow, initial.clone(), &source_rows); + let mut skip_view = bootstrap_view_with_compiled_executor_with_filter_coverage( + DataflowNode::Aggregate { + input: Box::new(DataflowNode::Filter { + input: Box::new(DataflowNode::source(1)), + predicate: Box::new(|row| { + row.get(1) + .and_then(Value::as_i64) + .map(|value| value > 10) + .unwrap_or(false) + }), + trace_predicate: None, + }), + group_by: vec![0], + functions: vec![(1, AggregateType::Sum)], + }, + initial, + &source_rows, + Some(vec![true]), + ); + + let follow_up = vec![ + Delta::insert(Row::new(3, vec![Value::Int64(1), Value::Int64(5)])), + Delta::insert(Row::new(4, vec![Value::Int64(1), Value::Int64(25)])), + ]; + let reference_output = reference_view.on_table_change(1, follow_up.clone()); + let skip_output = skip_view.on_table_change(1, follow_up); + + assert_eq!( + normalize_deltas(&reference_output), + normalize_deltas(&skip_output) + ); + assert_eq!( + normalize_rows(&reference_view.result()), + normalize_rows(&skip_view.result()) + ); + } + + #[test] + fn test_compiled_bootstrap_skip_covered_source_filter_matches_reference_join_state() { + let employee = make_employee(1, 200, 10); + let department = make_department(10, 100); + let initial = vec![merge_rows(&employee, &department)]; + let mut source_rows = HashMap::new(); + source_rows.insert((1, 0), vec![employee]); + source_rows.insert((2, 1), vec![department]); + + let filtered_join = || DataflowNode::Join { + left: Box::new(DataflowNode::Filter { + input: Box::new(DataflowNode::source(1)), + predicate: Box::new(|row| { + row.get(2) + .and_then(Value::as_i64) + .map(|dept_id| dept_id == 10) + .unwrap_or(false) + }), + trace_predicate: None, + }), + right: Box::new(DataflowNode::source(2)), + left_key: JoinKeySpec::Columns(vec![2]), + right_key: JoinKeySpec::Columns(vec![0]), + join_type: JoinType::Inner, + left_width: 3, + right_width: 2, + }; + + let mut reference_view = + bootstrap_view_with_compiled_executor(filtered_join(), initial.clone(), &source_rows); + let mut skip_view = bootstrap_view_with_compiled_executor_with_filter_coverage( + filtered_join(), + initial, + &source_rows, + Some(vec![true, false]), + ); + + let follow_up = vec![Delta::insert(make_employee(2, 201, 10))]; + let reference_output = reference_view.on_table_change(1, follow_up.clone()); + let skip_output = skip_view.on_table_change(1, follow_up); + + assert_eq!( + normalize_deltas(&reference_output), + normalize_deltas(&skip_output) + ); + assert_eq!( + normalize_rows(&reference_view.result()), + normalize_rows(&skip_view.result()) + ); + } + + #[test] + fn test_compiled_bootstrap_matches_legacy_inner_join_followup_delta() { + let employee = make_employee(1, 200, 10); + let department = make_department(10, 100); + let initial = vec![merge_rows(&employee, &department)]; + + let mut source_rows = HashMap::new(); + source_rows.insert((1, 0), vec![employee]); + source_rows.insert((2, 1), vec![department]); + + let mut legacy_view = bootstrap_view_with_legacy_executor( + make_employee_department_inner_join(), + initial.clone(), + &source_rows, + ); + let mut compiled_view = bootstrap_view_with_compiled_executor( + make_employee_department_inner_join(), + initial, + &source_rows, + ); + + let follow_up = vec![Delta::insert(make_employee(2, 201, 10))]; + let legacy_output = legacy_view.on_table_change(1, follow_up.clone()); + let compiled_output = compiled_view.on_table_change(1, follow_up); + + assert_eq!( + normalize_deltas(&legacy_output), + normalize_deltas(&compiled_output) + ); + assert_eq!( + normalize_rows(&legacy_view.result()), + normalize_rows(&compiled_view.result()) + ); + } + + #[test] + fn test_compiled_bootstrap_matches_legacy_left_outer_join_followup_delta() { + let employee = make_employee(1, 200, 99); + let initial = vec![merge_rows_null_right(&employee, 2)]; + + let mut source_rows = HashMap::new(); + source_rows.insert((1, 0), vec![employee]); + source_rows.insert((2, 1), Vec::new()); + + let mut legacy_view = bootstrap_view_with_legacy_executor( + make_employee_department_left_outer_join(), + initial.clone(), + &source_rows, + ); + let mut compiled_view = bootstrap_view_with_compiled_executor( + make_employee_department_left_outer_join(), + initial, + &source_rows, + ); + + let follow_up = vec![Delta::insert(make_department(99, 300))]; + let legacy_output = legacy_view.on_table_change(2, follow_up.clone()); + let compiled_output = compiled_view.on_table_change(2, follow_up); + + assert_eq!( + normalize_deltas(&legacy_output), + normalize_deltas(&compiled_output) + ); + assert_eq!( + normalize_rows(&legacy_view.result()), + normalize_rows(&compiled_view.result()) + ); + } + + #[test] + fn test_compiled_bootstrap_matches_legacy_aggregate_followup_delta() { + let initial = vec![Row::new( + aggregate_group_row_id(&[Value::Int64(1)]), + vec![Value::Int64(1), Value::Float64(30.0)], + )]; + let mut source_rows = HashMap::new(); + source_rows.insert( + (1, 0), + vec![ + Row::new(1, vec![Value::Int64(1), Value::Int64(10)]), + Row::new(2, vec![Value::Int64(1), Value::Int64(20)]), + ], + ); + + let mut legacy_view = bootstrap_view_with_legacy_executor( + make_sum_aggregate(), + initial.clone(), + &source_rows, + ); + let mut compiled_view = + bootstrap_view_with_compiled_executor(make_sum_aggregate(), initial, &source_rows); + + let follow_up = vec![Delta::insert(Row::new( + 3, + vec![Value::Int64(1), Value::Int64(5)], + ))]; + let legacy_output = legacy_view.on_table_change(1, follow_up.clone()); + let compiled_output = compiled_view.on_table_change(1, follow_up); + + assert_eq!( + normalize_deltas(&legacy_output), + normalize_deltas(&compiled_output) + ); + assert_eq!( + normalize_rows(&legacy_view.result()), + normalize_rows(&compiled_view.result()) + ); + } + + #[test] + fn test_compiled_bootstrap_uses_source_index_for_self_join_sources() { + let left = Row::new(1, vec![Value::Int64(1), Value::Int64(10)]); + let right = Row::new(2, vec![Value::Int64(10), Value::Int64(99)]); + let initial = vec![merge_rows(&left, &right)]; + + let mut source_rows = HashMap::new(); + source_rows.insert((1, 0), vec![left]); + source_rows.insert((1, 1), vec![right]); + + let legacy_view = + bootstrap_view_with_legacy_executor(make_self_join(), initial.clone(), &source_rows); + let compiled_view = + bootstrap_view_with_compiled_executor(make_self_join(), initial, &source_rows); + + let legacy_state = legacy_view.join_states.values().next().unwrap(); + let compiled_state = compiled_view.join_states.values().next().unwrap(); + assert_eq!(legacy_state.left.len(), 1); + assert_eq!(legacy_state.right.len(), 1); + assert_eq!(compiled_state.left.len(), 1); + assert_eq!(compiled_state.right.len(), 1); + assert_eq!(legacy_view.len(), compiled_view.len()); + } + + #[test] + fn test_compiled_trace_program_fuses_filter_project_trace_map_chain() { + let plan = CompiledIvmPlan::compile(&make_trace_unary_fusion_dataflow()); + assert_eq!(unary_block_op_counts(&plan), vec![3]); + } + + #[test] + fn test_compiled_trace_program_fuses_consecutive_filters_and_projects() { + let plan = CompiledIvmPlan::compile(&make_trace_filter_project_chain()); + assert_eq!(unary_block_op_counts(&plan), vec![4]); + } + + #[test] + fn test_compiled_trace_program_keeps_dynamic_map_barrier_split() { + let plan = CompiledIvmPlan::compile(&make_dynamic_map_barrier_chain()); + assert_eq!(unary_block_op_counts(&plan), vec![2, 1]); + } + + #[test] + fn test_compiled_unary_block_matches_legacy_recursive_path() { + let mut compiled_view = MaterializedView::new(make_trace_unary_fusion_dataflow()); + let mut legacy_view = MaterializedView::new(make_trace_unary_fusion_dataflow()); + let deltas = vec![ + Delta::insert(make_row(1, 18)), + Delta::insert(make_row(2, 17)), + Delta::insert(make_row(3, 26)), + ]; + + let compiled_output = compiled_view.on_table_change(1, deltas.clone()); + let legacy_output = apply_legacy_update(&mut legacy_view, 1, deltas); + + assert_eq!( + normalize_deltas(&compiled_output), + normalize_deltas(&legacy_output) + ); + assert_eq!( + normalize_rows(&compiled_view.result()), + normalize_rows(&legacy_view.result()) + ); + } + + #[test] + fn test_compiled_unary_filter_project_chain_matches_legacy_recursive_path() { + let mut compiled_view = MaterializedView::new(make_trace_filter_project_chain()); + let mut legacy_view = MaterializedView::new(make_trace_filter_project_chain()); + let deltas = vec![ + Delta::insert(make_row(1, 18)), + Delta::insert(make_row(2, 21)), + Delta::insert(make_row(3, 26)), + ]; + + let compiled_output = compiled_view.on_table_change(1, deltas.clone()); + let legacy_output = apply_legacy_update(&mut legacy_view, 1, deltas); + + assert_eq!( + normalize_deltas(&compiled_output), + normalize_deltas(&legacy_output) + ); + assert_eq!( + normalize_rows(&compiled_view.result()), + normalize_rows(&legacy_view.result()) + ); + } + + #[test] + fn test_dynamic_map_barrier_matches_legacy_recursive_path() { + let mut compiled_view = MaterializedView::new(make_dynamic_map_barrier_chain()); + let mut legacy_view = MaterializedView::new(make_dynamic_map_barrier_chain()); + let deltas = vec![ + Delta::insert(make_row(1, 18)), + Delta::insert(make_row(2, 21)), + ]; + + let compiled_output = compiled_view.on_table_change(1, deltas.clone()); + let legacy_output = apply_legacy_update(&mut legacy_view, 1, deltas); + + assert_eq!( + normalize_deltas(&compiled_output), + normalize_deltas(&legacy_output) + ); + assert_eq!( + normalize_rows(&compiled_view.result()), + normalize_rows(&legacy_view.result()) + ); + } + + #[test] + fn test_join_same_key_update_normalizes_non_adjacent_pair() { + let dataflow = make_employee_department_left_outer_join(); + let mut view = MaterializedView::new(dataflow); + + let employee = make_employee(1, 200, 10); + let department_v1 = make_department(10, 100); + let department_v2 = Row::new_with_version(10, 1, vec![Value::Int64(10), Value::Int64(101)]); + let unrelated = make_department(20, 999); + + view.on_table_change(2, vec![Delta::insert(department_v1.clone())]); + view.on_table_change(1, vec![Delta::insert(employee.clone())]); + + let output = view.on_table_change( + 2, + vec![ + Delta::delete(department_v1), + Delta::insert(unrelated), + Delta::insert(department_v2), + ], + ); + + let inserts: Vec<_> = output.iter().filter(|delta| delta.is_insert()).collect(); + let deletes: Vec<_> = output.iter().filter(|delta| delta.is_delete()).collect(); + assert_eq!(inserts.len(), 1); + assert_eq!(deletes.len(), 1); + assert_eq!(deletes[0].data.get(4), Some(&Value::Int64(100))); + assert_eq!(inserts[0].data.get(4), Some(&Value::Int64(101))); + assert_eq!(view.len(), 1); + } + + #[test] + fn test_self_join_update_matches_legacy_recursive_path() { + let mut compiled_view = MaterializedView::new(make_self_join()); + let mut legacy_view = MaterializedView::new(make_self_join()); + let deltas = vec![ + Delta::insert(Row::new(1, vec![Value::Int64(1), Value::Int64(10)])), + Delta::insert(Row::new(2, vec![Value::Int64(10), Value::Int64(99)])), + ]; + + let compiled_output = compiled_view.on_table_change(1, deltas.clone()); + let legacy_output = apply_legacy_update(&mut legacy_view, 1, deltas); + + assert_eq!( + normalize_deltas(&compiled_output), + normalize_deltas(&legacy_output) + ); + assert_eq!( + normalize_rows(&compiled_view.result()), + normalize_rows(&legacy_view.result()) + ); + } + // ==================== Bug 2 Test: Sum is_empty() incorrect logic ==================== // This test demonstrates Bug 2: Sum uses sum == 0.0 to check if group is empty, // which is incorrect when values sum to zero (e.g., +5 and -5). @@ -1215,4 +4706,82 @@ mod tests { "Sum group with 2 rows should NOT be empty, even if sum is 0.0" ); } + + #[test] + fn test_left_join_fanout_project_update_across_downstream_joins() { + let join_issues_projects = DataflowNode::Join { + left: Box::new(DataflowNode::source(1)), + right: Box::new(DataflowNode::source(2)), + left_key: JoinKeySpec::Columns(vec![1]), + right_key: JoinKeySpec::Columns(vec![0]), + join_type: JoinType::LeftOuter, + left_width: 2, + right_width: 2, + }; + let join_with_counters = DataflowNode::Join { + left: Box::new(join_issues_projects), + right: Box::new(DataflowNode::source(3)), + left_key: JoinKeySpec::Columns(vec![2]), + right_key: JoinKeySpec::Columns(vec![0]), + join_type: JoinType::LeftOuter, + left_width: 4, + right_width: 2, + }; + let join_with_snapshots = DataflowNode::Join { + left: Box::new(join_with_counters), + right: Box::new(DataflowNode::source(4)), + left_key: JoinKeySpec::Columns(vec![2]), + right_key: JoinKeySpec::Columns(vec![0]), + join_type: JoinType::LeftOuter, + left_width: 6, + right_width: 2, + }; + let dataflow = DataflowNode::filter(join_with_snapshots, |row| { + row.get(3) + .and_then(Value::as_i64) + .map(|health| health >= 45) + .unwrap_or(false) + && row + .get(5) + .and_then(Value::as_i64) + .map(|count| count >= 5) + .unwrap_or(false) + && row + .get(7) + .and_then(Value::as_i64) + .map(|velocity| velocity >= 18) + .unwrap_or(false) + }); + let mut view = MaterializedView::new(dataflow); + + let counter = Row::new(100, vec![Value::Int64(100), Value::Int64(7)]); + let snapshot = Row::new(100, vec![Value::Int64(100), Value::Int64(35)]); + let project_v1 = Row::new(100, vec![Value::Int64(100), Value::Int64(61)]); + let project_v2 = Row::new_with_version(100, 1, vec![Value::Int64(100), Value::Int64(12)]); + let issue_a = Row::new(1, vec![Value::Int64(1), Value::Int64(100)]); + let issue_b = Row::new(2, vec![Value::Int64(2), Value::Int64(100)]); + + view.on_table_change(3, vec![Delta::insert(counter)]); + view.on_table_change(4, vec![Delta::insert(snapshot)]); + view.on_table_change(2, vec![Delta::insert(project_v1.clone())]); + + let initial = view.on_table_change( + 1, + vec![ + Delta::insert(issue_a.clone()), + Delta::insert(issue_b.clone()), + ], + ); + assert_eq!(initial.len(), 2); + assert!(initial.iter().all(Delta::is_insert)); + assert_eq!(view.len(), 2); + + let update = view.on_table_change( + 2, + vec![Delta::delete(project_v1), Delta::insert(project_v2)], + ); + assert_eq!(update.len(), 2); + assert!(update.iter().all(Delta::is_delete)); + assert_eq!(view.len(), 0); + } } diff --git a/crates/incremental/src/operators/map.rs b/crates/incremental/src/operators/map.rs index 297a2f1..f956d23 100644 --- a/crates/incremental/src/operators/map.rs +++ b/crates/incremental/src/operators/map.rs @@ -41,7 +41,10 @@ pub fn project_incremental(input: &[Delta], columns: &[usize]) -> Vec); + +#[derive(Debug)] +struct TraceTupleNode { + kind: TraceTupleKind, + materialized: RefCell>>, +} + +#[derive(Clone, Debug)] +enum TraceTupleKind { + Base(Rc), + Concat { + left: TraceTupleHandle, + right: TraceTupleHandle, + left_width: usize, + }, + NullPadLeft { + right: TraceTupleHandle, + left_width: usize, + }, + NullPadRight { + left: TraceTupleHandle, + right_width: usize, + }, + Project { + input: TraceTupleHandle, + columns: Rc<[usize]>, + }, + Owned(Rc), +} + +impl TraceTupleHandle { + fn new(kind: TraceTupleKind, materialized: Option>) -> Self { + Self(Rc::new(TraceTupleNode { + kind, + materialized: RefCell::new(materialized), + })) + } + + pub fn len(&self) -> usize { + match &self.0.kind { + TraceTupleKind::Base(row) | TraceTupleKind::Owned(row) => row.len(), + TraceTupleKind::Concat { + left_width, right, .. + } => left_width.saturating_add(right.len()), + TraceTupleKind::NullPadLeft { left_width, right } => { + left_width.saturating_add(right.len()) + } + TraceTupleKind::NullPadRight { left, right_width } => { + left.len().saturating_add(*right_width) + } + TraceTupleKind::Project { columns, .. } => columns.len(), + } + } + + pub fn row_id(&self) -> RowId { + match &self.0.kind { + TraceTupleKind::Base(row) | TraceTupleKind::Owned(row) => row.id(), + TraceTupleKind::Concat { left, right, .. } => { + join_row_id(left.row_id(), right.row_id()) + } + TraceTupleKind::NullPadLeft { right, .. } => right_join_null_row_id(right.row_id()), + TraceTupleKind::NullPadRight { left, .. } => left_join_null_row_id(left.row_id()), + TraceTupleKind::Project { input, .. } => input.row_id(), + } + } + + pub fn version(&self) -> u64 { + match &self.0.kind { + TraceTupleKind::Base(row) | TraceTupleKind::Owned(row) => row.version(), + TraceTupleKind::Concat { left, right, .. } => { + left.version().wrapping_add(right.version()) + } + TraceTupleKind::NullPadLeft { right, .. } => right.version(), + TraceTupleKind::NullPadRight { left, .. } => left.version(), + TraceTupleKind::Project { input, .. } => input.version(), + } + } + + pub fn value_at(&self, index: usize) -> Option { + match &self.0.kind { + TraceTupleKind::Base(row) | TraceTupleKind::Owned(row) => row.get(index).cloned(), + TraceTupleKind::Concat { + left, + right, + left_width, + } => { + if index < *left_width { + left.value_at(index) + } else { + right.value_at(index.saturating_sub(*left_width)) + } + } + TraceTupleKind::NullPadLeft { right, left_width } => { + if index < *left_width { + Some(Value::Null) + } else { + right.value_at(index.saturating_sub(*left_width)) + } + } + TraceTupleKind::NullPadRight { left, right_width } => { + if index < left.len() { + left.value_at(index) + } else if index < left.len().saturating_add(*right_width) { + Some(Value::Null) + } else { + None + } + } + TraceTupleKind::Project { input, columns } => columns + .get(index) + .and_then(|source_index| input.value_at(*source_index)), + } + } + + pub fn materialize_rc(&self) -> Rc { + if let Some(row) = self.0.materialized.borrow().as_ref() { + return row.clone(); + } + + let row = match &self.0.kind { + TraceTupleKind::Base(row) | TraceTupleKind::Owned(row) => row.clone(), + TraceTupleKind::Concat { left, right, .. } => { + let left_row = left.materialize_rc(); + let right_row = right.materialize_rc(); + let mut values = Vec::with_capacity(left_row.len().saturating_add(right_row.len())); + values.extend(left_row.values().iter().cloned()); + values.extend(right_row.values().iter().cloned()); + Rc::new(Row::new_with_version(self.row_id(), self.version(), values)) + } + TraceTupleKind::NullPadLeft { right, left_width } => { + let right_row = right.materialize_rc(); + let mut values = Vec::with_capacity(left_width.saturating_add(right_row.len())); + values.resize(*left_width, Value::Null); + values.extend(right_row.values().iter().cloned()); + Rc::new(Row::new_with_version(self.row_id(), self.version(), values)) + } + TraceTupleKind::NullPadRight { left, right_width } => { + let left_row = left.materialize_rc(); + let mut values = Vec::with_capacity(left_row.len().saturating_add(*right_width)); + values.extend(left_row.values().iter().cloned()); + values.resize(values.len().saturating_add(*right_width), Value::Null); + Rc::new(Row::new_with_version(self.row_id(), self.version(), values)) + } + TraceTupleKind::Project { input, columns } => { + let input_row = input.materialize_rc(); + let values = columns + .iter() + .filter_map(|index| input_row.get(*index).cloned()) + .collect(); + Rc::new(Row::new_with_version(self.row_id(), self.version(), values)) + } + }; + + *self.0.materialized.borrow_mut() = Some(row.clone()); + row + } + + pub fn materialize_row(&self) -> Row { + (*self.materialize_rc()).clone() + } + + pub fn has_materialized_row(&self) -> bool { + self.0.materialized.borrow().is_some() + } +} + +#[derive(Clone, Debug, Default)] +pub struct TraceTupleArena; + +impl TraceTupleArena { + pub fn base_rc(&self, row: Rc) -> TraceTupleHandle { + TraceTupleHandle::new(TraceTupleKind::Base(row.clone()), Some(row)) + } + + pub fn owned(&self, row: Row) -> TraceTupleHandle { + let row = Rc::new(row); + TraceTupleHandle::new(TraceTupleKind::Owned(row.clone()), Some(row)) + } + + pub fn owned_rc(&self, row: Rc) -> TraceTupleHandle { + TraceTupleHandle::new(TraceTupleKind::Owned(row.clone()), Some(row)) + } + + pub fn concat( + &self, + left: TraceTupleHandle, + right: TraceTupleHandle, + left_width: usize, + ) -> TraceTupleHandle { + TraceTupleHandle::new( + TraceTupleKind::Concat { + left, + right, + left_width, + }, + None, + ) + } + + pub fn null_pad_left(&self, right: TraceTupleHandle, left_width: usize) -> TraceTupleHandle { + TraceTupleHandle::new(TraceTupleKind::NullPadLeft { right, left_width }, None) + } + + pub fn null_pad_right(&self, left: TraceTupleHandle, right_width: usize) -> TraceTupleHandle { + TraceTupleHandle::new(TraceTupleKind::NullPadRight { left, right_width }, None) + } + + pub fn project(&self, input: TraceTupleHandle, columns: Rc<[usize]>) -> TraceTupleHandle { + TraceTupleHandle::new(TraceTupleKind::Project { input, columns }, None) + } + + #[inline] + pub fn len(&self, handle: &TraceTupleHandle) -> usize { + handle.len() + } + + #[inline] + pub fn row_id(&self, handle: &TraceTupleHandle) -> RowId { + handle.row_id() + } + + #[inline] + pub fn version(&self, handle: &TraceTupleHandle) -> u64 { + handle.version() + } + + #[inline] + pub fn value_at(&self, handle: &TraceTupleHandle, index: usize) -> Option { + handle.value_at(index) + } + + #[inline] + pub fn materialize_rc(&self, handle: &TraceTupleHandle) -> Rc { + handle.materialize_rc() + } + + #[inline] + pub fn materialize_row(&self, handle: &TraceTupleHandle) -> Row { + handle.materialize_row() + } + + #[inline] + pub fn has_materialized_row(&self, handle: &TraceTupleHandle) -> bool { + handle.has_materialized_row() + } +} + +#[derive(Clone, Debug)] +pub struct TraceDeltaBatch { + arena: TraceTupleArena, + deltas: Vec>, + insert_count: usize, + delete_count: usize, + materialized_rows: RefCell>>>, +} + +impl TraceDeltaBatch { + pub fn empty() -> Self { + Self::new(TraceTupleArena, Vec::new()) + } + + pub fn new(arena: TraceTupleArena, deltas: Vec>) -> Self { + let insert_count = deltas.iter().filter(|delta| delta.is_insert()).count(); + let delete_count = deltas.iter().filter(|delta| delta.is_delete()).count(); + Self { + arena, + deltas, + insert_count, + delete_count, + materialized_rows: RefCell::new(None), + } + } + + pub fn from_row_deltas(deltas: Vec>) -> Self { + let arena = TraceTupleArena; + let handles = deltas + .into_iter() + .map(|delta| Delta::new(arena.owned(delta.data), delta.diff)) + .collect(); + Self::new(arena, handles) + } + + #[inline] + pub fn arena(&self) -> &TraceTupleArena { + &self.arena + } + + #[inline] + pub fn deltas(&self) -> &[Delta] { + &self.deltas + } + + #[inline] + pub fn is_empty(&self) -> bool { + self.deltas.is_empty() + } + + #[inline] + pub fn insert_count(&self) -> usize { + self.insert_count + } + + #[inline] + pub fn delete_count(&self) -> usize { + self.delete_count + } + + pub fn materialize_rows(&self) -> Vec> { + if let Some(rows) = self.materialized_rows.borrow().as_ref() { + return rows.clone(); + } + + let rows = self + .deltas + .iter() + .map(|delta| Delta::new(self.arena.materialize_row(&delta.data), delta.diff)) + .collect::>(); + *self.materialized_rows.borrow_mut() = Some(rows.clone()); + rows + } +} + +#[derive(Clone, Debug, Default)] +pub struct VisibleResultStore { + slots: Vec>>, + row_to_slot: HashMap, + free_list: Vec, +} + +impl VisibleResultStore { + pub fn from_rows(rows: Vec) -> Self { + let mut store = Self::default(); + store.replace_rows(rows); + store + } + + pub fn from_rc_rows(rows: Vec>) -> Self { + let mut store = Self::default(); + store.replace_rc_rows(rows); + store + } + + pub fn apply(&mut self, batch: &TraceDeltaBatch) { + for delta in batch.deltas() { + let row_id = batch.arena().row_id(&delta.data); + if delta.is_insert() { + self.upsert_rc(batch.arena().materialize_rc(&delta.data)); + } else if delta.is_delete() { + self.remove(row_id); + } + } + } + + pub fn replace_rows(&mut self, rows: Vec) { + self.clear(); + for row in rows { + self.upsert_rc(Rc::new(row)); + } + } + + pub fn replace_rc_rows(&mut self, rows: Vec>) { + self.clear(); + for row in rows { + self.upsert_rc(row); + } + } + + pub fn rows(&self) -> Vec { + self.slots + .iter() + .filter_map(|row| row.as_ref().map(|row| (**row).clone())) + .collect() + } + + pub fn rc_rows(&self) -> Vec> { + self.slots + .iter() + .filter_map(|row| row.as_ref().cloned()) + .collect() + } + + pub fn row_refs(&self) -> impl Iterator> + '_ { + self.slots.iter().filter_map(|row| row.as_ref()) + } + + pub fn visit_rc_rows(&self, mut visitor: F) + where + F: FnMut(&Rc) -> bool, + { + for row in self.row_refs() { + if !visitor(row) { + break; + } + } + } + + #[inline] + pub fn len(&self) -> usize { + self.row_to_slot.len() + } + + #[inline] + pub fn is_empty(&self) -> bool { + self.row_to_slot.is_empty() + } + + pub fn clear(&mut self) { + self.slots.clear(); + self.row_to_slot.clear(); + self.free_list.clear(); + } + + fn upsert_rc(&mut self, row: Rc) { + if let Some(slot_id) = self.row_to_slot.get(&row.id()).copied() { + self.slots[slot_id] = Some(row); + return; + } + + let slot_id = if let Some(slot_id) = self.free_list.pop() { + self.slots[slot_id] = Some(row.clone()); + slot_id + } else { + self.slots.push(Some(row.clone())); + self.slots.len().saturating_sub(1) + }; + self.row_to_slot.insert(row.id(), slot_id); + } + + fn remove(&mut self, row_id: RowId) { + if let Some(slot_id) = self.row_to_slot.remove(&row_id) { + self.slots[slot_id] = None; + self.free_list.push(slot_id); + } + } +} diff --git a/crates/index/src/btree/tree.rs b/crates/index/src/btree/tree.rs index 6d7596e..c3e283f 100644 --- a/crates/index/src/btree/tree.rs +++ b/crates/index/src/btree/tree.rs @@ -56,6 +56,15 @@ impl BTreeIndex { &self.stats } + /// Returns the number of distinct keys currently stored in the tree. + pub fn distinct_key_count(&self) -> usize { + self.arena + .iter() + .filter(|node| node.is_leaf) + .map(Node::key_count) + .sum() + } + /// Returns whether this is a unique index. pub fn is_unique(&self) -> bool { self.unique @@ -84,6 +93,37 @@ impl BTreeIndex { } } + /// Finds the leaf that should contain the given key, starting from a previous leaf hint + /// when keys are inserted in non-decreasing order. + fn find_leaf_from_hint(&self, key: &K, hint: Option) -> NodeId { + let Some(mut leaf_id) = hint else { + return self.find_leaf(key); + }; + + loop { + let leaf = &self.arena[leaf_id]; + debug_assert!(leaf.is_leaf); + + match leaf.keys.last() { + None => return leaf_id, + Some(last_key) if !self.comparator.is_less(last_key, key) => return leaf_id, + Some(_) => {} + } + + let Some(next_id) = leaf.next else { + return leaf_id; + }; + let next = &self.arena[next_id]; + match next.keys.first() { + None => return next_id, + Some(first_key) if !self.comparator.is_less(key, first_key) => { + leaf_id = next_id; + } + Some(_) => return leaf_id, + } + } + } + /// Finds the position of the child to descend into for an internal node. /// Uses binary search for O(log n) performance instead of linear scan. #[inline] @@ -596,6 +636,58 @@ impl Index for BTreeIndex { self.insert(key, value) } + fn add_batch(&mut self, entries: &[(K, RowId)]) -> Result<(), IndexError> { + if entries.is_empty() { + return Ok(()); + } + + let mut sorted_entries = entries.to_vec(); + sorted_entries.sort_by(|left, right| left.0.cmp(&right.0)); + + if self.unique { + let mut last_key: Option<&K> = None; + for (key, _) in &sorted_entries { + if self.contains_key(key) || last_key == Some(key) { + return Err(IndexError::DuplicateKey); + } + last_key = Some(key); + } + } + + let mut leaf_hint = None; + let mut i = 0usize; + while i < sorted_entries.len() { + let key = sorted_entries[i].0.clone(); + let mut values = Vec::new(); + while i < sorted_entries.len() && sorted_entries[i].0 == key { + values.push(sorted_entries[i].1); + i += 1; + } + + let leaf_id = self.find_leaf_from_hint(&key, leaf_hint); + let pos = self.arena[leaf_id].find_key_position(&key); + if pos < self.arena[leaf_id].key_count() && self.arena[leaf_id].keys[pos] == key { + if self.unique { + return Err(IndexError::DuplicateKey); + } + self.arena[leaf_id].values[pos].extend(values.iter().copied()); + } else { + self.arena[leaf_id].keys.insert(pos, key); + self.arena[leaf_id].values.insert(pos, values.clone()); + } + self.stats.add_rows(values.len()); + + if self.arena[leaf_id].key_count() >= self.order { + self.split_leaf(leaf_id); + leaf_hint = None; + } else { + leaf_hint = Some(leaf_id); + } + } + + Ok(()) + } + fn set(&mut self, key: K, value: RowId) { // Remove existing values for the key first self.delete(&key, None); @@ -1789,4 +1881,39 @@ mod tests { let result = tree.get_range(None, false, None, 0); assert_eq!(result, vec![0, 1, 2, 4, 5, 6, 8, 9]); } + + #[test] + fn test_btree_add_batch_groups_duplicate_keys() { + let mut tree: BTreeIndex = BTreeIndex::new(5, false); + + tree.add_batch(&[(3, 30), (1, 10), (1, 11), (2, 20), (1, 12)]) + .unwrap(); + + assert_eq!(tree.get(&1), vec![10, 11, 12]); + assert_eq!(tree.get(&2), vec![20]); + assert_eq!(tree.get(&3), vec![30]); + assert_eq!(tree.len(), 5); + assert_eq!(tree.distinct_key_count(), 3); + } + + #[test] + fn test_btree_add_batch_unique_detects_duplicate_keys() { + let mut tree: BTreeIndex = BTreeIndex::new(5, true); + + let err = tree.add_batch(&[(1, 10), (1, 11)]).unwrap_err(); + + assert_eq!(err, IndexError::DuplicateKey); + assert!(tree.is_empty()); + } + + #[test] + fn test_btree_add_batch_merges_into_existing_non_unique_key() { + let mut tree: BTreeIndex = BTreeIndex::new(5, false); + tree.add(1, 1).unwrap(); + + tree.add_batch(&[(2, 20), (1, 10), (1, 11)]).unwrap(); + + assert_eq!(tree.get(&1), vec![1, 10, 11]); + assert_eq!(tree.get(&2), vec![20]); + } } diff --git a/crates/index/src/gin/mod.rs b/crates/index/src/gin/mod.rs index 97ca965..d3f3f0f 100644 --- a/crates/index/src/gin/mod.rs +++ b/crates/index/src/gin/mod.rs @@ -8,13 +8,15 @@ mod posting; pub use posting::PostingList; use crate::stats::IndexStats; -use alloc::collections::{BTreeMap, BTreeSet}; use alloc::string::String; +use alloc::vec; use alloc::vec::Vec; use cynos_core::RowId; +use hashbrown::{HashMap, HashSet}; /// Synthetic key namespace used for JSONB_CONTAINS trigram prefilters. pub const CONTAINS_TRIGRAM_KEY_PREFIX: &str = "__cynos_contains3__:"; +const TRIGRAM_CACHE_CAPACITY: usize = 256; /// Builds the synthetic key used for path-scoped JSONB_CONTAINS trigrams. pub fn contains_trigram_key(path: &str) -> String { @@ -23,20 +25,127 @@ pub fn contains_trigram_key(path: &str) -> String { key } -/// Extracts unique trigrams from a string for substring prefiltering. -pub fn contains_trigrams(value: &str) -> Vec { - let chars: Vec = value.chars().collect(); - if chars.len() < 3 { - return Vec::new(); +const INLINE_TRIGRAM_DEDUP_CAPACITY: usize = 12; + +fn trigram_code(first: char, second: char, third: char) -> u128 { + ((first as u128) << 42) | ((second as u128) << 21) | (third as u128) +} + +fn push_trigram_string(out: &mut String, first: char, second: char, third: char) { + out.clear(); + out.push(first); + out.push(second); + out.push(third); +} + +fn trigram_chars(code: u128) -> Option<(char, char, char)> { + let first = core::char::from_u32(((code >> 42) & 0x1f_ffff) as u32)?; + let second = core::char::from_u32(((code >> 21) & 0x1f_ffff) as u32)?; + let third = core::char::from_u32((code & 0x1f_ffff) as u32)?; + Some((first, second, third)) +} + +fn trigram_string_from_code(code: u128) -> String { + let Some((first, second, third)) = trigram_chars(code) else { + return String::new(); + }; + + let mut gram = String::with_capacity(first.len_utf8() + second.len_utf8() + third.len_utf8()); + gram.push(first); + gram.push(second); + gram.push(third); + gram +} + +fn try_mark_trigram_seen( + code: u128, + inline_seen: &mut [u128; INLINE_TRIGRAM_DEDUP_CAPACITY], + inline_len: &mut usize, + spilled_seen: &mut Option>, +) -> bool { + if let Some(seen) = spilled_seen.as_mut() { + return seen.insert(code); } - let mut grams = BTreeSet::new(); - for window in chars.windows(3) { - let gram: String = window.iter().collect(); - grams.insert(gram); + if inline_seen[..*inline_len].contains(&code) { + return false; } - grams.into_iter().collect() + if *inline_len < INLINE_TRIGRAM_DEDUP_CAPACITY { + inline_seen[*inline_len] = code; + *inline_len += 1; + return true; + } + + let mut seen = HashSet::with_capacity(*inline_len * 2); + seen.extend(inline_seen[..*inline_len].iter().copied()); + let inserted = seen.insert(code); + *spilled_seen = Some(seen); + inserted +} + +fn for_each_unique_trigram_code(value: &str, mut visitor: F) -> usize +where + F: FnMut(u128), +{ + let mut chars = value.chars(); + let Some(mut first) = chars.next() else { + return 0; + }; + let Some(mut second) = chars.next() else { + return 0; + }; + let Some(mut third) = chars.next() else { + return 0; + }; + + let mut inline_seen = [0u128; INLINE_TRIGRAM_DEDUP_CAPACITY]; + let mut inline_len = 0usize; + let mut spilled_seen = None; + let mut count = 0usize; + + loop { + let code = trigram_code(first, second, third); + if try_mark_trigram_seen(code, &mut inline_seen, &mut inline_len, &mut spilled_seen) { + visitor(code); + count += 1; + } + + let Some(next) = chars.next() else { + break; + }; + first = second; + second = third; + third = next; + } + + count +} + +fn for_each_unique_trigram(value: &str, mut visitor: F) -> usize +where + F: FnMut(&str), +{ + let mut gram = String::new(); + for_each_unique_trigram_code(value, |code| { + let Some((first, second, third)) = trigram_chars(code) else { + return; + }; + + if gram.capacity() < first.len_utf8() + second.len_utf8() + third.len_utf8() { + gram.reserve(first.len_utf8() + second.len_utf8() + third.len_utf8() - gram.capacity()); + } + push_trigram_string(&mut gram, first, second, third); + visitor(gram.as_str()); + }) +} + +/// Extracts unique trigrams from a string for substring prefiltering. +pub fn contains_trigrams(value: &str) -> Vec { + let mut grams = Vec::new(); + for_each_unique_trigram(value, |gram| grams.push(gram.into())); + grams.sort_unstable(); + grams } /// Builds the synthetic (key, value) pairs used to prefilter JSONB_CONTAINS. @@ -48,6 +157,227 @@ pub fn contains_trigram_pairs(path: &str, needle: &str) -> Vec<(String, String)> .collect() } +fn push_sorted_unique_row(rows: &mut Vec, row_id: RowId) { + match rows.last().copied() { + None => rows.push(row_id), + Some(last) if row_id > last => rows.push(row_id), + Some(last) if row_id == last => {} + Some(_) => match rows.binary_search(&row_id) { + Ok(_) => {} + Err(index) => rows.insert(index, row_id), + }, + } +} + +fn extend_sorted_unique_rows(rows: &mut Vec, incoming: &[RowId]) { + if incoming.is_empty() { + return; + } + + if rows.is_empty() { + rows.extend_from_slice(incoming); + return; + } + + let Some(&incoming_first) = incoming.first() else { + return; + }; + if rows.last().copied().is_some_and(|last| last < incoming_first) { + rows.extend_from_slice(incoming); + return; + } + + let mut merged = Vec::with_capacity(rows.len() + incoming.len()); + let mut left = 0usize; + let mut right = 0usize; + + while left < rows.len() && right < incoming.len() { + match rows[left].cmp(&incoming[right]) { + core::cmp::Ordering::Less => { + merged.push(rows[left]); + left += 1; + } + core::cmp::Ordering::Greater => { + merged.push(incoming[right]); + right += 1; + } + core::cmp::Ordering::Equal => { + merged.push(rows[left]); + left += 1; + right += 1; + } + } + } + + if left < rows.len() { + merged.extend_from_slice(&rows[left..]); + } + if right < incoming.len() { + merged.extend_from_slice(&incoming[right..]); + } + + *rows = merged; +} + +#[derive(Debug, Clone, Default)] +pub struct GinBulkBuilder { + key_index: HashMap>, + key_value_index: HashMap>>, + contains_value_index: HashMap>>, + trigram_cache: HashMap>, + key_add_count: usize, +} + +impl GinBulkBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn add_key_ref(&mut self, key: &str, row_id: RowId) { + self.key_add_count += 1; + if let Some(rows) = self.key_index.get_mut(key) { + push_sorted_unique_row(rows, row_id); + return; + } + + self.key_index.insert(key.into(), vec![row_id]); + } + + pub fn add_key(&mut self, key: String, row_id: RowId) { + self.add_key_ref(&key, row_id); + } + + pub fn add_key_value_ref(&mut self, key: &str, value: &str, row_id: RowId) { + if let Some(values) = self.key_value_index.get_mut(key) { + if let Some(rows) = values.get_mut(value) { + push_sorted_unique_row(rows, row_id); + return; + } + + values.insert(value.into(), vec![row_id]); + return; + } + + let mut values = HashMap::new(); + values.insert(value.into(), vec![row_id]); + self.key_value_index.insert(key.into(), values); + } + + pub fn add_key_value(&mut self, key: String, value: String, row_id: RowId) { + self.add_key_value_ref(&key, &value, row_id); + } + + pub fn add_key_values( + &mut self, + pairs: impl IntoIterator, + row_id: RowId, + ) { + for (key, value) in pairs { + self.add_key_value(key, value, row_id); + } + } + + pub fn add_contains_trigrams(&mut self, path: &str, needle: &str, row_id: RowId) -> usize { + let key = contains_trigram_key(path); + self.add_contains_trigrams_for_key(&key, needle, row_id) + } + + pub fn add_contains_trigrams_for_key( + &mut self, + contains_key: &str, + needle: &str, + row_id: RowId, + ) -> usize { + self.add_contains_value_ref(contains_key, needle, row_id); + self.trigram_codes_for_value(needle).len() + } + + fn add_contains_value_ref(&mut self, contains_key: &str, value: &str, row_id: RowId) { + if let Some(values) = self.contains_value_index.get_mut(contains_key) { + if let Some(rows) = values.get_mut(value) { + push_sorted_unique_row(rows, row_id); + return; + } + + values.insert(value.into(), vec![row_id]); + return; + } + + let mut values = HashMap::new(); + values.insert(value.into(), vec![row_id]); + self.contains_value_index.insert(contains_key.into(), values); + } + + fn trigram_codes_for_value(&mut self, needle: &str) -> Vec { + if let Some(cached) = self.trigram_cache.get(needle) { + return cached.clone(); + } + + let mut codes = Vec::new(); + for_each_unique_trigram_code(needle, |code| codes.push(code)); + if self.trigram_cache.len() < TRIGRAM_CACHE_CAPACITY { + self.trigram_cache.insert(needle.into(), codes.clone()); + } + codes + } + + fn finish(self) -> GinBulkDelta { + let mut key_value_index: HashMap<(String, String), Vec> = self + .key_value_index + .into_iter() + .flat_map(|(key, values)| { + values + .into_iter() + .map(move |(value, rows)| ((key.clone(), value), rows)) + }) + .collect(); + + let mut trigram_cache = self.trigram_cache; + for (contains_key, values) in self.contains_value_index { + for (value, rows) in values { + let trigram_codes = if let Some(cached) = trigram_cache.get(&value) { + cached.clone() + } else { + let mut codes = Vec::new(); + for_each_unique_trigram_code(&value, |code| codes.push(code)); + if trigram_cache.len() < TRIGRAM_CACHE_CAPACITY { + trigram_cache.insert(value.clone(), codes.clone()); + } + codes + }; + + for trigram_code in trigram_codes { + let trigram = trigram_string_from_code(trigram_code); + let entry = key_value_index + .entry((contains_key.clone(), trigram)) + .or_default(); + extend_sorted_unique_rows(entry, &rows); + } + } + } + + GinBulkDelta { + key_index: self + .key_index + .into_iter() + .map(|(key, rows)| (key, PostingList::from_sorted_unique(rows))) + .collect(), + key_value_index: key_value_index + .into_iter() + .map(|(pair, rows)| (pair, PostingList::from_sorted_unique(rows))) + .collect(), + key_add_count: self.key_add_count, + } + } +} + +#[derive(Debug, Clone, Default)] +struct GinBulkDelta { + key_index: HashMap, + key_value_index: HashMap<(String, String), PostingList>, + key_add_count: usize, +} + /// A GIN index for JSONB and other composite types. /// /// This index maintains two inverted indexes: @@ -56,9 +386,9 @@ pub fn contains_trigram_pairs(path: &str, needle: &str) -> Vec<(String, String)> #[derive(Debug, Clone)] pub struct GinIndex { /// Key → Row IDs (for key existence queries) - key_index: BTreeMap, + key_index: HashMap, /// (Key, Value) → Row IDs (for containment queries) - key_value_index: BTreeMap<(String, String), PostingList>, + key_value_index: HashMap<(String, String), PostingList>, /// Statistics stats: IndexStats, } @@ -67,8 +397,8 @@ impl GinIndex { /// Creates a new empty GIN index. pub fn new() -> Self { Self { - key_index: BTreeMap::new(), - key_value_index: BTreeMap::new(), + key_index: HashMap::new(), + key_value_index: HashMap::new(), stats: IndexStats::new(), } } @@ -113,6 +443,35 @@ impl GinIndex { } } + pub fn add_contains_trigrams(&mut self, path: &str, needle: &str, row_id: RowId) -> usize { + let key = contains_trigram_key(path); + for_each_unique_trigram_code(needle, |code| { + self.add_key_value(key.clone(), trigram_string_from_code(code), row_id); + }) + } + + pub fn apply_bulk_builder(&mut self, builder: GinBulkBuilder) { + self.apply_bulk_delta(builder.finish()); + } + + fn apply_bulk_delta(&mut self, delta: GinBulkDelta) { + self.stats.add_rows(delta.key_add_count); + + for (key, posting) in delta.key_index { + self.key_index + .entry(key) + .and_modify(|existing| existing.merge(&posting)) + .or_insert(posting); + } + + for (pair, posting) in delta.key_value_index { + self.key_value_index + .entry(pair) + .and_modify(|existing| existing.merge(&posting)) + .or_insert(posting); + } + } + /// Removes a key entry for a given row. pub fn remove_key(&mut self, key: &str, row_id: RowId) { if let Some(posting) = self.key_index.get_mut(key) { @@ -261,6 +620,20 @@ impl GinIndex { result } + /// Gets all row IDs that contain ANY of the given key-value pairs (OR query). + pub fn get_by_key_values_any(&self, pairs: &[(&str, &str)]) -> Vec { + let mut result = PostingList::new(); + + for (key, value) in pairs { + let pair = ((*key).into(), (*value).into()); + if let Some(posting) = self.key_value_index.get(&pair) { + result = result.union(posting); + } + } + + result.to_vec() + } + /// Visits row IDs that contain all of the given key-value pairs. /// Return `false` from the visitor to stop early. pub fn visit_by_key_values_all(&self, pairs: &[(&str, &str)], mut visitor: F) @@ -274,6 +647,19 @@ impl GinIndex { } } + /// Visits row IDs that contain any of the given key-value pairs. + /// Return `false` from the visitor to stop early. + pub fn visit_by_key_values_any(&self, pairs: &[(&str, &str)], mut visitor: F) + where + F: FnMut(RowId) -> bool, + { + for row_id in self.get_by_key_values_any(pairs) { + if !visitor(row_id) { + break; + } + } + } + /// Returns the number of unique keys in the index. pub fn key_count(&self) -> usize { self.key_index.len() @@ -464,6 +850,26 @@ mod tests { assert_eq!(result, vec![1, 2]); } + #[test] + fn test_gin_get_by_key_values_any() { + let mut gin = GinIndex::new(); + + gin.add_key_value("status".into(), "active".into(), 1); + gin.add_key_value("type".into(), "user".into(), 1); + + gin.add_key_value("status".into(), "active".into(), 2); + gin.add_key_value("type".into(), "admin".into(), 2); + + gin.add_key_value("status".into(), "inactive".into(), 3); + gin.add_key_value("type".into(), "user".into(), 3); + + let result = gin.get_by_key_values_any(&[("status", "active"), ("type", "user")]); + assert_eq!(result, vec![1, 2, 3]); + + let result = gin.get_by_key_values_any(&[("status", "active")]); + assert_eq!(result, vec![1, 2]); + } + #[test] fn test_gin_get_by_key_values_all_order_independent() { let mut gin = GinIndex::new(); @@ -583,4 +989,53 @@ mod tests { ] ); } + + #[test] + fn test_gin_bulk_builder_merges_into_existing_index() { + let mut gin = GinIndex::new(); + gin.add_key("status".into(), 1); + gin.add_key_value("status".into(), "active".into(), 1); + + let mut builder = GinBulkBuilder::new(); + builder.add_key("status".into(), 2); + builder.add_key("status".into(), 3); + builder.add_key_value("status".into(), "active".into(), 2); + builder.add_key_value("status".into(), "active".into(), 3); + + gin.apply_bulk_builder(builder); + + assert_eq!(gin.get_by_key("status"), vec![1, 2, 3]); + assert_eq!(gin.get_by_key_value("status", "active"), vec![1, 2, 3]); + } + + #[test] + fn test_gin_bulk_builder_contains_trigrams_for_key() { + let mut gin = GinIndex::new(); + let contains_key = contains_trigram_key("status"); + let mut builder = GinBulkBuilder::new(); + builder.add_contains_trigrams_for_key(&contains_key, "enterprise", 7); + + gin.apply_bulk_builder(builder); + + for gram in contains_trigrams("enterprise") { + assert_eq!(gin.get_by_key_value(&contains_key, &gram), vec![7]); + } + } + + #[test] + fn test_gin_bulk_builder_reuses_repeated_trigram_needles_without_changing_postings() { + let mut gin = GinIndex::new(); + let contains_key = contains_trigram_key("status"); + let mut builder = GinBulkBuilder::new(); + + for row_id in [7, 8, 9] { + builder.add_contains_trigrams_for_key(&contains_key, "enterprise", row_id); + } + + gin.apply_bulk_builder(builder); + + for gram in contains_trigrams("enterprise") { + assert_eq!(gin.get_by_key_value(&contains_key, &gram), vec![7, 8, 9]); + } + } } diff --git a/crates/index/src/gin/posting.rs b/crates/index/src/gin/posting.rs index 9c382f3..47310b4 100644 --- a/crates/index/src/gin/posting.rs +++ b/crates/index/src/gin/posting.rs @@ -2,51 +2,107 @@ //! //! A posting list is a sorted list of row IDs that contain a particular key. -use alloc::collections::BTreeSet; use alloc::vec::Vec; use cynos_core::RowId; /// A posting list storing row IDs in sorted order. /// -/// Uses `BTreeSet` for `no_std` compatibility instead of `RoaringBitmap`. -/// For production use with large datasets, consider evolving this into a -/// hybrid representation while keeping the current API stable: -/// - `Small(Vec)` for short postings, which improves cache locality and -/// makes two-pointer intersections cheap. -/// - `Large(CompressedBitmap)` for wide postings, which can make AND/OR/DIFF -/// operations much faster on hot terms. -/// -/// Because `RowId` is a global `u64`, avoid dense bitmaps unless there is a -/// table-local ID remapping layer; sparse or compressed bitmaps are the safer -/// direction here. +/// The packed representation keeps append-heavy insert workloads and batch +/// intersections cache-friendly while preserving deterministic sorted scans. #[derive(Debug, Clone, Default)] pub struct PostingList { - /// Sorted set of row IDs. - rows: BTreeSet, + rows: Vec, } impl PostingList { /// Creates a new empty posting list. pub fn new() -> Self { - Self { - rows: BTreeSet::new(), - } + Self { rows: Vec::new() } + } + + /// Creates a posting list from a pre-sorted, unique row-id vector. + pub fn from_sorted_unique(rows: Vec) -> Self { + debug_assert!(rows.windows(2).all(|window| window[0] < window[1])); + Self { rows } } /// Adds a row ID to the posting list. pub fn add(&mut self, row_id: RowId) { - self.rows.insert(row_id); + match self.rows.last().copied() { + None => self.rows.push(row_id), + Some(last) if row_id > last => self.rows.push(row_id), + Some(last) if row_id == last => {} + Some(_) => match self.rows.binary_search(&row_id) { + Ok(_) => {} + Err(index) => self.rows.insert(index, row_id), + }, + } + } + + /// Merges another sorted, unique posting list into this posting list. + pub fn merge(&mut self, other: &PostingList) { + self.merge_sorted_unique(other.rows.as_slice()); + } + + /// Merges a sorted, unique slice of row IDs into this posting list. + pub fn merge_sorted_unique(&mut self, other: &[RowId]) { + if other.is_empty() { + return; + } + if self.rows.is_empty() { + self.rows.extend_from_slice(other); + return; + } + if self.rows.last().copied().unwrap_or(0) < other[0] { + self.rows.extend_from_slice(other); + return; + } + + let mut merged = Vec::with_capacity(self.rows.len() + other.len()); + let mut left = 0usize; + let mut right = 0usize; + + while left < self.rows.len() && right < other.len() { + let lhs = self.rows[left]; + let rhs = other[right]; + if lhs < rhs { + merged.push(lhs); + left += 1; + } else if lhs > rhs { + merged.push(rhs); + right += 1; + } else { + merged.push(lhs); + left += 1; + right += 1; + } + } + + if left < self.rows.len() { + merged.extend_from_slice(&self.rows[left..]); + } + if right < other.len() { + merged.extend_from_slice(&other[right..]); + } + + self.rows = merged; } /// Removes a row ID from the posting list. /// Returns true if the row was present. pub fn remove(&mut self, row_id: RowId) -> bool { - self.rows.remove(&row_id) + match self.rows.binary_search(&row_id) { + Ok(index) => { + self.rows.remove(index); + true + } + Err(_) => false, + } } /// Checks if the posting list contains a row ID. pub fn contains(&self, row_id: RowId) -> bool { - self.rows.contains(&row_id) + self.rows.binary_search(&row_id).is_ok() } /// Returns the number of row IDs in the posting list. @@ -61,7 +117,7 @@ impl PostingList { /// Converts the posting list to a vector. pub fn to_vec(&self) -> Vec { - self.rows.iter().copied().collect() + self.rows.clone() } /// Returns an iterator over the row IDs. @@ -71,9 +127,25 @@ impl PostingList { /// Computes the intersection of two posting lists. pub fn intersect(&self, other: &PostingList) -> PostingList { - PostingList { - rows: self.rows.intersection(&other.rows).copied().collect(), + let mut result = Vec::with_capacity(core::cmp::min(self.len(), other.len())); + let mut left = 0usize; + let mut right = 0usize; + + while left < self.rows.len() && right < other.rows.len() { + let lhs = self.rows[left]; + let rhs = other.rows[right]; + if lhs < rhs { + left += 1; + } else if lhs > rhs { + right += 1; + } else { + result.push(lhs); + left += 1; + right += 1; + } } + + PostingList::from_sorted_unique(result) } /// Intersects this posting list with a sorted list of candidate row IDs. @@ -87,25 +159,20 @@ impl PostingList { let mut result = Vec::with_capacity(core::cmp::min(candidates.len(), self.len())); let mut candidate_idx = 0usize; - let mut posting_iter = self.rows.iter().copied(); - let mut posting_row = posting_iter.next(); + let mut posting_idx = 0usize; - while candidate_idx < candidates.len() { + while candidate_idx < candidates.len() && posting_idx < self.rows.len() { let candidate = candidates[candidate_idx]; - - match posting_row { - Some(current) if current < candidate => { - posting_row = posting_iter.next(); - } - Some(current) if current == candidate => { - result.push(candidate); - candidate_idx += 1; - posting_row = posting_iter.next(); - } - Some(_) => { - candidate_idx += 1; - } - None => break, + let posting_row = self.rows[posting_idx]; + + if posting_row < candidate { + posting_idx += 1; + } else if posting_row > candidate { + candidate_idx += 1; + } else { + result.push(candidate); + candidate_idx += 1; + posting_idx += 1; } } @@ -114,16 +181,62 @@ impl PostingList { /// Computes the union of two posting lists. pub fn union(&self, other: &PostingList) -> PostingList { - PostingList { - rows: self.rows.union(&other.rows).copied().collect(), + let mut result = Vec::with_capacity(self.len() + other.len()); + let mut left = 0usize; + let mut right = 0usize; + + while left < self.rows.len() && right < other.rows.len() { + let lhs = self.rows[left]; + let rhs = other.rows[right]; + if lhs < rhs { + result.push(lhs); + left += 1; + } else if lhs > rhs { + result.push(rhs); + right += 1; + } else { + result.push(lhs); + left += 1; + right += 1; + } + } + + if left < self.rows.len() { + result.extend_from_slice(&self.rows[left..]); } + if right < other.rows.len() { + result.extend_from_slice(&other.rows[right..]); + } + + PostingList::from_sorted_unique(result) } /// Computes the difference of two posting lists (self - other). pub fn difference(&self, other: &PostingList) -> PostingList { - PostingList { - rows: self.rows.difference(&other.rows).copied().collect(), + let mut result = Vec::with_capacity(self.len()); + let mut left = 0usize; + let mut right = 0usize; + + while left < self.rows.len() { + if right >= other.rows.len() { + result.extend_from_slice(&self.rows[left..]); + break; + } + + let lhs = self.rows[left]; + let rhs = other.rows[right]; + if lhs < rhs { + result.push(lhs); + left += 1; + } else if lhs > rhs { + right += 1; + } else { + left += 1; + right += 1; + } } + + PostingList::from_sorted_unique(result) } } @@ -253,4 +366,11 @@ mod tests { let result = pl.intersect_sorted_candidates(&[1, 2, 3, 4, 8, 9]); assert_eq!(result, vec![2, 4, 8]); } + + #[test] + fn test_posting_list_merge_sorted_unique() { + let mut pl = PostingList::from_sorted_unique(vec![1, 3, 5]); + pl.merge_sorted_unique(&[2, 3, 4, 6]); + assert_eq!(pl.to_vec(), vec![1, 2, 3, 4, 5, 6]); + } } diff --git a/crates/index/src/hash.rs b/crates/index/src/hash.rs index f71b37a..c8fee46 100644 --- a/crates/index/src/hash.rs +++ b/crates/index/src/hash.rs @@ -6,7 +6,8 @@ use crate::stats::IndexStats; use crate::traits::{Index, IndexError, KeyRange, RangeIndex}; use alloc::vec::Vec; use cynos_core::RowId; -use hashbrown::HashMap; +use hashbrown::hash_map::Entry; +use hashbrown::{HashMap, HashSet}; /// A hash-based index for O(1) point queries. /// @@ -42,6 +43,11 @@ impl HashIndex { &self.stats } + /// Returns the number of distinct keys currently stored in the index. + pub fn distinct_key_count(&self) -> usize { + self.map.len() + } + /// Returns all row IDs in the index. pub fn get_all_row_ids(&self) -> Vec { self.map.values().flatten().copied().collect() @@ -59,6 +65,45 @@ impl Index for HashIndex { Ok(()) } + fn add_batch(&mut self, entries: &[(K, RowId)]) -> Result<(), IndexError> { + if entries.is_empty() { + return Ok(()); + } + + if self.unique { + let mut seen = HashSet::with_capacity(entries.len()); + for (key, _) in entries { + if self.map.contains_key(key) || !seen.insert(key.clone()) { + return Err(IndexError::DuplicateKey); + } + } + + self.map.reserve(entries.len()); + for (key, row_id) in entries { + self.map.insert(key.clone(), alloc::vec![*row_id]); + } + self.stats.add_rows(entries.len()); + return Ok(()); + } + + let mut grouped = HashMap::>::with_capacity(entries.len()); + for (key, row_id) in entries { + grouped.entry(key.clone()).or_default().push(*row_id); + } + + self.map.reserve(grouped.len()); + for (key, mut row_ids) in grouped { + match self.map.entry(key) { + Entry::Occupied(mut entry) => entry.get_mut().append(&mut row_ids), + Entry::Vacant(entry) => { + entry.insert(row_ids); + } + } + } + self.stats.add_rows(entries.len()); + Ok(()) + } + fn set(&mut self, key: K, value: RowId) { let old_count = self.map.get(&key).map(|v| v.len()).unwrap_or(0); self.map.insert(key, alloc::vec![value]); @@ -472,4 +517,28 @@ mod tests { assert_eq!(index.get(&1), vec![1000]); assert_eq!(index.get(&2), vec![2000]); } + + #[test] + fn test_hash_index_add_batch_groups_duplicate_keys() { + let mut index: HashIndex = HashIndex::new(false); + + index + .add_batch(&[(1, 10), (1, 11), (2, 20), (1, 12)]) + .unwrap(); + + assert_eq!(index.get(&1), vec![10, 11, 12]); + assert_eq!(index.get(&2), vec![20]); + assert_eq!(index.len(), 4); + assert_eq!(index.distinct_key_count(), 2); + } + + #[test] + fn test_hash_index_add_batch_unique_detects_duplicate_keys() { + let mut index: HashIndex = HashIndex::new(true); + + let err = index.add_batch(&[(1, 10), (1, 11)]).unwrap_err(); + + assert_eq!(err, IndexError::DuplicateKey); + assert!(index.is_empty()); + } } diff --git a/crates/index/src/lib.rs b/crates/index/src/lib.rs index 3dd67e8..a0dffd8 100644 --- a/crates/index/src/lib.rs +++ b/crates/index/src/lib.rs @@ -48,7 +48,8 @@ pub use comparator::{ Comparator, MultiKeyComparator, MultiKeyComparatorWithNull, Order, SimpleComparator, }; pub use gin::{ - contains_trigram_key, contains_trigram_pairs, contains_trigrams, GinIndex, PostingList, + contains_trigram_key, contains_trigram_pairs, contains_trigrams, GinBulkBuilder, GinIndex, + PostingList, }; pub use hash::HashIndex; pub use nullable::NullableIndex; diff --git a/crates/index/src/traits.rs b/crates/index/src/traits.rs index d8911b0..1c3fff5 100644 --- a/crates/index/src/traits.rs +++ b/crates/index/src/traits.rs @@ -228,6 +228,19 @@ pub trait Index { /// For unique indexes, this will fail if the key already exists. fn add(&mut self, key: K, value: RowId) -> Result<(), IndexError>; + /// Adds multiple key-value pairs to the index in batch. + /// + /// Implementations may override this to provide a more efficient merge path. + fn add_batch(&mut self, entries: &[(K, RowId)]) -> Result<(), IndexError> + where + K: Clone, + { + for (key, row_id) in entries { + self.add(key.clone(), *row_id)?; + } + Ok(()) + } + /// Sets a key-value pair, replacing any existing values for the key. fn set(&mut self, key: K, value: RowId); diff --git a/crates/perf/src/bin/query_profile.rs b/crates/perf/src/bin/query_profile.rs new file mode 100644 index 0000000..628494b --- /dev/null +++ b/crates/perf/src/bin/query_profile.rs @@ -0,0 +1,1459 @@ +use cynos_core::schema::TableBuilder; +use cynos_core::{DataType, JsonbValue, Row, Value}; +use cynos_database::query_engine::{ + build_execution_context_for_plan, compile_plan, explain_plan, TableCacheDataSource, +}; +use cynos_query::ast::Expr; +use cynos_query::executor::PhysicalPlanRunner; +use cynos_query::planner::{LogicalPlan, PhysicalPlan, QueryPlanner}; +use cynos_storage::TableCache; +use std::collections::HashMap; +use std::fmt::Write as _; +use std::fs; +use std::path::PathBuf; +use std::time::Instant; + +const ORGANIZATION_COUNT: usize = 200; +const TEAM_COUNT: usize = 1_000; +const USER_COUNT: usize = 12_000; +const PROJECT_COUNT: usize = 3_000; +const MILESTONE_COUNT: usize = 9_000; +const ISSUE_COUNT: usize = 50_000; +const SEED: u32 = 20260327; +const PROFILE_ROUNDS: usize = 5; + +const PROJECT_STATES: &[&str] = &["active", "at_risk", "planned", "paused", "archived"]; +const ORG_REGIONS: &[&str] = &["na", "emea", "apac", "latam"]; +const TEAM_FUNCTIONS: &[&str] = &["product", "design", "engineering", "ops", "growth"]; +const CUSTOMER_TIERS: &[&str] = &["self_serve", "mid_market", "enterprise"]; +const ISSUE_LANES: &[&str] = &["backlog", "triage", "delivery", "follow_up"]; +const PRIMARY_TAGS: &[&str] = &["ux", "api", "infra", "growth", "security", "sales"]; +const SECONDARY_TAGS: &[&str] = &["mobile", "web", "sync", "billing", "search", "ai"]; +const RISK_BUCKETS: &[&str] = &["low", "medium", "high", "critical"]; + +#[derive(Clone)] +struct ProjectInfo { + id: i64, + organization_id: i64, + team_id: i64, + lead_user_id: i64, + state: &'static str, + health_score: i32, + updated_at: i64, + priority_band: &'static str, + metadata_json: String, +} + +#[derive(Clone)] +struct MilestoneInfo { + id: i64, + project_id: i64, + name: String, + due_at: i64, + status: &'static str, + metadata_json: String, +} + +#[derive(Clone)] +struct IssueInfo { + id: i64, + project_id: i64, + assignee_id: i64, + current_milestone_id: Option, + title: String, + status: &'static str, + priority: &'static str, + estimate: i32, + updated_at: i64, + severity_rank: i32, + metadata_json: String, +} + +#[derive(Clone, Copy, Default)] +struct CounterState { + open_issue_count: i32, + blocker_count: i32, + stale_issue_count: i32, + last_updated_at: i64, +} + +#[derive(Clone, Copy)] +struct Mulberry32 { + value: u32, +} + +impl Mulberry32 { + fn new(seed: u32) -> Self { + Self { value: seed } + } + + fn next_f64(&mut self) -> f64 { + self.value = self.value.wrapping_add(0x6d2b79f5); + let mut result = self.value; + result = ((result ^ (result >> 15)).wrapping_mul(1 | result)) as u32; + result ^= result.wrapping_add((result ^ (result >> 7)).wrapping_mul(61 | result)); + ((result ^ (result >> 14)) as f64) / 4_294_967_296.0 + } + + fn maybe(&mut self, threshold: f64) -> bool { + self.next_f64() < threshold + } + + fn int_between_i32(&mut self, min: i32, max: i32) -> i32 { + let span = (max - min + 1) as f64; + min + (self.next_f64() * span).floor() as i32 + } + + fn int_between_i64(&mut self, min: i64, max: i64) -> i64 { + let span = (max - min + 1) as f64; + min + (self.next_f64() * span).floor() as i64 + } + + fn pick<'a>(&mut self, values: &'a [&'a str]) -> &'a str { + let idx = (self.next_f64() * values.len() as f64).floor() as usize; + values.get(idx).copied().unwrap_or(values[0]) + } +} + +#[derive(Clone)] +struct Scenario { + id: &'static str, + label: &'static str, + root_table: &'static str, + logical: LogicalPlan, +} + +#[derive(Clone)] +struct Measure { + median_ms: f64, + mean_ms: f64, + row_count: usize, +} + +#[derive(Clone)] +struct PlanProfile { + label: String, + measure: Measure, + children: Vec, +} + +fn stable_modulo(value: i64, modulo: usize) -> usize { + (((value % modulo as i64) + modulo as i64) % modulo as i64) as usize +} + +fn median(values: &[f64]) -> f64 { + let mut sorted = values.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let mid = sorted.len() / 2; + if sorted.len() % 2 == 0 { + (sorted[mid - 1] + sorted[mid]) / 2.0 + } else { + sorted[mid] + } +} + +fn mean(values: &[f64]) -> f64 { + values.iter().sum::() / values.len() as f64 +} + +fn format_ms(value: f64) -> String { + if value < 1.0 { + format!("{value:.3} ms") + } else { + format!("{value:.2} ms") + } +} + +fn create_cache() -> TableCache { + let mut cache = TableCache::new(); + create_tables(&mut cache); + populate_tables(&mut cache); + cache +} + +fn create_tables(cache: &mut TableCache) { + let organizations = TableBuilder::new("organizations") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("name", DataType::String) + .unwrap() + .add_column("tier", DataType::String) + .unwrap() + .add_column("region", DataType::String) + .unwrap() + .add_column("metadata", DataType::Jsonb) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .add_index("idx_organizations_region", &["region"], false) + .unwrap() + .build() + .unwrap(); + + let teams = TableBuilder::new("teams") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("organizationId", DataType::Int64) + .unwrap() + .add_column("name", DataType::String) + .unwrap() + .add_column("function", DataType::String) + .unwrap() + .add_column("metadata", DataType::Jsonb) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .add_index("idx_teams_organizationId", &["organizationId"], false) + .unwrap() + .build() + .unwrap(); + + let users = TableBuilder::new("users") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("teamId", DataType::Int64) + .unwrap() + .add_column("name", DataType::String) + .unwrap() + .add_column("role", DataType::String) + .unwrap() + .add_column("metadata", DataType::Jsonb) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .add_index("idx_users_teamId", &["teamId"], false) + .unwrap() + .build() + .unwrap(); + + let projects = TableBuilder::new("projects") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("organizationId", DataType::Int64) + .unwrap() + .add_column("teamId", DataType::Int64) + .unwrap() + .add_column("leadUserId", DataType::Int64) + .unwrap() + .add_column("name", DataType::String) + .unwrap() + .add_column("state", DataType::String) + .unwrap() + .add_column("healthScore", DataType::Int32) + .unwrap() + .add_column("updatedAt", DataType::Int64) + .unwrap() + .add_column("priorityBand", DataType::String) + .unwrap() + .add_column("metadata", DataType::Jsonb) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .add_index("idx_projects_organizationId", &["organizationId"], false) + .unwrap() + .add_index("idx_projects_teamId", &["teamId"], false) + .unwrap() + .add_index("idx_projects_leadUserId", &["leadUserId"], false) + .unwrap() + .add_index("idx_projects_state", &["state"], false) + .unwrap() + .add_index("idx_projects_healthScore", &["healthScore"], false) + .unwrap() + .add_index("idx_projects_updatedAt", &["updatedAt"], false) + .unwrap() + .add_jsonb_index( + "idx_projects_metadata_gin", + "metadata", + &["risk.bucket", "risk.score", "flags.strategic"], + ) + .unwrap() + .build() + .unwrap(); + + let project_snapshots = TableBuilder::new("projectSnapshots") + .unwrap() + .add_column("projectId", DataType::Int64) + .unwrap() + .add_column("velocity", DataType::Int32) + .unwrap() + .add_column("completionRate", DataType::Float64) + .unwrap() + .add_column("blockedRatio", DataType::Float64) + .unwrap() + .add_column("updatedAt", DataType::Int64) + .unwrap() + .add_primary_key(&["projectId"], false) + .unwrap() + .add_index("idx_projectSnapshots_velocity", &["velocity"], false) + .unwrap() + .build() + .unwrap(); + + let project_counters = TableBuilder::new("projectCounters") + .unwrap() + .add_column("projectId", DataType::Int64) + .unwrap() + .add_column("openIssueCount", DataType::Int32) + .unwrap() + .add_column("blockerCount", DataType::Int32) + .unwrap() + .add_column("staleIssueCount", DataType::Int32) + .unwrap() + .add_column("updatedAt", DataType::Int64) + .unwrap() + .add_primary_key(&["projectId"], false) + .unwrap() + .add_index( + "idx_projectCounters_openIssueCount", + &["openIssueCount"], + false, + ) + .unwrap() + .build() + .unwrap(); + + let current_milestones = TableBuilder::new("currentMilestones") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("projectId", DataType::Int64) + .unwrap() + .add_column("name", DataType::String) + .unwrap() + .add_column("dueAt", DataType::Int64) + .unwrap() + .add_column("status", DataType::String) + .unwrap() + .add_column("metadata", DataType::Jsonb) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .add_index("idx_currentMilestones_projectId", &["projectId"], false) + .unwrap() + .add_index("idx_currentMilestones_dueAt", &["dueAt"], false) + .unwrap() + .build() + .unwrap(); + + let issues = TableBuilder::new("issues") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("projectId", DataType::Int64) + .unwrap() + .add_column("assigneeId", DataType::Int64) + .unwrap() + .add_column("currentMilestoneId", DataType::Int64) + .unwrap() + .add_column("title", DataType::String) + .unwrap() + .add_column("status", DataType::String) + .unwrap() + .add_column("priority", DataType::String) + .unwrap() + .add_column("estimate", DataType::Int32) + .unwrap() + .add_column("updatedAt", DataType::Int64) + .unwrap() + .add_column("metadata", DataType::Jsonb) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .add_index("idx_issues_projectId", &["projectId"], false) + .unwrap() + .add_index("idx_issues_assigneeId", &["assigneeId"], false) + .unwrap() + .add_index( + "idx_issues_currentMilestoneId", + &["currentMilestoneId"], + false, + ) + .unwrap() + .add_index("idx_issues_status", &["status"], false) + .unwrap() + .add_index("idx_issues_estimate", &["estimate"], false) + .unwrap() + .add_index("idx_issues_updatedAt", &["updatedAt"], false) + .unwrap() + .add_jsonb_index( + "idx_issues_metadata_gin", + "metadata", + &["severityRank", "customer.tier", "workflow.lane"], + ) + .unwrap() + .build() + .unwrap(); + + for table in [ + organizations, + teams, + users, + projects, + project_snapshots, + project_counters, + current_milestones, + issues, + ] { + cache.create_table(table).unwrap(); + } +} + +fn populate_tables(cache: &mut TableCache) { + let mut random = Mulberry32::new(SEED); + let now = 1_774_944_000_000i64; + + { + let store = cache.get_table_mut("organizations").unwrap(); + for idx in 0..ORGANIZATION_COUNT { + let id = (idx + 1) as i64; + let tier = CUSTOMER_TIERS[stable_modulo(id, CUSTOMER_TIERS.len())]; + let region = ORG_REGIONS[stable_modulo(id, ORG_REGIONS.len())]; + let metadata = format!( + "{{\"spendBand\":{},\"contract\":{{\"renewed\":{},\"seats\":{}}}}}", + random.int_between_i32(1, 5), + random.maybe(0.72), + random.int_between_i32(50, 5_000), + ); + store + .insert(Row::new( + id as u64, + vec![ + Value::Int64(id), + Value::String(format!("Organization {id}").into()), + Value::String(tier.into()), + Value::String(region.into()), + Value::Jsonb(JsonbValue(metadata.into_bytes())), + ], + )) + .unwrap(); + } + } + + { + let store = cache.get_table_mut("teams").unwrap(); + for idx in 0..TEAM_COUNT { + let id = (idx + 1) as i64; + let organization_id = stable_modulo(id - 1, ORGANIZATION_COUNT) as i64 + 1; + let function = TEAM_FUNCTIONS[stable_modulo(id, TEAM_FUNCTIONS.len())]; + let metadata = format!( + "{{\"timezoneOffset\":{},\"budgetCode\":\"BGT-{}-{}\"}}", + stable_modulo(id, 12) as i64 - 6, + organization_id, + id, + ); + store + .insert(Row::new( + id as u64, + vec![ + Value::Int64(id), + Value::Int64(organization_id), + Value::String(format!("Team {id}").into()), + Value::String(function.into()), + Value::Jsonb(JsonbValue(metadata.into_bytes())), + ], + )) + .unwrap(); + } + } + + let mut team_user_ids: HashMap> = HashMap::new(); + { + let store = cache.get_table_mut("users").unwrap(); + for idx in 0..USER_COUNT { + let id = (idx + 1) as i64; + let team_id = stable_modulo(id - 1, TEAM_COUNT) as i64 + 1; + let role = if random.maybe(0.08) { + "staff" + } else if random.maybe(0.2) { + "lead" + } else { + "member" + }; + let focus = random.pick(&["product", "platform", "growth", "design"]); + let metadata = format!( + "{{\"locale\":\"{}\",\"focus\":\"{}\",\"seniority\":{}}}", + if stable_modulo(id, 2) == 0 { + "en-US" + } else { + "en-GB" + }, + focus, + random.int_between_i32(1, 6), + ); + team_user_ids.entry(team_id).or_default().push(id); + store + .insert(Row::new( + id as u64, + vec![ + Value::Int64(id), + Value::Int64(team_id), + Value::String(format!("User {id}").into()), + Value::String(role.into()), + Value::Jsonb(JsonbValue(metadata.into_bytes())), + ], + )) + .unwrap(); + } + } + + let mut projects = Vec::with_capacity(PROJECT_COUNT); + { + let store = cache.get_table_mut("projects").unwrap(); + for idx in 0..PROJECT_COUNT { + let id = (idx + 1) as i64; + let team_id = stable_modulo(id - 1, TEAM_COUNT) as i64 + 1; + let organization_id = stable_modulo(team_id - 1, ORGANIZATION_COUNT) as i64 + 1; + let candidate_users = team_user_ids + .get(&team_id) + .cloned() + .unwrap_or_else(|| vec![1]); + let lead_user_id = candidate_users[stable_modulo(id, candidate_users.len())]; + let health_score = random.int_between_i32(25, 95); + let risk_score = random.int_between_i32(10, 95); + let risk_bucket = random.pick(RISK_BUCKETS); + let updated_at = now - id * 17_000; + let metadata_json = format!( + "{{\"risk\":{{\"score\":{},\"bucket\":\"{}\"}},\"flags\":{{\"strategic\":{},\"regulated\":{}}},\"topology\":{{\"shard\":{},\"market\":\"{}\"}}}}", + risk_score, + risk_bucket, + random.maybe(0.28), + random.maybe(0.14), + stable_modulo(id, 32), + random.pick(ORG_REGIONS), + ); + let state = random.pick(PROJECT_STATES); + let priority_band = if health_score > 75 { + "p0" + } else if health_score > 55 { + "p1" + } else { + "p2" + }; + projects.push(ProjectInfo { + id, + organization_id, + team_id, + lead_user_id, + state, + health_score, + updated_at, + priority_band, + metadata_json: metadata_json.clone(), + }); + store + .insert(Row::new( + id as u64, + vec![ + Value::Int64(id), + Value::Int64(organization_id), + Value::Int64(team_id), + Value::Int64(lead_user_id), + Value::String(format!("Project {id}").into()), + Value::String(state.into()), + Value::Int32(health_score), + Value::Int64(updated_at), + Value::String(priority_band.into()), + Value::Jsonb(JsonbValue(metadata_json.into_bytes())), + ], + )) + .unwrap(); + } + } + + let mut milestones = Vec::with_capacity(MILESTONE_COUNT); + let mut milestones_by_project: HashMap> = HashMap::new(); + let mut milestone_id = 1i64; + while milestones.len() < MILESTONE_COUNT { + let project = &projects[stable_modulo(milestones.len() as i64, projects.len())]; + let row = MilestoneInfo { + id: milestone_id, + project_id: project.id, + name: format!("Milestone {milestone_id}"), + due_at: now + random.int_between_i64(1, 180) * 86_400_000, + status: if random.maybe(0.7) { + "active" + } else { + "planned" + }, + metadata_json: format!( + "{{\"quarter\":\"2026-Q{}\",\"slipDays\":{}}}", + stable_modulo(milestone_id, 4) + 1, + random.int_between_i32(0, 18), + ), + }; + milestones_by_project + .entry(project.id) + .or_default() + .push(row.id); + milestones.push(row); + milestone_id += 1; + } + + { + let store = cache.get_table_mut("currentMilestones").unwrap(); + for milestone in milestones_by_project + .values() + .filter_map(|ids| ids.first()) + .map(|id| milestones[(id - 1) as usize].clone()) + { + store + .insert(Row::new( + milestone.id as u64, + vec![ + Value::Int64(milestone.id), + Value::Int64(milestone.project_id), + Value::String(milestone.name.into()), + Value::Int64(milestone.due_at), + Value::String(milestone.status.into()), + Value::Jsonb(JsonbValue(milestone.metadata_json.into_bytes())), + ], + )) + .unwrap(); + } + } + + let mut issues = Vec::with_capacity(ISSUE_COUNT); + let mut issue_counters: HashMap = HashMap::new(); + { + let store = cache.get_table_mut("issues").unwrap(); + for idx in 0..ISSUE_COUNT { + let id = (idx + 1) as i64; + let project = &projects[(random.next_f64() * projects.len() as f64).floor() as usize]; + let assignee_pool = team_user_ids + .get(&project.team_id) + .cloned() + .unwrap_or_else(|| vec![project.lead_user_id]); + let current_milestone_ids = milestones_by_project + .get(&project.id) + .cloned() + .unwrap_or_default(); + let current_milestone_id = if !current_milestone_ids.is_empty() && random.maybe(0.78) { + Some( + current_milestone_ids + [(random.next_f64() * current_milestone_ids.len() as f64).floor() as usize], + ) + } else { + None + }; + let status = { + let roll = random.next_f64(); + if roll < 0.52 { + "open" + } else if roll < 0.72 { + "in_progress" + } else if roll < 0.88 { + "blocked" + } else { + "closed" + } + }; + let severity_rank = random.int_between_i32(1, 5); + let updated_at = now - random.int_between_i64(0, 14 * 24 * 60) * 60_000; + let tier = random.pick(CUSTOMER_TIERS); + let metadata_json = format!( + "{{\"severityRank\":{},\"tags\":{{\"primary\":\"{}\",\"secondary\":\"{}\"}},\"customer\":{{\"tier\":\"{}\"}},\"workflow\":{{\"lane\":\"{}\",\"slaHours\":{}}}}}", + severity_rank, + random.pick(PRIMARY_TAGS), + random.pick(SECONDARY_TAGS), + tier, + random.pick(ISSUE_LANES), + random.int_between_i32(4, 96), + ); + let issue = IssueInfo { + id, + project_id: project.id, + assignee_id: assignee_pool + [(random.next_f64() * assignee_pool.len() as f64).floor() as usize], + current_milestone_id, + title: format!("Issue {id}"), + status, + priority: random.pick(&["low", "medium", "high", "urgent"]), + estimate: random.int_between_i32(1, 8), + updated_at, + severity_rank, + metadata_json: metadata_json.clone(), + }; + let counters = issue_counters.entry(issue.project_id).or_default(); + if issue.status != "closed" { + counters.open_issue_count += 1; + } + if issue.status == "blocked" || issue.severity_rank >= 4 { + counters.blocker_count += 1; + } + if now - issue.updated_at > 72 * 60 * 60 * 1000 { + counters.stale_issue_count += 1; + } + if issue.updated_at > counters.last_updated_at { + counters.last_updated_at = issue.updated_at; + } + store + .insert(Row::new( + issue.id as u64, + vec![ + Value::Int64(issue.id), + Value::Int64(issue.project_id), + Value::Int64(issue.assignee_id), + issue + .current_milestone_id + .map(Value::Int64) + .unwrap_or(Value::Null), + Value::String(issue.title.clone().into()), + Value::String(issue.status.into()), + Value::String(issue.priority.into()), + Value::Int32(issue.estimate), + Value::Int64(issue.updated_at), + Value::Jsonb(JsonbValue(metadata_json.into_bytes())), + ], + )) + .unwrap(); + issues.push(issue); + } + } + + { + let store = cache.get_table_mut("projectCounters").unwrap(); + for project in &projects { + let counters = issue_counters.get(&project.id).copied().unwrap_or_default(); + store + .insert(Row::new( + project.id as u64, + vec![ + Value::Int64(project.id), + Value::Int32(counters.open_issue_count), + Value::Int32(counters.blocker_count), + Value::Int32(counters.stale_issue_count), + Value::Int64(if counters.last_updated_at == 0 { + project.updated_at + } else { + counters.last_updated_at + }), + ], + )) + .unwrap(); + } + } + + { + let store = cache.get_table_mut("projectSnapshots").unwrap(); + for project in &projects { + let counters = issue_counters.get(&project.id).copied().unwrap_or_default(); + let velocity = (80 - counters.blocker_count * 2 - counters.stale_issue_count).max(8); + let completion_rate = (project.health_score as f64 / 100.0).clamp(0.1, 0.98); + let blocked_ratio = if counters.open_issue_count == 0 { + 0.0 + } else { + (counters.blocker_count as f64 / counters.open_issue_count as f64).min(1.0) + }; + store + .insert(Row::new( + project.id as u64, + vec![ + Value::Int64(project.id), + Value::Int32(velocity), + Value::Float64(completion_rate), + Value::Float64(blocked_ratio), + Value::Int64(project.updated_at), + ], + )) + .unwrap(); + } + } + + let _ = issues; +} + +fn issue_status_predicate() -> Expr { + Expr::or( + Expr::eq( + Expr::column("issues", "status", 5), + Expr::literal(Value::String("open".into())), + ), + Expr::eq( + Expr::column("issues", "status", 5), + Expr::literal(Value::String("in_progress".into())), + ), + ) +} + +fn issue_estimate_predicate() -> Expr { + Expr::ge( + Expr::column("issues", "estimate", 7), + Expr::literal(Value::Int32(3)), + ) +} + +fn issue_customer_tier_predicate() -> Expr { + Expr::or( + Expr::jsonb_path_eq( + Expr::column("issues", "metadata", 9), + "$.customer.tier", + Value::String("enterprise".into()), + ), + Expr::jsonb_path_eq( + Expr::column("issues", "metadata", 9), + "$.customer.tier", + Value::String("mid_market".into()), + ), + ) +} + +fn issue_customer_tier_enterprise_predicate() -> Expr { + Expr::jsonb_path_eq( + Expr::column("issues", "metadata", 9), + "$.customer.tier", + Value::String("enterprise".into()), + ) +} + +fn issue_root_predicate() -> Expr { + Expr::and( + Expr::and(issue_status_predicate(), issue_estimate_predicate()), + issue_customer_tier_predicate(), + ) +} + +fn issue_joined_predicate() -> Expr { + Expr::and( + Expr::and( + issue_root_predicate(), + Expr::ge( + Expr::column("projects", "healthScore", 6), + Expr::literal(Value::Int32(45)), + ), + ), + Expr::and( + Expr::or( + Expr::jsonb_path_eq( + Expr::column("projects", "metadata", 9), + "$.risk.bucket", + Value::String("high".into()), + ), + Expr::jsonb_path_eq( + Expr::column("projects", "metadata", 9), + "$.risk.bucket", + Value::String("critical".into()), + ), + ), + Expr::and( + Expr::ge( + Expr::column("projectCounters", "openIssueCount", 1), + Expr::literal(Value::Int32(5)), + ), + Expr::ge( + Expr::column("projectSnapshots", "velocity", 1), + Expr::literal(Value::Int32(18)), + ), + ), + ), + ) +} + +fn issue_join_base() -> LogicalPlan { + let issues = LogicalPlan::scan("issues"); + let projects = LogicalPlan::scan("projects"); + let organizations = LogicalPlan::scan("organizations"); + let teams = LogicalPlan::scan("teams"); + let users = LogicalPlan::scan("users"); + let milestones = LogicalPlan::scan("currentMilestones"); + let counters = LogicalPlan::scan("projectCounters"); + let snapshots = LogicalPlan::scan("projectSnapshots"); + + let plan = LogicalPlan::left_join( + issues, + projects, + Expr::eq( + Expr::column("issues", "projectId", 1), + Expr::column("projects", "id", 0), + ), + ); + let plan = LogicalPlan::left_join( + plan, + organizations, + Expr::eq( + Expr::column("projects", "organizationId", 1), + Expr::column("organizations", "id", 0), + ), + ); + let plan = LogicalPlan::left_join( + plan, + teams, + Expr::eq( + Expr::column("projects", "teamId", 2), + Expr::column("teams", "id", 0), + ), + ); + let plan = LogicalPlan::left_join( + plan, + users, + Expr::eq( + Expr::column("issues", "assigneeId", 2), + Expr::column("users", "id", 0), + ), + ); + let plan = LogicalPlan::left_join( + plan, + milestones, + Expr::eq( + Expr::column("issues", "currentMilestoneId", 3), + Expr::column("currentMilestones", "id", 0), + ), + ); + let plan = LogicalPlan::left_join( + plan, + counters, + Expr::eq( + Expr::column("projects", "id", 0), + Expr::column("projectCounters", "projectId", 0), + ), + ); + LogicalPlan::left_join( + plan, + snapshots, + Expr::eq( + Expr::column("projects", "id", 0), + Expr::column("projectSnapshots", "projectId", 0), + ), + ) +} + +fn issue_root_plan() -> LogicalPlan { + LogicalPlan::project( + LogicalPlan::filter(LogicalPlan::scan("issues"), issue_root_predicate()), + vec![ + Expr::column("issues", "id", 0), + Expr::column("issues", "updatedAt", 8), + ], + ) +} + +fn issue_status_only_plan() -> LogicalPlan { + LogicalPlan::project( + LogicalPlan::filter(LogicalPlan::scan("issues"), issue_status_predicate()), + vec![ + Expr::column("issues", "id", 0), + Expr::column("issues", "updatedAt", 8), + ], + ) +} + +fn issue_estimate_only_plan() -> LogicalPlan { + LogicalPlan::project( + LogicalPlan::filter(LogicalPlan::scan("issues"), issue_estimate_predicate()), + vec![ + Expr::column("issues", "id", 0), + Expr::column("issues", "updatedAt", 8), + ], + ) +} + +fn issue_customer_tier_only_plan() -> LogicalPlan { + LogicalPlan::project( + LogicalPlan::filter(LogicalPlan::scan("issues"), issue_customer_tier_predicate()), + vec![ + Expr::column("issues", "id", 0), + Expr::column("issues", "updatedAt", 8), + ], + ) +} + +fn issue_customer_tier_enterprise_only_plan() -> LogicalPlan { + LogicalPlan::project( + LogicalPlan::filter( + LogicalPlan::scan("issues"), + issue_customer_tier_enterprise_predicate(), + ), + vec![ + Expr::column("issues", "id", 0), + Expr::column("issues", "updatedAt", 8), + ], + ) +} + +fn issue_status_estimate_plan() -> LogicalPlan { + LogicalPlan::project( + LogicalPlan::filter( + LogicalPlan::scan("issues"), + Expr::and(issue_status_predicate(), issue_estimate_predicate()), + ), + vec![ + Expr::column("issues", "id", 0), + Expr::column("issues", "updatedAt", 8), + ], + ) +} + +fn issue_estimate_customer_tier_plan() -> LogicalPlan { + LogicalPlan::project( + LogicalPlan::filter( + LogicalPlan::scan("issues"), + Expr::and(issue_estimate_predicate(), issue_customer_tier_predicate()), + ), + vec![ + Expr::column("issues", "id", 0), + Expr::column("issues", "updatedAt", 8), + ], + ) +} + +fn issue_join_root_plan() -> LogicalPlan { + LogicalPlan::project( + LogicalPlan::filter(issue_join_base(), issue_root_predicate()), + vec![ + Expr::column("issues", "id", 0), + Expr::column("issues", "updatedAt", 8), + ], + ) +} + +fn issue_full_plan() -> LogicalPlan { + LogicalPlan::project( + LogicalPlan::filter(issue_join_base(), issue_joined_predicate()), + vec![ + Expr::column("issues", "id", 0), + Expr::column("issues", "updatedAt", 8), + Expr::column("issues", "status", 5), + Expr::column("issues", "estimate", 7), + Expr::column("projects", "id", 0), + Expr::column("projects", "healthScore", 6), + Expr::column("projects", "metadata", 9), + Expr::column("projectCounters", "openIssueCount", 1), + Expr::column("projectSnapshots", "velocity", 1), + ], + ) +} + +fn project_root_predicate() -> Expr { + Expr::and( + Expr::and( + Expr::or( + Expr::eq( + Expr::column("projects", "state", 5), + Expr::literal(Value::String("active".into())), + ), + Expr::eq( + Expr::column("projects", "state", 5), + Expr::literal(Value::String("at_risk".into())), + ), + ), + Expr::ge( + Expr::column("projects", "healthScore", 6), + Expr::literal(Value::Int32(45)), + ), + ), + Expr::or( + Expr::jsonb_path_eq( + Expr::column("projects", "metadata", 9), + "$.risk.bucket", + Value::String("high".into()), + ), + Expr::jsonb_path_eq( + Expr::column("projects", "metadata", 9), + "$.risk.bucket", + Value::String("critical".into()), + ), + ), + ) +} + +fn project_joined_predicate() -> Expr { + Expr::and( + project_root_predicate(), + Expr::and( + Expr::ge( + Expr::column("projectCounters", "openIssueCount", 1), + Expr::literal(Value::Int32(4)), + ), + Expr::ge( + Expr::column("projectSnapshots", "velocity", 1), + Expr::literal(Value::Int32(20)), + ), + ), + ) +} + +fn project_join_base() -> LogicalPlan { + let projects = LogicalPlan::scan("projects"); + let organizations = LogicalPlan::scan("organizations"); + let teams = LogicalPlan::scan("teams"); + let users = LogicalPlan::scan("users"); + let counters = LogicalPlan::scan("projectCounters"); + let snapshots = LogicalPlan::scan("projectSnapshots"); + let milestones = LogicalPlan::scan("currentMilestones"); + + let plan = LogicalPlan::left_join( + projects, + organizations, + Expr::eq( + Expr::column("projects", "organizationId", 1), + Expr::column("organizations", "id", 0), + ), + ); + let plan = LogicalPlan::left_join( + plan, + teams, + Expr::eq( + Expr::column("projects", "teamId", 2), + Expr::column("teams", "id", 0), + ), + ); + let plan = LogicalPlan::left_join( + plan, + users, + Expr::eq( + Expr::column("projects", "leadUserId", 3), + Expr::column("users", "id", 0), + ), + ); + let plan = LogicalPlan::left_join( + plan, + counters, + Expr::eq( + Expr::column("projects", "id", 0), + Expr::column("projectCounters", "projectId", 0), + ), + ); + let plan = LogicalPlan::left_join( + plan, + snapshots, + Expr::eq( + Expr::column("projects", "id", 0), + Expr::column("projectSnapshots", "projectId", 0), + ), + ); + LogicalPlan::left_join( + plan, + milestones, + Expr::eq( + Expr::column("projects", "id", 0), + Expr::column("currentMilestones", "projectId", 1), + ), + ) +} + +fn project_root_plan() -> LogicalPlan { + LogicalPlan::project( + LogicalPlan::filter(LogicalPlan::scan("projects"), project_root_predicate()), + vec![ + Expr::column("projects", "id", 0), + Expr::column("projects", "healthScore", 6), + ], + ) +} + +fn project_full_plan() -> LogicalPlan { + LogicalPlan::project( + LogicalPlan::filter(project_join_base(), project_joined_predicate()), + vec![ + Expr::column("projects", "id", 0), + Expr::column("projects", "healthScore", 6), + Expr::column("projects", "updatedAt", 7), + Expr::column("projects", "metadata", 9), + Expr::column("projectCounters", "openIssueCount", 1), + Expr::column("projectSnapshots", "velocity", 1), + Expr::column("currentMilestones", "name", 2), + ], + ) +} + +fn scenarios() -> Vec { + vec![ + Scenario { + id: "issue_status_only", + label: "Issues status predicate only", + root_table: "issues", + logical: issue_status_only_plan(), + }, + Scenario { + id: "issue_estimate_only", + label: "Issues estimate predicate only", + root_table: "issues", + logical: issue_estimate_only_plan(), + }, + Scenario { + id: "issue_customer_tier_enterprise_only", + label: "Issues JSON-path single equality", + root_table: "issues", + logical: issue_customer_tier_enterprise_only_plan(), + }, + Scenario { + id: "issue_customer_tier_only", + label: "Issues JSON-path predicate only", + root_table: "issues", + logical: issue_customer_tier_only_plan(), + }, + Scenario { + id: "issue_status_estimate", + label: "Issues scalar predicates only", + root_table: "issues", + logical: issue_status_estimate_plan(), + }, + Scenario { + id: "issue_estimate_customer_tier", + label: "Issues estimate + JSON-path predicates", + root_table: "issues", + logical: issue_estimate_customer_tier_plan(), + }, + Scenario { + id: "issue_root_filter_only", + label: "Issues root filter only", + root_table: "issues", + logical: issue_root_plan(), + }, + Scenario { + id: "issue_join_root_filter_only", + label: "7-way join + root-table predicates only", + root_table: "issues", + logical: issue_join_root_plan(), + }, + Scenario { + id: "issue_join_full_filter", + label: "7-way join + full benchmark predicates", + root_table: "issues", + logical: issue_full_plan(), + }, + Scenario { + id: "project_root_filter_only", + label: "Projects root filter only", + root_table: "projects", + logical: project_root_plan(), + }, + Scenario { + id: "project_join_full_filter", + label: "6-way join + full board predicates", + root_table: "projects", + logical: project_full_plan(), + }, + ] +} + +fn node_label(plan: &PhysicalPlan) -> String { + match plan { + PhysicalPlan::TableScan { table } => format!("TableScan[{table}]"), + PhysicalPlan::IndexScan { table, index, .. } => format!("IndexScan[{table}.{index}]"), + PhysicalPlan::IndexGet { table, index, .. } => format!("IndexGet[{table}.{index}]"), + PhysicalPlan::IndexInGet { table, index, .. } => format!("IndexInGet[{table}.{index}]"), + PhysicalPlan::GinIndexScan { table, index, .. } => format!("GinIndexScan[{table}.{index}]"), + PhysicalPlan::GinIndexScanMulti { table, index, .. } => { + format!("GinIndexScanMulti[{table}.{index}]") + } + PhysicalPlan::Filter { .. } => "Filter".into(), + PhysicalPlan::Project { columns, .. } => format!("Project[{} cols]", columns.len()), + PhysicalPlan::HashJoin { + join_type, + output_tables, + .. + } => { + format!("HashJoin[{join_type:?}; {} tables]", output_tables.len()) + } + PhysicalPlan::SortMergeJoin { + join_type, + output_tables, + .. + } => { + format!( + "SortMergeJoin[{join_type:?}; {} tables]", + output_tables.len() + ) + } + PhysicalPlan::NestedLoopJoin { + join_type, + output_tables, + .. + } => { + format!( + "NestedLoopJoin[{join_type:?}; {} tables]", + output_tables.len() + ) + } + PhysicalPlan::IndexNestedLoopJoin { + join_type, + inner_table, + outer_is_left, + .. + } => format!( + "IndexNestedLoopJoin[{join_type:?}; inner={inner_table}; outer_is_left={outer_is_left}]" + ), + PhysicalPlan::HashAggregate { .. } => "HashAggregate".into(), + PhysicalPlan::Sort { .. } => "Sort".into(), + PhysicalPlan::TopN { limit, offset, .. } => format!("TopN[limit={limit}, offset={offset}]"), + PhysicalPlan::Limit { limit, offset, .. } => { + format!("Limit[limit={limit}, offset={offset}]") + } + PhysicalPlan::CrossProduct { .. } => "CrossProduct".into(), + PhysicalPlan::Union { all, .. } => format!("Union[all={all}]"), + PhysicalPlan::NoOp { .. } => "NoOp".into(), + PhysicalPlan::Empty => "Empty".into(), + } +} + +fn measure_streaming(cache: &TableCache, plan: &PhysicalPlan) -> Measure { + let data_source = TableCacheDataSource::new(cache); + let runner = PhysicalPlanRunner::new(&data_source); + let artifact = runner.compile_execution_artifact_with_data_source(plan); + let mut times = Vec::with_capacity(PROFILE_ROUNDS); + let mut row_count = 0usize; + + for _ in 0..PROFILE_ROUNDS { + let mut emitted = 0usize; + let started_at = Instant::now(); + runner + .execute_with_artifact_rows(plan, &artifact, |_row| { + emitted += 1; + Ok(true) + }) + .unwrap(); + times.push(started_at.elapsed().as_secs_f64() * 1_000.0); + row_count = emitted; + } + + Measure { + median_ms: median(×), + mean_ms: mean(×), + row_count, + } +} + +fn measure_collect(cache: &TableCache, plan: &PhysicalPlan) -> Measure { + let data_source = TableCacheDataSource::new(cache); + let runner = PhysicalPlanRunner::new(&data_source); + let artifact = runner.compile_execution_artifact_with_data_source(plan); + let mut times = Vec::with_capacity(PROFILE_ROUNDS); + let mut row_count = 0usize; + + for _ in 0..PROFILE_ROUNDS { + let started_at = Instant::now(); + let rows = runner + .execute_with_artifact_row_vec(plan, &artifact) + .unwrap(); + times.push(started_at.elapsed().as_secs_f64() * 1_000.0); + row_count = rows.len(); + } + + Measure { + median_ms: median(×), + mean_ms: mean(×), + row_count, + } +} + +fn measure_planning(cache: &TableCache, root_table: &str, logical: &LogicalPlan) -> f64 { + let mut times = Vec::with_capacity(PROFILE_ROUNDS); + for _ in 0..PROFILE_ROUNDS { + let started_at = Instant::now(); + let _ = compile_plan(cache, root_table, logical.clone()); + times.push(started_at.elapsed().as_secs_f64() * 1_000.0); + } + median(×) +} + +fn measure_artifact_compile(cache: &TableCache, physical: &PhysicalPlan) -> f64 { + let data_source = TableCacheDataSource::new(cache); + let runner = PhysicalPlanRunner::new(&data_source); + let mut times = Vec::with_capacity(PROFILE_ROUNDS); + for _ in 0..PROFILE_ROUNDS { + let started_at = Instant::now(); + let _ = runner.compile_execution_artifact_with_data_source(physical); + times.push(started_at.elapsed().as_secs_f64() * 1_000.0); + } + median(×) +} + +fn profile_subtree(cache: &TableCache, plan: &PhysicalPlan) -> PlanProfile { + let children = plan + .inputs() + .into_iter() + .map(|child| profile_subtree(cache, child)) + .collect(); + PlanProfile { + label: node_label(plan), + measure: measure_streaming(cache, plan), + children, + } +} + +fn render_profile_tree(profile: &PlanProfile, depth: usize, out: &mut String) { + let indent = " ".repeat(depth); + let _ = writeln!( + out, + "{indent}- {}: {} median, {} rows", + profile.label, + format_ms(profile.measure.median_ms), + profile.measure.row_count, + ); + for child in &profile.children { + render_profile_tree(child, depth + 1, out); + } +} + +fn main() { + let cache = create_cache(); + let mut report = String::new(); + let output_path = PathBuf::from("tmp").join("cynos_native_query_profile.md"); + + let _ = writeln!(report, "# Cynos Native Query Profile"); + let _ = writeln!(report); + let _ = writeln!( + report, + "Dataset: orgs={}, teams={}, users={}, projects={}, milestones={}, issues={}", + ORGANIZATION_COUNT, TEAM_COUNT, USER_COUNT, PROJECT_COUNT, MILESTONE_COUNT, ISSUE_COUNT + ); + let _ = writeln!(report, "Rounds per measurement: {PROFILE_ROUNDS}"); + let _ = writeln!(report); + + for scenario in scenarios() { + let explain = explain_plan(&cache, scenario.root_table, scenario.logical.clone()); + let planning_median = measure_planning(&cache, scenario.root_table, &scenario.logical); + let physical = compile_plan(&cache, scenario.root_table, scenario.logical.clone()); + let artifact_compile_median = measure_artifact_compile(&cache, &physical); + let stream_measure = measure_streaming(&cache, &physical); + let collect_measure = measure_collect(&cache, &physical); + let profile = profile_subtree(&cache, &physical); + let ctx = build_execution_context_for_plan(&cache, scenario.root_table, &scenario.logical); + let planner = QueryPlanner::new(ctx); + let optimized_logical = planner.optimize_logical(scenario.logical.clone()); + + let _ = writeln!(report, "## {}", scenario.label); + let _ = writeln!(report); + let _ = writeln!(report, "- id: `{}`", scenario.id); + let _ = writeln!(report, "- planning median: {}", format_ms(planning_median)); + let _ = writeln!( + report, + "- artifact compile median: {}", + format_ms(artifact_compile_median) + ); + let _ = writeln!( + report, + "- compiled execute (streaming count) median: {}, rows={}", + format_ms(stream_measure.median_ms), + stream_measure.row_count + ); + let _ = writeln!( + report, + "- compiled execute (collect rows) median: {}, rows={}", + format_ms(collect_measure.median_ms), + collect_measure.row_count + ); + let _ = writeln!(report); + let _ = writeln!(report, "### Optimized Logical"); + let _ = writeln!(report, "```text"); + let _ = writeln!(report, "{:#?}", optimized_logical); + let _ = writeln!(report, "```"); + let _ = writeln!(report); + let _ = writeln!(report, "### Physical"); + let _ = writeln!(report, "```text"); + let _ = writeln!(report, "{}", explain.physical_plan); + let _ = writeln!(report, "```"); + let _ = writeln!(report); + let _ = writeln!(report, "### Subtree Profile"); + render_profile_tree(&profile, 0, &mut report); + let _ = writeln!(report); + } + + fs::create_dir_all(output_path.parent().unwrap()).unwrap(); + fs::write(&output_path, report).unwrap(); + println!("Wrote native profile to {}", output_path.display()); +} diff --git a/crates/query/src/context.rs b/crates/query/src/context.rs index 2f5c6bc..b041c61 100644 --- a/crates/query/src/context.rs +++ b/crates/query/src/context.rs @@ -1,5 +1,6 @@ //! Execution context for query execution. +use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; @@ -15,6 +16,60 @@ pub enum QueryIndexType { Gin, } +/// Preferred restricted access mode when executing against a row-id subset. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum RestrictedAccessMode { + /// Let the planner choose between subset-driven probing and global index intersection. + #[default] + Auto, + /// Prefer iterating the restricted row-id subset directly. + SubsetDriven, + /// Prefer global index access followed by subset intersection. + IndexDrivenIntersect, +} + +/// High-level planning mode for the current query compilation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PlanningMode { + /// Ordinary full-relation planning. + #[default] + Default, + /// Planning for a query whose root relation is restricted to a row-id subset. + RestrictedRelation, +} + +/// Internal planner feature flags. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct PlannerFeatureFlags { + pub restricted_relation_cbo: bool, +} + +/// Additional planning-time hints for restricted-relation execution. +#[derive(Clone, Debug)] +pub struct PlanningIntent { + pub mode: PlanningMode, + pub restricted_table: Option, + pub exact_subset_rows: Option, + pub subset_fraction: Option, + pub preferred_access_mode: RestrictedAccessMode, + pub anchor_table: Option, + pub allow_global_fallback: bool, +} + +impl Default for PlanningIntent { + fn default() -> Self { + Self { + mode: PlanningMode::Default, + restricted_table: None, + exact_subset_rows: None, + subset_fraction: None, + preferred_access_mode: RestrictedAccessMode::Auto, + anchor_table: None, + allow_global_fallback: true, + } + } +} + /// Statistics about a table for query optimization. #[derive(Clone, Debug, Default)] pub struct TableStats { @@ -37,6 +92,8 @@ pub struct IndexInfo { pub is_unique: bool, /// Index type (BTree or GIN). pub index_type: QueryIndexType, + /// Optional normalized JSON paths covered by a GIN index. + pub gin_paths: Option>, } impl IndexInfo { @@ -47,6 +104,7 @@ impl IndexInfo { columns, is_unique, index_type: QueryIndexType::BTree, + gin_paths: None, } } @@ -57,6 +115,7 @@ impl IndexInfo { columns, is_unique: false, // GIN indexes are never unique index_type: QueryIndexType::Gin, + gin_paths: None, } } @@ -66,6 +125,16 @@ impl IndexInfo { self } + /// Restricts a GIN index to a set of normalized JSON paths. + pub fn with_gin_paths(mut self, gin_paths: Vec) -> Self { + self.gin_paths = if gin_paths.is_empty() { + None + } else { + Some(gin_paths) + }; + self + } + /// Returns true if this is a GIN index. pub fn is_gin(&self) -> bool { self.index_type == QueryIndexType::Gin @@ -90,20 +159,46 @@ impl IndexInfo { pub fn supports_ordering(&self) -> bool { self.supports_range() } + + /// Returns whether this GIN index supports the given normalized path. + pub fn supports_gin_path(&self, path: &str) -> bool { + self.is_gin() + && self.gin_paths.as_ref().map_or(true, |paths| { + paths.iter().any(|candidate| candidate == path) + }) + } } /// Execution context providing access to table metadata and statistics. #[derive(Clone, Debug, Default)] pub struct ExecutionContext { /// Table statistics for optimization. - table_stats: alloc::collections::BTreeMap, + table_stats: BTreeMap, + /// Optional effective row-count overrides used by restricted-relation planning. + effective_row_counts: BTreeMap, + /// Optional distinct key counts keyed by `(table, index_name)`. + index_distinct_counts: BTreeMap<(String, String), usize>, + /// Precomputed GIN key posting costs keyed by `(table, index_name, key)`. + gin_key_costs: BTreeMap<(String, String, String), usize>, + /// Precomputed GIN key/value posting costs keyed by `(table, index_name, key, value)`. + gin_key_value_costs: BTreeMap<(String, String, String, String), usize>, + /// Internal planner feature flags. + planner_feature_flags: PlannerFeatureFlags, + /// Additional planning hints. + planning_intent: PlanningIntent, } impl ExecutionContext { /// Creates a new empty execution context. pub fn new() -> Self { Self { - table_stats: alloc::collections::BTreeMap::new(), + table_stats: BTreeMap::new(), + effective_row_counts: BTreeMap::new(), + index_distinct_counts: BTreeMap::new(), + gin_key_costs: BTreeMap::new(), + gin_key_value_costs: BTreeMap::new(), + planner_feature_flags: PlannerFeatureFlags::default(), + planning_intent: PlanningIntent::default(), } } @@ -117,14 +212,22 @@ impl ExecutionContext { self.table_stats.get(table) } - /// Gets the row count for a table. - pub fn row_count(&self, table: &str) -> usize { + /// Returns the base row count for a table, before any restricted-relation override. + pub fn base_row_count(&self, table: &str) -> usize { self.table_stats .get(table) .map(|s| s.row_count) .unwrap_or(0) } + /// Gets the row count for a table. + pub fn row_count(&self, table: &str) -> usize { + self.effective_row_counts + .get(table) + .copied() + .unwrap_or_else(|| self.base_row_count(table)) + } + /// Checks if a table has an index on the given columns. pub fn has_index(&self, table: &str, columns: &[&str]) -> bool { self.table_stats @@ -175,6 +278,35 @@ impl ExecutionContext { }) } + /// Finds the most specific GIN index for the given column/path pair. + pub fn find_gin_index_for_path( + &self, + table: &str, + column: &str, + path: &str, + ) -> Option<&IndexInfo> { + let stats = self.table_stats.get(table)?; + + stats + .indexes + .iter() + .find(|idx| { + idx.is_gin() + && idx.columns.iter().any(|c| c == column) + && idx + .gin_paths + .as_ref() + .is_some_and(|paths| paths.iter().any(|candidate| candidate == path)) + }) + .or_else(|| { + stats.indexes.iter().find(|idx| { + idx.is_gin() + && idx.columns.iter().any(|c| c == column) + && idx.gin_paths.is_none() + }) + }) + } + /// Finds the primary key index (unique BTree index) for a table. /// Returns the first unique BTree index found, which is typically the primary key. pub fn find_primary_index(&self, table: &str) -> Option<&IndexInfo> { @@ -184,6 +316,159 @@ impl ExecutionContext { .find(|idx| idx.is_unique && idx.supports_ordering()) }) } + + /// Registers an effective row-count override for a table. + pub fn register_effective_row_count(&mut self, table: impl Into, row_count: usize) { + self.effective_row_counts.insert(table.into(), row_count); + } + + /// Returns the effective row-count override for a table, if one exists. + pub fn effective_row_count_override(&self, table: &str) -> Option { + self.effective_row_counts.get(table).copied() + } + + /// Registers the distinct-key count for a specific index. + pub fn register_index_distinct_count( + &mut self, + table: impl Into, + index_name: impl Into, + distinct_keys: usize, + ) { + self.index_distinct_counts + .insert((table.into(), index_name.into()), distinct_keys); + } + + /// Returns the distinct-key count for a specific index, if known. + pub fn index_distinct_count(&self, table: &str, index_name: &str) -> Option { + self.index_distinct_counts + .get(&(table.into(), index_name.into())) + .copied() + } + + /// Returns an estimated row count for a point lookup on the given index. + pub fn estimate_point_lookup_rows( + &self, + table: &str, + index_name: &str, + is_unique: bool, + ) -> usize { + if is_unique { + return 1; + } + + let table_rows = self.row_count(table); + if table_rows == 0 { + return 0; + } + + if let Some(distinct) = self.index_distinct_count(table, index_name) { + return core::cmp::max(table_rows / distinct.max(1), 1); + } + + core::cmp::max(table_rows / 10, 1) + } + + /// Registers a GIN key posting cost. + pub fn register_gin_key_cost( + &mut self, + table: impl Into, + index_name: impl Into, + key: impl Into, + cost: usize, + ) { + self.gin_key_costs + .insert((table.into(), index_name.into(), key.into()), cost); + } + + /// Returns a precomputed GIN key posting cost, if one exists. + pub fn gin_key_cost(&self, table: &str, index_name: &str, key: &str) -> Option { + self.gin_key_costs + .get(&(table.into(), index_name.into(), key.into())) + .copied() + } + + /// Registers a GIN key/value posting cost. + pub fn register_gin_key_value_cost( + &mut self, + table: impl Into, + index_name: impl Into, + key: impl Into, + value: impl Into, + cost: usize, + ) { + self.gin_key_value_costs.insert( + (table.into(), index_name.into(), key.into(), value.into()), + cost, + ); + } + + /// Returns a precomputed GIN key/value posting cost, if one exists. + pub fn gin_key_value_cost( + &self, + table: &str, + index_name: &str, + key: &str, + value: &str, + ) -> Option { + self.gin_key_value_costs + .get(&(table.into(), index_name.into(), key.into(), value.into())) + .copied() + } + + /// Replaces the current internal planner feature flags. + pub fn set_planner_feature_flags(&mut self, flags: PlannerFeatureFlags) { + self.planner_feature_flags = flags; + } + + /// Returns the current internal planner feature flags. + pub fn planner_feature_flags(&self) -> PlannerFeatureFlags { + self.planner_feature_flags + } + + /// Replaces the current planning intent. + pub fn set_planning_intent(&mut self, planning_intent: PlanningIntent) { + self.planning_intent = planning_intent; + } + + /// Returns the current planning intent. + pub fn planning_intent(&self) -> &PlanningIntent { + &self.planning_intent + } + + /// Returns true when restricted-relation CBO is enabled for the current plan. + pub fn restricted_relation_cbo_enabled(&self) -> bool { + self.planner_feature_flags.restricted_relation_cbo + && self.planning_intent.mode == PlanningMode::RestrictedRelation + } + + /// Returns true when `table` is the restricted relation for the current plan. + pub fn is_restricted_relation(&self, table: &str) -> bool { + self.restricted_relation_cbo_enabled() + && self + .planning_intent + .restricted_table + .as_deref() + .is_some_and(|candidate| candidate == table) + } + + /// Returns true when `table` is the anchor relation for the current plan. + pub fn is_anchor_relation(&self, table: &str) -> bool { + self.restricted_relation_cbo_enabled() + && self + .planning_intent + .anchor_table + .as_deref() + .is_some_and(|candidate| candidate == table) + } + + /// Returns the preferred restricted access mode for `table`, if applicable. + pub fn restricted_access_mode(&self, table: &str) -> RestrictedAccessMode { + if self.is_restricted_relation(table) { + self.planning_intent.preferred_access_mode + } else { + RestrictedAccessMode::Auto + } + } } #[cfg(test)] @@ -240,4 +525,31 @@ mod tests { let idx = ctx.find_index("users", &["email"]); assert!(idx.is_none()); } + + #[test] + fn test_find_gin_index_for_path() { + let mut ctx = ExecutionContext::new(); + + let stats = TableStats { + row_count: 100, + is_sorted: false, + indexes: alloc::vec![ + IndexInfo::new_gin("idx_metadata_tier", alloc::vec!["metadata".into()]) + .with_gin_paths(alloc::vec!["customer.tier".into()]), + IndexInfo::new_gin("idx_metadata_full", alloc::vec!["metadata".into()]), + ], + }; + + ctx.register_table("issues", stats); + + let idx = ctx + .find_gin_index_for_path("issues", "metadata", "customer.tier") + .unwrap(); + assert_eq!(idx.name, "idx_metadata_tier"); + + let fallback = ctx + .find_gin_index_for_path("issues", "metadata", "risk.bucket") + .unwrap(); + assert_eq!(fallback.name, "idx_metadata_full"); + } } diff --git a/crates/query/src/executor/aggregate.rs b/crates/query/src/executor/aggregate.rs index a4b6497..328b314 100644 --- a/crates/query/src/executor/aggregate.rs +++ b/crates/query/src/executor/aggregate.rs @@ -6,7 +6,7 @@ use alloc::collections::{BTreeMap, BTreeSet}; use alloc::rc::Rc; use alloc::string::String; use alloc::vec::Vec; -use cynos_core::{Row, Value}; +use cynos_core::{aggregate_group_row_id, Row, Value}; use libm::{exp, log, sqrt}; /// Aggregate executor - computes aggregate functions. @@ -113,7 +113,11 @@ impl AggregateExecutor { } let values = self.finalize_states(states); let entry = RelationEntry::new_combined( - Rc::new(Row::dummy_with_version(version_sum, values)), + Rc::new(Row::new_with_version( + aggregate_group_row_id(&[]), + version_sum, + values, + )), shared_tables, ); return Relation { @@ -140,11 +144,16 @@ impl AggregateExecutor { let entries: Vec = groups .into_iter() .map(|(_, group_state)| { + let row_id = aggregate_group_row_id(&group_state.group_values); let mut values = group_state.group_values; values.extend(self.finalize_states(group_state.aggregate_states)); RelationEntry::new_combined( - Rc::new(Row::dummy_with_version(group_state.version_sum, values)), + Rc::new(Row::new_with_version( + row_id, + group_state.version_sum, + values, + )), shared_tables.clone(), ) }) diff --git a/crates/query/src/executor/join/hash.rs b/crates/query/src/executor/join/hash.rs index 5e9fbd9..80aa8bb 100644 --- a/crates/query/src/executor/join/hash.rs +++ b/crates/query/src/executor/join/hash.rs @@ -4,7 +4,7 @@ use crate::executor::{Relation, RelationEntry, SharedTables, SqlValueRef}; use alloc::rc::Rc; use alloc::sync::Arc; use alloc::vec::Vec; -use cynos_core::{Row, Value}; +use cynos_core::{join_row_id, left_join_null_row_id, Row, Value}; use hashbrown::HashMap; /// Hash Join executor. @@ -146,7 +146,15 @@ impl HashJoin { }; result_entries.push(RelationEntry::new_combined( - Rc::new(Row::dummy_with_version(combined_version, values)), + Rc::new(Row::new_with_version( + if swap { + join_row_id(probe_entry.row.id(), build_entry.row.id()) + } else { + join_row_id(build_entry.row.id(), probe_entry.row.id()) + }, + combined_version, + values, + )), Arc::clone(&combined_tables), )); } @@ -163,7 +171,11 @@ impl HashJoin { let combined_version = probe_entry.row.version(); result_entries.push(RelationEntry::new_combined( - Rc::new(Row::dummy_with_version(combined_version, values)), + Rc::new(Row::new_with_version( + left_join_null_row_id(probe_entry.row.id()), + combined_version, + values, + )), Arc::clone(&combined_tables), )); } diff --git a/crates/query/src/executor/relation.rs b/crates/query/src/executor/relation.rs index b92c9ce..6f215a0 100644 --- a/crates/query/src/executor/relation.rs +++ b/crates/query/src/executor/relation.rs @@ -4,7 +4,7 @@ use alloc::rc::Rc; use alloc::string::String; use alloc::sync::Arc; use alloc::vec::Vec; -use cynos_core::{Row, RowId, Value}; +use cynos_core::{join_row_id, left_join_null_row_id, right_join_null_row_id, Row, RowId, Value}; /// Shared table names to avoid repeated cloning during joins. pub type SharedTables = Arc<[String]>; @@ -154,7 +154,11 @@ impl RelationEntry { let combined_version = left.row.version().wrapping_add(right.row.version()); Self { - row: Rc::new(Row::dummy_with_version(combined_version, values)), + row: Rc::new(Row::new_with_version( + join_row_id(left.row.id(), right.row.id()), + combined_version, + values, + )), is_combined: true, tables: TablesStorage::Owned(tables), } @@ -183,7 +187,11 @@ impl RelationEntry { let combined_version = left.row.version(); Self { - row: Rc::new(Row::dummy_with_version(combined_version, values)), + row: Rc::new(Row::new_with_version( + left_join_null_row_id(left.row.id()), + combined_version, + values, + )), is_combined: true, tables: TablesStorage::Owned(tables), } @@ -209,7 +217,11 @@ impl RelationEntry { let combined_version = left.row.version().wrapping_add(right.row.version()); Self { - row: Rc::new(Row::dummy_with_version(combined_version, values)), + row: Rc::new(Row::new_with_version( + join_row_id(left.row.id(), right.row.id()), + combined_version, + values, + )), is_combined: true, tables: TablesStorage::Shared(combined_tables), } @@ -234,7 +246,11 @@ impl RelationEntry { let combined_version = left.row.version(); Self { - row: Rc::new(Row::dummy_with_version(combined_version, values)), + row: Rc::new(Row::new_with_version( + left_join_null_row_id(left.row.id()), + combined_version, + values, + )), is_combined: true, tables: TablesStorage::Shared(combined_tables), } @@ -258,7 +274,11 @@ impl RelationEntry { let combined_version = right.row.version(); Self { - row: Rc::new(Row::dummy_with_version(combined_version, values)), + row: Rc::new(Row::new_with_version( + right_join_null_row_id(right.row.id()), + combined_version, + values, + )), is_combined: true, tables: TablesStorage::Shared(combined_tables), } @@ -287,7 +307,11 @@ impl RelationEntry { let combined_version = right.row.version(); Self { - row: Rc::new(Row::dummy_with_version(combined_version, values)), + row: Rc::new(Row::new_with_version( + right_join_null_row_id(right.row.id()), + combined_version, + values, + )), is_combined: true, tables: TablesStorage::Owned(tables), } diff --git a/crates/query/src/executor/runner.rs b/crates/query/src/executor/runner.rs index 5147b6b..b21688e 100644 --- a/crates/query/src/executor/runner.rs +++ b/crates/query/src/executor/runner.rs @@ -17,7 +17,7 @@ use alloc::string::String; use alloc::vec; use alloc::vec::Vec; use core::cmp::Ordering; -use cynos_core::{Row, Value, DUMMY_ROW_ID}; +use cynos_core::{join_row_id, left_join_null_row_id, right_join_null_row_id, Row, Value}; use cynos_index::KeyRange; use cynos_jsonb::{JsonPath, JsonbObject, JsonbValue}; @@ -443,6 +443,14 @@ impl<'a> JoinRowSource<'a> { JoinRowSource::View(view) => view.row_version(), } } + + #[inline] + fn row_id(self) -> u64 { + match self { + JoinRowSource::Entry(entry) => entry.row.id(), + JoinRowSource::View(view) => view.row_id(), + } + } } impl<'a> JoinedRowView<'a> { @@ -482,6 +490,16 @@ impl<'a> JoinedRowView<'a> { } } + #[inline] + fn row_id(&self) -> u64 { + match (self.left, self.right) { + (Some(left), Some(right)) => join_row_id(left.row_id(), right.row_id()), + (Some(left), None) => left_join_null_row_id(left.row_id()), + (None, Some(right)) => right_join_null_row_id(right.row_id()), + (None, None) => 0, + } + } + fn materialize_row(&self) -> Rc { let mut values = Vec::with_capacity(self.layout.total_width); for segment in &self.layout.segments { @@ -504,7 +522,7 @@ impl<'a> JoinedRowView<'a> { } } - Rc::new(Row::dummy_with_version(self.version(), values)) + Rc::new(Row::new_with_version(self.row_id(), self.version(), values)) } #[inline] @@ -516,10 +534,10 @@ impl<'a> JoinedRowView<'a> { JoinMaterializationPattern::LeftThenRight, ) => RelationEntry::combine_fast(left, right, shared_tables), ( - Some(JoinRowSource::Entry(left)), - Some(JoinRowSource::Entry(right)), + Some(JoinRowSource::Entry(_)), + Some(JoinRowSource::Entry(_)), JoinMaterializationPattern::RightThenLeft, - ) => RelationEntry::combine_fast(right, left, shared_tables), + ) => RelationEntry::new_combined(self.materialize_row(), shared_tables), (Some(JoinRowSource::Entry(left)), None, JoinMaterializationPattern::LeftThenRight) => { RelationEntry::combine_with_null_fast( left, @@ -527,12 +545,8 @@ impl<'a> JoinedRowView<'a> { shared_tables, ) } - (Some(JoinRowSource::Entry(left)), None, JoinMaterializationPattern::RightThenLeft) => { - RelationEntry::combine_null_with_fast( - self.layout.right_total_width, - left, - shared_tables, - ) + (Some(JoinRowSource::Entry(_)), None, JoinMaterializationPattern::RightThenLeft) => { + RelationEntry::new_combined(self.materialize_row(), shared_tables) } ( None, @@ -543,15 +557,9 @@ impl<'a> JoinedRowView<'a> { right, shared_tables, ), - ( - None, - Some(JoinRowSource::Entry(right)), - JoinMaterializationPattern::RightThenLeft, - ) => RelationEntry::combine_with_null_fast( - right, - self.layout.left_total_width, - shared_tables, - ), + (None, Some(JoinRowSource::Entry(_)), JoinMaterializationPattern::RightThenLeft) => { + RelationEntry::new_combined(self.materialize_row(), shared_tables) + } _ => RelationEntry::new_combined(self.materialize_row(), shared_tables), } } @@ -590,7 +598,7 @@ impl RowAccessor for JoinedRowView<'_> { impl ExecRowView for JoinedRowView<'_> { #[inline] fn row_id(&self) -> u64 { - DUMMY_ROW_ID + JoinedRowView::row_id(self) } #[inline] @@ -988,6 +996,7 @@ enum CompiledSourcePlan { table: String, index: String, pairs: Vec<(String, String)>, + match_all: bool, }, } @@ -1591,16 +1600,16 @@ pub trait DataSource { Ok(()) } - /// Returns rows from a GIN index lookup by multiple key-value pairs (AND query). - /// Used for combined JSONB path equality queries like `$.category = 'A' AND $.status = 'active'`. + /// Returns rows from a GIN index lookup by multiple key-value pairs. fn get_gin_index_rows_multi( &self, table: &str, index: &str, pairs: &[(&str, &str)], + match_all: bool, ) -> ExecutionResult>> { // Default implementation: fall back to table scan (no GIN index support) - let _ = (index, pairs); + let _ = (index, pairs, match_all); self.get_table_rows(table) } @@ -1611,12 +1620,13 @@ pub trait DataSource { table: &str, index: &str, pairs: &[(&str, &str)], + match_all: bool, mut visitor: F, ) -> ExecutionResult<()> where F: FnMut(&Rc) -> bool, { - for row in self.get_gin_index_rows_multi(table, index, pairs)? { + for row in self.get_gin_index_rows_multi(table, index, pairs, match_all)? { if !visitor(&row) { break; } @@ -1801,6 +1811,11 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { }) } + #[inline] + fn estimate_single_table_upper_bound(&self, table: &str) -> ExecutionResult> { + Ok(Some(self.data_source.get_table_row_count(table)?)) + } + fn compile_exec_plan(&self, plan: &PhysicalPlan) -> ExecutionResult { let plan = Self::strip_noop(plan); match plan { @@ -1846,7 +1861,7 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { limit, } => Ok(CompiledExecPlan { meta: self.compile_single_table_meta(table)?, - estimated_rows: *limit, + estimated_rows: Some(limit.unwrap_or(1)), kind: CompiledExecPlanKind::Source(CompiledSourcePlan::IndexGet { table: table.clone(), index: index.clone(), @@ -1856,7 +1871,10 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { }), PhysicalPlan::IndexInGet { table, index, keys } => Ok(CompiledExecPlan { meta: self.compile_single_table_meta(table)?, - estimated_rows: None, + // Multi-point lookups can still fan out heavily on non-unique indexes. + // Keep a finite upper bound so downstream join planning does not fall + // back to arbitrary build-side choices. + estimated_rows: self.estimate_single_table_upper_bound(table)?, kind: CompiledExecPlanKind::Source(CompiledSourcePlan::IndexInGet { table: table.clone(), index: index.clone(), @@ -1872,7 +1890,9 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { .. } => Ok(CompiledExecPlan { meta: self.compile_single_table_meta(table)?, - estimated_rows: None, + // JSON-path sources do not expose exact selectivity at compile time, but + // hash join build-side selection still needs a stable size signal. + estimated_rows: self.estimate_single_table_upper_bound(table)?, kind: CompiledExecPlanKind::Source(CompiledSourcePlan::GinIndexScan { table: table.clone(), index: index.clone(), @@ -1885,14 +1905,16 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { table, index, pairs, + match_all, .. } => Ok(CompiledExecPlan { meta: self.compile_single_table_meta(table)?, - estimated_rows: None, + estimated_rows: self.estimate_single_table_upper_bound(table)?, kind: CompiledExecPlanKind::Source(CompiledSourcePlan::GinIndexScanMulti { table: table.clone(), index: index.clone(), pairs: pairs.clone(), + match_all: *match_all, }), }), PhysicalPlan::Filter { input, predicate } => { @@ -2084,6 +2106,7 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { estimated_rows: Self::estimate_index_join_output_rows( outer.estimated_rows, *join_type, + *outer_is_left, ), kind: CompiledExecPlanKind::IndexNestedLoopJoin { outer: Box::new(outer), @@ -2234,12 +2257,26 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { fn estimate_index_join_output_rows( outer_rows: Option, join_type: crate::ast::JoinType, + outer_is_left: bool, ) -> Option { match join_type { - crate::ast::JoinType::LeftOuter | crate::ast::JoinType::Inner => outer_rows, - crate::ast::JoinType::RightOuter - | crate::ast::JoinType::FullOuter - | crate::ast::JoinType::Cross => None, + crate::ast::JoinType::Inner => outer_rows, + crate::ast::JoinType::LeftOuter => outer_is_left.then_some(outer_rows).flatten(), + crate::ast::JoinType::RightOuter => (!outer_is_left).then_some(outer_rows).flatten(), + crate::ast::JoinType::FullOuter | crate::ast::JoinType::Cross => None, + } + } + + #[inline] + fn index_join_emits_unmatched_outer( + join_type: crate::ast::JoinType, + outer_is_left: bool, + ) -> bool { + match join_type { + crate::ast::JoinType::Inner | crate::ast::JoinType::Cross => false, + crate::ast::JoinType::LeftOuter => outer_is_left, + crate::ast::JoinType::RightOuter => !outer_is_left, + crate::ast::JoinType::FullOuter => true, } } @@ -2504,8 +2541,9 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { table, index, pairs, + match_all, .. - } => self.execute_gin_index_scan_multi(table, index, pairs), + } => self.execute_gin_index_scan_multi(table, index, pairs, *match_all), PhysicalPlan::Filter { input, predicate } => { let input_rel = self.execute(input)?; @@ -2865,10 +2903,7 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { output_tables: &[String], emit: &mut ExecRowEmitter<'_>, ) -> ExecutionResult { - let is_outer = matches!( - join_type, - crate::ast::JoinType::LeftOuter | crate::ast::JoinType::FullOuter - ); + let emit_unmatched_outer = Self::index_join_emits_unmatched_outer(join_type, outer_is_left); let inner_col_count = self.data_source.get_column_count(inner_table)?; let layout = Self::index_join_output_layout_from_meta( &outer.meta, @@ -2922,7 +2957,7 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { return Ok(false); } - if is_outer && !matched { + if emit_unmatched_outer && !matched { let view = Self::index_nested_loop_view(&outer_row, None, &layout, outer_is_left); if !emit(ExecRowRef::Joined(view))? { return Ok(false); @@ -3196,6 +3231,7 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { table, index, pairs, + match_all, } => { let pair_refs: Vec<(&str, &str)> = pairs .iter() @@ -3203,7 +3239,7 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { .collect(); self.visit_compiled_source_rows(emit, |visit| { self.data_source - .visit_gin_index_rows_multi(table, index, &pair_refs, visit) + .visit_gin_index_rows_multi(table, index, &pair_refs, *match_all, visit) }) } } @@ -4163,6 +4199,7 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { table: &str, index: &str, pairs: &[(String, String)], + match_all: bool, ) -> ExecutionResult { // Convert to slice of references for the DataSource trait let pair_refs: Vec<(&str, &str)> = pairs @@ -4171,7 +4208,7 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { .collect(); let rows = self .data_source - .get_gin_index_rows_multi(table, index, &pair_refs)?; + .get_gin_index_rows_multi(table, index, &pair_refs, match_all)?; let column_count = self.data_source.get_column_count(table)?; Ok(Relation::from_rows_with_column_count( rows, @@ -4260,6 +4297,9 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { op: BinaryOp::Or, right, } => { + if let Some(compiled) = Self::compile_or_equality_chain(predicate) { + return compiled; + } let mut predicates = Vec::new(); Self::push_compiled_logical_terms(left, BinaryOp::Or, &mut predicates); Self::push_compiled_logical_terms(right, BinaryOp::Or, &mut predicates); @@ -4381,6 +4421,48 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { } } + fn compile_or_equality_chain(predicate: &Expr) -> Option { + let mut terms = Vec::new(); + Self::collect_logical_terms(predicate, BinaryOp::Or, &mut terms); + if terms.len() < 2 { + return None; + } + + let mut column_index = None; + let mut literals = Vec::with_capacity(terms.len()); + + for term in terms { + let predicate = Self::simple_binary_predicate(term)?; + if Self::normalize_comparison_op(predicate.op, predicate.column_on_left)? + != ComparisonOp::Eq + { + return None; + } + if predicate.literal.is_null() { + return None; + } + + match column_index { + Some(existing) if existing != predicate.column_index => return None, + Some(_) => {} + None => column_index = Some(predicate.column_index), + } + + if !literals + .iter() + .any(|existing| existing == &predicate.literal) + { + literals.push(predicate.literal); + } + } + + Some(CompiledRowPredicate::InList { + column_index: column_index?, + kernel: Self::compile_in_list_predicate_kernel(literals), + negated: false, + }) + } + fn compile_json_row_predicate(name: &str, args: &[Expr]) -> Option { let func = name.to_ascii_uppercase(); match func.as_str() { @@ -4490,6 +4572,27 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { predicates.push(Self::compile_row_predicate(predicate)); } + fn collect_logical_terms<'expr>( + predicate: &'expr Expr, + op: BinaryOp, + predicates: &mut Vec<&'expr Expr>, + ) { + if let Expr::BinaryOp { + left, + op: inner_op, + right, + } = predicate + { + if *inner_op == op { + Self::collect_logical_terms(left, op, predicates); + Self::collect_logical_terms(right, op, predicates); + return; + } + } + + predicates.push(predicate); + } + #[inline] fn compile_like_pattern(pattern: &str) -> LikePatternKernel { if pattern.contains('_') { @@ -5956,10 +6059,7 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { output_tables: &[String], emit: &mut dyn FnMut(RelationEntry) -> ExecutionResult, ) -> ExecutionResult { - let is_outer = matches!( - join_type, - crate::ast::JoinType::LeftOuter | crate::ast::JoinType::FullOuter - ); + let emit_unmatched_outer = Self::index_join_emits_unmatched_outer(join_type, outer_is_left); let outer_key_idx = self.extract_outer_key_index(condition, outer)?; let inner_col_count = self.data_source.get_column_count(inner_table)?; let layout = Self::index_join_output_layout( @@ -6015,7 +6115,7 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { return Ok(false); } - if is_outer && !matched { + if emit_unmatched_outer && !matched { let view = Self::index_nested_loop_view(outer_entry, None, &layout, outer_is_left); if !self.emit_join_view(&view, &shared_tables, emit)? { return Ok(false); @@ -7523,11 +7623,12 @@ impl<'a, D: DataSource> PhysicalPlanRunner<'a, D> { let right_col = self.extract_column_ref(right_expr)?; let outer_tables = outer.tables(); + let outer_ctx = EvalContext::new(outer_tables, outer.table_column_counts()); if outer_tables.contains(&left_col.table) { - Ok(left_col.index) + Ok(outer_ctx.resolve_column_index(&left_col.table, left_col.index)) } else if outer_tables.contains(&right_col.table) { - Ok(right_col.index) + Ok(outer_ctx.resolve_column_index(&right_col.table, right_col.index)) } else { Err(ExecutionError::InvalidOperation( "Outer key column not found in outer relation".into(), @@ -8085,6 +8186,74 @@ mod tests { } } + struct CardinalityOnlyDataSource { + tables: Vec<(&'static str, usize, usize)>, + } + + impl CardinalityOnlyDataSource { + fn new(tables: Vec<(&'static str, usize, usize)>) -> Self { + Self { tables } + } + + fn lookup(&self, table: &str) -> ExecutionResult<(usize, usize)> { + self.tables + .iter() + .find(|(name, _, _)| *name == table) + .map(|(_, row_count, column_count)| (*row_count, *column_count)) + .ok_or_else(|| ExecutionError::TableNotFound(table.into())) + } + } + + impl DataSource for CardinalityOnlyDataSource { + fn get_table_rows(&self, _table: &str) -> ExecutionResult>> { + unreachable!("cardinality-only data source should not materialize rows in tests") + } + + fn get_index_range_with_limit( + &self, + _table: &str, + _index: &str, + _range_start: Option<&Value>, + _range_end: Option<&Value>, + _include_start: bool, + _include_end: bool, + _limit: Option, + _offset: usize, + _reverse: bool, + ) -> ExecutionResult>> { + unreachable!("cardinality-only data source should not scan indexes in tests") + } + + fn get_index_range_composite_with_limit( + &self, + _table: &str, + _index: &str, + _range: Option<&KeyRange>>, + _limit: Option, + _offset: usize, + _reverse: bool, + ) -> ExecutionResult>> { + unreachable!("cardinality-only data source should not scan composite indexes in tests") + } + + fn get_index_point( + &self, + _table: &str, + _index: &str, + _key: &Value, + ) -> ExecutionResult>> { + unreachable!("cardinality-only data source should not perform point lookups in tests") + } + + fn get_column_count(&self, table: &str) -> ExecutionResult { + self.lookup(table).map(|(_, column_count)| column_count) + } + + fn get_table_row_count(&self, table: &str) -> ExecutionResult { + self.lookup(table).map(|(row_count, _)| row_count) + } + } + fn create_test_data_source() -> InMemoryDataSource { let mut ds = InMemoryDataSource::new(); @@ -8454,6 +8623,114 @@ mod tests { } } + #[test] + fn test_compile_row_predicate_compiles_or_equality_chain_into_in_list() { + let predicate = Expr::and( + Expr::gte(Expr::column("issues", "estimate", 7), Expr::literal(3i64)), + Expr::or( + Expr::eq( + Expr::column("issues", "status", 5), + Expr::literal(Value::String("open".into())), + ), + Expr::eq( + Expr::column("issues", "status", 5), + Expr::literal(Value::String("in_progress".into())), + ), + ), + ); + + let compiled = PhysicalPlanRunner::::compile_row_predicate(&predicate); + match compiled { + CompiledRowPredicate::And(predicates) => { + assert_eq!(predicates.len(), 2); + assert!(predicates.iter().any(|predicate| { + matches!( + predicate, + CompiledRowPredicate::Comparison(SimpleBinaryPredicate { + column_index: 7, + .. + }) + ) + })); + assert!(predicates.iter().any(|predicate| { + matches!( + predicate, + CompiledRowPredicate::InList { + column_index: 5, + kernel: + InListPredicateKernel::String { + literals, + contains_null: false, + }, + negated: false, + } if literals == &vec![ + String::from("open"), + String::from("in_progress"), + ] + ) + })); + } + other => panic!("expected AND predicate with compiled IN-list branch, got {other:?}"), + } + } + + #[test] + fn test_filtered_table_scan_compiles_or_chain_into_in_list_and_preserves_results() { + let mut ds = InMemoryDataSource::new(); + ds.add_table( + "issues", + vec![ + Row::new(1, vec![Value::String("open".into())]), + Row::new(2, vec![Value::String("in_progress".into())]), + Row::new(3, vec![Value::String("closed".into())]), + Row::new(4, vec![Value::Null]), + ], + 1, + ); + + let runner = PhysicalPlanRunner::new(&ds); + let plan = PhysicalPlan::filter( + PhysicalPlan::table_scan("issues"), + Expr::or( + Expr::eq( + Expr::column("issues", "status", 0), + Expr::literal(Value::String("open".into())), + ), + Expr::eq( + Expr::column("issues", "status", 0), + Expr::literal(Value::String("in_progress".into())), + ), + ), + ); + + let expected = relation_snapshot(runner.execute(&plan).unwrap()); + let artifact = PhysicalPlanRunner::::compile_execution_artifact(&plan); + + match &artifact.kind { + PlanExecutionArtifactKind::FilteredTableScan { predicate, .. } => { + assert!(matches!( + predicate, + CompiledRowPredicate::InList { + column_index: 0, + kernel: + InListPredicateKernel::String { + literals, + contains_null: false, + }, + negated: false, + } if literals == &vec![ + String::from("open"), + String::from("in_progress"), + ] + )); + } + _ => panic!("expected filtered table scan artifact"), + } + + let actual = relation_snapshot(runner.execute_with_artifact(&plan, &artifact).unwrap()); + assert_eq!(actual, expected); + } + #[test] fn test_compile_row_predicate_uses_compiled_json_kernel() { let predicate = Expr::jsonb_contains( @@ -9307,6 +9584,56 @@ mod tests { assert_eq!(ds.visited(), 2); } + #[test] + fn test_compiled_hash_join_uses_table_upper_bound_for_gin_filtered_inputs() { + let ds = CardinalityOnlyDataSource::new(vec![("small", 128, 2), ("large", 50_000, 2)]); + let runner = PhysicalPlanRunner::new(&ds); + + let plan = PhysicalPlan::hash_join( + PhysicalPlan::filter( + PhysicalPlan::GinIndexScanMulti { + table: "small".into(), + index: "idx_small_metadata_gin".into(), + pairs: vec![("priority".into(), "p1".into())], + match_all: true, + recheck: None, + }, + Expr::eq( + Expr::column("small", "flag", 1), + Expr::literal(Value::Boolean(true)), + ), + ), + PhysicalPlan::filter( + PhysicalPlan::GinIndexScanMulti { + table: "large".into(), + index: "idx_large_metadata_gin".into(), + pairs: vec![("tier".into(), "enterprise".into())], + match_all: true, + recheck: None, + }, + Expr::eq( + Expr::column("large", "flag", 1), + Expr::literal(Value::Boolean(true)), + ), + ), + Expr::eq( + Expr::column("small", "id", 0), + Expr::column("large", "id", 0), + ), + JoinType::Inner, + ); + + let artifact = runner.compile_execution_artifact_with_data_source(&plan); + let PlanExecutionArtifactKind::CompiledExecPlan(exec_plan) = artifact.kind else { + panic!("expected compiled exec plan artifact"); + }; + let CompiledExecPlanKind::HashJoin { build_side, .. } = exec_plan.kind else { + panic!("expected compiled hash join"); + }; + + assert!(matches!(build_side, HashJoinBuildSide::Left)); + } + #[test] fn test_nested_loop_join_with_predicate() { let mut ds = InMemoryDataSource::new(); @@ -9405,6 +9732,193 @@ mod tests { assert_eq!(actual, expected); } + #[test] + fn test_index_nested_loop_left_outer_emits_unmatched_outer_rows() { + let mut ds = InMemoryDataSource::new(); + ds.add_table( + "left", + vec![ + Row::new(1, vec![Value::Int64(1)]), + Row::new(2, vec![Value::Int64(2)]), + Row::new(3, vec![Value::Int64(3)]), + ], + 1, + ); + ds.add_table("right", vec![Row::new(10, vec![Value::Int64(1)])], 1); + ds.create_index("right", "idx_id", 0).unwrap(); + + let runner = PhysicalPlanRunner::new(&ds); + let plan = PhysicalPlan::IndexNestedLoopJoin { + outer: Box::new(PhysicalPlan::table_scan("left")), + inner_table: "right".into(), + inner_index: "idx_id".into(), + condition: Expr::eq( + Expr::column("left", "id", 0), + Expr::column("right", "id", 0), + ), + join_type: JoinType::LeftOuter, + outer_is_left: true, + output_tables: alloc::vec!["left".into(), "right".into()], + }; + + let result = runner.execute(&plan).unwrap(); + assert_eq!(result.len(), 3); + assert_eq!( + result.entries[0].row.values(), + &[Value::Int64(1), Value::Int64(1)] + ); + assert_eq!( + result.entries[1].row.values(), + &[Value::Int64(2), Value::Null] + ); + assert_eq!( + result.entries[2].row.values(), + &[Value::Int64(3), Value::Null] + ); + + let artifact = runner.compile_execution_artifact_with_data_source(&plan); + let actual = relation_snapshot(runner.execute_with_artifact(&plan, &artifact).unwrap()); + let expected = relation_snapshot(result); + assert_eq!(actual, expected); + } + + #[test] + fn test_index_nested_loop_right_outer_emits_unmatched_outer_rows() { + let mut ds = InMemoryDataSource::new(); + ds.add_table( + "left", + vec![ + Row::new(1, vec![Value::Int64(1)]), + Row::new(2, vec![Value::Int64(2)]), + ], + 1, + ); + ds.add_table( + "right", + vec![ + Row::new(10, vec![Value::Int64(1)]), + Row::new(11, vec![Value::Int64(3)]), + ], + 1, + ); + ds.create_index("left", "idx_id", 0).unwrap(); + + let runner = PhysicalPlanRunner::new(&ds); + let plan = PhysicalPlan::IndexNestedLoopJoin { + outer: Box::new(PhysicalPlan::table_scan("right")), + inner_table: "left".into(), + inner_index: "idx_id".into(), + condition: Expr::eq( + Expr::column("left", "id", 0), + Expr::column("right", "id", 0), + ), + join_type: JoinType::RightOuter, + outer_is_left: false, + output_tables: alloc::vec!["left".into(), "right".into()], + }; + + let result = runner.execute(&plan).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!( + result.entries[0].row.values(), + &[Value::Int64(1), Value::Int64(1)] + ); + assert_eq!( + result.entries[1].row.values(), + &[Value::Null, Value::Int64(3)] + ); + + let artifact = runner.compile_execution_artifact_with_data_source(&plan); + let actual = relation_snapshot(runner.execute_with_artifact(&plan, &artifact).unwrap()); + let expected = relation_snapshot(result); + assert_eq!(actual, expected); + } + + #[test] + fn test_index_nested_loop_join_resolves_probe_key_from_multi_table_outer() { + let mut ds = InMemoryDataSource::new(); + ds.add_table( + "issues", + vec![ + Row::new(100, vec![Value::Int64(100), Value::Int64(1)]), + Row::new(101, vec![Value::Int64(101), Value::Int64(2)]), + ], + 2, + ); + ds.add_table( + "projects", + vec![ + Row::new(1, vec![Value::Int64(1)]), + Row::new(2, vec![Value::Int64(2)]), + ], + 1, + ); + ds.add_table( + "project_counters", + vec![ + Row::new(1, vec![Value::Int64(1), Value::Int64(10)]), + Row::new(2, vec![Value::Int64(2), Value::Int64(20)]), + ], + 2, + ); + ds.create_index("project_counters", "idx_project_id", 0) + .unwrap(); + + let runner = PhysicalPlanRunner::new(&ds); + let plan = PhysicalPlan::IndexNestedLoopJoin { + outer: Box::new(PhysicalPlan::hash_join( + PhysicalPlan::table_scan("issues"), + PhysicalPlan::table_scan("projects"), + Expr::eq( + Expr::column("issues", "project_id", 1), + Expr::column("projects", "id", 0), + ), + JoinType::Inner, + )), + inner_table: "project_counters".into(), + inner_index: "idx_project_id".into(), + condition: Expr::eq( + Expr::column("projects", "id", 0), + Expr::column("project_counters", "project_id", 0), + ), + join_type: JoinType::Inner, + outer_is_left: true, + output_tables: alloc::vec![ + "issues".into(), + "projects".into(), + "project_counters".into(), + ], + }; + + let result = runner.execute(&plan).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!( + result.entries[0].row.values(), + &[ + Value::Int64(100), + Value::Int64(1), + Value::Int64(1), + Value::Int64(1), + Value::Int64(10), + ] + ); + assert_eq!( + result.entries[1].row.values(), + &[ + Value::Int64(101), + Value::Int64(2), + Value::Int64(2), + Value::Int64(2), + Value::Int64(20), + ] + ); + + let artifact = runner.compile_execution_artifact_with_data_source(&plan); + let actual = relation_snapshot(runner.execute_with_artifact(&plan, &artifact).unwrap()); + let expected = relation_snapshot(result); + assert_eq!(actual, expected); + } + #[test] fn test_cross_product() { let mut ds = InMemoryDataSource::new(); diff --git a/crates/query/src/optimizer/index_join.rs b/crates/query/src/optimizer/index_join.rs index b20375f..6a94cd5 100644 --- a/crates/query/src/optimizer/index_join.rs +++ b/crates/query/src/optimizer/index_join.rs @@ -20,6 +20,7 @@ use crate::context::{ExecutionContext, IndexInfo}; use crate::planner::PhysicalPlan; use alloc::boxed::Box; use alloc::string::String; +use alloc::vec::Vec; const INDEX_JOIN_ALWAYS_OUTER_ROWS: usize = 64; const INDEX_JOIN_MAX_OUTER_ROWS: usize = 4096; @@ -56,8 +57,7 @@ impl<'a> IndexJoinPass<'a> { let left = self.traverse(*left, None); let right = self.traverse(*right, None); - // Only optimize inner equi-joins - if join_type != JoinType::Inner || !condition.is_equi_join() { + if !condition.is_equi_join() { return PhysicalPlan::HashJoin { left: Box::new(left), right: Box::new(right), @@ -69,7 +69,7 @@ impl<'a> IndexJoinPass<'a> { // Try to find an index on either side if let Some((outer, inner_table, inner_index, outer_is_left)) = - self.find_index_join_candidate(&left, &right, &condition, row_goal) + self.find_index_join_candidate(&left, &right, &condition, join_type, row_goal) { return PhysicalPlan::IndexNestedLoopJoin { outer: Box::new(outer), @@ -102,8 +102,7 @@ impl<'a> IndexJoinPass<'a> { let left = self.traverse(*left, None); let right = self.traverse(*right, None); - // Only optimize inner equi-joins - if join_type != JoinType::Inner || !condition.is_equi_join() { + if !condition.is_equi_join() { return PhysicalPlan::NestedLoopJoin { left: Box::new(left), right: Box::new(right), @@ -115,7 +114,7 @@ impl<'a> IndexJoinPass<'a> { // Try to find an index on either side if let Some((outer, inner_table, inner_index, outer_is_left)) = - self.find_index_join_candidate(&left, &right, &condition, row_goal) + self.find_index_join_candidate(&left, &right, &condition, join_type, row_goal) { return PhysicalPlan::IndexNestedLoopJoin { outer: Box::new(outer), @@ -239,6 +238,7 @@ impl<'a> IndexJoinPass<'a> { left: &PhysicalPlan, right: &PhysicalPlan, condition: &Expr, + join_type: JoinType, row_goal: Option, ) -> Option<(PhysicalPlan, String, String, bool)> { // Extract join columns from the condition @@ -246,18 +246,39 @@ impl<'a> IndexJoinPass<'a> { let (left_col, right_col) = self.align_join_columns(left, right, cond_left_col, cond_right_col)?; - // Check if right side is a table scan with an index on the join column - if let Some((table, index)) = self.get_indexed_table_scan(right, right_col) { - if self.should_use_index_join(left, &table, &index, row_goal) { - return Some((left.clone(), table, index.name.clone(), true)); - } - } + match join_type { + JoinType::Inner => { + // Check if right side is a table scan with an index on the join column + if let Some((table, index)) = self.get_indexed_table_scan(right, right_col) { + if self.should_use_index_join(left, &table, &index, row_goal) { + return Some((left.clone(), table, index.name.clone(), true)); + } + } - // Check if left side is a table scan with an index on the join column - if let Some((table, index)) = self.get_indexed_table_scan(left, left_col) { - if self.should_use_index_join(right, &table, &index, row_goal) { - return Some((right.clone(), table, index.name.clone(), false)); + // Check if left side is a table scan with an index on the join column + if let Some((table, index)) = self.get_indexed_table_scan(left, left_col) { + if self.should_use_index_join(right, &table, &index, row_goal) { + return Some((right.clone(), table, index.name.clone(), false)); + } + } + } + JoinType::LeftOuter => { + // Preserve LEFT JOIN semantics by probing the nullable right side. + if let Some((table, index)) = self.get_indexed_table_scan(right, right_col) { + if self.should_use_index_join(left, &table, &index, row_goal) { + return Some((left.clone(), table, index.name.clone(), true)); + } + } } + JoinType::RightOuter => { + // Preserve RIGHT JOIN semantics by probing the nullable left side. + if let Some((table, index)) = self.get_indexed_table_scan(left, left_col) { + if self.should_use_index_join(right, &table, &index, row_goal) { + return Some((right.clone(), table, index.name.clone(), false)); + } + } + } + JoinType::FullOuter | JoinType::Cross => {} } None @@ -329,7 +350,7 @@ impl<'a> IndexJoinPass<'a> { } // Check if there's an index on this column let index = self.ctx.find_index(table, &[column.column.as_str()])?; - if index.is_gin() { + if !index.supports_point_lookup() { return None; } Some((table.clone(), index.clone())) @@ -345,17 +366,14 @@ impl<'a> IndexJoinPass<'a> { inner_index: &IndexInfo, row_goal: Option, ) -> bool { - if outer.collect_tables().len() != 1 { - return false; - } - let outer_rows = self.estimate_rows(outer); let effective_outer_rows = row_goal.map_or(outer_rows, |goal| outer_rows.min(goal)); - let inner_rows = self - .ctx - .get_stats(inner_table) - .map(|stats| stats.row_count) - .unwrap_or(1000); + let inner_rows = self.ctx.row_count(inner_table); + let point_lookup_rows = self.ctx.estimate_point_lookup_rows( + inner_table, + &inner_index.name, + inner_index.is_unique, + ); if effective_outer_rows == 0 || inner_rows == 0 { return false; @@ -369,6 +387,14 @@ impl<'a> IndexJoinPass<'a> { return effective_outer_rows <= INDEX_JOIN_MAX_UNIQUE_EFFECTIVE_OUTER_ROWS; } + if point_lookup_rows > 0 { + let hash_join_cost = inner_rows.saturating_add(effective_outer_rows); + let index_join_cost = effective_outer_rows.saturating_mul(point_lookup_rows); + if index_join_cost <= hash_join_cost { + return true; + } + } + if row_goal.is_some() && effective_outer_rows <= INDEX_JOIN_MAX_OUTER_ROWS && inner_rows <= INDEX_JOIN_ROW_GOAL_SMALL_INNER_ROWS @@ -386,24 +412,84 @@ impl<'a> IndexJoinPass<'a> { fn estimate_rows(&self, plan: &PhysicalPlan) -> usize { match plan { - PhysicalPlan::TableScan { table } => self - .ctx - .get_stats(table) - .map(|stats| stats.row_count) - .unwrap_or(1000), + PhysicalPlan::TableScan { table } => { + let count = self.ctx.row_count(table); + if count > 0 { + count + } else { + 1000 + } + } PhysicalPlan::IndexGet { .. } => 1, PhysicalPlan::IndexInGet { keys, .. } => keys.len(), - PhysicalPlan::IndexScan { table, .. } | PhysicalPlan::GinIndexScan { table, .. } => { - self.ctx - .get_stats(table) - .map(|stats| core::cmp::max(stats.row_count / 10, 1)) - .unwrap_or(100) + PhysicalPlan::IndexScan { table, .. } => { + let row_count = self.ctx.row_count(table); + if row_count == 0 { + 100 + } else { + core::cmp::max(row_count / 10, 1) + } + } + PhysicalPlan::GinIndexScan { + table, + index, + key, + value, + .. + } => { + if let Some(value) = value { + self.ctx + .gin_key_value_cost(table, index, key, value) + .filter(|cost| *cost > 0) + .unwrap_or_else(|| { + let row_count = self.ctx.row_count(table); + if row_count == 0 { + 100 + } else { + core::cmp::max(row_count / 10, 1) + } + }) + } else { + self.ctx + .gin_key_cost(table, index, key) + .filter(|cost| *cost > 0) + .unwrap_or_else(|| { + let row_count = self.ctx.row_count(table); + if row_count == 0 { + 100 + } else { + core::cmp::max(row_count / 10, 1) + } + }) + } + } + PhysicalPlan::GinIndexScanMulti { + table, + index, + pairs, + match_all, + .. + } => { + let costs: Vec = pairs + .iter() + .filter_map(|(key, value)| { + self.ctx.gin_key_value_cost(table, index, key, value) + }) + .collect(); + let estimated = if *match_all { + costs.iter().copied().min() + } else { + Some(costs.iter().copied().sum()) + }; + estimated.filter(|cost| *cost > 0).unwrap_or_else(|| { + let row_count = self.ctx.row_count(table); + if row_count == 0 { + 50 + } else { + core::cmp::max(row_count / 20, 1) + } + }) } - PhysicalPlan::GinIndexScanMulti { table, .. } => self - .ctx - .get_stats(table) - .map(|stats| core::cmp::max(stats.row_count / 20, 1)) - .unwrap_or(50), PhysicalPlan::Filter { input, .. } => core::cmp::max(self.estimate_rows(input) / 10, 1), PhysicalPlan::Project { input, .. } | PhysicalPlan::Sort { input, .. } @@ -428,13 +514,30 @@ impl<'a> IndexJoinPass<'a> { core::cmp::max(self.estimate_rows(input) / 10, 1) } } - PhysicalPlan::HashJoin { left, right, .. } - | PhysicalPlan::SortMergeJoin { left, right, .. } - | PhysicalPlan::NestedLoopJoin { left, right, .. } - | PhysicalPlan::CrossProduct { left, right } => core::cmp::max( + PhysicalPlan::HashJoin { + left, + right, + condition, + join_type, + .. + } + | PhysicalPlan::SortMergeJoin { + left, + right, + condition, + join_type, + .. + } + | PhysicalPlan::NestedLoopJoin { + left, + right, + condition, + join_type, + .. + } => self.estimate_join_rows(left, right, condition, *join_type), + PhysicalPlan::CrossProduct { left, right } => core::cmp::max( self.estimate_rows(left) - .saturating_mul(self.estimate_rows(right)) - / 10, + .saturating_mul(self.estimate_rows(right)), 1, ), PhysicalPlan::Union { left, right, .. } => self @@ -444,6 +547,68 @@ impl<'a> IndexJoinPass<'a> { PhysicalPlan::Empty => 0, } } + + fn estimate_join_rows( + &self, + left: &PhysicalPlan, + right: &PhysicalPlan, + condition: &Expr, + join_type: JoinType, + ) -> usize { + let left_rows = self.estimate_rows(left); + let right_rows = self.estimate_rows(right); + + if let Some((left_col, right_col)) = self.join_columns_for_sides(left, right, condition) { + let left_unique = self.relation_has_unique_lookup(left, left_col); + let right_unique = self.relation_has_unique_lookup(right, right_col); + + match join_type { + JoinType::LeftOuter if right_unique => return left_rows.max(1), + JoinType::RightOuter if left_unique => return right_rows.max(1), + JoinType::Inner => { + if right_unique { + return left_rows.max(1); + } + if left_unique { + return right_rows.max(1); + } + } + JoinType::FullOuter | JoinType::Cross => {} + JoinType::LeftOuter => return left_rows.max(1), + JoinType::RightOuter => return right_rows.max(1), + } + } + + match join_type { + JoinType::LeftOuter => left_rows.max(1), + JoinType::RightOuter => right_rows.max(1), + JoinType::FullOuter => left_rows.saturating_add(right_rows).max(1), + JoinType::Cross => left_rows.saturating_mul(right_rows).max(1), + JoinType::Inner => core::cmp::max(left_rows.saturating_mul(right_rows) / 10, 1), + } + } + + fn join_columns_for_sides<'b>( + &self, + left: &PhysicalPlan, + right: &PhysicalPlan, + condition: &'b Expr, + ) -> Option<(&'b ColumnRef, &'b ColumnRef)> { + let (first, second) = self.extract_join_columns(condition)?; + self.align_join_columns(left, right, first, second) + } + + fn relation_has_unique_lookup(&self, plan: &PhysicalPlan, column: &ColumnRef) -> bool { + let tables = plan.collect_tables(); + if tables.len() != 1 || !tables.iter().any(|table| table == &column.table) { + return false; + } + + self.ctx + .find_index(&column.table, &[column.column.as_str()]) + .map(|index| index.is_unique && index.supports_point_lookup()) + .unwrap_or(false) + } } #[cfg(test)] @@ -579,11 +744,10 @@ mod tests { } #[test] - fn test_outer_join_not_optimized() { + fn test_left_outer_join_can_use_index_join() { let ctx = create_test_context(); let pass = IndexJoinPass::new(&ctx); - // Left outer join should not be converted to index join let plan = PhysicalPlan::hash_join( PhysicalPlan::table_scan("a"), PhysicalPlan::table_scan("b"), @@ -592,11 +756,156 @@ mod tests { ); let result = pass.optimize(plan); + if let PhysicalPlan::IndexNestedLoopJoin { + inner_table, + outer_is_left, + join_type, + .. + } = result + { + assert_eq!(inner_table, "b"); + assert!(outer_is_left); + assert_eq!(join_type, JoinType::LeftOuter); + } else { + panic!("expected left outer index nested loop join"); + } + } - // Should remain as HashJoin + #[test] + fn test_right_outer_join_can_use_index_join() { + let mut ctx = ExecutionContext::new(); + ctx.register_table( + "a", + TableStats { + row_count: 100, + is_sorted: false, + indexes: alloc::vec![IndexInfo::new("idx_id", alloc::vec!["id".into()], true,)], + }, + ); + ctx.register_table( + "b", + TableStats { + row_count: 2, + is_sorted: false, + indexes: alloc::vec![], + }, + ); + let pass = IndexJoinPass::new(&ctx); + + let plan = PhysicalPlan::hash_join( + PhysicalPlan::table_scan("a"), + PhysicalPlan::table_scan("b"), + Expr::eq(Expr::column("a", "id", 0), Expr::column("b", "a_id", 0)), + JoinType::RightOuter, + ); + + let result = pass.optimize(plan); + if let PhysicalPlan::IndexNestedLoopJoin { + inner_table, + outer_is_left, + join_type, + .. + } = result + { + assert_eq!(inner_table, "a"); + assert!(!outer_is_left); + assert_eq!(join_type, JoinType::RightOuter); + } else { + panic!("expected right outer index nested loop join"); + } + } + + #[test] + fn test_full_outer_join_remains_hash_join() { + let ctx = create_test_context(); + let pass = IndexJoinPass::new(&ctx); + + let plan = PhysicalPlan::hash_join( + PhysicalPlan::table_scan("a"), + PhysicalPlan::table_scan("b"), + Expr::eq(Expr::column("a", "id", 0), Expr::column("b", "a_id", 0)), + JoinType::FullOuter, + ); + + let result = pass.optimize(plan); assert!(matches!(result, PhysicalPlan::HashJoin { .. })); } + #[test] + fn test_multi_table_inner_join_can_still_use_index_join() { + let mut ctx = ExecutionContext::new(); + ctx.register_table( + "issues", + TableStats { + row_count: 1_000, + is_sorted: false, + indexes: alloc::vec![], + }, + ); + ctx.register_table( + "projects", + TableStats { + row_count: 100, + is_sorted: false, + indexes: alloc::vec![IndexInfo::new( + "pk_projects_id", + alloc::vec!["id".into()], + true, + )], + }, + ); + ctx.register_table( + "project_counters", + TableStats { + row_count: 100, + is_sorted: false, + indexes: alloc::vec![IndexInfo::new( + "pk_project_counters_project_id", + alloc::vec!["project_id".into()], + true, + )], + }, + ); + + let pass = IndexJoinPass::new(&ctx); + let plan = PhysicalPlan::hash_join( + PhysicalPlan::hash_join( + PhysicalPlan::table_scan("issues"), + PhysicalPlan::table_scan("projects"), + Expr::eq( + Expr::column("issues", "project_id", 1), + Expr::column("projects", "id", 0), + ), + JoinType::Inner, + ), + PhysicalPlan::table_scan("project_counters"), + Expr::eq( + Expr::column("projects", "id", 0), + Expr::column("project_counters", "project_id", 0), + ), + JoinType::Inner, + ); + + let result = pass.optimize(plan); + + match result { + PhysicalPlan::IndexNestedLoopJoin { + inner_table, + join_type, + outer, + .. + } => { + assert_eq!(inner_table, "project_counters"); + assert_eq!(join_type, JoinType::Inner); + assert!(matches!(*outer, PhysicalPlan::IndexNestedLoopJoin { .. })); + } + other => panic!( + "expected nested index joins for multi-table inner join, got {:?}", + other + ), + } + } + #[test] fn test_non_equi_join_not_optimized() { let ctx = create_test_context(); diff --git a/crates/query/src/optimizer/index_selection.rs b/crates/query/src/optimizer/index_selection.rs index 7fdc6ae..ec379b0 100644 --- a/crates/query/src/optimizer/index_selection.rs +++ b/crates/query/src/optimizer/index_selection.rs @@ -1,7 +1,7 @@ //! Index selection optimization pass. use crate::ast::{BinaryOp, Expr}; -use crate::context::{ExecutionContext, IndexInfo}; +use crate::context::{ExecutionContext, IndexInfo, RestrictedAccessMode}; use crate::optimizer::OptimizerPass; use crate::planner::{IndexBounds, LogicalPlan}; use alloc::boxed::Box; @@ -193,6 +193,7 @@ struct GinPredicateInfo { path: String, value: Option, prefilter_pairs: Option>, + match_all: bool, query_type: String, original_predicate: Expr, requires_post_filter: bool, @@ -297,8 +298,19 @@ impl IndexSelection { // Check if we have context with index information let ctx = self.context.as_ref()?; - // First, try to handle IN predicates - if let Some(in_info) = self.analyze_in_predicate(predicate) { + // For small restricted subsets, preserving a table scan lets the restricted + // datasource iterate only the affected row ids and recheck predicates in-place. + if ctx.is_restricted_relation(table) + && ctx.restricted_access_mode(table) == RestrictedAccessMode::SubsetDriven + { + return None; + } + + // First, try to handle IN predicates and equivalent OR chains. + if let Some(in_info) = self + .analyze_in_predicate(predicate) + .or_else(|| self.analyze_or_in_predicate(predicate)) + { // Find an index that covers the IN column if let Some(index) = ctx.find_index(table, &[in_info.column.as_str()]) { // Use IndexInGet for IN queries with indexed columns @@ -777,6 +789,26 @@ impl IndexSelection { } } + fn flatten_or_predicates(&self, predicate: &Expr) -> Vec { + let mut predicates = Vec::new(); + Self::flatten_or_predicates_into(predicate, &mut predicates); + predicates + } + + fn flatten_or_predicates_into(predicate: &Expr, predicates: &mut Vec) { + match predicate { + Expr::BinaryOp { + left, + op: BinaryOp::Or, + right, + } => { + Self::flatten_or_predicates_into(left, predicates); + Self::flatten_or_predicates_into(right, predicates); + } + _ => predicates.push(predicate.clone()), + } + } + fn build_composite_bounds_for_index( &self, index_columns: &[String], @@ -925,6 +957,46 @@ impl IndexSelection { } } + /// Analyzes an OR-of-equality predicate that is equivalent to IN. + fn analyze_or_in_predicate(&self, predicate: &Expr) -> Option { + let predicates = self.flatten_or_predicates(predicate); + if predicates.len() < 2 { + return None; + } + + let mut table = None; + let mut column = None; + let mut values = Vec::with_capacity(predicates.len()); + + for predicate in predicates { + let info = self.analyze_predicate(&predicate)?; + if !info.is_point_lookup { + return None; + } + + let value = info.value?; + match (&table, &column) { + (None, None) => { + table = Some(info.table.clone()); + column = Some(info.column.clone()); + } + (Some(existing_table), Some(existing_column)) + if *existing_table == info.table && *existing_column == info.column => {} + _ => return None, + } + + if !values.iter().any(|existing| existing == &value) { + values.push(value); + } + } + + Some(InPredicateInfo { + table: table?, + column: column?, + values, + }) + } + /// Attempts to use a GIN index for JSONB function queries. /// Supports both single predicates and AND combinations of multiple predicates. /// @@ -970,10 +1042,11 @@ impl IndexSelection { index: first.index.clone(), column: first.column.clone(), pairs, + match_all: true, recheck: None, } } else { - let best_idx = self.choose_best_single_gin_predicate(&gin_predicates)?; + let best_idx = self.choose_best_single_gin_predicate(table, &gin_predicates, ctx)?; used_predicates[best_idx] = true; let info = &gin_predicates[best_idx]; if let Some(pairs) = &info.prefilter_pairs { @@ -982,6 +1055,7 @@ impl IndexSelection { index: info.index.clone(), column: info.column.clone(), pairs: pairs.clone(), + match_all: info.match_all, recheck: None, } } else { @@ -1030,12 +1104,14 @@ impl IndexSelection { index, column, pairs, + match_all, .. } => LogicalPlan::GinIndexScanMulti { table, index, column, pairs, + match_all, recheck, }, _ => unreachable!("GIN selection must produce a GIN scan"), @@ -1099,15 +1175,75 @@ impl IndexSelection { fn choose_best_single_gin_predicate( &self, + table: &str, gin_predicates: &[GinPredicateInfo], + ctx: &ExecutionContext, ) -> Option { gin_predicates .iter() .enumerate() - .max_by_key(|(_, info)| Self::gin_single_predicate_rank(info)) + .min_by_key(|(_, info)| { + ( + Self::gin_predicate_cost(table, info, ctx), + u8::MAX - Self::gin_single_predicate_rank(info), + ) + }) .map(|(idx, _)| idx) } + fn gin_predicate_cost(table: &str, info: &GinPredicateInfo, ctx: &ExecutionContext) -> usize { + if let Some(value) = &info.value { + if let Some(literal) = Self::literal_to_gin_cost_value(value) { + if let Some(cost) = + ctx.gin_key_value_cost(table, &info.index, &info.path, literal.as_str()) + { + if cost > 0 { + return cost; + } + } + } + } + + if let Some(pairs) = &info.prefilter_pairs { + if let Some(cost) = pairs + .iter() + .filter_map(|(key, value)| { + let literal = Self::literal_to_gin_cost_value(value)?; + ctx.gin_key_value_cost(table, &info.index, key, literal.as_str()) + }) + .min() + { + if cost > 0 { + return cost; + } + } + } + + if let Some(cost) = ctx.gin_key_cost(table, &info.index, &info.path) { + if cost > 0 { + return cost; + } + } + + match info.query_type.as_str() { + "eq" => 8, + "exists" => 64, + "contains" => 256, + _ => 1024, + } + } + + fn literal_to_gin_cost_value(value: &Value) -> Option { + match value { + Value::String(value) => Some(value.clone()), + Value::Int32(value) => Some(value.to_string()), + Value::Int64(value) => Some(value.to_string()), + Value::Float64(value) => Some(value.to_string()), + Value::Boolean(value) => Some(if *value { "true" } else { "false" }.into()), + _ => None, + } + } + fn gin_single_predicate_rank(info: &GinPredicateInfo) -> u8 { if info.query_type == "eq" && !info.requires_post_filter { 4 @@ -1172,6 +1308,15 @@ impl IndexSelection { remaining_result, ); } + Expr::BinaryOp { + op: BinaryOp::Or, .. + } => { + if let Some(info) = self.analyze_gin_or_predicate(predicate, table, ctx) { + gin_result.push(info); + } else { + remaining_result.push(predicate.clone()); + } + } // Handle JSONB function calls - these can potentially use GIN index Expr::Function { name, args } => { if let Some(info) = self.analyze_gin_function(predicate, name, args, table, ctx) { @@ -1203,9 +1348,8 @@ impl IndexSelection { if let Expr::Column(col) = &args[0] { let column_name = &col.column; let column_index = col.index; - if let Some(index) = ctx.find_gin_index(table, column_name) { - let path = - self.normalize_gin_path(&self.extract_string_literal(&args[1])?)?; + let path = self.normalize_gin_path(&self.extract_string_literal(&args[1])?)?; + if let Some(index) = ctx.find_gin_index_for_path(table, column_name, &path) { let value = self.extract_literal(&args[2])?; return Some(GinPredicateInfo { index: index.name.clone(), @@ -1214,6 +1358,7 @@ impl IndexSelection { path, value: Some(value), prefilter_pairs: None, + match_all: true, query_type: "eq".into(), original_predicate: predicate.clone(), requires_post_filter: false, @@ -1226,9 +1371,8 @@ impl IndexSelection { if let Expr::Column(col) = &args[0] { let column_name = &col.column; let column_index = col.index; - if let Some(index) = ctx.find_gin_index(table, column_name) { - let path = - self.normalize_gin_path(&self.extract_string_literal(&args[1])?)?; + let path = self.normalize_gin_path(&self.extract_string_literal(&args[1])?)?; + if let Some(index) = ctx.find_gin_index_for_path(table, column_name, &path) { let prefilter_pairs = self.extract_string_literal(&args[2]).and_then(|needle| { let pairs = contains_trigram_pairs(&path, &needle); @@ -1250,6 +1394,7 @@ impl IndexSelection { path, value: None, prefilter_pairs, + match_all: true, query_type: "contains".into(), original_predicate: predicate.clone(), requires_post_filter: true, @@ -1262,9 +1407,8 @@ impl IndexSelection { if let Expr::Column(col) = &args[0] { let column_name = &col.column; let column_index = col.index; - if let Some(index) = ctx.find_gin_index(table, column_name) { - let path = - self.normalize_gin_path(&self.extract_string_literal(&args[1])?)?; + let path = self.normalize_gin_path(&self.extract_string_literal(&args[1])?)?; + if let Some(index) = ctx.find_gin_index_for_path(table, column_name, &path) { return Some(GinPredicateInfo { index: index.name.clone(), column: column_name.clone(), @@ -1272,6 +1416,7 @@ impl IndexSelection { path, value: None, prefilter_pairs: None, + match_all: true, query_type: "exists".into(), original_predicate: predicate.clone(), requires_post_filter: false, @@ -1285,6 +1430,67 @@ impl IndexSelection { None } + fn analyze_gin_or_predicate( + &self, + predicate: &Expr, + table: &str, + ctx: &ExecutionContext, + ) -> Option { + let terms = self.flatten_or_predicates(predicate); + if terms.len() < 2 { + return None; + } + + let mut first: Option = None; + let mut pairs = Vec::with_capacity(terms.len()); + + for term in terms { + let Expr::Function { name, args } = &term else { + return None; + }; + let info = self.analyze_gin_function(&term, name, args, table, ctx)?; + if info.query_type != "eq" + || info.requires_post_filter + || info.prefilter_pairs.is_some() + || info.value.is_none() + { + return None; + } + + if let Some(existing) = &first { + if existing.index != info.index + || existing.column != info.column + || existing.column_index != info.column_index + || existing.path != info.path + { + return None; + } + } else { + first = Some(info.clone()); + } + + let pair = (info.path.clone(), info.value.expect("checked above")); + if !pairs.iter().any(|existing| existing == &pair) { + pairs.push(pair); + } + } + + let first = first?; + Some(GinPredicateInfo { + index: first.index, + column: first.column, + column_index: first.column_index, + path: first.path, + value: None, + prefilter_pairs: Some(pairs), + match_all: false, + query_type: "eq".into(), + original_predicate: predicate.clone(), + requires_post_filter: false, + supports_multi_scan: false, + }) + } + fn normalize_gin_path(&self, path: &str) -> Option { let parsed = JsonPath::parse(path).ok()?; let mut segments = Vec::new(); @@ -1779,6 +1985,79 @@ mod tests { assert!(info.is_none()); } + #[test] + fn test_or_equality_query_index_selection() { + let mut ctx = ExecutionContext::new(); + ctx.register_table( + "users", + TableStats { + row_count: 1000, + is_sorted: false, + indexes: alloc::vec![IndexInfo::new( + "idx_status", + alloc::vec!["status".into()], + false + )], + }, + ); + + let pass = IndexSelection::with_context(ctx); + let plan = LogicalPlan::filter( + LogicalPlan::scan("users"), + Expr::or( + Expr::eq( + Expr::column("users", "status", 1), + Expr::literal(Value::String("open".into())), + ), + Expr::eq( + Expr::column("users", "status", 1), + Expr::literal(Value::String("in_progress".into())), + ), + ), + ); + + let optimized = pass.optimize(plan); + assert!(matches!(optimized, LogicalPlan::IndexInGet { .. })); + + if let LogicalPlan::IndexInGet { table, index, keys } = optimized { + assert_eq!(table, "users"); + assert_eq!(index, "idx_status"); + assert_eq!( + keys, + alloc::vec![ + Value::String("open".into()), + Value::String("in_progress".into()) + ] + ); + } + } + + #[test] + fn test_analyze_or_in_predicate() { + let pass = IndexSelection::new(); + let pred = Expr::or( + Expr::eq( + Expr::column("users", "status", 1), + Expr::literal(Value::String("open".into())), + ), + Expr::eq( + Expr::column("users", "status", 1), + Expr::literal(Value::String("in_progress".into())), + ), + ); + + let info = pass.analyze_or_in_predicate(&pred).unwrap(); + assert_eq!(info.table, "users"); + assert_eq!(info.column, "status"); + assert_eq!( + info.values, + alloc::vec![ + Value::String("open".into()), + Value::String("in_progress".into()) + ] + ); + } + /// Test case for bug: mixed GIN + non-GIN predicates should preserve non-GIN predicates /// /// Query: col('status').eq('published') AND col('tags').get('$.primary').eq('tech') @@ -1921,6 +2200,130 @@ mod tests { } } + #[test] + fn test_jsonb_or_equality_uses_gin_any_scan() { + let mut ctx = ExecutionContext::new(); + ctx.register_table( + "issues", + TableStats { + row_count: 1000, + is_sorted: false, + indexes: alloc::vec![IndexInfo::new_gin( + "idx_metadata", + alloc::vec!["metadata".into()] + ),], + }, + ); + + let pass = IndexSelection::with_context(ctx); + let predicate = Expr::or( + Expr::Function { + name: "JSONB_PATH_EQ".into(), + args: alloc::vec![ + Expr::column("issues", "metadata", 2), + Expr::literal(Value::String("$.customer.tier".into())), + Expr::literal(Value::String("enterprise".into())), + ], + }, + Expr::Function { + name: "JSONB_PATH_EQ".into(), + args: alloc::vec![ + Expr::column("issues", "metadata", 2), + Expr::literal(Value::String("$.customer.tier".into())), + Expr::literal(Value::String("mid_market".into())), + ], + }, + ); + + let optimized = pass.optimize(LogicalPlan::filter(LogicalPlan::scan("issues"), predicate)); + match optimized { + LogicalPlan::GinIndexScanMulti { + index, + column, + pairs, + match_all, + recheck, + .. + } => { + assert_eq!(index, "idx_metadata"); + assert_eq!(column, "metadata"); + assert!(!match_all); + assert_eq!( + pairs, + alloc::vec![ + ("customer.tier".into(), Value::String("enterprise".into())), + ("customer.tier".into(), Value::String("mid_market".into())), + ] + ); + let recheck_debug = + format!("{:?}", recheck.expect("recheck should be preserved")).to_lowercase(); + assert!(recheck_debug.contains("jsonb_path_eq")); + assert!(recheck_debug.contains("or")); + } + other => panic!("Unexpected plan type: {:?}", other), + } + } + + #[test] + fn test_jsonb_or_equality_inside_and_preserves_remaining_predicates() { + let mut ctx = ExecutionContext::new(); + ctx.register_table( + "issues", + TableStats { + row_count: 1000, + is_sorted: false, + indexes: alloc::vec![ + IndexInfo::new_gin("idx_metadata", alloc::vec!["metadata".into()]), + IndexInfo::new("idx_estimate", alloc::vec!["estimate".into()], false), + ], + }, + ); + + let pass = IndexSelection::with_context(ctx); + let predicate = Expr::and( + Expr::ge( + Expr::column("issues", "estimate", 1), + Expr::literal(Value::Int32(3)), + ), + Expr::or( + Expr::Function { + name: "JSONB_PATH_EQ".into(), + args: alloc::vec![ + Expr::column("issues", "metadata", 2), + Expr::literal(Value::String("$.customer.tier".into())), + Expr::literal(Value::String("enterprise".into())), + ], + }, + Expr::Function { + name: "JSONB_PATH_EQ".into(), + args: alloc::vec![ + Expr::column("issues", "metadata", 2), + Expr::literal(Value::String("$.customer.tier".into())), + Expr::literal(Value::String("mid_market".into())), + ], + }, + ), + ); + + let optimized = pass.optimize(LogicalPlan::filter(LogicalPlan::scan("issues"), predicate)); + match optimized { + LogicalPlan::Filter { input, predicate } => { + let predicate_debug = format!("{:?}", predicate).to_lowercase(); + assert!(predicate_debug.contains("estimate")); + match input.as_ref() { + LogicalPlan::GinIndexScanMulti { + pairs, match_all, .. + } => { + assert!(!match_all); + assert_eq!(pairs.len(), 2); + } + other => panic!("Expected GIN ANY scan under Filter, got {:?}", other), + } + } + other => panic!("Unexpected plan type: {:?}", other), + } + } + /// Test case for bug: a handled GIN equality predicate must not drop a second /// GIN predicate that currently cannot be merged into GinIndexScanMulti. #[test] @@ -2020,8 +2423,8 @@ mod tests { assert!( matches!( input.as_ref(), - LogicalPlan::GinIndexScanMulti { pairs, .. } - if pairs == &expected_pairs + LogicalPlan::GinIndexScanMulti { pairs, match_all, .. } + if *match_all && pairs == &expected_pairs ), "Expected GIN prefilter for JSONB_CONTAINS, got {:?}", input diff --git a/crates/query/src/optimizer/join_reorder.rs b/crates/query/src/optimizer/join_reorder.rs index be1fa27..7fc2f32 100644 --- a/crates/query/src/optimizer/join_reorder.rs +++ b/crates/query/src/optimizer/join_reorder.rs @@ -269,8 +269,10 @@ impl JoinReorder { // Sort nodes by cardinality (smallest first) nodes.sort_by_key(|n| n.cardinality); - // Build left-deep tree greedily - let mut result_node = nodes.remove(0); + // Build left-deep tree greedily. Under restricted-relation planning, keep the + // anchor table as the left-deep driver whenever it is present in an inner-join island. + let start_idx = self.anchor_start_index(&nodes).unwrap_or(0); + let mut result_node = nodes.remove(start_idx); let mut used_conditions: Vec = alloc::vec![false; conditions.len()]; while !nodes.is_empty() { @@ -456,6 +458,22 @@ impl JoinReorder { (best_idx, best_condition_idx) } + fn anchor_start_index(&self, nodes: &[JoinNode]) -> Option { + let anchor_table = self + .context + .as_ref()? + .planning_intent() + .anchor_table + .as_ref()?; + + nodes + .iter() + .enumerate() + .filter(|(_, node)| node.tables.iter().any(|table| table == anchor_table)) + .min_by_key(|(_, node)| node.cardinality) + .map(|(idx, _)| idx) + } + /// Find a join condition that applies between two sets of tables. fn find_applicable_condition( &self, diff --git a/crates/query/src/optimizer/mod.rs b/crates/query/src/optimizer/mod.rs index 8e7beff..9d833e9 100644 --- a/crates/query/src/optimizer/mod.rs +++ b/crates/query/src/optimizer/mod.rs @@ -11,6 +11,7 @@ mod limit_skip_by_index; mod multi_column_or; mod not_simplification; mod order_by_index; +mod outer_join_removal; mod outer_join_simplification; mod pass; mod predicate_pushdown; @@ -27,6 +28,7 @@ pub use limit_skip_by_index::LimitSkipByIndexPass; pub use multi_column_or::{MultiColumnOrConfig, MultiColumnOrPass}; pub use not_simplification::NotSimplification; pub use order_by_index::OrderByIndexPass; +pub use outer_join_removal::OuterJoinRemoval; pub use outer_join_simplification::OuterJoinSimplification; pub use pass::OptimizerPass; pub use predicate_pushdown::PredicatePushdown; @@ -154,6 +156,7 @@ impl Optimizer { index, column: _, pairs, + match_all, recheck, } => { // Convert (path, value) pairs to (key, value_str) pairs @@ -173,7 +176,7 @@ impl Optimizer { }) .collect(); - PhysicalPlan::gin_index_scan_multi(table, index, string_pairs, recheck) + PhysicalPlan::gin_index_scan_multi(table, index, string_pairs, match_all, recheck) } LogicalPlan::Filter { input, predicate } => { diff --git a/crates/query/src/optimizer/order_by_index.rs b/crates/query/src/optimizer/order_by_index.rs index f7492bc..a830f99 100644 --- a/crates/query/src/optimizer/order_by_index.rs +++ b/crates/query/src/optimizer/order_by_index.rs @@ -569,6 +569,7 @@ mod tests { table: "documents".into(), index: "idx_metadata".into(), pairs: alloc::vec![("category".into(), "tech".into())], + match_all: true, recheck: Some(recheck.clone()), }), order_by: alloc::vec![(Expr::column("documents", "updated_at", 2), SortOrder::Desc)], diff --git a/crates/query/src/optimizer/outer_join_removal.rs b/crates/query/src/optimizer/outer_join_removal.rs new file mode 100644 index 0000000..ea2ffba --- /dev/null +++ b/crates/query/src/optimizer/outer_join_removal.rs @@ -0,0 +1,736 @@ +//! Redundant outer-join removal. +//! +//! This pass removes LEFT/RIGHT OUTER JOINs when the nullable side is provably +//! redundant: +//! - columns from the nullable side are not needed by ancestors +//! - the join cannot multiply rows from the preserved side because the nullable +//! side is constrained by a unique key +//! +//! The rule is intentionally conservative and mirrors the core idea used by +//! mainstream optimizers such as PostgreSQL's join removal and SQLite's omit +//! OUTER JOIN optimization: only eliminate an outer join when the dropped side +//! is both unused and cardinality-preserving. + +use crate::ast::{BinaryOp, Expr, JoinType}; +use crate::context::ExecutionContext; +use crate::optimizer::OptimizerPass; +use crate::planner::LogicalPlan; +use alloc::boxed::Box; +use alloc::string::String; +use alloc::vec::Vec; +use hashbrown::HashSet; + +pub struct OuterJoinRemoval<'a> { + ctx: &'a ExecutionContext, +} + +impl<'a> OuterJoinRemoval<'a> { + pub fn new(ctx: &'a ExecutionContext) -> Self { + Self { ctx } + } + + fn rewrite(&self, plan: LogicalPlan, required_tables: &HashSet) -> LogicalPlan { + match plan { + LogicalPlan::Filter { input, predicate } => { + let mut child_required = required_tables.clone(); + self.collect_expr_tables(&predicate, &mut child_required); + LogicalPlan::Filter { + input: Box::new(self.rewrite(*input, &child_required)), + predicate, + } + } + + LogicalPlan::Project { input, columns } => { + let child_required = self.tables_in_exprs(&columns); + LogicalPlan::Project { + input: Box::new(self.rewrite(*input, &child_required)), + columns, + } + } + + LogicalPlan::Aggregate { + input, + group_by, + aggregates, + } => { + let mut child_required = self.tables_in_exprs(&group_by); + for (_, expr) in &aggregates { + self.collect_expr_tables(expr, &mut child_required); + } + LogicalPlan::Aggregate { + input: Box::new(self.rewrite(*input, &child_required)), + group_by, + aggregates, + } + } + + LogicalPlan::Sort { input, order_by } => { + let mut child_required = required_tables.clone(); + for (expr, _) in &order_by { + self.collect_expr_tables(expr, &mut child_required); + } + LogicalPlan::Sort { + input: Box::new(self.rewrite(*input, &child_required)), + order_by, + } + } + + LogicalPlan::Limit { + input, + limit, + offset, + } => LogicalPlan::Limit { + input: Box::new(self.rewrite(*input, required_tables)), + limit, + offset, + }, + + LogicalPlan::Join { + left, + right, + condition, + join_type, + output_tables, + } => self.rewrite_join( + *left, + *right, + condition, + join_type, + output_tables, + required_tables, + ), + + LogicalPlan::CrossProduct { left, right } => { + let left_tables = self.plan_tables(&left); + let right_tables = self.plan_tables(&right); + let left_required = self.filter_required_tables(required_tables, &left_tables); + let right_required = self.filter_required_tables(required_tables, &right_tables); + LogicalPlan::CrossProduct { + left: Box::new(self.rewrite(*left, &left_required)), + right: Box::new(self.rewrite(*right, &right_required)), + } + } + + LogicalPlan::Union { left, right, all } => LogicalPlan::Union { + left: Box::new(self.rewrite(*left, required_tables)), + right: Box::new(self.rewrite(*right, required_tables)), + all, + }, + + LogicalPlan::Scan { .. } + | LogicalPlan::IndexScan { .. } + | LogicalPlan::IndexGet { .. } + | LogicalPlan::IndexInGet { .. } + | LogicalPlan::GinIndexScan { .. } + | LogicalPlan::GinIndexScanMulti { .. } + | LogicalPlan::Empty => plan, + } + } + + fn rewrite_join( + &self, + left: LogicalPlan, + right: LogicalPlan, + condition: Expr, + join_type: JoinType, + output_tables: Vec, + required_tables: &HashSet, + ) -> LogicalPlan { + let left_tables = self.plan_tables(&left); + let right_tables = self.plan_tables(&right); + + match join_type { + JoinType::LeftOuter => { + if !self.any_required(required_tables, &right_tables) + && self.can_remove_outer_side(&left_tables, &right, &condition) + { + let left_required = self.filter_required_tables(required_tables, &left_tables); + return self.rewrite(left, &left_required); + } + } + JoinType::RightOuter => { + if !self.any_required(required_tables, &left_tables) + && self.can_remove_outer_side(&right_tables, &left, &condition) + { + let right_required = + self.filter_required_tables(required_tables, &right_tables); + return self.rewrite(right, &right_required); + } + } + JoinType::Inner | JoinType::FullOuter | JoinType::Cross => {} + } + + let condition_tables = self.tables_in_expr(&condition); + let left_required = + self.side_required_tables(required_tables, &condition_tables, &left_tables); + let right_required = + self.side_required_tables(required_tables, &condition_tables, &right_tables); + + let rewritten_left = self.rewrite(left, &left_required); + let rewritten_right = self.rewrite(right, &right_required); + let filtered_output_tables = Self::filter_output_tables( + &output_tables, + &rewritten_left.output_tables(), + &rewritten_right.output_tables(), + ); + + LogicalPlan::join_with_output_tables( + rewritten_left, + rewritten_right, + condition, + join_type, + filtered_output_tables, + ) + } + + fn can_remove_outer_side( + &self, + preserved_tables: &HashSet, + nullable_side: &LogicalPlan, + condition: &Expr, + ) -> bool { + let Some(source) = self.analyze_single_table_source(nullable_side) else { + return false; + }; + + // Duplicate base-table names indicate self-joins or repeated references. + // Those need alias-aware reasoning and are intentionally left alone. + if preserved_tables.contains(&source.table) { + return false; + } + + if source.at_most_one_row { + return true; + } + + let mut constrained_columns = HashSet::new(); + self.collect_join_constrained_columns(condition, &source.table, &mut constrained_columns); + for predicate in &source.local_predicates { + self.collect_local_constrained_columns( + predicate, + &source.table, + &mut constrained_columns, + ); + } + + self.ctx + .get_stats(&source.table) + .map(|stats| { + stats.indexes.iter().any(|index| { + index.is_unique + && !index.is_gin() + && !index.columns.is_empty() + && index + .columns + .iter() + .all(|column| constrained_columns.contains(column)) + }) + }) + .unwrap_or(false) + } + + fn analyze_single_table_source(&self, plan: &LogicalPlan) -> Option { + match plan { + LogicalPlan::Scan { table } + | LogicalPlan::IndexScan { table, .. } + | LogicalPlan::IndexInGet { table, .. } + | LogicalPlan::GinIndexScan { table, .. } + | LogicalPlan::GinIndexScanMulti { table, .. } => Some(SingleTableSource { + table: table.clone(), + local_predicates: Vec::new(), + at_most_one_row: false, + }), + LogicalPlan::IndexGet { table, index, .. } => Some(SingleTableSource { + table: table.clone(), + local_predicates: Vec::new(), + at_most_one_row: self + .ctx + .find_index_by_name(table, index) + .map(|info| info.is_unique) + .unwrap_or(false), + }), + LogicalPlan::Filter { input, predicate } => { + let mut source = self.analyze_single_table_source(input)?; + source.local_predicates.push(predicate.clone()); + Some(source) + } + LogicalPlan::Sort { input, .. } => self.analyze_single_table_source(input), + LogicalPlan::Project { .. } + | LogicalPlan::Aggregate { .. } + | LogicalPlan::Limit { .. } + | LogicalPlan::Join { .. } + | LogicalPlan::CrossProduct { .. } + | LogicalPlan::Union { .. } + | LogicalPlan::Empty => None, + } + } + + fn root_required_tables(&self, plan: &LogicalPlan) -> HashSet { + match plan { + LogicalPlan::Project { columns, .. } => self.tables_in_exprs(columns), + LogicalPlan::Aggregate { + group_by, + aggregates, + .. + } => { + let mut tables = self.tables_in_exprs(group_by); + for (_, expr) in aggregates { + self.collect_expr_tables(expr, &mut tables); + } + tables + } + LogicalPlan::Filter { input, predicate } => { + let mut tables = self.root_required_tables(input); + self.collect_expr_tables(predicate, &mut tables); + tables + } + LogicalPlan::Sort { input, order_by } => { + let mut tables = self.root_required_tables(input); + for (expr, _) in order_by { + self.collect_expr_tables(expr, &mut tables); + } + tables + } + LogicalPlan::Limit { input, .. } => self.root_required_tables(input), + LogicalPlan::Scan { .. } + | LogicalPlan::IndexScan { .. } + | LogicalPlan::IndexGet { .. } + | LogicalPlan::IndexInGet { .. } + | LogicalPlan::GinIndexScan { .. } + | LogicalPlan::GinIndexScanMulti { .. } + | LogicalPlan::Join { .. } + | LogicalPlan::CrossProduct { .. } + | LogicalPlan::Union { .. } + | LogicalPlan::Empty => plan.output_tables().into_iter().collect(), + } + } + + fn side_required_tables( + &self, + required_tables: &HashSet, + condition_tables: &HashSet, + side_tables: &HashSet, + ) -> HashSet { + let mut side_required = self.filter_required_tables(required_tables, side_tables); + for table in condition_tables { + if side_tables.contains(table) { + side_required.insert(table.clone()); + } + } + side_required + } + + fn filter_required_tables( + &self, + required_tables: &HashSet, + side_tables: &HashSet, + ) -> HashSet { + required_tables + .iter() + .filter(|table| side_tables.contains(*table)) + .cloned() + .collect() + } + + fn any_required( + &self, + required_tables: &HashSet, + side_tables: &HashSet, + ) -> bool { + required_tables + .iter() + .any(|table| side_tables.contains(table)) + } + + fn plan_tables(&self, plan: &LogicalPlan) -> HashSet { + plan.collect_tables().into_iter().collect() + } + + fn tables_in_exprs(&self, exprs: &[Expr]) -> HashSet { + let mut tables = HashSet::new(); + for expr in exprs { + self.collect_expr_tables(expr, &mut tables); + } + tables + } + + fn tables_in_expr(&self, expr: &Expr) -> HashSet { + let mut tables = HashSet::new(); + self.collect_expr_tables(expr, &mut tables); + tables + } + + fn collect_expr_tables(&self, expr: &Expr, tables: &mut HashSet) { + match expr { + Expr::Column(col) => { + if !col.table.is_empty() { + tables.insert(col.table.clone()); + } + } + Expr::BinaryOp { left, right, .. } => { + self.collect_expr_tables(left, tables); + self.collect_expr_tables(right, tables); + } + Expr::UnaryOp { expr, .. } => self.collect_expr_tables(expr, tables), + Expr::Function { args, .. } => { + for arg in args { + self.collect_expr_tables(arg, tables); + } + } + Expr::Aggregate { expr, .. } => { + if let Some(expr) = expr { + self.collect_expr_tables(expr, tables); + } + } + Expr::Between { expr, low, high } | Expr::NotBetween { expr, low, high } => { + self.collect_expr_tables(expr, tables); + self.collect_expr_tables(low, tables); + self.collect_expr_tables(high, tables); + } + Expr::In { expr, list } | Expr::NotIn { expr, list } => { + self.collect_expr_tables(expr, tables); + for item in list { + self.collect_expr_tables(item, tables); + } + } + Expr::Like { expr, .. } + | Expr::NotLike { expr, .. } + | Expr::Match { expr, .. } + | Expr::NotMatch { expr, .. } => self.collect_expr_tables(expr, tables), + Expr::Literal(_) => {} + } + } + + fn collect_join_constrained_columns( + &self, + expr: &Expr, + table: &str, + constrained_columns: &mut HashSet, + ) { + match expr { + Expr::BinaryOp { + left, + op: BinaryOp::And, + right, + } => { + self.collect_join_constrained_columns(left, table, constrained_columns); + self.collect_join_constrained_columns(right, table, constrained_columns); + } + Expr::BinaryOp { + left, + op: BinaryOp::Eq, + right, + } => { + self.collect_constraint_from_equality(left, right, table, constrained_columns); + self.collect_constraint_from_equality(right, left, table, constrained_columns); + } + _ => {} + } + } + + fn collect_local_constrained_columns( + &self, + expr: &Expr, + table: &str, + constrained_columns: &mut HashSet, + ) { + match expr { + Expr::BinaryOp { + left, + op: BinaryOp::And, + right, + } => { + self.collect_local_constrained_columns(left, table, constrained_columns); + self.collect_local_constrained_columns(right, table, constrained_columns); + } + Expr::BinaryOp { + left, + op: BinaryOp::Eq, + right, + } => { + self.collect_local_constraint_from_equality( + left, + right, + table, + constrained_columns, + ); + self.collect_local_constraint_from_equality( + right, + left, + table, + constrained_columns, + ); + } + _ => {} + } + } + + fn collect_constraint_from_equality( + &self, + candidate: &Expr, + other: &Expr, + table: &str, + constrained_columns: &mut HashSet, + ) { + let Expr::Column(column) = candidate else { + return; + }; + + if column.table != table || self.expr_references_table(other, table) { + return; + } + + constrained_columns.insert(column.column.clone()); + } + + fn collect_local_constraint_from_equality( + &self, + candidate: &Expr, + other: &Expr, + table: &str, + constrained_columns: &mut HashSet, + ) { + let Expr::Column(column) = candidate else { + return; + }; + + if column.table != table || !matches!(other, Expr::Literal(_)) { + return; + } + + constrained_columns.insert(column.column.clone()); + } + + fn expr_references_table(&self, expr: &Expr, table: &str) -> bool { + match expr { + Expr::Column(column) => column.table == table, + Expr::BinaryOp { left, right, .. } => { + self.expr_references_table(left, table) || self.expr_references_table(right, table) + } + Expr::UnaryOp { expr, .. } => self.expr_references_table(expr, table), + Expr::Function { args, .. } => args + .iter() + .any(|arg| self.expr_references_table(arg, table)), + Expr::Aggregate { expr, .. } => expr + .as_ref() + .map(|expr| self.expr_references_table(expr, table)) + .unwrap_or(false), + Expr::Between { expr, low, high } | Expr::NotBetween { expr, low, high } => { + self.expr_references_table(expr, table) + || self.expr_references_table(low, table) + || self.expr_references_table(high, table) + } + Expr::In { expr, list } | Expr::NotIn { expr, list } => { + self.expr_references_table(expr, table) + || list + .iter() + .any(|item| self.expr_references_table(item, table)) + } + Expr::Like { expr, .. } + | Expr::NotLike { expr, .. } + | Expr::Match { expr, .. } + | Expr::NotMatch { expr, .. } => self.expr_references_table(expr, table), + Expr::Literal(_) => false, + } + } + + fn filter_output_tables( + original_output_tables: &[String], + left_output_tables: &[String], + right_output_tables: &[String], + ) -> Vec { + let mut filtered = Vec::new(); + for table in original_output_tables { + if left_output_tables.contains(table) || right_output_tables.contains(table) { + filtered.push(table.clone()); + } + } + filtered + } +} + +impl OptimizerPass for OuterJoinRemoval<'_> { + fn optimize(&self, plan: LogicalPlan) -> LogicalPlan { + let required_tables = self.root_required_tables(&plan); + self.rewrite(plan, &required_tables) + } + + fn name(&self) -> &'static str { + "outer_join_removal" + } +} + +struct SingleTableSource { + table: String, + local_predicates: Vec, + at_most_one_row: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ast::Expr; + use crate::context::{IndexInfo, TableStats}; + use alloc::string::ToString; + + fn create_context() -> ExecutionContext { + let mut ctx = ExecutionContext::new(); + ctx.register_table( + "issues", + TableStats { + row_count: 50_000, + is_sorted: false, + indexes: alloc::vec![ + IndexInfo::new("pk_issues", alloc::vec!["id".into()], true), + IndexInfo::new( + "idx_issues_project_id", + alloc::vec!["project_id".into()], + false + ), + ], + }, + ); + ctx.register_table( + "projects", + TableStats { + row_count: 3_000, + is_sorted: false, + indexes: alloc::vec![IndexInfo::new( + "pk_projects", + alloc::vec!["id".into()], + true + )], + }, + ); + ctx.register_table( + "orgs", + TableStats { + row_count: 200, + is_sorted: false, + indexes: alloc::vec![IndexInfo::new("pk_orgs", alloc::vec!["id".into()], true)], + }, + ); + ctx.register_table( + "project_tags", + TableStats { + row_count: 8_000, + is_sorted: false, + indexes: alloc::vec![IndexInfo::new( + "idx_project_tags_project_id", + alloc::vec!["project_id".into()], + false, + )], + }, + ); + ctx + } + + #[test] + fn removes_unused_left_join_with_unique_right_side() { + let ctx = create_context(); + let pass = OuterJoinRemoval::new(&ctx); + + let plan = LogicalPlan::project( + LogicalPlan::left_join( + LogicalPlan::left_join( + LogicalPlan::scan("issues"), + LogicalPlan::scan("projects"), + Expr::eq( + Expr::column("issues", "project_id", 1), + Expr::column("projects", "id", 0), + ), + ), + LogicalPlan::scan("orgs"), + Expr::eq( + Expr::column("projects", "org_id", 2), + Expr::column("orgs", "id", 0), + ), + ), + alloc::vec![ + Expr::column("issues", "id", 0), + Expr::column("projects", "id", 0), + ], + ); + + let optimized = pass.optimize(plan); + let text = alloc::format!("{:#?}", optimized); + + assert!(text.contains("table: \"issues\"")); + assert!(text.contains("table: \"projects\"")); + assert!(!text.contains("table: \"orgs\"")); + } + + #[test] + fn keeps_left_join_when_nullable_side_is_projected() { + let ctx = create_context(); + let pass = OuterJoinRemoval::new(&ctx); + + let plan = LogicalPlan::project( + LogicalPlan::left_join( + LogicalPlan::scan("issues"), + LogicalPlan::scan("projects"), + Expr::eq( + Expr::column("issues", "project_id", 1), + Expr::column("projects", "id", 0), + ), + ), + alloc::vec![ + Expr::column("issues", "id", 0), + Expr::column("projects", "id", 0), + ], + ); + + let optimized = pass.optimize(plan); + let text = alloc::format!("{:#?}", optimized); + assert!(text.contains("Join")); + assert!(text.contains("table: \"projects\"")); + } + + #[test] + fn keeps_left_join_when_nullable_side_can_multiply_rows() { + let ctx = create_context(); + let pass = OuterJoinRemoval::new(&ctx); + + let plan = LogicalPlan::project( + LogicalPlan::left_join( + LogicalPlan::scan("projects"), + LogicalPlan::scan("project_tags"), + Expr::eq( + Expr::column("projects", "id", 0), + Expr::column("project_tags", "project_id", 0), + ), + ), + alloc::vec![Expr::column("projects", "id", 0)], + ); + + let optimized = pass.optimize(plan); + let text = alloc::format!("{:#?}", optimized); + assert!(text.contains("table: \"project_tags\"")); + } + + #[test] + fn removes_unused_right_join_symmetrically() { + let ctx = create_context(); + let pass = OuterJoinRemoval::new(&ctx); + + let plan = LogicalPlan::project( + LogicalPlan::Join { + left: Box::new(LogicalPlan::scan("orgs")), + right: Box::new(LogicalPlan::scan("projects")), + condition: Expr::eq( + Expr::column("orgs", "id", 0), + Expr::column("projects", "org_id", 2), + ), + join_type: JoinType::RightOuter, + output_tables: alloc::vec!["orgs".to_string(), "projects".to_string()], + }, + alloc::vec![Expr::column("projects", "id", 0)], + ); + + let optimized = pass.optimize(plan); + let text = alloc::format!("{:#?}", optimized); + + assert!(text.contains("table: \"projects\"")); + assert!(!text.contains("table: \"orgs\"")); + } +} diff --git a/crates/query/src/optimizer/predicate_pushdown.rs b/crates/query/src/optimizer/predicate_pushdown.rs index beb8b86..ba20d7e 100644 --- a/crates/query/src/optimizer/predicate_pushdown.rs +++ b/crates/query/src/optimizer/predicate_pushdown.rs @@ -197,122 +197,103 @@ impl PredicatePushdown { output_tables: Vec, predicate: Expr, ) -> LogicalPlan { - // Extract tables referenced by each side of the join let left_tables = self.extract_tables(&left); let right_tables = self.extract_tables(&right); - - // Extract tables referenced by the predicate - let pred_tables = self.extract_predicate_tables(&predicate); - - // Check if predicate references only left side - let refs_left = pred_tables.iter().any(|t| left_tables.contains(t)); - let refs_right = pred_tables.iter().any(|t| right_tables.contains(t)); - - match join_type { - JoinType::Inner => { - // For inner join, we can push to either side - if refs_left && !refs_right { - // Push to left side - LogicalPlan::Join { - left: Box::new(self.try_push_filter(left, predicate)), - right: Box::new(right), - condition, - join_type, - output_tables, - } - } else if refs_right && !refs_left { - // Push to right side - LogicalPlan::Join { - left: Box::new(left), - right: Box::new(self.try_push_filter(right, predicate)), - condition, - join_type, - output_tables, - } - } else { - // References both sides or neither - keep above join - LogicalPlan::Filter { - input: Box::new(LogicalPlan::Join { - left: Box::new(left), - right: Box::new(right), - condition, - join_type, - output_tables, - }), - predicate, + let mut left_predicates = Vec::new(); + let mut right_predicates = Vec::new(); + let mut remaining_predicates = Vec::new(); + + for predicate in Self::split_conjuncts(predicate) { + let pred_tables = self.extract_predicate_tables(&predicate); + let refs_left = pred_tables.iter().any(|t| left_tables.contains(t)); + let refs_right = pred_tables.iter().any(|t| right_tables.contains(t)); + + match join_type { + JoinType::Inner => { + if refs_left && !refs_right { + left_predicates.push(predicate); + } else if refs_right && !refs_left { + right_predicates.push(predicate); + } else { + remaining_predicates.push(predicate); } } - } - - JoinType::LeftOuter => { - // For left outer join: - // - Can push predicates on LEFT side down (preserves NULL extension) - // - Cannot push predicates on RIGHT side (would filter out NULLs incorrectly) - if refs_left && !refs_right { - LogicalPlan::Join { - left: Box::new(self.try_push_filter(left, predicate)), - right: Box::new(right), - condition, - join_type, - output_tables, + JoinType::LeftOuter => { + if refs_left && !refs_right { + left_predicates.push(predicate); + } else { + remaining_predicates.push(predicate); } - } else { - // Keep above join - LogicalPlan::Filter { - input: Box::new(LogicalPlan::Join { - left: Box::new(left), - right: Box::new(right), - condition, - join_type, - output_tables, - }), - predicate, + } + JoinType::RightOuter => { + if refs_right && !refs_left { + right_predicates.push(predicate); + } else { + remaining_predicates.push(predicate); } } + JoinType::FullOuter | JoinType::Cross => { + remaining_predicates.push(predicate); + } } + } - JoinType::RightOuter => { - // For right outer join: - // - Can push predicates on RIGHT side down - // - Cannot push predicates on LEFT side - if refs_right && !refs_left { - LogicalPlan::Join { - left: Box::new(left), - right: Box::new(self.try_push_filter(right, predicate)), - condition, - join_type, - output_tables, - } - } else { - LogicalPlan::Filter { - input: Box::new(LogicalPlan::Join { - left: Box::new(left), - right: Box::new(right), - condition, - join_type, - output_tables, - }), - predicate, - } - } + let left = if left_predicates.is_empty() { + left + } else { + self.try_push_filter(left, Self::combine_predicates(left_predicates)) + }; + let right = if right_predicates.is_empty() { + right + } else { + self.try_push_filter(right, Self::combine_predicates(right_predicates)) + }; + + let join = LogicalPlan::Join { + left: Box::new(left), + right: Box::new(right), + condition, + join_type, + output_tables, + }; + + if remaining_predicates.is_empty() { + join + } else { + LogicalPlan::Filter { + input: Box::new(join), + predicate: Self::combine_predicates(remaining_predicates), } + } + } - JoinType::FullOuter | JoinType::Cross => { - // For full outer join and cross join, cannot push predicates - LogicalPlan::Filter { - input: Box::new(LogicalPlan::Join { - left: Box::new(left), - right: Box::new(right), - condition, - join_type, - output_tables, - }), - predicate, - } + fn split_conjuncts(predicate: Expr) -> Vec { + let mut predicates = Vec::new(); + Self::split_conjuncts_into(predicate, &mut predicates); + predicates + } + + fn split_conjuncts_into(predicate: Expr, predicates: &mut Vec) { + match predicate { + Expr::BinaryOp { + left, + op: crate::ast::BinaryOp::And, + right, + } => { + Self::split_conjuncts_into(*left, predicates); + Self::split_conjuncts_into(*right, predicates); } + other => predicates.push(other), } } + fn combine_predicates(predicates: Vec) -> Expr { + predicates + .into_iter() + .reduce(Expr::and) + .expect("combine_predicates requires at least one predicate") + } + /// Extract all table names referenced by a plan. fn extract_tables(&self, plan: &LogicalPlan) -> HashSet { let mut tables = HashSet::new(); @@ -421,6 +402,7 @@ impl PredicatePushdown { mod tests { use super::*; use crate::ast::{BinaryOp, SortOrder}; + use alloc::format; #[test] fn test_predicate_pushdown_basic() { @@ -538,6 +520,36 @@ mod tests { } } + #[test] + fn test_pushdown_splits_and_across_inner_join_sides() { + let pass = PredicatePushdown; + + let plan = LogicalPlan::filter( + LogicalPlan::inner_join( + LogicalPlan::scan("users"), + LogicalPlan::scan("orders"), + Expr::eq( + Expr::column("users", "id", 0), + Expr::column("orders", "user_id", 0), + ), + ), + Expr::and( + Expr::eq(Expr::column("users", "active", 1), Expr::literal(true)), + Expr::gt(Expr::column("orders", "amount", 1), Expr::literal(100i64)), + ), + ); + + let optimized = pass.optimize(plan); + + match optimized { + LogicalPlan::Join { left, right, .. } => { + assert!(matches!(*left, LogicalPlan::Filter { .. })); + assert!(matches!(*right, LogicalPlan::Filter { .. })); + } + other => panic!("Expected Join with pushed predicates, got {:?}", other), + } + } + #[test] fn test_filter_on_both_sides_stays_above_join() { let pass = PredicatePushdown; @@ -624,6 +636,43 @@ mod tests { } } + #[test] + fn test_left_join_splits_and_pushes_left_only() { + let pass = PredicatePushdown; + + let plan = LogicalPlan::filter( + LogicalPlan::left_join( + LogicalPlan::scan("users"), + LogicalPlan::scan("orders"), + Expr::eq( + Expr::column("users", "id", 0), + Expr::column("orders", "user_id", 0), + ), + ), + Expr::and( + Expr::eq(Expr::column("users", "active", 1), Expr::literal(true)), + Expr::gt(Expr::column("orders", "amount", 1), Expr::literal(100i64)), + ), + ); + + let optimized = pass.optimize(plan); + + match optimized { + LogicalPlan::Filter { input, predicate } => { + let predicate_debug = format!("{:?}", predicate).to_lowercase(); + assert!(predicate_debug.contains("orders")); + match *input { + LogicalPlan::Join { left, right, .. } => { + assert!(matches!(*left, LogicalPlan::Filter { .. })); + assert!(matches!(*right, LogicalPlan::Scan { .. })); + } + other => panic!("Expected Join under Filter, got {:?}", other), + } + } + other => panic!("Expected Filter over Join, got {:?}", other), + } + } + #[test] fn test_extract_tables() { let pass = PredicatePushdown; diff --git a/crates/query/src/plan_cache.rs b/crates/query/src/plan_cache.rs index 0bd8e8d..d6e8982 100644 --- a/crates/query/src/plan_cache.rs +++ b/crates/query/src/plan_cache.rs @@ -112,6 +112,7 @@ fn hash_logical_plan(plan: &LogicalPlan, hasher: &mut H) { index, column, pairs, + match_all, recheck, } => { hasher.write(b"gin_index_scan_multi"); @@ -122,6 +123,7 @@ fn hash_logical_plan(plan: &LogicalPlan, hasher: &mut H) { hasher.write(path.as_bytes()); hash_value(value, hasher); } + hasher.write(&[*match_all as u8]); if let Some(expr) = recheck { hash_expr(expr, hasher); } diff --git a/crates/query/src/planner/logical.rs b/crates/query/src/planner/logical.rs index d6b4831..e1e8a22 100644 --- a/crates/query/src/planner/logical.rs +++ b/crates/query/src/planner/logical.rs @@ -55,14 +55,16 @@ pub enum LogicalPlan { }, /// GIN index scan for multiple JSONB predicates (AND combination). - /// More efficient than multiple single GIN scans followed by intersection. + /// More efficient than multiple single GIN scans followed by set operations. GinIndexScanMulti { table: String, index: String, /// The JSONB column being queried. column: String, - /// Multiple (path, value) pairs to match (all must match - AND semantics). + /// Multiple (path, value) pairs to match. pairs: Vec<(String, Value)>, + /// `true` for AND semantics, `false` for OR semantics. + match_all: bool, /// Optional recheck predicate preserved for later physical rewrites. recheck: Option, }, diff --git a/crates/query/src/planner/mod.rs b/crates/query/src/planner/mod.rs index 469f693..b8a66c7 100644 --- a/crates/query/src/planner/mod.rs +++ b/crates/query/src/planner/mod.rs @@ -10,4 +10,4 @@ pub use index_bounds::IndexBounds; pub use logical::LogicalPlan; pub use physical::{JoinAlgorithm, PhysicalPlan}; pub use properties::{OrderingColumn, OrderingProperty, PhysicalProperties}; -pub use query_planner::QueryPlanner; +pub use query_planner::{PlannerProfile, QueryPlanner}; diff --git a/crates/query/src/planner/physical.rs b/crates/query/src/planner/physical.rs index beaac16..47c2677 100644 --- a/crates/query/src/planner/physical.rs +++ b/crates/query/src/planner/physical.rs @@ -71,13 +71,14 @@ pub enum PhysicalPlan { recheck: Option, }, - /// GIN index scan for multiple JSONB predicates (AND combination). - /// More efficient than multiple single GIN scans followed by intersection. + /// GIN index scan for multiple JSONB predicates. GinIndexScanMulti { table: String, index: String, - /// Multiple key-value pairs to match (all must match - AND semantics). + /// Multiple key-value pairs to match. pairs: Vec<(String, String)>, + /// `true` for AND semantics, `false` for OR semantics. + match_all: bool, /// Optional recheck predicate preserved for later physical rewrites. recheck: Option, }, @@ -321,17 +322,19 @@ impl PhysicalPlan { } } - /// Creates a GIN index scan plan for multiple key-value pairs (AND combination). + /// Creates a GIN index scan plan for multiple key-value pairs. pub fn gin_index_scan_multi( table: impl Into, index: impl Into, pairs: Vec<(String, String)>, + match_all: bool, recheck: Option, ) -> Self { PhysicalPlan::GinIndexScanMulti { table: table.into(), index: index.into(), pairs, + match_all, recheck, } } diff --git a/crates/query/src/planner/query_planner.rs b/crates/query/src/planner/query_planner.rs index 91f6d2d..57a92ba 100644 --- a/crates/query/src/planner/query_planner.rs +++ b/crates/query/src/planner/query_planner.rs @@ -14,10 +14,15 @@ //! - ImplicitJoinsPass //! - OuterJoinSimplification //! - PredicatePushdown +//! - OuterJoinSimplification (rerun after pushdown to expose deeper join collapses) +//! - PredicatePushdown +//! - OuterJoinSimplification //! - JoinReorder +//! - PredicatePushdown (rerun after join reorder to expose new single-table filters) //! //! 2. **Context-Aware Logical Optimization** - Requires ExecutionContext: //! - IndexSelection (converts Filter+Scan to IndexScan/IndexGet) +//! - OuterJoinRemoval (removes unused cardinality-preserving outer joins) //! //! 3. **Physical Plan Conversion** - Converts logical to physical plan //! @@ -39,7 +44,7 @@ use crate::context::ExecutionContext; use crate::optimizer::{ AndPredicatePass, CrossProductPass, ImplicitJoinsPass, IndexJoinPass, IndexSelection, JoinReorder, LimitSkipByIndexPass, NotSimplification, OptimizerPass, OrderByIndexPass, - OuterJoinSimplification, PredicatePushdown, TopNPushdown, + OuterJoinRemoval, OuterJoinSimplification, PredicatePushdown, TopNPushdown, }; use crate::planner::{LogicalPlan, PhysicalPlan}; use alloc::boxed::Box; @@ -56,26 +61,72 @@ pub struct QueryPlanner { logical_passes: Vec>, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlannerProfile { + Default, + RootSubset, +} + impl QueryPlanner { + fn default_logical_passes(ctx: &ExecutionContext) -> Vec> { + alloc::vec![ + Box::new(NotSimplification), + Box::new(AndPredicatePass), + Box::new(CrossProductPass), + Box::new(ImplicitJoinsPass), + Box::new(OuterJoinSimplification), + Box::new(PredicatePushdown), + // Predicate pushdown can surface new null-rejecting filters directly above + // deeper outer joins, so rerun simplification before join reordering. + Box::new(OuterJoinSimplification), + // When an intermediate outer join collapses to inner, another pushdown/simplify + // round can expose the next join in the chain. + Box::new(PredicatePushdown), + Box::new(OuterJoinSimplification), + Box::new(JoinReorder::with_context(ctx.clone())), + // Join reordering can surface a new join boundary for a filter that still + // references only one side, so give pushdown one final pass before index selection. + Box::new(PredicatePushdown), + ] + } + + fn root_subset_logical_passes(ctx: &ExecutionContext) -> Vec> { + // Root-subset planning now uses the ordinary logical pipeline and relies on + // restricted-relation intent plus costed join/index choices to keep the anchor + // relation as the driver, rather than disabling logical passes wholesale. + Self::default_logical_passes(ctx) + } + /// Creates a new QueryPlanner with the given execution context. /// /// The planner is initialized with default optimization passes: /// - Logical: NotSimplification, AndPredicatePass, CrossProductPass, - /// ImplicitJoinsPass, OuterJoinSimplification, PredicatePushdown, JoinReorder + /// ImplicitJoinsPass, OuterJoinSimplification, PredicatePushdown, + /// OuterJoinSimplification, PredicatePushdown, OuterJoinSimplification, + /// JoinReorder, PredicatePushdown /// - Context-aware logical: IndexSelection /// - Physical: TopNPushdown, OrderByIndexPass, LimitSkipByIndexPass pub fn new(ctx: ExecutionContext) -> Self { + Self::for_profile(ctx, PlannerProfile::Default) + } + + /// Creates a planner profile tailored for root-subset snapshot refresh. + /// + /// The subset profile now uses the normal logical pipeline and relies on + /// restricted-relation planning intent plus physical costing to keep the + /// declared root table as the driver. + pub fn for_root_subset(ctx: ExecutionContext) -> Self { + Self::for_profile(ctx, PlannerProfile::RootSubset) + } + + pub fn for_profile(ctx: ExecutionContext, profile: PlannerProfile) -> Self { + let logical_passes = match profile { + PlannerProfile::Default => Self::default_logical_passes(&ctx), + PlannerProfile::RootSubset => Self::root_subset_logical_passes(&ctx), + }; Self { - ctx: ctx.clone(), - logical_passes: alloc::vec![ - Box::new(NotSimplification), - Box::new(AndPredicatePass), - Box::new(CrossProductPass), - Box::new(ImplicitJoinsPass), - Box::new(OuterJoinSimplification), - Box::new(PredicatePushdown), - Box::new(JoinReorder::with_context(ctx.clone())), - ], + ctx, + logical_passes, } } @@ -95,11 +146,17 @@ impl QueryPlanner { &self.ctx } + fn optimize_context_aware_logical(&self, mut logical: LogicalPlan) -> LogicalPlan { + let index_selection = IndexSelection::with_context(self.ctx.clone()); + logical = index_selection.optimize(logical); + OuterJoinRemoval::new(&self.ctx).optimize(logical) + } + /// Plans a logical query into an optimized physical plan. /// /// This is the main entry point that runs the complete optimization pipeline: /// 1. Apply context-free logical optimizations - /// 2. Apply context-aware logical optimizations (IndexSelection) + /// 2. Apply context-aware logical optimizations (IndexSelection, OuterJoinRemoval) /// 3. Convert to physical plan /// 4. Apply physical optimizations (TopNPushdown, OrderByIndexPass, LimitSkipByIndexPass) pub fn plan(&self, plan: LogicalPlan) -> PhysicalPlan { @@ -110,8 +167,7 @@ impl QueryPlanner { } // Phase 2: Context-aware logical optimizations - let index_selection = IndexSelection::with_context(self.ctx.clone()); - logical = index_selection.optimize(logical); + logical = self.optimize_context_aware_logical(logical); // Phase 3: Convert to physical plan self.optimize_physical(self.logical_to_physical(logical)) @@ -129,10 +185,7 @@ impl QueryPlanner { } // Context-aware passes - let index_selection = IndexSelection::with_context(self.ctx.clone()); - logical = index_selection.optimize(logical); - - logical + self.optimize_context_aware_logical(logical) } /// Converts a logical plan to physical and applies physical optimizations. @@ -197,6 +250,7 @@ impl QueryPlanner { index, column: _, pairs, + match_all, recheck, } => { let string_pairs: Vec<(alloc::string::String, alloc::string::String)> = pairs @@ -214,7 +268,7 @@ impl QueryPlanner { (key, value_str) }) .collect(); - PhysicalPlan::gin_index_scan_multi(table, index, string_pairs, recheck) + PhysicalPlan::gin_index_scan_multi(table, index, string_pairs, match_all, recheck) } LogicalPlan::Filter { input, predicate } => { @@ -328,7 +382,7 @@ impl QueryPlanner { #[cfg(test)] mod tests { use super::*; - use crate::ast::{Expr, SortOrder}; + use crate::ast::{Expr, JoinType, SortOrder}; use crate::context::{IndexInfo, TableStats}; use alloc::string::String; @@ -371,6 +425,75 @@ mod tests { } } + fn collect_join_types(plan: &LogicalPlan, join_types: &mut Vec) { + match plan { + LogicalPlan::Join { + left, + right, + join_type, + .. + } => { + join_types.push(*join_type); + collect_join_types(left, join_types); + collect_join_types(right, join_types); + } + LogicalPlan::Filter { input, .. } + | LogicalPlan::Project { input, .. } + | LogicalPlan::Aggregate { input, .. } + | LogicalPlan::Sort { input, .. } + | LogicalPlan::Limit { input, .. } => collect_join_types(input, join_types), + LogicalPlan::CrossProduct { left, right } | LogicalPlan::Union { left, right, .. } => { + collect_join_types(left, join_types); + collect_join_types(right, join_types); + } + LogicalPlan::Scan { .. } + | LogicalPlan::IndexScan { .. } + | LogicalPlan::IndexGet { .. } + | LogicalPlan::IndexInGet { .. } + | LogicalPlan::GinIndexScan { .. } + | LogicalPlan::GinIndexScanMulti { .. } + | LogicalPlan::Empty => {} + } + } + + fn build_issue_project_counter_outer_join_plan() -> LogicalPlan { + LogicalPlan::filter( + LogicalPlan::Join { + left: Box::new(LogicalPlan::Join { + left: Box::new(LogicalPlan::scan("issues")), + right: Box::new(LogicalPlan::scan("projects")), + condition: Expr::eq( + Expr::column("issues", "project_id", 1), + Expr::column("projects", "id", 0), + ), + join_type: JoinType::LeftOuter, + output_tables: alloc::vec!["issues".into(), "projects".into()], + }), + right: Box::new(LogicalPlan::scan("project_counters")), + condition: Expr::eq( + Expr::column("projects", "id", 0), + Expr::column("project_counters", "project_id", 0), + ), + join_type: JoinType::LeftOuter, + output_tables: alloc::vec![ + "issues".into(), + "projects".into(), + "project_counters".into(), + ], + }, + Expr::and( + Expr::gte( + Expr::column("projects", "health_score", 1), + Expr::literal(45i64), + ), + Expr::gte( + Expr::column("project_counters", "open_issue_count", 1), + Expr::literal(5i64), + ), + ), + ) + } + #[test] fn test_query_planner_basic() { let ctx = create_test_context(); @@ -598,4 +721,105 @@ mod tests { ] ); } + + #[test] + fn test_query_planner_reruns_outer_join_simplification_after_pushdown() { + let mut ctx = ExecutionContext::new(); + ctx.register_table( + "issues", + TableStats { + row_count: 10_000, + is_sorted: false, + indexes: alloc::vec![], + }, + ); + ctx.register_table( + "projects", + TableStats { + row_count: 1_000, + is_sorted: false, + indexes: alloc::vec![], + }, + ); + ctx.register_table( + "project_counters", + TableStats { + row_count: 1_000, + is_sorted: false, + indexes: alloc::vec![], + }, + ); + + let planner = QueryPlanner::new(ctx); + let plan = build_issue_project_counter_outer_join_plan(); + + let optimized = planner.optimize_logical(plan); + let mut join_types = Vec::new(); + collect_join_types(&optimized, &mut join_types); + + assert!(!join_types.is_empty()); + assert!(join_types + .iter() + .all(|join_type| *join_type == JoinType::Inner)); + } + + #[test] + fn test_root_subset_profile_keeps_anchor_table_as_driver() { + let mut ctx = ExecutionContext::new(); + ctx.register_table( + "a", + TableStats { + row_count: 1000, + is_sorted: false, + indexes: alloc::vec![], + }, + ); + ctx.register_table( + "b", + TableStats { + row_count: 10, + is_sorted: false, + indexes: alloc::vec![], + }, + ); + ctx.register_table( + "c", + TableStats { + row_count: 100, + is_sorted: false, + indexes: alloc::vec![], + }, + ); + ctx.set_planner_feature_flags(crate::context::PlannerFeatureFlags { + restricted_relation_cbo: true, + }); + ctx.set_planning_intent(crate::context::PlanningIntent { + mode: crate::context::PlanningMode::RestrictedRelation, + restricted_table: Some("a".into()), + exact_subset_rows: Some(32), + subset_fraction: Some(0.032), + preferred_access_mode: crate::context::RestrictedAccessMode::SubsetDriven, + anchor_table: Some("a".into()), + allow_global_fallback: true, + }); + ctx.register_effective_row_count("a", 32); + + let planner = QueryPlanner::for_root_subset(ctx); + let plan = LogicalPlan::inner_join( + LogicalPlan::inner_join( + LogicalPlan::scan("a"), + LogicalPlan::scan("c"), + Expr::eq(Expr::column("a", "id", 0), Expr::column("c", "a_id", 0)), + ), + LogicalPlan::scan("b"), + Expr::eq(Expr::column("a", "id", 0), Expr::column("b", "a_id", 0)), + ); + + let optimized = planner.optimize_logical(plan); + let mut order = Vec::new(); + collect_scan_order(&optimized, &mut order); + + assert!(!order.is_empty()); + assert_eq!(order.first().map(String::as_str), Some("a")); + } } diff --git a/crates/reactive/src/notify.rs b/crates/reactive/src/notify.rs index 39a2802..db5138c 100644 --- a/crates/reactive/src/notify.rs +++ b/crates/reactive/src/notify.rs @@ -339,9 +339,11 @@ mod tests { let dataflow = DataflowNode::Join { left: Box::new(DataflowNode::source(1)), right: Box::new(DataflowNode::source(2)), - left_key: Box::new(|_| vec![]), - right_key: Box::new(|_| vec![]), + left_key: cynos_incremental::JoinKeySpec::Constant(vec![]), + right_key: cynos_incremental::JoinKeySpec::Constant(vec![]), join_type: cynos_incremental::JoinType::Inner, + left_width: 0, + right_width: 0, }; let query = Rc::new(RefCell::new(ObservableQuery::new(dataflow))); diff --git a/crates/reactive/src/observable.rs b/crates/reactive/src/observable.rs index 85b079b..3c57de0 100644 --- a/crates/reactive/src/observable.rs +++ b/crates/reactive/src/observable.rs @@ -7,10 +7,16 @@ //! that yields the initial result followed by incremental changes. use crate::change_set::ChangeSet; -use crate::subscription::{SubscriptionId, SubscriptionManager}; +use crate::subscription::{ + RawDeltaSubscriptionManager, SubscriptionId, SubscriptionManager, TraceBatchSubscriptionManager, +}; use alloc::vec::Vec; use cynos_core::{Row, Value}; -use cynos_incremental::{DataflowNode, Delta, MaterializedView, TableId}; +use cynos_incremental::{ + BootstrapExecutionProfile, CompiledBootstrapPlan, CompiledIvmPlan, DataflowNode, Delta, + MaterializedView, TableId, TraceDeltaBatch, TraceUpdateProfile, +}; +use hashbrown::HashMap; /// An observable query that tracks changes and notifies subscribers. /// @@ -43,6 +49,10 @@ pub struct ObservableQuery { view: MaterializedView, /// Subscription manager for change notifications subscriptions: SubscriptionManager, + /// Raw delta subscribers used by low-level bridge paths. + raw_delta_subscriptions: RawDeltaSubscriptionManager, + /// Trace batch subscribers used by JS delta bridge paths. + trace_batch_subscriptions: TraceBatchSubscriptionManager, /// Whether initial value has been emitted initialized: bool, } @@ -53,6 +63,8 @@ impl ObservableQuery { Self { view: MaterializedView::new(dataflow), subscriptions: SubscriptionManager::new(), + raw_delta_subscriptions: RawDeltaSubscriptionManager::new(), + trace_batch_subscriptions: TraceBatchSubscriptionManager::new(), initialized: false, } } @@ -62,10 +74,137 @@ impl ObservableQuery { Self { view: MaterializedView::with_initial(dataflow, initial), subscriptions: SubscriptionManager::new(), + raw_delta_subscriptions: RawDeltaSubscriptionManager::new(), + trace_batch_subscriptions: TraceBatchSubscriptionManager::new(), initialized: true, } } + /// Creates an observable query with an initial result set and bootstrapped source state. + pub fn with_sources( + dataflow: DataflowNode, + initial: Vec, + source_rows: &HashMap>, + ) -> Self { + Self { + view: MaterializedView::with_sources(dataflow, initial, source_rows), + subscriptions: SubscriptionManager::new(), + raw_delta_subscriptions: RawDeltaSubscriptionManager::new(), + trace_batch_subscriptions: TraceBatchSubscriptionManager::new(), + initialized: true, + } + } + + /// Creates an observable query with precompiled IVM metadata and lazy source loading. + pub fn with_compiled_loader( + dataflow: DataflowNode, + compiled_plan: CompiledIvmPlan, + compiled_bootstrap_plan: CompiledBootstrapPlan, + initial: Vec>, + load_source_rows: F, + ) -> Self + where + F: FnMut(TableId) -> Vec>, + { + Self { + view: MaterializedView::with_compiled_loader_and_bootstrap( + dataflow, + compiled_plan, + compiled_bootstrap_plan, + initial, + load_source_rows, + ), + subscriptions: SubscriptionManager::new(), + raw_delta_subscriptions: RawDeltaSubscriptionManager::new(), + trace_batch_subscriptions: TraceBatchSubscriptionManager::new(), + initialized: true, + } + } + + /// Creates an observable query with store-backed bootstrap streaming. + pub fn with_compiled_source_visitor( + dataflow: DataflowNode, + compiled_plan: CompiledIvmPlan, + compiled_bootstrap_plan: CompiledBootstrapPlan, + initial: Vec>, + visit_source_rows: F, + ) -> Self + where + F: FnMut(TableId, usize, &mut dyn FnMut(alloc::rc::Rc)), + { + Self { + view: MaterializedView::with_compiled_source_visitor_and_bootstrap( + dataflow, + compiled_plan, + compiled_bootstrap_plan, + initial, + visit_source_rows, + ), + subscriptions: SubscriptionManager::new(), + raw_delta_subscriptions: RawDeltaSubscriptionManager::new(), + trace_batch_subscriptions: TraceBatchSubscriptionManager::new(), + initialized: true, + } + } + + #[doc(hidden)] + pub fn with_compiled_source_visitor_profiled( + dataflow: DataflowNode, + compiled_plan: CompiledIvmPlan, + compiled_bootstrap_plan: CompiledBootstrapPlan, + initial: Vec>, + visit_source_rows: F, + now_fn: Option f64>, + ) -> (Self, BootstrapExecutionProfile) + where + F: FnMut(TableId, usize, &mut dyn FnMut(alloc::rc::Rc)), + { + Self::with_compiled_source_visitor_profiled_with_filter_coverage( + dataflow, + compiled_plan, + compiled_bootstrap_plan, + initial, + visit_source_rows, + None, + now_fn, + ) + } + + #[doc(hidden)] + pub fn with_compiled_source_visitor_profiled_with_filter_coverage( + dataflow: DataflowNode, + compiled_plan: CompiledIvmPlan, + compiled_bootstrap_plan: CompiledBootstrapPlan, + initial: Vec>, + visit_source_rows: F, + source_filter_coverage: Option>, + now_fn: Option f64>, + ) -> (Self, BootstrapExecutionProfile) + where + F: FnMut(TableId, usize, &mut dyn FnMut(alloc::rc::Rc)), + { + let (view, bootstrap_profile) = + MaterializedView::with_compiled_source_visitor_and_bootstrap_profiled_with_filter_coverage( + dataflow, + compiled_plan, + compiled_bootstrap_plan, + initial, + visit_source_rows, + source_filter_coverage, + now_fn, + ); + ( + Self { + view, + subscriptions: SubscriptionManager::new(), + raw_delta_subscriptions: RawDeltaSubscriptionManager::new(), + trace_batch_subscriptions: TraceBatchSubscriptionManager::new(), + initialized: true, + }, + bootstrap_profile, + ) + } + /// Initializes join state from source data. /// This must be called for join queries to properly track incremental changes. pub fn initialize_join_state( @@ -85,6 +224,18 @@ impl ObservableQuery { self.view.result() } + /// Returns the current result as shared rows for bridge/binary paths. + #[inline] + pub fn result_rc(&self) -> Vec> { + self.view.result_rc() + } + + /// Returns shared row references without cloning the current result vector. + #[inline] + pub fn result_row_refs(&self) -> impl Iterator> + '_ { + self.view.result_row_refs() + } + /// Returns the number of rows in the result. #[inline] pub fn len(&self) -> usize { @@ -120,17 +271,37 @@ impl ObservableQuery { self.subscriptions.subscribe(callback) } + /// Subscribes to raw deltas without constructing a `ChangeSet`. + pub fn subscribe_raw_deltas(&mut self, callback: F) -> SubscriptionId + where + F: Fn(&[Delta]) + 'static, + { + self.raw_delta_subscriptions.subscribe(callback) + } + + /// Subscribes to internal trace batches without materializing full rows. + pub fn subscribe_trace_batches(&mut self, callback: F) -> SubscriptionId + where + F: Fn(&TraceDeltaBatch) + 'static, + { + self.trace_batch_subscriptions.subscribe(callback) + } + /// Unsubscribes by ID. /// /// Returns true if the subscription was found and removed. pub fn unsubscribe(&mut self, id: SubscriptionId) -> bool { self.subscriptions.unsubscribe(id) + || self.raw_delta_subscriptions.unsubscribe(id) + || self.trace_batch_subscriptions.unsubscribe(id) } /// Returns the number of active subscriptions. #[inline] pub fn subscription_count(&self) -> usize { self.subscriptions.len() + + self.raw_delta_subscriptions.len() + + self.trace_batch_subscriptions.len() } /// Handles changes to a source table. @@ -142,19 +313,56 @@ impl ObservableQuery { /// `self.view.result()`. Subscribers receive only added/removed rows. /// If a subscriber needs the full result, it can call `result()` explicitly. pub fn on_table_change(&mut self, table_id: TableId, deltas: Vec>) { + let _ = self.on_table_change_profiled(table_id, deltas, None); + } + + #[doc(hidden)] + pub fn on_table_change_profiled( + &mut self, + table_id: TableId, + deltas: Vec>, + now_fn: Option f64>, + ) -> TraceUpdateProfile { // Skip dataflow propagation entirely when no one is listening. // The result_map will be stale, but getResult() is only called // right after subscribe, at which point we re-initialize anyway. - if self.subscriptions.is_empty() { - return; + if self.subscriptions.is_empty() + && self.raw_delta_subscriptions.is_empty() + && self.trace_batch_subscriptions.is_empty() + { + return TraceUpdateProfile::default(); } - let output_deltas = self.view.on_table_change(table_id, deltas); + let (output_batch, profile) = self + .view + .on_table_change_batch_profiled(table_id, deltas, now_fn); + + if !output_batch.is_empty() { + if !self.trace_batch_subscriptions.is_empty() { + self.trace_batch_subscriptions.notify_all(&output_batch); + } - if !output_deltas.is_empty() { - let changes = ChangeSet::from_deltas_only(&output_deltas); - self.subscriptions.notify_all(&changes); + let materialized = + if self.raw_delta_subscriptions.is_empty() && self.subscriptions.is_empty() { + None + } else { + Some(output_batch.materialize_rows()) + }; + + if !self.raw_delta_subscriptions.is_empty() { + if let Some(ref output_deltas) = materialized { + self.raw_delta_subscriptions.notify_all(output_deltas); + } + } + if !self.subscriptions.is_empty() { + if let Some(ref output_deltas) = materialized { + let changes = ChangeSet::from_deltas_only(output_deltas); + self.subscriptions.notify_all(&changes); + } + } } + + profile } /// Initializes the query with the given rows and notifies subscribers. diff --git a/crates/reactive/src/subscription.rs b/crates/reactive/src/subscription.rs index b05609e..63c9403 100644 --- a/crates/reactive/src/subscription.rs +++ b/crates/reactive/src/subscription.rs @@ -6,6 +6,8 @@ use crate::change_set::ChangeSet; use alloc::boxed::Box; use alloc::vec::Vec; +use cynos_core::Row; +use cynos_incremental::{Delta, TraceDeltaBatch}; use hashbrown::HashMap; /// Unique identifier for a subscription. @@ -14,6 +16,12 @@ pub type SubscriptionId = u64; /// Callback type for change notifications. pub type ChangeCallback = Box; +/// Callback type for raw delta notifications. +pub type RawDeltaCallback = Box])>; + +/// Callback type for late-materialized trace batches. +pub type TraceBatchCallback = Box; + /// A subscription to query changes. pub struct Subscription { /// Unique identifier @@ -71,6 +79,179 @@ pub struct SubscriptionManager { next_id: SubscriptionId, } +/// A raw-delta subscription that receives `Delta` slices directly. +pub struct RawDeltaSubscription { + id: SubscriptionId, + callback: RawDeltaCallback, + active: bool, +} + +impl RawDeltaSubscription { + pub fn new(id: SubscriptionId, callback: F) -> Self + where + F: Fn(&[Delta]) + 'static, + { + Self { + id, + callback: Box::new(callback), + active: true, + } + } + + #[inline] + pub fn id(&self) -> SubscriptionId { + self.id + } + + #[inline] + pub fn is_active(&self) -> bool { + self.active + } + + #[inline] + pub fn deactivate(&mut self) { + self.active = false; + } + + pub fn notify(&self, deltas: &[Delta]) { + if self.active { + (self.callback)(deltas); + } + } +} + +/// Manages raw-delta subscriptions for an observable query. +pub struct RawDeltaSubscriptionManager { + subscriptions: HashMap, + next_id: SubscriptionId, +} + +/// A trace-batch subscription that receives `TraceDeltaBatch` values directly. +pub struct TraceBatchSubscription { + _id: SubscriptionId, + callback: TraceBatchCallback, + active: bool, +} + +impl TraceBatchSubscription { + pub fn new(id: SubscriptionId, callback: F) -> Self + where + F: Fn(&TraceDeltaBatch) + 'static, + { + Self { + _id: id, + callback: Box::new(callback), + active: true, + } + } + + pub fn notify(&self, batch: &TraceDeltaBatch) { + if self.active { + (self.callback)(batch); + } + } +} + +/// Manages trace-batch subscriptions for an observable query. +pub struct TraceBatchSubscriptionManager { + subscriptions: HashMap, + next_id: SubscriptionId, +} + +impl Default for RawDeltaSubscriptionManager { + fn default() -> Self { + Self::new() + } +} + +impl Default for TraceBatchSubscriptionManager { + fn default() -> Self { + Self::new() + } +} + +impl RawDeltaSubscriptionManager { + pub fn new() -> Self { + Self { + subscriptions: HashMap::new(), + next_id: 1, + } + } + + pub fn subscribe(&mut self, callback: F) -> SubscriptionId + where + F: Fn(&[Delta]) + 'static, + { + let id = self.next_id; + self.next_id += 1; + + let subscription = RawDeltaSubscription::new(id, callback); + self.subscriptions.insert(id, subscription); + id + } + + pub fn unsubscribe(&mut self, id: SubscriptionId) -> bool { + self.subscriptions.remove(&id).is_some() + } + + pub fn notify_all(&self, deltas: &[Delta]) { + for subscription in self.subscriptions.values() { + subscription.notify(deltas); + } + } + + #[inline] + pub fn len(&self) -> usize { + self.subscriptions.len() + } + + #[inline] + pub fn is_empty(&self) -> bool { + self.subscriptions.is_empty() + } +} + +impl TraceBatchSubscriptionManager { + pub fn new() -> Self { + Self { + subscriptions: HashMap::new(), + next_id: 1, + } + } + + pub fn subscribe(&mut self, callback: F) -> SubscriptionId + where + F: Fn(&TraceDeltaBatch) + 'static, + { + let id = self.next_id; + self.next_id += 1; + + let subscription = TraceBatchSubscription::new(id, callback); + self.subscriptions.insert(id, subscription); + id + } + + pub fn unsubscribe(&mut self, id: SubscriptionId) -> bool { + self.subscriptions.remove(&id).is_some() + } + + pub fn notify_all(&self, batch: &TraceDeltaBatch) { + for subscription in self.subscriptions.values() { + subscription.notify(batch); + } + } + + #[inline] + pub fn len(&self) -> usize { + self.subscriptions.len() + } + + #[inline] + pub fn is_empty(&self) -> bool { + self.subscriptions.is_empty() + } +} + impl Default for SubscriptionManager { fn default() -> Self { Self::new() diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 15dacaf..c87bf58 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -8,6 +8,7 @@ description = "Storage layer for Cynos in-memory database" [features] default = [] +benchmark = [] hash-store = ["hashbrown"] [dependencies] diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 2fd5a62..26752e6 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -47,6 +47,8 @@ pub mod cache; pub mod constraint; pub mod journal; pub mod lock; +#[cfg(feature = "benchmark")] +pub mod profiling; pub mod row_store; pub mod transaction; @@ -54,5 +56,7 @@ pub use cache::TableCache; pub use constraint::ConstraintChecker; pub use journal::{Journal, JournalEntry, TableDiff}; pub use lock::{LockManager, LockType}; +#[cfg(feature = "benchmark")] +pub use profiling::{StorageGinInsertProfile, StorageInsertProfile}; pub use row_store::{BTreeIndexStore, HashIndexStore, IndexStore, RowStore}; pub use transaction::{Transaction, TransactionId, TransactionState}; diff --git a/crates/storage/src/profiling.rs b/crates/storage/src/profiling.rs new file mode 100644 index 0000000..7d3cb2e --- /dev/null +++ b/crates/storage/src/profiling.rs @@ -0,0 +1,31 @@ +#[derive(Clone, Debug, Default)] +pub struct StorageGinInsertProfile { + pub parse_json_ms: f64, + pub path_lookup_ms: f64, + pub scalar_emit_ms: f64, + pub contains_stringify_ms: f64, + pub contains_trigram_emit_ms: f64, + pub parse_call_count: usize, + pub selected_path_eval_count: usize, + pub selected_path_hit_count: usize, + pub path_key_emit_count: usize, + pub scalar_value_count: usize, + pub contains_value_count: usize, + pub contains_trigram_count: usize, +} + +#[derive(Clone, Debug, Default)] +pub struct StorageInsertProfile { + pub row_count: usize, + pub secondary_index_count: usize, + pub gin_index_count: usize, + pub validation_ms: f64, + pub row_id_index_ms: f64, + pub primary_index_ms: f64, + pub secondary_index_ms: f64, + pub gin_collect_ms: f64, + pub gin_flush_ms: f64, + pub row_slot_ms: f64, + pub total_ms: f64, + pub gin: StorageGinInsertProfile, +} diff --git a/crates/storage/src/row_store.rs b/crates/storage/src/row_store.rs index 2543b70..56d013e 100644 --- a/crates/storage/src/row_store.rs +++ b/crates/storage/src/row_store.rs @@ -3,16 +3,20 @@ //! This module provides the `RowStore` struct which manages rows for a single table, //! including primary key and secondary index maintenance. -use alloc::collections::BTreeMap; +#[cfg(feature = "benchmark")] +use crate::profiling::StorageInsertProfile; +use alloc::collections::{BTreeMap, BTreeSet}; use alloc::format; use alloc::rc::Rc; use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; use cynos_core::schema::{IndexType, Table}; use cynos_core::{Error, Result, Row, RowId, Value}; use cynos_incremental::Delta; use cynos_index::{ - contains_trigram_pairs, BTreeIndex, GinIndex, HashIndex, Index, KeyRange, RangeIndex, + contains_trigram_pairs, BTreeIndex, GinBulkBuilder, GinIndex, HashIndex, Index, KeyRange, + RangeIndex, }; use cynos_jsonb::{JsonbObject, JsonbValue as ParsedJsonbValue}; @@ -28,6 +32,357 @@ struct RowSlot { row: Rc, } +#[derive(Clone, Debug)] +struct GinIndexConfig { + column_idx: usize, + indexed_paths: Option>, + compiled_indexed_paths: Option>, + compiled_indexed_path_tree: Option, +} + +#[derive(Clone, Debug)] +struct CompiledGinPath { + encoded_path: String, + contains_key: String, + segments: Vec, +} + +#[derive(Clone, Debug)] +struct CompiledGinPathSegment { + key: String, + lookup_key: Option, + array_index: Option, +} + +#[derive(Clone, Debug, Default)] +struct CompiledGinPathTree { + nodes: Vec, +} + +#[derive(Clone, Debug, Default)] +struct CompiledGinPathNode { + terminal_path_indices: Vec, + object_children: BTreeMap, + array_children: BTreeMap, +} + +enum ExtractedJsonbTextValue<'a> { + ScalarText(&'a str), + Parsed(ParsedJsonbValue), +} + +#[derive(Debug, PartialEq, Eq)] +enum JsonScalarIndexValue<'a> { + Borrowed(&'a str), + Owned(String), +} + +impl JsonScalarIndexValue<'_> { + fn as_str(&self) -> &str { + match self { + Self::Borrowed(value) => value, + Self::Owned(value) => value.as_str(), + } + } + + #[cfg(test)] + fn into_owned(self) -> String { + match self { + Self::Borrowed(value) => value.into(), + Self::Owned(value) => value, + } + } +} + +impl GinIndexConfig { + fn new(column_idx: usize, indexed_paths: Option>) -> Self { + let indexed_paths = indexed_paths.filter(|paths| !paths.is_empty()); + let compiled_indexed_paths = indexed_paths + .as_ref() + .map(|paths| { + paths + .iter() + .cloned() + .map(CompiledGinPath::new) + .collect::>() + }); + let compiled_indexed_path_tree = compiled_indexed_paths + .as_ref() + .map(|paths| CompiledGinPathTree::new(paths)); + Self { + column_idx, + indexed_paths, + compiled_indexed_paths, + compiled_indexed_path_tree, + } + } +} + +impl CompiledGinPath { + fn new(encoded_path: String) -> Self { + let segments = decode_gin_path_segments(&encoded_path) + .into_iter() + .map(|key| CompiledGinPathSegment { + array_index: key.parse::().ok(), + lookup_key: Some(escape_json_string_fragment(&key)), + key, + }) + .collect(); + Self { + contains_key: cynos_index::contains_trigram_key(&encoded_path), + encoded_path, + segments, + } + } +} + +impl CompiledGinPathTree { + fn new(paths: &[CompiledGinPath]) -> Self { + let mut tree = Self { + nodes: vec![CompiledGinPathNode::default()], + }; + + for (path_index, path) in paths.iter().enumerate() { + let mut node_index = 0usize; + for segment in &path.segments { + let next_node_index = if let Some(array_index) = segment.array_index { + if let Some(existing) = tree.nodes[node_index].array_children.get(&array_index) { + *existing + } else { + let next = tree.nodes.len(); + tree.nodes.push(CompiledGinPathNode::default()); + tree.nodes[node_index] + .array_children + .insert(array_index, next); + next + } + } else if let Some(lookup_key) = segment.lookup_key.as_ref() { + if let Some(existing) = tree.nodes[node_index].object_children.get(lookup_key) { + *existing + } else { + let next = tree.nodes.len(); + tree.nodes.push(CompiledGinPathNode::default()); + tree.nodes[node_index] + .object_children + .insert(lookup_key.clone(), next); + next + } + } else { + node_index + }; + node_index = next_node_index; + } + + tree.nodes[node_index].terminal_path_indices.push(path_index); + } + + tree + } +} + +#[derive(Clone, Debug)] +struct BatchSecondaryDef { + name: String, + cols: Vec, + unique: bool, +} + +struct PreparedBatchInsert { + row_id_entries: Vec<(Value, RowId)>, + primary_entries: Option>, + secondary_entries: Vec>, +} + +#[derive(Clone, Copy)] +enum InsertProfilePhase { + Validation, + RowIdIndex, + PrimaryIndex, + SecondaryIndex, + GinCollect, + GinFlush, + RowSlot, +} + +#[derive(Clone, Copy)] +enum GinInsertProfilePhase { + ParseJson, + PathLookup, + ScalarEmit, + ContainsStringify, + ContainsTrigramEmit, +} + +struct InsertBatchProfiler { + #[cfg(feature = "benchmark")] + now_ms: Option f64>, + #[cfg(feature = "benchmark")] + profile: StorageInsertProfile, +} + +impl InsertBatchProfiler { + fn disabled(row_count: usize, secondary_index_count: usize, gin_index_count: usize) -> Self { + #[cfg(feature = "benchmark")] + { + Self { + now_ms: None, + profile: StorageInsertProfile { + row_count, + secondary_index_count, + gin_index_count, + ..StorageInsertProfile::default() + }, + } + } + + #[cfg(not(feature = "benchmark"))] + { + let _ = (row_count, secondary_index_count, gin_index_count); + Self {} + } + } + + #[cfg(feature = "benchmark")] + fn enabled( + row_count: usize, + secondary_index_count: usize, + gin_index_count: usize, + now_ms: fn() -> f64, + ) -> Self { + Self { + now_ms: Some(now_ms), + profile: StorageInsertProfile { + row_count, + secondary_index_count, + gin_index_count, + ..StorageInsertProfile::default() + }, + } + } + + fn start_timer(&self) -> Option { + #[cfg(feature = "benchmark")] + { + self.now_ms.map(|now_ms| now_ms()) + } + + #[cfg(not(feature = "benchmark"))] + { + None + } + } + + fn finish_phase(&mut self, phase: InsertProfilePhase, started_at: Option) { + #[cfg(feature = "benchmark")] + { + let Some(started_at) = started_at else { + return; + }; + let Some(now_ms) = self.now_ms else { + return; + }; + let elapsed = now_ms() - started_at; + match phase { + InsertProfilePhase::Validation => self.profile.validation_ms += elapsed, + InsertProfilePhase::RowIdIndex => self.profile.row_id_index_ms += elapsed, + InsertProfilePhase::PrimaryIndex => self.profile.primary_index_ms += elapsed, + InsertProfilePhase::SecondaryIndex => self.profile.secondary_index_ms += elapsed, + InsertProfilePhase::GinCollect => self.profile.gin_collect_ms += elapsed, + InsertProfilePhase::GinFlush => self.profile.gin_flush_ms += elapsed, + InsertProfilePhase::RowSlot => self.profile.row_slot_ms += elapsed, + } + } + + #[cfg(not(feature = "benchmark"))] + let _ = (phase, started_at); + } + + fn finish_gin_phase(&mut self, phase: GinInsertProfilePhase, started_at: Option) { + #[cfg(feature = "benchmark")] + { + let Some(started_at) = started_at else { + return; + }; + let Some(now_ms) = self.now_ms else { + return; + }; + let elapsed = now_ms() - started_at; + match phase { + GinInsertProfilePhase::ParseJson => self.profile.gin.parse_json_ms += elapsed, + GinInsertProfilePhase::PathLookup => self.profile.gin.path_lookup_ms += elapsed, + GinInsertProfilePhase::ScalarEmit => self.profile.gin.scalar_emit_ms += elapsed, + GinInsertProfilePhase::ContainsStringify => { + self.profile.gin.contains_stringify_ms += elapsed + } + GinInsertProfilePhase::ContainsTrigramEmit => { + self.profile.gin.contains_trigram_emit_ms += elapsed + } + } + } + + #[cfg(not(feature = "benchmark"))] + let _ = (phase, started_at); + } + + fn record_gin_parse_call(&mut self) { + #[cfg(feature = "benchmark")] + { + self.profile.gin.parse_call_count += 1; + } + } + + fn record_gin_selected_path_eval(&mut self) { + #[cfg(feature = "benchmark")] + { + self.profile.gin.selected_path_eval_count += 1; + } + } + + fn record_gin_selected_path_hit(&mut self) { + #[cfg(feature = "benchmark")] + { + self.profile.gin.selected_path_hit_count += 1; + } + } + + fn record_gin_path_key_emit(&mut self) { + #[cfg(feature = "benchmark")] + { + self.profile.gin.path_key_emit_count += 1; + } + } + + fn record_gin_scalar_value(&mut self) { + #[cfg(feature = "benchmark")] + { + self.profile.gin.scalar_value_count += 1; + } + } + + fn record_gin_contains_value(&mut self) { + #[cfg(feature = "benchmark")] + { + self.profile.gin.contains_value_count += 1; + } + } + + fn record_gin_contains_trigrams(&mut self, count: usize) { + #[cfg(feature = "benchmark")] + { + self.profile.gin.contains_trigram_count += count; + } + + #[cfg(not(feature = "benchmark"))] + let _ = count; + } + + #[cfg(feature = "benchmark")] + fn finish(mut self, total_ms: f64) -> StorageInsertProfile { + self.profile.total_ms = total_ms; + self.profile + } +} + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] enum IndexKey { Scalar(Value), @@ -126,6 +481,16 @@ pub trait IndexStore { key: Value, row_id: RowId, ) -> core::result::Result<(), cynos_index::IndexError>; + /// Adds multiple key-value pairs to the index. + fn add_batch( + &mut self, + entries: &[(Value, RowId)], + ) -> core::result::Result<(), cynos_index::IndexError> { + for (key, row_id) in entries { + self.add(key.clone(), *row_id)?; + } + Ok(()) + } /// Sets a key-value pair, replacing any existing values. fn set(&mut self, key: Value, row_id: RowId); /// Gets all row IDs for a key. @@ -174,6 +539,8 @@ pub trait IndexStore { } /// Returns all row IDs in the index. fn get_all(&self) -> Vec; + /// Returns the number of distinct keys currently stored in the index. + fn distinct_key_count(&self) -> usize; } /// Wrapper for BTreeIndex that implements IndexStore. @@ -197,6 +564,13 @@ impl BTreeIndexStore { self.inner.add(key, row_id) } + fn add_batch_index_keys( + &mut self, + entries: &[(IndexKey, RowId)], + ) -> core::result::Result<(), cynos_index::IndexError> { + self.inner.add_batch(entries) + } + fn set_index_key(&mut self, key: IndexKey, row_id: RowId) { self.inner.set(key, row_id); } @@ -239,6 +613,10 @@ impl BTreeIndexStore { { self.inner.visit_range(range, reverse, limit, skip, visitor); } + + fn distinct_key_count(&self) -> usize { + self.inner.distinct_key_count() + } } impl IndexStore for BTreeIndexStore { @@ -250,6 +628,17 @@ impl IndexStore for BTreeIndexStore { self.add_index_key(IndexKey::scalar(key), row_id) } + fn add_batch( + &mut self, + entries: &[(Value, RowId)], + ) -> core::result::Result<(), cynos_index::IndexError> { + let entries: Vec<(IndexKey, RowId)> = entries + .iter() + .map(|(key, row_id)| (IndexKey::scalar(key.clone()), *row_id)) + .collect(); + self.add_batch_index_keys(&entries) + } + fn set(&mut self, key: Value, row_id: RowId) { self.set_index_key(IndexKey::scalar(key), row_id); } @@ -301,6 +690,10 @@ impl IndexStore for BTreeIndexStore { self.get_range_index_keys(None, false, None, 0) } + fn distinct_key_count(&self) -> usize { + self.inner.distinct_key_count() + } + fn visit_range( &self, range: Option<&KeyRange>, @@ -337,6 +730,13 @@ impl HashIndexStore { self.inner.add(key, row_id) } + fn add_batch_index_keys( + &mut self, + entries: &[(IndexKey, RowId)], + ) -> core::result::Result<(), cynos_index::IndexError> { + self.inner.add_batch(entries) + } + fn set_index_key(&mut self, key: IndexKey, row_id: RowId) { self.inner.set(key, row_id); } @@ -383,6 +783,10 @@ impl HashIndexStore { } } } + + fn distinct_key_count(&self) -> usize { + self.inner.distinct_key_count() + } } impl IndexStore for HashIndexStore { @@ -394,6 +798,17 @@ impl IndexStore for HashIndexStore { self.add_index_key(IndexKey::scalar(key), row_id) } + fn add_batch( + &mut self, + entries: &[(Value, RowId)], + ) -> core::result::Result<(), cynos_index::IndexError> { + let entries: Vec<(IndexKey, RowId)> = entries + .iter() + .map(|(key, row_id)| (IndexKey::scalar(key.clone()), *row_id)) + .collect(); + self.add_batch_index_keys(&entries) + } + fn set(&mut self, key: Value, row_id: RowId) { self.set_index_key(IndexKey::scalar(key), row_id); } @@ -443,6 +858,10 @@ impl IndexStore for HashIndexStore { self.inner.get_all_row_ids() } + fn distinct_key_count(&self) -> usize { + self.inner.distinct_key_count() + } + fn visit_range( &self, range: Option<&KeyRange>, @@ -485,6 +904,16 @@ impl SecondaryIndexStore { } } + fn add_batch_index_keys( + &mut self, + entries: &[(IndexKey, RowId)], + ) -> core::result::Result<(), cynos_index::IndexError> { + match self { + Self::BTree(index) => index.add_batch_index_keys(entries), + Self::Hash(index) => index.add_batch_index_keys(entries), + } + } + fn remove_index_key(&mut self, key: &IndexKey, row_id: Option) { match self { Self::BTree(index) => index.remove_index_key(key, row_id), @@ -520,6 +949,13 @@ impl SecondaryIndexStore { } } + fn distinct_key_count(&self) -> usize { + match self { + Self::BTree(index) => index.distinct_key_count(), + Self::Hash(index) => index.distinct_key_count(), + } + } + fn visit_range_index_keys( &self, range: Option<&KeyRange>, @@ -548,6 +984,17 @@ fn extract_key_from_values(values: &[Value]) -> IndexKey { IndexKey::from_values(values.to_vec()) } +fn first_duplicate_batch_key(entries: &[(K, RowId)]) -> Option { + let mut keys: Vec<&K> = entries.iter().map(|(key, _)| key).collect(); + keys.sort(); + keys.windows(2).find_map(|window| { + let [left, right] = window else { + return None; + }; + (left == right).then(|| (*left).clone()) + }) +} + fn composite_range_has_expected_arity(range: &KeyRange>, expected: usize) -> bool { match range { KeyRange::All => true, @@ -574,8 +1021,8 @@ pub struct RowStore { index_columns: BTreeMap>, /// GIN indexes for JSONB columns gin_indices: BTreeMap, - /// Column indices for GIN indexes - gin_index_columns: BTreeMap, + /// Column index and path configuration for GIN indexes + gin_index_configs: BTreeMap, } impl RowStore { @@ -592,7 +1039,7 @@ impl RowStore { secondary_indices: BTreeMap::new(), index_columns: BTreeMap::new(), gin_indices: BTreeMap::new(), - gin_index_columns: BTreeMap::new(), + gin_index_configs: BTreeMap::new(), }; if let Some(pk) = schema.primary_key() { @@ -617,9 +1064,10 @@ impl RowStore { store .gin_indices .insert(idx.name().to_string(), GinIndex::new()); - store - .gin_index_columns - .insert(idx.name().to_string(), col_idx); + store.gin_index_configs.insert( + idx.name().to_string(), + GinIndexConfig::new(col_idx, idx.gin_paths().map(|paths| paths.to_vec())), + ); } } else { store.secondary_indices.insert( @@ -638,6 +1086,36 @@ impl RowStore { &self.schema } + /// Returns the number of distinct primary-key values, if this table has a primary key. + pub fn primary_index_distinct_key_count(&self) -> Option { + self.primary_index + .as_ref() + .map(BTreeIndexStore::distinct_key_count) + } + + /// Returns the number of distinct keys tracked by the named secondary index. + pub fn secondary_index_distinct_key_count(&self, index_name: &str) -> Option { + self.secondary_indices + .get(index_name) + .map(SecondaryIndexStore::distinct_key_count) + } + + /// Returns the posting-list size for a GIN key lookup. + pub fn gin_index_cost_key(&self, index_name: &str, key: &str) -> usize { + self.gin_indices + .get(index_name) + .map(|gin| gin.cost_key(key)) + .unwrap_or(0) + } + + /// Returns the posting-list size for a GIN key/value lookup. + pub fn gin_index_cost_key_value(&self, index_name: &str, key: &str, value: &str) -> usize { + self.gin_indices + .get(index_name) + .map(|gin| gin.cost_key_value(key, value)) + .unwrap_or(0) + } + /// Returns the number of rows. pub fn len(&self) -> usize { self.rows.len() @@ -673,6 +1151,16 @@ impl RowStore { self.row_slots.push(RowSlot { row_id, row }); self.rows.insert(row_id, slot_idx); + let append_only = self + .scan_order + .last() + .map(|&last_slot_idx| self.row_slots[last_slot_idx].row_id < row_id) + .unwrap_or(true); + if append_only { + self.scan_order.push(slot_idx); + return; + } + let scan_pos = self.scan_position(row_id).unwrap_or_else(|pos| pos); self.scan_order.insert(scan_pos, slot_idx); } @@ -714,86 +1202,780 @@ impl RowStore { Some(removed_slot.row) } - /// Inserts a row into the store. - pub fn insert(&mut self, row: Row) -> Result { - let row_id = row.id(); + /// Bulk loads rows into an empty store without per-row slot/index interleaving. + /// + /// This is intended for initial hydration paths where the table is known to be + /// empty and no live-query notifications should be emitted between rows. + pub fn bulk_load(&mut self, rows: Vec) -> Result { + if rows.is_empty() { + return Ok(0); + } - if self.rows.contains_key(&row_id) { - return Err(Error::invalid_operation("Row ID already exists")); + if !self.is_empty() { + return Err(Error::invalid_operation( + "Bulk load requires an empty table store", + )); } - // Check primary key uniqueness - let pk_value = if !self.pk_columns.is_empty() { - let pk = extract_key(&row, &self.pk_columns); - if let Some(ref pk_index) = self.primary_index { - if pk_index.contains_index_key(&pk) { - return Err(Error::UniqueConstraint { - column: "primary_key".into(), - value: pk.to_error_value(), - }); - } + let secondary_defs: Vec<(String, Vec)> = self + .index_columns + .iter() + .map(|(name, cols)| (name.clone(), cols.clone())) + .collect(); + let gin_defs: Vec<(String, GinIndexConfig)> = self + .gin_index_configs + .iter() + .map(|(name, config)| (name.clone(), config.clone())) + .collect(); + let mut profiler = + InsertBatchProfiler::disabled(rows.len(), secondary_defs.len(), gin_defs.len()); + self.bulk_load_with_profiler(rows, &secondary_defs, &gin_defs, &mut profiler) + } + + fn bulk_load_with_profiler( + &mut self, + rows: Vec, + secondary_defs: &[(String, Vec)], + gin_defs: &[(String, GinIndexConfig)], + profiler: &mut InsertBatchProfiler, + ) -> Result { + debug_assert!(rows + .windows(2) + .all(|window| window[0].id() <= window[1].id())); + + let mut row_id_entries = Vec::with_capacity(rows.len()); + let mut primary_entries = self + .primary_index + .as_ref() + .map(|_| Vec::with_capacity(rows.len())); + let mut secondary_entries: Vec> = secondary_defs + .iter() + .map(|_| Vec::with_capacity(rows.len())) + .collect(); + for row in &rows { + let row_id = row.id(); + row_id_entries.push((Value::Int64(row_id as i64), row_id)); + if let Some(entries) = primary_entries.as_mut() { + entries.push((extract_key(row, &self.pk_columns), row_id)); } - Some(pk) - } else { - None - }; + for ((_, cols), entries) in secondary_defs.iter().zip(secondary_entries.iter_mut()) { + entries.push((extract_key(row, cols), row_id)); + } + } - // Add to row ID index - self.row_id_index - .add(Value::Int64(row_id as i64), row_id) - .map_err(|_| Error::invalid_operation("Failed to add to row ID index"))?; + let started = profiler.start_timer(); + if self.row_id_index.add_batch(&row_id_entries).is_err() { + self.clear(); + return Err(Error::invalid_operation( + "Failed to add to row ID index during bulk load", + )); + } + profiler.finish_phase(InsertProfilePhase::RowIdIndex, started); - // Add to primary key index - if let (Some(ref mut pk_index), Some(pk)) = (&mut self.primary_index, pk_value.clone()) { - if pk_index.add_index_key(pk.clone(), row_id).is_err() { - self.row_id_index - .remove(&Value::Int64(row_id as i64), Some(row_id)); - return Err(Error::UniqueConstraint { + if let Some(ref mut pk_index) = self.primary_index { + let started = profiler.start_timer(); + let violation = primary_entries + .as_ref() + .and_then(|entries| { + pk_index + .add_batch_index_keys(entries) + .err() + .map(|_| entries) + }) + .and_then(|entries| first_duplicate_batch_key(entries)) + .map(|pk: IndexKey| Error::UniqueConstraint { column: "primary_key".into(), value: pk.to_error_value(), }); + profiler.finish_phase(InsertProfilePhase::PrimaryIndex, started); + if let Some(error) = violation { + self.clear(); + return Err(error); } } - // Add to secondary indices - // Collect index names first to avoid borrow conflict - let index_names: Vec = self.index_columns.keys().cloned().collect(); - for idx_name in &index_names { - let cols = &self.index_columns[idx_name]; - let key = extract_key(&row, cols); - if let Some(idx) = self.secondary_indices.get_mut(idx_name) { - if idx.add_index_key(key.clone(), row_id).is_err() { - self.rollback_insert(row_id, &row); - return Err(Error::UniqueConstraint { + let started = profiler.start_timer(); + for ((idx_name, _), entries) in secondary_defs.iter().zip(secondary_entries.iter()) { + let mut violation = None; + { + let Some(idx) = self.secondary_indices.get_mut(idx_name) else { + continue; + }; + if idx.add_batch_index_keys(entries).is_err() { + let value = first_duplicate_batch_key(entries) + .map(|key| key.to_error_value()) + .unwrap_or(Value::Null); + violation = Some(Error::UniqueConstraint { column: idx_name.clone(), - value: key.to_error_value(), + value, }); } } + if let Some(error) = violation { + profiler.finish_phase(InsertProfilePhase::SecondaryIndex, started); + self.clear(); + return Err(error); + } } + profiler.finish_phase(InsertProfilePhase::SecondaryIndex, started); - // Add to GIN indices - let gin_index_names: Vec = self.gin_index_columns.keys().cloned().collect(); - for idx_name in &gin_index_names { - let col_idx = self.gin_index_columns[idx_name]; - if let Some(gin_idx) = self.gin_indices.get_mut(idx_name) { - if let Some(value) = row.get(col_idx) { - Self::index_jsonb_value(gin_idx, value, row_id); + for (idx_name, config) in gin_defs { + let Some(gin_idx) = self.gin_indices.get_mut(idx_name) else { + continue; + }; + let mut builder = GinBulkBuilder::new(); + let collect_started = profiler.start_timer(); + for row in &rows { + if let Some(value) = row.get(config.column_idx) { + Self::collect_jsonb_value_into_builder_profiled( + &mut builder, + value, + row.id(), + config.compiled_indexed_paths.as_deref(), + config.compiled_indexed_path_tree.as_ref(), + profiler, + ); } } + profiler.finish_phase(InsertProfilePhase::GinCollect, collect_started); + let flush_started = profiler.start_timer(); + gin_idx.apply_bulk_builder(builder); + profiler.finish_phase(InsertProfilePhase::GinFlush, flush_started); } - self.insert_row_slot(row_id, Rc::new(row)); - Ok(row_id) - } + self.row_slots.reserve(rows.len()); + self.scan_order.reserve(rows.len()); - fn rollback_insert(&mut self, row_id: RowId, row: &Row) { - self.row_id_index - .remove(&Value::Int64(row_id as i64), Some(row_id)); + let started = profiler.start_timer(); + for (slot_idx, row) in rows.into_iter().enumerate() { + let row_id = row.id(); + self.rows.insert(row_id, slot_idx); + self.row_slots.push(RowSlot { + row_id, + row: Rc::new(row), + }); + self.scan_order.push(slot_idx); + } + profiler.finish_phase(InsertProfilePhase::RowSlot, started); - if let Some(ref mut pk_index) = self.primary_index { - let pk_value = extract_key(row, &self.pk_columns); - pk_index.remove_index_key(&pk_value, Some(row_id)); + Ok(self.row_slots.len()) + } + + /// Inserts multiple rows while batching GIN index maintenance per insert call. + /// + /// The visible semantics remain aligned with sequential inserts: rows accepted + /// before a later validation failure remain committed. + pub fn insert_batch(&mut self, rows: Vec) -> Result { + if rows.is_empty() { + return Ok(0); + } + + let secondary_defs: Vec = self + .index_columns + .iter() + .map(|(name, cols)| BatchSecondaryDef { + name: name.clone(), + cols: cols.clone(), + unique: self + .secondary_indices + .get(name) + .map(|idx| idx.is_unique()) + .unwrap_or(false), + }) + .collect(); + let gin_defs: Vec<(String, GinIndexConfig)> = self + .gin_index_configs + .iter() + .map(|(name, config)| (name.clone(), config.clone())) + .collect(); + let mut profiler = + InsertBatchProfiler::disabled(rows.len(), secondary_defs.len(), gin_defs.len()); + self.insert_batch_with_profiler(rows, &secondary_defs, &gin_defs, &mut profiler) + } + + #[cfg(feature = "benchmark")] + pub fn insert_batch_profiled( + &mut self, + rows: Vec, + now_ms: fn() -> f64, + ) -> Result<(usize, StorageInsertProfile)> { + let secondary_defs: Vec = self + .index_columns + .iter() + .map(|(name, cols)| BatchSecondaryDef { + name: name.clone(), + cols: cols.clone(), + unique: self + .secondary_indices + .get(name) + .map(|idx| idx.is_unique()) + .unwrap_or(false), + }) + .collect(); + let gin_defs: Vec<(String, GinIndexConfig)> = self + .gin_index_configs + .iter() + .map(|(name, config)| (name.clone(), config.clone())) + .collect(); + let total_started = now_ms(); + let mut profiler = + InsertBatchProfiler::enabled(rows.len(), secondary_defs.len(), gin_defs.len(), now_ms); + let inserted = + self.insert_batch_with_profiler(rows, &secondary_defs, &gin_defs, &mut profiler)?; + Ok((inserted, profiler.finish(now_ms() - total_started))) + } + + fn insert_batch_with_profiler( + &mut self, + rows: Vec, + secondary_defs: &[BatchSecondaryDef], + gin_defs: &[(String, GinIndexConfig)], + profiler: &mut InsertBatchProfiler, + ) -> Result { + let validation_started = profiler.start_timer(); + if self.is_empty() && self.can_use_bulk_insert_fast_path(&rows, secondary_defs) { + profiler.finish_phase(InsertProfilePhase::Validation, validation_started); + let bulk_secondary_defs: Vec<(String, Vec)> = secondary_defs + .iter() + .map(|def| (def.name.clone(), def.cols.clone())) + .collect(); + return self.bulk_load_with_profiler(rows, &bulk_secondary_defs, gin_defs, profiler); + } + let prepared_batch = self.prepare_batch_insert_merge(&rows, secondary_defs); + profiler.finish_phase(InsertProfilePhase::Validation, validation_started); + + if let Some(prepared_batch) = prepared_batch { + return self.apply_prepared_batch_insert_with_profiler( + rows, + prepared_batch, + secondary_defs, + gin_defs, + profiler, + ); + } + + let mut gin_builders: Vec = + gin_defs.iter().map(|_| GinBulkBuilder::new()).collect(); + + self.row_slots.reserve(rows.len()); + self.scan_order.reserve(rows.len()); + + let mut inserted = 0usize; + + for row in rows { + let row_id = row.id(); + + if self.rows.contains_key(&row_id) { + Self::flush_gin_bulk_builders_profiled( + &mut self.gin_indices, + gin_defs, + &mut gin_builders, + profiler, + ); + return Err(Error::invalid_operation("Row ID already exists")); + } + + let pk_value = if !self.pk_columns.is_empty() { + let pk = extract_key(&row, &self.pk_columns); + if let Some(ref pk_index) = self.primary_index { + if pk_index.contains_index_key(&pk) { + Self::flush_gin_bulk_builders_profiled( + &mut self.gin_indices, + gin_defs, + &mut gin_builders, + profiler, + ); + return Err(Error::UniqueConstraint { + column: "primary_key".into(), + value: pk.to_error_value(), + }); + } + } + Some(pk) + } else { + None + }; + + let mut secondary_keys = Vec::with_capacity(secondary_defs.len()); + for def in secondary_defs { + let key = extract_key(&row, &def.cols); + if def.unique { + if let Some(idx) = self.secondary_indices.get(&def.name) { + if idx.contains_index_key(&key) { + Self::flush_gin_bulk_builders_profiled( + &mut self.gin_indices, + gin_defs, + &mut gin_builders, + profiler, + ); + return Err(Error::UniqueConstraint { + column: def.name.clone(), + value: key.to_error_value(), + }); + } + } + } + secondary_keys.push(key); + } + + let started = profiler.start_timer(); + if self + .row_id_index + .add(Value::Int64(row_id as i64), row_id) + .is_err() + { + profiler.finish_phase(InsertProfilePhase::RowIdIndex, started); + Self::flush_gin_bulk_builders_profiled( + &mut self.gin_indices, + gin_defs, + &mut gin_builders, + profiler, + ); + return Err(Error::invalid_operation("Failed to add to row ID index")); + } + profiler.finish_phase(InsertProfilePhase::RowIdIndex, started); + + let started = profiler.start_timer(); + if let (Some(ref mut pk_index), Some(pk)) = (&mut self.primary_index, pk_value.clone()) + { + if pk_index.add_index_key(pk.clone(), row_id).is_err() { + self.row_id_index + .remove(&Value::Int64(row_id as i64), Some(row_id)); + profiler.finish_phase(InsertProfilePhase::PrimaryIndex, started); + Self::flush_gin_bulk_builders_profiled( + &mut self.gin_indices, + gin_defs, + &mut gin_builders, + profiler, + ); + return Err(Error::UniqueConstraint { + column: "primary_key".into(), + value: pk.to_error_value(), + }); + } + } + profiler.finish_phase(InsertProfilePhase::PrimaryIndex, started); + + for (secondary_offset, key) in secondary_keys.iter().enumerate() { + let def = &secondary_defs[secondary_offset]; + if let Some(idx) = self.secondary_indices.get_mut(&def.name) { + let started = profiler.start_timer(); + if idx.add_index_key(key.clone(), row_id).is_err() { + profiler.finish_phase(InsertProfilePhase::SecondaryIndex, started); + for (rollback_offset, rollback_key) in + secondary_keys.iter().take(secondary_offset).enumerate() + { + let rollback_def = &secondary_defs[rollback_offset]; + if let Some(rollback_idx) = + self.secondary_indices.get_mut(&rollback_def.name) + { + rollback_idx.remove_index_key(rollback_key, Some(row_id)); + } + } + if let (Some(ref mut pk_index), Some(pk)) = + (&mut self.primary_index, pk_value.as_ref()) + { + pk_index.remove_index_key(pk, Some(row_id)); + } + self.row_id_index + .remove(&Value::Int64(row_id as i64), Some(row_id)); + Self::flush_gin_bulk_builders_profiled( + &mut self.gin_indices, + gin_defs, + &mut gin_builders, + profiler, + ); + return Err(Error::UniqueConstraint { + column: def.name.clone(), + value: key.to_error_value(), + }); + } + profiler.finish_phase(InsertProfilePhase::SecondaryIndex, started); + } + } + + let started = profiler.start_timer(); + for ((_, config), builder) in gin_defs.iter().zip(gin_builders.iter_mut()) { + if let Some(value) = row.get(config.column_idx) { + Self::collect_jsonb_value_into_builder_profiled( + builder, + value, + row_id, + config.compiled_indexed_paths.as_deref(), + config.compiled_indexed_path_tree.as_ref(), + profiler, + ); + } + } + profiler.finish_phase(InsertProfilePhase::GinCollect, started); + + let started = profiler.start_timer(); + self.insert_row_slot(row_id, Rc::new(row)); + profiler.finish_phase(InsertProfilePhase::RowSlot, started); + inserted += 1; + } + + Self::flush_gin_bulk_builders_profiled( + &mut self.gin_indices, + gin_defs, + &mut gin_builders, + profiler, + ); + Ok(inserted) + } + + fn prepare_batch_insert_merge( + &self, + rows: &[Row], + secondary_defs: &[BatchSecondaryDef], + ) -> Option { + if rows.is_empty() + || !rows + .windows(2) + .all(|window| window[0].id() <= window[1].id()) + { + return None; + } + + let mut row_id_entries = Vec::with_capacity(rows.len()); + let mut primary_entries = + (!self.pk_columns.is_empty()).then(|| Vec::with_capacity(rows.len())); + let mut secondary_entries: Vec> = secondary_defs + .iter() + .map(|_| Vec::with_capacity(rows.len())) + .collect(); + + let mut seen_row_ids = BTreeSet::new(); + let mut seen_primary_keys = (!self.pk_columns.is_empty()).then(BTreeSet::new); + let mut seen_secondary_keys: Vec>> = secondary_defs + .iter() + .map(|def| def.unique.then(BTreeSet::new)) + .collect(); + + for row in rows { + let row_id = row.id(); + if self.rows.contains_key(&row_id) || !seen_row_ids.insert(row_id) { + return None; + } + row_id_entries.push((Value::Int64(row_id as i64), row_id)); + + if let Some(entries) = primary_entries.as_mut() { + let pk = extract_key(row, &self.pk_columns); + if self + .primary_index + .as_ref() + .is_some_and(|pk_index| pk_index.contains_index_key(&pk)) + { + return None; + } + if !seen_primary_keys + .as_mut() + .is_some_and(|seen| seen.insert(pk.clone())) + { + return None; + } + entries.push((pk, row_id)); + } + + for ((def, entries), maybe_seen) in secondary_defs + .iter() + .zip(secondary_entries.iter_mut()) + .zip(seen_secondary_keys.iter_mut()) + { + let key = extract_key(row, &def.cols); + if let Some(seen) = maybe_seen.as_mut() { + if self + .secondary_indices + .get(&def.name) + .is_some_and(|idx| idx.contains_index_key(&key)) + || !seen.insert(key.clone()) + { + return None; + } + } + entries.push((key, row_id)); + } + } + + Some(PreparedBatchInsert { + row_id_entries, + primary_entries, + secondary_entries, + }) + } + + fn apply_prepared_batch_insert_with_profiler( + &mut self, + rows: Vec, + prepared_batch: PreparedBatchInsert, + secondary_defs: &[BatchSecondaryDef], + gin_defs: &[(String, GinIndexConfig)], + profiler: &mut InsertBatchProfiler, + ) -> Result { + let PreparedBatchInsert { + row_id_entries, + primary_entries, + secondary_entries, + } = prepared_batch; + + let row_count = rows.len(); + + let started = profiler.start_timer(); + if self.row_id_index.add_batch(&row_id_entries).is_err() { + profiler.finish_phase(InsertProfilePhase::RowIdIndex, started); + return Err(Error::invalid_operation("Failed to add to row ID index")); + } + profiler.finish_phase(InsertProfilePhase::RowIdIndex, started); + + let started = profiler.start_timer(); + if let (Some(ref mut pk_index), Some(entries)) = + (&mut self.primary_index, primary_entries.as_ref()) + { + if pk_index.add_batch_index_keys(entries).is_err() { + self.row_id_index.remove_batch(&row_id_entries); + profiler.finish_phase(InsertProfilePhase::PrimaryIndex, started); + let value = first_duplicate_batch_key(entries) + .map(|pk| pk.to_error_value()) + .unwrap_or(Value::Null); + return Err(Error::UniqueConstraint { + column: "primary_key".into(), + value, + }); + } + } + profiler.finish_phase(InsertProfilePhase::PrimaryIndex, started); + + let started = profiler.start_timer(); + for (secondary_offset, def) in secondary_defs.iter().enumerate() { + let entries = &secondary_entries[secondary_offset]; + let Some(idx) = self.secondary_indices.get_mut(&def.name) else { + continue; + }; + if idx.add_batch_index_keys(entries).is_err() { + for (rollback_def, rollback_entries) in secondary_defs + .iter() + .zip(secondary_entries.iter()) + .take(secondary_offset) + { + if let Some(rollback_idx) = self.secondary_indices.get_mut(&rollback_def.name) { + rollback_idx.remove_batch_index_keys(rollback_entries); + } + } + if let (Some(ref mut pk_index), Some(entries)) = + (&mut self.primary_index, primary_entries.as_ref()) + { + pk_index.remove_batch_index_keys(entries); + } + self.row_id_index.remove_batch(&row_id_entries); + profiler.finish_phase(InsertProfilePhase::SecondaryIndex, started); + let value = first_duplicate_batch_key(entries) + .map(|key| key.to_error_value()) + .unwrap_or(Value::Null); + return Err(Error::UniqueConstraint { + column: def.name.clone(), + value, + }); + } + } + profiler.finish_phase(InsertProfilePhase::SecondaryIndex, started); + + let mut gin_builders: Vec = + gin_defs.iter().map(|_| GinBulkBuilder::new()).collect(); + let started = profiler.start_timer(); + for row in &rows { + let row_id = row.id(); + for ((_, config), builder) in gin_defs.iter().zip(gin_builders.iter_mut()) { + if let Some(value) = row.get(config.column_idx) { + Self::collect_jsonb_value_into_builder_profiled( + builder, + value, + row_id, + config.compiled_indexed_paths.as_deref(), + config.compiled_indexed_path_tree.as_ref(), + profiler, + ); + } + } + } + profiler.finish_phase(InsertProfilePhase::GinCollect, started); + + Self::flush_gin_bulk_builders_profiled( + &mut self.gin_indices, + gin_defs, + &mut gin_builders, + profiler, + ); + + self.row_slots.reserve(row_count); + self.scan_order.reserve(row_count); + + let started = profiler.start_timer(); + for row in rows { + let row_id = row.id(); + self.insert_row_slot(row_id, Rc::new(row)); + } + profiler.finish_phase(InsertProfilePhase::RowSlot, started); + + Ok(row_count) + } + + fn flush_gin_bulk_builders( + gin_indices: &mut BTreeMap, + gin_defs: &[(String, GinIndexConfig)], + gin_builders: &mut [GinBulkBuilder], + ) { + for ((idx_name, _), builder) in gin_defs.iter().zip(gin_builders.iter_mut()) { + let Some(gin_idx) = gin_indices.get_mut(idx_name) else { + continue; + }; + gin_idx.apply_bulk_builder(core::mem::take(builder)); + } + } + + fn flush_gin_bulk_builders_profiled( + gin_indices: &mut BTreeMap, + gin_defs: &[(String, GinIndexConfig)], + gin_builders: &mut [GinBulkBuilder], + profiler: &mut InsertBatchProfiler, + ) { + let started = profiler.start_timer(); + Self::flush_gin_bulk_builders(gin_indices, gin_defs, gin_builders); + profiler.finish_phase(InsertProfilePhase::GinFlush, started); + } + + fn can_use_bulk_insert_fast_path( + &self, + rows: &[Row], + secondary_defs: &[BatchSecondaryDef], + ) -> bool { + if !rows + .windows(2) + .all(|window| window[0].id() <= window[1].id()) + { + return false; + } + + let mut seen_row_ids = BTreeSet::new(); + let mut seen_primary_keys = BTreeSet::new(); + let mut seen_secondary_keys: Vec>> = secondary_defs + .iter() + .map(|def| def.unique.then(BTreeSet::new)) + .collect(); + + for row in rows { + if !seen_row_ids.insert(row.id()) { + return false; + } + + if !self.pk_columns.is_empty() { + let pk = extract_key(row, &self.pk_columns); + if !seen_primary_keys.insert(pk) { + return false; + } + } + + for (def, maybe_seen) in secondary_defs.iter().zip(seen_secondary_keys.iter_mut()) { + let Some(seen) = maybe_seen.as_mut() else { + continue; + }; + let key = extract_key(row, &def.cols); + if !seen.insert(key) { + return false; + } + } + } + + true + } + + /// Inserts a row into the store. + pub fn insert(&mut self, row: Row) -> Result { + let row_id = row.id(); + + if self.rows.contains_key(&row_id) { + return Err(Error::invalid_operation("Row ID already exists")); + } + + // Check primary key uniqueness + let pk_value = if !self.pk_columns.is_empty() { + let pk = extract_key(&row, &self.pk_columns); + if let Some(ref pk_index) = self.primary_index { + if pk_index.contains_index_key(&pk) { + return Err(Error::UniqueConstraint { + column: "primary_key".into(), + value: pk.to_error_value(), + }); + } + } + Some(pk) + } else { + None + }; + + // Add to row ID index + self.row_id_index + .add(Value::Int64(row_id as i64), row_id) + .map_err(|_| Error::invalid_operation("Failed to add to row ID index"))?; + + // Add to primary key index + if let (Some(ref mut pk_index), Some(pk)) = (&mut self.primary_index, pk_value.clone()) { + if pk_index.add_index_key(pk.clone(), row_id).is_err() { + self.row_id_index + .remove(&Value::Int64(row_id as i64), Some(row_id)); + return Err(Error::UniqueConstraint { + column: "primary_key".into(), + value: pk.to_error_value(), + }); + } + } + + // Add to secondary indices + // Collect index names first to avoid borrow conflict + let index_names: Vec = self.index_columns.keys().cloned().collect(); + for idx_name in &index_names { + let cols = &self.index_columns[idx_name]; + let key = extract_key(&row, cols); + if let Some(idx) = self.secondary_indices.get_mut(idx_name) { + if idx.add_index_key(key.clone(), row_id).is_err() { + self.rollback_insert(row_id, &row); + return Err(Error::UniqueConstraint { + column: idx_name.clone(), + value: key.to_error_value(), + }); + } + } + } + + // Add to GIN indices + let gin_index_names: Vec = self.gin_index_configs.keys().cloned().collect(); + for idx_name in &gin_index_names { + let Some(config) = self.gin_index_configs.get(idx_name).cloned() else { + continue; + }; + if let Some(gin_idx) = self.gin_indices.get_mut(idx_name) { + if let Some(value) = row.get(config.column_idx) { + Self::index_jsonb_value( + gin_idx, + value, + row_id, + config.compiled_indexed_paths.as_deref(), + config.compiled_indexed_path_tree.as_ref(), + ); + } + } + } + + self.insert_row_slot(row_id, Rc::new(row)); + Ok(row_id) + } + + fn rollback_insert(&mut self, row_id: RowId, row: &Row) { + self.row_id_index + .remove(&Value::Int64(row_id as i64), Some(row_id)); + + if let Some(ref mut pk_index) = self.primary_index { + let pk_value = extract_key(row, &self.pk_columns); + pk_index.remove_index_key(&pk_value, Some(row_id)); } let index_names: Vec = self.index_columns.keys().cloned().collect(); @@ -806,12 +1988,20 @@ impl RowStore { } // Remove from GIN indices - let gin_index_names: Vec = self.gin_index_columns.keys().cloned().collect(); + let gin_index_names: Vec = self.gin_index_configs.keys().cloned().collect(); for idx_name in &gin_index_names { - let col_idx = self.gin_index_columns[idx_name]; + let Some(config) = self.gin_index_configs.get(idx_name).cloned() else { + continue; + }; if let Some(gin_idx) = self.gin_indices.get_mut(idx_name) { - if let Some(value) = row.get(col_idx) { - Self::remove_jsonb_from_gin(gin_idx, value, row_id); + if let Some(value) = row.get(config.column_idx) { + Self::remove_jsonb_from_gin( + gin_idx, + value, + row_id, + config.compiled_indexed_paths.as_deref(), + config.compiled_indexed_path_tree.as_ref(), + ); } } } @@ -879,19 +2069,33 @@ impl RowStore { } // Update GIN indices - let gin_index_names: Vec = self.gin_index_columns.keys().cloned().collect(); + let gin_index_names: Vec = self.gin_index_configs.keys().cloned().collect(); for idx_name in &gin_index_names { - let col_idx = self.gin_index_columns[idx_name]; + let Some(config) = self.gin_index_configs.get(idx_name).cloned() else { + continue; + }; if let Some(gin_idx) = self.gin_indices.get_mut(idx_name) { - let old_value = old_row.get(col_idx); - let new_value = new_row.get(col_idx); + let old_value = old_row.get(config.column_idx); + let new_value = new_row.get(config.column_idx); // Only update if the JSONB value changed if old_value != new_value { if let Some(old_val) = old_value { - Self::remove_jsonb_from_gin(gin_idx, old_val, row_id); + Self::remove_jsonb_from_gin( + gin_idx, + old_val, + row_id, + config.compiled_indexed_paths.as_deref(), + config.compiled_indexed_path_tree.as_ref(), + ); } if let Some(new_val) = new_value { - Self::index_jsonb_value(gin_idx, new_val, row_id); + Self::index_jsonb_value( + gin_idx, + new_val, + row_id, + config.compiled_indexed_paths.as_deref(), + config.compiled_indexed_path_tree.as_ref(), + ); } } } @@ -927,12 +2131,20 @@ impl RowStore { } // Remove from GIN indices - let gin_index_names: Vec = self.gin_index_columns.keys().cloned().collect(); + let gin_index_names: Vec = self.gin_index_configs.keys().cloned().collect(); for idx_name in &gin_index_names { - let col_idx = self.gin_index_columns[idx_name]; + let Some(config) = self.gin_index_configs.get(idx_name).cloned() else { + continue; + }; if let Some(gin_idx) = self.gin_indices.get_mut(idx_name) { - if let Some(value) = row.get(col_idx) { - Self::remove_jsonb_from_gin(gin_idx, value, row_id); + if let Some(value) = row.get(config.column_idx) { + Self::remove_jsonb_from_gin( + gin_idx, + value, + row_id, + config.compiled_indexed_paths.as_deref(), + config.compiled_indexed_path_tree.as_ref(), + ); } } } @@ -992,13 +2204,21 @@ impl RowStore { } // Remove from GIN indices - let gin_index_names: Vec = self.gin_index_columns.keys().cloned().collect(); + let gin_index_names: Vec = self.gin_index_configs.keys().cloned().collect(); for idx_name in &gin_index_names { - let col_idx = self.gin_index_columns[idx_name]; + let Some(config) = self.gin_index_configs.get(idx_name).cloned() else { + continue; + }; if let Some(gin_idx) = self.gin_indices.get_mut(idx_name) { for row in &deleted_rows { - if let Some(value) = row.get(col_idx) { - Self::remove_jsonb_from_gin(gin_idx, value, row.id()); + if let Some(value) = row.get(config.column_idx) { + Self::remove_jsonb_from_gin( + gin_idx, + value, + row.id(), + config.compiled_indexed_paths.as_deref(), + config.compiled_indexed_path_tree.as_ref(), + ); } } } @@ -1045,6 +2265,57 @@ impl RowStore { } } + /// Visits rows identified by a row-id subset in the same deterministic order as table scans. + /// Missing row ids are skipped. + pub fn visit_rows_by_ids(&self, row_ids: &[RowId], mut visitor: F) + where + F: FnMut(&Rc) -> bool, + { + for &row_id in row_ids { + let Some(row) = self.row_ref_by_id(row_id) else { + continue; + }; + if !visitor(row) { + break; + } + } + } + + /// Visits rows identified by an arbitrary row-id iterator. + /// Missing row ids are skipped and iteration order follows the provided iterator. + pub fn visit_rows_by_iter(&self, row_ids: I, mut visitor: F) + where + I: IntoIterator, + F: FnMut(&Rc) -> bool, + { + for row_id in row_ids { + let Some(row) = self.row_ref_by_id(row_id) else { + continue; + }; + if !visitor(row) { + break; + } + } + } + + /// Returns rows identified by a row-id subset in scan order. + pub fn get_rows_by_ids(&self, row_ids: &[RowId]) -> Vec> { + let mut rows = Vec::with_capacity(row_ids.len().min(self.len())); + self.visit_rows_by_ids(row_ids, |row| { + rows.push(row.clone()); + true + }); + rows + } + + /// Counts how many row ids in a subset still exist in the store. + pub fn count_existing_rows_by_ids(&self, row_ids: &[RowId]) -> usize { + row_ids + .iter() + .filter(|row_id| self.rows.contains_key(row_id)) + .count() + } + /// Returns all row IDs. pub fn row_ids(&self) -> Vec { self.scan_order @@ -1076,11 +2347,41 @@ impl RowStore { } } - /// Finds existing row ID by primary key. - pub fn find_row_id_by_pk(&self, row: &Row) -> Option { - if let Some(ref pk_index) = self.primary_index { - let pk_value = extract_key(row, &self.pk_columns); - pk_index.get_index_key(&pk_value).first().copied() + /// Visits row ids matching the provided primary-key components. + pub fn visit_row_ids_by_pk_values(&self, pk_values: &[Value], mut visitor: F) + where + F: FnMut(RowId) -> bool, + { + if pk_values.len() != self.pk_columns.len() { + return; + } + + let Some(ref pk_index) = self.primary_index else { + return; + }; + let pk_key = extract_key_from_values(pk_values); + for row_id in pk_index.get_index_key(&pk_key) { + if !visitor(row_id) { + break; + } + } + } + + /// Finds the first row id matching the provided primary-key components. + pub fn find_row_id_by_pk_values(&self, pk_values: &[Value]) -> Option { + let mut found = None; + self.visit_row_ids_by_pk_values(pk_values, |row_id| { + found = Some(row_id); + false + }); + found + } + + /// Finds existing row ID by primary key. + pub fn find_row_id_by_pk(&self, row: &Row) -> Option { + if let Some(ref pk_index) = self.primary_index { + let pk_value = extract_key(row, &self.pk_columns); + pk_index.get_index_key(&pk_value).first().copied() } else { None } @@ -1194,6 +2495,109 @@ impl RowStore { } } + /// Visits row ids from a single-column index equality scan without materializing rows. + pub fn visit_index_row_ids_by_value(&self, index_name: &str, value: &Value, visitor: F) + where + F: FnMut(RowId) -> bool, + { + let Some(idx) = self.secondary_indices.get(index_name) else { + return; + }; + let Some(columns) = self.index_columns.get(index_name) else { + return; + }; + if columns.len() != 1 { + return; + } + + let range = IndexKey::from_scalar_range(Some(&KeyRange::only(value.clone()))); + idx.visit_range_index_keys(range.as_ref(), false, None, 0, visitor); + } + + /// Returns whether a row matches the scalar key range for a single-column index. + pub fn row_matches_index_range( + &self, + index_name: &str, + range: Option<&KeyRange>, + row: &Row, + ) -> bool { + let Some(columns) = self.index_columns.get(index_name) else { + return false; + }; + if columns.len() != 1 { + return false; + } + + let key = row.get(columns[0]).cloned().unwrap_or(Value::Null); + range.map_or(true, |range| range.contains(&key)) + } + + /// Visits rows from an index scan restricted to a row-id subset. + /// `subset_driven=true` iterates the subset directly; otherwise it intersects an index scan. + pub fn visit_index_scan_with_options_restricted( + &self, + index_name: &str, + range: Option<&KeyRange>, + limit: Option, + offset: usize, + reverse: bool, + allowed_row_ids: &[RowId], + subset_driven: bool, + mut visitor: F, + ) where + F: FnMut(&Rc) -> bool, + { + let mut skipped = 0usize; + let mut emitted = 0usize; + let mut visit_matched = |row: &Rc| { + if skipped < offset { + skipped += 1; + return true; + } + if let Some(limit) = limit { + if emitted >= limit { + return false; + } + } + emitted += 1; + visitor(row) + }; + + if subset_driven { + let mut visit_subset_row_id = |row_id: RowId| { + let Some(row) = self.row_ref_by_id(row_id) else { + return true; + }; + if !self.row_matches_index_range(index_name, range, row) { + return true; + } + visit_matched(row) + }; + + if reverse { + for &row_id in allowed_row_ids.iter().rev() { + if !visit_subset_row_id(row_id) { + break; + } + } + } else { + for &row_id in allowed_row_ids { + if !visit_subset_row_id(row_id) { + break; + } + } + } + return; + } + + self.visit_index_scan_with_options(index_name, range, None, 0, reverse, |row| { + if allowed_row_ids.binary_search(&row.id()).is_err() { + return true; + } + visit_matched(row) + }); + } + /// Gets rows by composite index scan. /// Use this for multi-column indexes where the bounds are real tuple keys. pub fn index_scan_composite( @@ -1292,6 +2696,93 @@ impl RowStore { ); } + /// Returns whether a row matches the tuple key range for a composite index. + pub fn row_matches_index_composite_range( + &self, + index_name: &str, + range: Option<&KeyRange>>, + row: &Row, + ) -> bool { + let Some(columns) = self.index_columns.get(index_name) else { + return false; + }; + if columns.len() <= 1 { + return false; + } + + let key: Vec = columns + .iter() + .map(|&column_index| row.get(column_index).cloned().unwrap_or(Value::Null)) + .collect(); + range.map_or(true, |range| range.contains(&key)) + } + + /// Visits rows from a composite index scan restricted to a row-id subset. + /// `subset_driven=true` iterates the subset directly; otherwise it intersects an index scan. + pub fn visit_index_scan_composite_with_options_restricted( + &self, + index_name: &str, + range: Option<&KeyRange>>, + limit: Option, + offset: usize, + reverse: bool, + allowed_row_ids: &[RowId], + subset_driven: bool, + mut visitor: F, + ) where + F: FnMut(&Rc) -> bool, + { + let mut skipped = 0usize; + let mut emitted = 0usize; + let mut visit_matched = |row: &Rc| { + if skipped < offset { + skipped += 1; + return true; + } + if let Some(limit) = limit { + if emitted >= limit { + return false; + } + } + emitted += 1; + visitor(row) + }; + + if subset_driven { + let mut visit_subset_row_id = |row_id: RowId| { + let Some(row) = self.row_ref_by_id(row_id) else { + return true; + }; + if !self.row_matches_index_composite_range(index_name, range, row) { + return true; + } + visit_matched(row) + }; + + if reverse { + for &row_id in allowed_row_ids.iter().rev() { + if !visit_subset_row_id(row_id) { + break; + } + } + } else { + for &row_id in allowed_row_ids { + if !visit_subset_row_id(row_id) { + break; + } + } + } + return; + } + + self.visit_index_scan_composite_with_options(index_name, range, None, 0, reverse, |row| { + if allowed_row_ids.binary_search(&row.id()).is_err() { + return true; + } + visit_matched(row) + }); + } + /// Clears all rows and indices. pub fn clear(&mut self) { self.rows.clear(); @@ -1419,13 +2910,83 @@ impl RowStore { // ========== GIN Index Methods ========== /// Indexes a JSONB value into the GIN index. - fn index_jsonb_value(gin_idx: &mut GinIndex, value: &Value, row_id: RowId) { + fn index_jsonb_value( + gin_idx: &mut GinIndex, + value: &Value, + row_id: RowId, + indexed_paths: Option<&[CompiledGinPath]>, + indexed_path_tree: Option<&CompiledGinPathTree>, + ) { + if let Some(paths) = indexed_paths { + if let Some(extracted) = + Self::extract_selected_jsonb_entries_from_text(value, paths, indexed_path_tree, None) + { + Self::index_extracted_jsonb_selected_paths(gin_idx, row_id, paths, extracted); + return; + } + } + let Some(parsed) = Self::parse_jsonb_value(value) else { return; }; + if let Some(paths) = indexed_paths { + Self::index_jsonb_selected_paths(gin_idx, &parsed, row_id, paths); + return; + } + + let mut current_path = String::new(); + Self::index_jsonb_node(gin_idx, &parsed, row_id, &mut current_path, None); + } + + fn collect_jsonb_value_into_builder_profiled( + builder: &mut GinBulkBuilder, + value: &Value, + row_id: RowId, + indexed_paths: Option<&[CompiledGinPath]>, + indexed_path_tree: Option<&CompiledGinPathTree>, + profiler: &mut InsertBatchProfiler, + ) { + if let Some(paths) = indexed_paths { + if let Some(extracted) = + Self::extract_selected_jsonb_entries_from_text( + value, + paths, + indexed_path_tree, + Some(profiler), + ) + { + Self::collect_extracted_jsonb_selected_paths_into_builder_profiled( + builder, row_id, paths, extracted, profiler, + ); + return; + } + } + + let parse_started = profiler.start_timer(); + let parsed = Self::parse_jsonb_value(value); + profiler.record_gin_parse_call(); + profiler.finish_gin_phase(GinInsertProfilePhase::ParseJson, parse_started); + let Some(parsed) = parsed else { + return; + }; + + if let Some(paths) = indexed_paths { + Self::collect_jsonb_selected_paths_into_builder_profiled( + builder, &parsed, row_id, paths, profiler, + ); + return; + } + let mut current_path = String::new(); - Self::index_jsonb_node(gin_idx, &parsed, row_id, &mut current_path); + Self::collect_jsonb_node_into_builder_profiled( + builder, + &parsed, + row_id, + &mut current_path, + None, + profiler, + ); } fn index_jsonb_node( @@ -1433,16 +2994,21 @@ impl RowStore { value: &ParsedJsonbValue, row_id: RowId, current_path: &mut String, + indexed_paths: Option<&[String]>, ) { match value { ParsedJsonbValue::Object(obj) => { for (key, child) in obj.iter() { let saved_len = current_path.len(); Self::append_gin_path_segment(current_path, key); - gin_idx.add_key(current_path.clone(), row_id); - Self::index_jsonb_scalar(gin_idx, current_path, child, row_id); - Self::index_jsonb_contains_prefilter(gin_idx, current_path, child, row_id); - Self::index_jsonb_node(gin_idx, child, row_id, current_path); + if Self::should_index_gin_path(indexed_paths, current_path) { + gin_idx.add_key(current_path.clone(), row_id); + Self::index_jsonb_scalar(gin_idx, current_path, child, row_id); + Self::index_jsonb_contains_prefilter(gin_idx, current_path, child, row_id); + } + if Self::should_descend_gin_path(indexed_paths, current_path) { + Self::index_jsonb_node(gin_idx, child, row_id, current_path, indexed_paths); + } current_path.truncate(saved_len); } } @@ -1451,10 +3017,14 @@ impl RowStore { let saved_len = current_path.len(); let segment = idx.to_string(); Self::append_gin_path_segment(current_path, &segment); - gin_idx.add_key(current_path.clone(), row_id); - Self::index_jsonb_scalar(gin_idx, current_path, child, row_id); - Self::index_jsonb_contains_prefilter(gin_idx, current_path, child, row_id); - Self::index_jsonb_node(gin_idx, child, row_id, current_path); + if Self::should_index_gin_path(indexed_paths, current_path) { + gin_idx.add_key(current_path.clone(), row_id); + Self::index_jsonb_scalar(gin_idx, current_path, child, row_id); + Self::index_jsonb_contains_prefilter(gin_idx, current_path, child, row_id); + } + if Self::should_descend_gin_path(indexed_paths, current_path) { + Self::index_jsonb_node(gin_idx, child, row_id, current_path, indexed_paths); + } current_path.truncate(saved_len); } } @@ -1480,34 +3050,62 @@ impl RowStore { row_id: RowId, ) { let value_str = value.stringify_for_contains(); - gin_idx.add_key_values(contains_trigram_pairs(current_path, &value_str), row_id); + gin_idx.add_contains_trigrams(current_path, &value_str, row_id); } - /// Removes JSONB value from the GIN index. - fn remove_jsonb_from_gin(gin_idx: &mut GinIndex, value: &Value, row_id: RowId) { - let Some(parsed) = Self::parse_jsonb_value(value) else { - return; - }; - - let mut current_path = String::new(); - Self::remove_jsonb_node(gin_idx, &parsed, row_id, &mut current_path); + fn index_jsonb_contains_prefilter_for_key( + gin_idx: &mut GinIndex, + contains_key: &str, + value: &ParsedJsonbValue, + row_id: RowId, + ) { + let value_str = value.stringify_for_contains(); + for gram in cynos_index::contains_trigrams(&value_str) { + gin_idx.add_key_value(contains_key.into(), gram, row_id); + } } - fn remove_jsonb_node( - gin_idx: &mut GinIndex, + fn collect_jsonb_node_into_builder_profiled( + builder: &mut GinBulkBuilder, value: &ParsedJsonbValue, row_id: RowId, current_path: &mut String, + indexed_paths: Option<&[String]>, + profiler: &mut InsertBatchProfiler, ) { match value { ParsedJsonbValue::Object(obj) => { for (key, child) in obj.iter() { let saved_len = current_path.len(); Self::append_gin_path_segment(current_path, key); - gin_idx.remove_key(current_path, row_id); - Self::remove_jsonb_scalar(gin_idx, current_path, child, row_id); - Self::remove_jsonb_contains_prefilter(gin_idx, current_path, child, row_id); - Self::remove_jsonb_node(gin_idx, child, row_id, current_path); + if Self::should_index_gin_path(indexed_paths, current_path) { + builder.add_key_ref(current_path, row_id); + profiler.record_gin_path_key_emit(); + Self::collect_jsonb_scalar_into_builder_profiled( + builder, + current_path, + child, + row_id, + profiler, + ); + Self::collect_jsonb_contains_prefilter_into_builder_profiled( + builder, + current_path, + child, + row_id, + profiler, + ); + } + if Self::should_descend_gin_path(indexed_paths, current_path) { + Self::collect_jsonb_node_into_builder_profiled( + builder, + child, + row_id, + current_path, + indexed_paths, + profiler, + ); + } current_path.truncate(saved_len); } } @@ -1516,10 +3114,34 @@ impl RowStore { let saved_len = current_path.len(); let segment = idx.to_string(); Self::append_gin_path_segment(current_path, &segment); - gin_idx.remove_key(current_path, row_id); - Self::remove_jsonb_scalar(gin_idx, current_path, child, row_id); - Self::remove_jsonb_contains_prefilter(gin_idx, current_path, child, row_id); - Self::remove_jsonb_node(gin_idx, child, row_id, current_path); + if Self::should_index_gin_path(indexed_paths, current_path) { + builder.add_key_ref(current_path, row_id); + profiler.record_gin_path_key_emit(); + Self::collect_jsonb_scalar_into_builder_profiled( + builder, + current_path, + child, + row_id, + profiler, + ); + Self::collect_jsonb_contains_prefilter_into_builder_profiled( + builder, + current_path, + child, + row_id, + profiler, + ); + } + if Self::should_descend_gin_path(indexed_paths, current_path) { + Self::collect_jsonb_node_into_builder_profiled( + builder, + child, + row_id, + current_path, + indexed_paths, + profiler, + ); + } current_path.truncate(saved_len); } } @@ -1527,77 +3149,763 @@ impl RowStore { } } - fn remove_jsonb_scalar( - gin_idx: &mut GinIndex, + fn collect_jsonb_scalar_into_builder_profiled( + builder: &mut GinBulkBuilder, current_path: &str, value: &ParsedJsonbValue, row_id: RowId, + profiler: &mut InsertBatchProfiler, ) { - if let Some(value_str) = Self::jsonb_scalar_to_index_value(value) { - gin_idx.remove_key_value(current_path, &value_str, row_id); + let started = profiler.start_timer(); + let value_str = Self::jsonb_scalar_to_index_value(value); + profiler.finish_gin_phase(GinInsertProfilePhase::ScalarEmit, started); + if let Some(value_str) = value_str { + profiler.record_gin_scalar_value(); + builder.add_key_value_ref(current_path, &value_str, row_id); } } - fn remove_jsonb_contains_prefilter( + fn index_jsonb_scalar_text_for_selected_path( gin_idx: &mut GinIndex, - current_path: &str, - value: &ParsedJsonbValue, + path: &CompiledGinPath, + scalar_text: &str, row_id: RowId, ) { - let value_str = value.stringify_for_contains(); - for (key, gram) in contains_trigram_pairs(current_path, &value_str) { - gin_idx.remove_key_value(&key, &gram, row_id); + let Some(value_str) = Self::json_text_scalar_to_index_value_ref(scalar_text) else { + return; + }; + gin_idx.add_key_value(path.encoded_path.clone(), value_str.as_str().into(), row_id); + for gram in cynos_index::contains_trigrams(value_str.as_str()) { + gin_idx.add_key_value(path.contains_key.clone(), gram, row_id); } } - fn parse_jsonb_value(value: &Value) -> Option { - let Value::Jsonb(jsonb) = value else { - return None; + fn collect_jsonb_scalar_text_into_builder_for_key_profiled( + builder: &mut GinBulkBuilder, + path: &CompiledGinPath, + scalar_text: &str, + row_id: RowId, + profiler: &mut InsertBatchProfiler, + ) { + let scalar_started = profiler.start_timer(); + let scalar_value = Self::json_text_scalar_to_index_value_ref(scalar_text); + profiler.finish_gin_phase(GinInsertProfilePhase::ScalarEmit, scalar_started); + let Some(scalar_value) = scalar_value else { + return; }; - let json_str = core::str::from_utf8(&jsonb.0).ok()?; - parse_json_text(json_str) - } - fn jsonb_scalar_to_index_value(value: &ParsedJsonbValue) -> Option { - match value { - ParsedJsonbValue::Null => Some("null".into()), - ParsedJsonbValue::Bool(b) => Some(if *b { "true" } else { "false" }.into()), - ParsedJsonbValue::Number(n) => Some(format!("{}", n)), - ParsedJsonbValue::String(s) => Some(s.clone()), - ParsedJsonbValue::Object(_) | ParsedJsonbValue::Array(_) => None, - } + profiler.record_gin_scalar_value(); + builder.add_key_value_ref(&path.encoded_path, scalar_value.as_str(), row_id); + + let stringify_started = profiler.start_timer(); + let contains_value = scalar_value.as_str(); + profiler.finish_gin_phase(GinInsertProfilePhase::ContainsStringify, stringify_started); + profiler.record_gin_contains_value(); + + let trigram_started = profiler.start_timer(); + let trigram_count = + builder.add_contains_trigrams_for_key(&path.contains_key, &contains_value, row_id); + profiler.finish_gin_phase(GinInsertProfilePhase::ContainsTrigramEmit, trigram_started); + profiler.record_gin_contains_trigrams(trigram_count); } - fn append_gin_path_segment(path: &mut String, segment: &str) { - if !path.is_empty() { - path.push('.'); - } + fn collect_jsonb_contains_prefilter_into_builder_profiled( + builder: &mut GinBulkBuilder, + current_path: &str, + value: &ParsedJsonbValue, + row_id: RowId, + profiler: &mut InsertBatchProfiler, + ) { + let stringify_started = profiler.start_timer(); + let value_str = value.stringify_for_contains(); + profiler.finish_gin_phase(GinInsertProfilePhase::ContainsStringify, stringify_started); + profiler.record_gin_contains_value(); - if segment.is_empty() { - path.push('\\'); - path.push('0'); - return; + let trigram_started = profiler.start_timer(); + let trigram_count = builder.add_contains_trigrams(current_path, &value_str, row_id); + profiler.finish_gin_phase(GinInsertProfilePhase::ContainsTrigramEmit, trigram_started); + profiler.record_gin_contains_trigrams(trigram_count); + } + + fn index_jsonb_selected_paths( + gin_idx: &mut GinIndex, + value: &ParsedJsonbValue, + row_id: RowId, + indexed_paths: &[CompiledGinPath], + ) { + for path in indexed_paths { + let Some(selected) = Self::jsonb_value_at_compiled_gin_path(value, path) else { + continue; + }; + gin_idx.add_key(path.encoded_path.clone(), row_id); + Self::index_jsonb_scalar(gin_idx, &path.encoded_path, selected, row_id); + Self::index_jsonb_contains_prefilter_for_key( + gin_idx, + &path.contains_key, + selected, + row_id, + ); } + } - for ch in segment.chars() { - match ch { - '\\' => { - path.push('\\'); - path.push('\\'); + fn index_extracted_jsonb_selected_paths( + gin_idx: &mut GinIndex, + row_id: RowId, + indexed_paths: &[CompiledGinPath], + extracted: Vec<(usize, ExtractedJsonbTextValue<'_>)>, + ) { + for (path_index, selected) in extracted { + let path = &indexed_paths[path_index]; + gin_idx.add_key(path.encoded_path.clone(), row_id); + match selected { + ExtractedJsonbTextValue::ScalarText(scalar_text) => { + Self::index_jsonb_scalar_text_for_selected_path( + gin_idx, + path, + scalar_text, + row_id, + ); } - '.' => { - path.push('\\'); - path.push('.'); + ExtractedJsonbTextValue::Parsed(selected) => { + Self::index_jsonb_scalar(gin_idx, &path.encoded_path, &selected, row_id); + Self::index_jsonb_contains_prefilter_for_key( + gin_idx, + &path.contains_key, + &selected, + row_id, + ); } - _ => path.push(ch), } } } - /// Queries the GIN index by key-value pair. - pub fn gin_index_get_by_key_value( - &self, - index_name: &str, + fn collect_jsonb_selected_paths_into_builder_profiled( + builder: &mut GinBulkBuilder, + value: &ParsedJsonbValue, + row_id: RowId, + indexed_paths: &[CompiledGinPath], + profiler: &mut InsertBatchProfiler, + ) { + for path in indexed_paths { + profiler.record_gin_selected_path_eval(); + let lookup_started = profiler.start_timer(); + let selected = Self::jsonb_value_at_compiled_gin_path(value, path); + profiler.finish_gin_phase(GinInsertProfilePhase::PathLookup, lookup_started); + let Some(selected) = selected else { + continue; + }; + + profiler.record_gin_selected_path_hit(); + builder.add_key_ref(&path.encoded_path, row_id); + profiler.record_gin_path_key_emit(); + Self::collect_jsonb_scalar_into_builder_profiled( + builder, + &path.encoded_path, + selected, + row_id, + profiler, + ); + Self::collect_jsonb_contains_prefilter_into_builder_for_key_profiled( + builder, + &path.contains_key, + selected, + row_id, + profiler, + ); + } + } + + fn collect_extracted_jsonb_selected_paths_into_builder_profiled( + builder: &mut GinBulkBuilder, + row_id: RowId, + indexed_paths: &[CompiledGinPath], + extracted: Vec<(usize, ExtractedJsonbTextValue<'_>)>, + profiler: &mut InsertBatchProfiler, + ) { + for (path_index, selected) in extracted { + let path = &indexed_paths[path_index]; + builder.add_key_ref(&path.encoded_path, row_id); + profiler.record_gin_path_key_emit(); + profiler.record_gin_selected_path_hit(); + match selected { + ExtractedJsonbTextValue::ScalarText(scalar_text) => { + Self::collect_jsonb_scalar_text_into_builder_for_key_profiled( + builder, + path, + scalar_text, + row_id, + profiler, + ); + } + ExtractedJsonbTextValue::Parsed(selected) => { + Self::collect_jsonb_scalar_into_builder_profiled( + builder, + &path.encoded_path, + &selected, + row_id, + profiler, + ); + Self::collect_jsonb_contains_prefilter_into_builder_for_key_profiled( + builder, + &path.contains_key, + &selected, + row_id, + profiler, + ); + } + } + } + } + + fn collect_jsonb_contains_prefilter_into_builder_for_key_profiled( + builder: &mut GinBulkBuilder, + contains_key: &str, + value: &ParsedJsonbValue, + row_id: RowId, + profiler: &mut InsertBatchProfiler, + ) { + let stringify_started = profiler.start_timer(); + let value_str = value.stringify_for_contains(); + profiler.finish_gin_phase(GinInsertProfilePhase::ContainsStringify, stringify_started); + profiler.record_gin_contains_value(); + + let trigram_started = profiler.start_timer(); + let trigram_count = builder.add_contains_trigrams_for_key(contains_key, &value_str, row_id); + profiler.finish_gin_phase(GinInsertProfilePhase::ContainsTrigramEmit, trigram_started); + profiler.record_gin_contains_trigrams(trigram_count); + } + + fn remove_jsonb_scalar_text_for_selected_path( + gin_idx: &mut GinIndex, + path: &CompiledGinPath, + scalar_text: &str, + row_id: RowId, + ) { + let Some(value_str) = Self::json_text_scalar_to_index_value_ref(scalar_text) else { + return; + }; + gin_idx.remove_key_value(&path.encoded_path, value_str.as_str(), row_id); + for gram in cynos_index::contains_trigrams(value_str.as_str()) { + gin_idx.remove_key_value(&path.contains_key, &gram, row_id); + } + } + + /// Removes JSONB value from the GIN index. + fn remove_jsonb_from_gin( + gin_idx: &mut GinIndex, + value: &Value, + row_id: RowId, + indexed_paths: Option<&[CompiledGinPath]>, + indexed_path_tree: Option<&CompiledGinPathTree>, + ) { + if let Some(paths) = indexed_paths { + if let Some(extracted) = + Self::extract_selected_jsonb_entries_from_text(value, paths, indexed_path_tree, None) + { + Self::remove_extracted_jsonb_selected_paths(gin_idx, row_id, paths, extracted); + return; + } + } + + let Some(parsed) = Self::parse_jsonb_value(value) else { + return; + }; + + if let Some(paths) = indexed_paths { + Self::remove_jsonb_selected_paths(gin_idx, &parsed, row_id, paths); + return; + } + + let mut current_path = String::new(); + Self::remove_jsonb_node(gin_idx, &parsed, row_id, &mut current_path, None); + } + + fn remove_jsonb_node( + gin_idx: &mut GinIndex, + value: &ParsedJsonbValue, + row_id: RowId, + current_path: &mut String, + indexed_paths: Option<&[String]>, + ) { + match value { + ParsedJsonbValue::Object(obj) => { + for (key, child) in obj.iter() { + let saved_len = current_path.len(); + Self::append_gin_path_segment(current_path, key); + if Self::should_index_gin_path(indexed_paths, current_path) { + gin_idx.remove_key(current_path, row_id); + Self::remove_jsonb_scalar(gin_idx, current_path, child, row_id); + Self::remove_jsonb_contains_prefilter(gin_idx, current_path, child, row_id); + } + if Self::should_descend_gin_path(indexed_paths, current_path) { + Self::remove_jsonb_node( + gin_idx, + child, + row_id, + current_path, + indexed_paths, + ); + } + current_path.truncate(saved_len); + } + } + ParsedJsonbValue::Array(items) => { + for (idx, child) in items.iter().enumerate() { + let saved_len = current_path.len(); + let segment = idx.to_string(); + Self::append_gin_path_segment(current_path, &segment); + if Self::should_index_gin_path(indexed_paths, current_path) { + gin_idx.remove_key(current_path, row_id); + Self::remove_jsonb_scalar(gin_idx, current_path, child, row_id); + Self::remove_jsonb_contains_prefilter(gin_idx, current_path, child, row_id); + } + if Self::should_descend_gin_path(indexed_paths, current_path) { + Self::remove_jsonb_node( + gin_idx, + child, + row_id, + current_path, + indexed_paths, + ); + } + current_path.truncate(saved_len); + } + } + _ => {} + } + } + + fn should_index_gin_path(indexed_paths: Option<&[String]>, current_path: &str) -> bool { + indexed_paths.map_or(true, |paths| { + paths + .iter() + .any(|indexed_path| indexed_path == current_path) + }) + } + + fn should_descend_gin_path(indexed_paths: Option<&[String]>, current_path: &str) -> bool { + indexed_paths.map_or(true, |paths| { + let mut prefix = String::with_capacity(current_path.len() + 1); + prefix.push_str(current_path); + prefix.push('.'); + paths + .iter() + .any(|indexed_path| indexed_path.starts_with(&prefix)) + }) + } + + fn remove_jsonb_scalar( + gin_idx: &mut GinIndex, + current_path: &str, + value: &ParsedJsonbValue, + row_id: RowId, + ) { + if let Some(value_str) = Self::jsonb_scalar_to_index_value(value) { + gin_idx.remove_key_value(current_path, &value_str, row_id); + } + } + + fn remove_jsonb_contains_prefilter( + gin_idx: &mut GinIndex, + current_path: &str, + value: &ParsedJsonbValue, + row_id: RowId, + ) { + let value_str = value.stringify_for_contains(); + for (key, gram) in contains_trigram_pairs(current_path, &value_str) { + gin_idx.remove_key_value(&key, &gram, row_id); + } + } + + fn remove_jsonb_contains_prefilter_for_key( + gin_idx: &mut GinIndex, + contains_key: &str, + value: &ParsedJsonbValue, + row_id: RowId, + ) { + let value_str = value.stringify_for_contains(); + for gram in cynos_index::contains_trigrams(&value_str) { + gin_idx.remove_key_value(contains_key, &gram, row_id); + } + } + + fn remove_jsonb_selected_paths( + gin_idx: &mut GinIndex, + value: &ParsedJsonbValue, + row_id: RowId, + indexed_paths: &[CompiledGinPath], + ) { + for path in indexed_paths { + let Some(selected) = Self::jsonb_value_at_compiled_gin_path(value, path) else { + continue; + }; + gin_idx.remove_key(&path.encoded_path, row_id); + Self::remove_jsonb_scalar(gin_idx, &path.encoded_path, selected, row_id); + Self::remove_jsonb_contains_prefilter_for_key( + gin_idx, + &path.contains_key, + selected, + row_id, + ); + } + } + + fn remove_extracted_jsonb_selected_paths( + gin_idx: &mut GinIndex, + row_id: RowId, + indexed_paths: &[CompiledGinPath], + extracted: Vec<(usize, ExtractedJsonbTextValue<'_>)>, + ) { + for (path_index, selected) in extracted { + let path = &indexed_paths[path_index]; + gin_idx.remove_key(&path.encoded_path, row_id); + match selected { + ExtractedJsonbTextValue::ScalarText(scalar_text) => { + Self::remove_jsonb_scalar_text_for_selected_path( + gin_idx, + path, + scalar_text, + row_id, + ); + } + ExtractedJsonbTextValue::Parsed(selected) => { + Self::remove_jsonb_scalar(gin_idx, &path.encoded_path, &selected, row_id); + Self::remove_jsonb_contains_prefilter_for_key( + gin_idx, + &path.contains_key, + &selected, + row_id, + ); + } + } + } + } + + fn parse_jsonb_value(value: &Value) -> Option { + let Value::Jsonb(jsonb) = value else { + return None; + }; + let json_str = core::str::from_utf8(&jsonb.0).ok()?; + parse_json_text(json_str) + } + + #[cfg(test)] + fn json_text_scalar_to_index_value(value_slice: &str) -> Option { + Self::json_text_scalar_to_index_value_ref(value_slice) + .map(JsonScalarIndexValue::into_owned) + } + + fn json_text_scalar_to_index_value_ref(value_slice: &str) -> Option> { + let value_slice = value_slice.trim(); + if value_slice == "null" || value_slice == "true" || value_slice == "false" { + return Some(JsonScalarIndexValue::Borrowed(value_slice)); + } + if value_slice.starts_with('"') && value_slice.ends_with('"') && value_slice.len() >= 2 { + let inner = &value_slice[1..value_slice.len() - 1]; + if !inner.as_bytes().contains(&b'\\') { + return Some(JsonScalarIndexValue::Borrowed(inner)); + } + return Some(JsonScalarIndexValue::Owned(unescape_json(inner))); + } + if is_fast_json_integer_literal(value_slice) { + return Some(JsonScalarIndexValue::Borrowed(value_slice)); + } + let number = value_slice.parse::().ok()?; + Some(JsonScalarIndexValue::Owned(format!("{number}"))) + } + + fn extract_selected_jsonb_entries_from_text<'a>( + value: &'a Value, + indexed_paths: &[CompiledGinPath], + indexed_path_tree: Option<&CompiledGinPathTree>, + profiler: Option<&mut InsertBatchProfiler>, + ) -> Option)>> { + let Value::Jsonb(jsonb) = value else { + return None; + }; + let json_text = core::str::from_utf8(&jsonb.0).ok()?; + let mut extracted = Vec::with_capacity(indexed_paths.len()); + let mut profiler = profiler; + let indexed_path_tree = indexed_path_tree?; + + if let Some(profiler) = profiler.as_deref_mut() { + for _ in indexed_paths { + profiler.record_gin_selected_path_eval(); + } + } + + let lookup_started = profiler + .as_deref_mut() + .and_then(|profiler| profiler.start_timer()); + if Self::extract_selected_jsonb_entries_from_slice( + json_text.trim(), + indexed_paths, + indexed_path_tree, + 0, + &mut extracted, + &mut profiler, + ) + .is_err() + { + return None; + } + if let Some(profiler) = profiler.as_deref_mut() { + profiler.finish_gin_phase(GinInsertProfilePhase::PathLookup, lookup_started); + } + + extracted.sort_unstable_by_key(|(path_index, _)| *path_index); + + Some(extracted) + } + + fn extract_selected_jsonb_entries_from_slice<'a>( + json_text: &'a str, + indexed_paths: &[CompiledGinPath], + indexed_path_tree: &CompiledGinPathTree, + node_index: usize, + extracted: &mut Vec<(usize, ExtractedJsonbTextValue<'a>)>, + profiler: &mut Option<&mut InsertBatchProfiler>, + ) -> core::result::Result<(), ()> { + let json_text = json_text.trim(); + let Some(node) = indexed_path_tree.nodes.get(node_index) else { + return Err(()); + }; + + for &path_index in &node.terminal_path_indices { + Self::push_extracted_jsonb_entry(json_text, path_index, extracted, profiler)?; + } + + if node.object_children.is_empty() && node.array_children.is_empty() { + return Ok(()); + } + + match json_text.as_bytes().first().copied() { + Some(b'{') => Self::extract_selected_jsonb_entries_from_object( + json_text, + indexed_paths, + indexed_path_tree, + node_index, + extracted, + profiler, + ), + Some(b'[') => Self::extract_selected_jsonb_entries_from_array( + json_text, + indexed_paths, + indexed_path_tree, + node_index, + extracted, + profiler, + ), + Some(_) => Ok(()), + None => Err(()), + } + } + + fn extract_selected_jsonb_entries_from_object<'a>( + json_text: &'a str, + indexed_paths: &[CompiledGinPath], + indexed_path_tree: &CompiledGinPathTree, + node_index: usize, + extracted: &mut Vec<(usize, ExtractedJsonbTextValue<'a>)>, + profiler: &mut Option<&mut InsertBatchProfiler>, + ) -> core::result::Result<(), ()> { + let bytes = json_text.as_bytes(); + if bytes.first().copied() != Some(b'{') { + return Err(()); + } + let Some(node) = indexed_path_tree.nodes.get(node_index) else { + return Err(()); + }; + + let mut index = 1usize; + loop { + index = skip_json_whitespace_bytes(bytes, index); + match bytes.get(index).copied() { + Some(b'}') => return Ok(()), + Some(b'"') => {} + Some(_) => return Err(()), + None => return Err(()), + } + + let key_end = scan_json_string_end_bytes(bytes, index).ok_or(())?; + let key = &json_text[index + 1..key_end - 1]; + index = skip_json_whitespace_bytes(bytes, key_end); + if bytes.get(index).copied() != Some(b':') { + return Err(()); + } + index = skip_json_whitespace_bytes(bytes, index + 1); + + let value_start = index; + let value_end = scan_json_value_end_bytes(bytes, value_start).ok_or(())?; + let value_slice = json_text[value_start..value_end].trim(); + if let Some(&child_node_index) = node.object_children.get(key) { + Self::extract_selected_jsonb_entries_from_slice( + value_slice, + indexed_paths, + indexed_path_tree, + child_node_index, + extracted, + profiler, + )?; + } + + index = skip_json_whitespace_bytes(bytes, value_end); + match bytes.get(index).copied() { + Some(b',') => index += 1, + Some(b'}') => return Ok(()), + Some(_) => return Err(()), + None => return Err(()), + } + } + } + + fn extract_selected_jsonb_entries_from_array<'a>( + json_text: &'a str, + indexed_paths: &[CompiledGinPath], + indexed_path_tree: &CompiledGinPathTree, + node_index: usize, + extracted: &mut Vec<(usize, ExtractedJsonbTextValue<'a>)>, + profiler: &mut Option<&mut InsertBatchProfiler>, + ) -> core::result::Result<(), ()> { + let bytes = json_text.as_bytes(); + if bytes.first().copied() != Some(b'[') { + return Err(()); + } + let Some(node) = indexed_path_tree.nodes.get(node_index) else { + return Err(()); + }; + + let mut index = 1usize; + let mut current_index = 0usize; + loop { + index = skip_json_whitespace_bytes(bytes, index); + match bytes.get(index).copied() { + Some(b']') => return Ok(()), + Some(_) => {} + None => return Err(()), + } + + let value_start = index; + let value_end = scan_json_value_end_bytes(bytes, value_start).ok_or(())?; + let value_slice = json_text[value_start..value_end].trim(); + if let Some(&child_node_index) = node.array_children.get(¤t_index) { + Self::extract_selected_jsonb_entries_from_slice( + value_slice, + indexed_paths, + indexed_path_tree, + child_node_index, + extracted, + profiler, + )?; + } + + current_index += 1; + index = skip_json_whitespace_bytes(bytes, value_end); + match bytes.get(index).copied() { + Some(b',') => index += 1, + Some(b']') => return Ok(()), + Some(_) => return Err(()), + None => return Err(()), + } + } + } + + fn push_extracted_jsonb_entry<'a>( + value_slice: &'a str, + path_index: usize, + extracted: &mut Vec<(usize, ExtractedJsonbTextValue<'a>)>, + profiler: &mut Option<&mut InsertBatchProfiler>, + ) -> core::result::Result<(), ()> { + match value_slice.as_bytes().first().copied() { + Some(b'{') | Some(b'[') => { + let parse_started = profiler + .as_deref_mut() + .and_then(|profiler| profiler.start_timer()); + let parsed = parse_json_text(value_slice); + if let Some(profiler) = profiler.as_deref_mut() { + profiler.record_gin_parse_call(); + profiler.finish_gin_phase(GinInsertProfilePhase::ParseJson, parse_started); + } + + let Some(parsed) = parsed else { + return Err(()); + }; + extracted.push((path_index, ExtractedJsonbTextValue::Parsed(parsed))); + } + Some(_) => { + extracted.push((path_index, ExtractedJsonbTextValue::ScalarText(value_slice))) + } + None => return Err(()), + } + + Ok(()) + } + + #[cfg(test)] + fn extract_selected_jsonb_values_from_text( + value: &Value, + indexed_paths: &[CompiledGinPath], + indexed_path_tree: Option<&CompiledGinPathTree>, + profiler: Option<&mut InsertBatchProfiler>, + ) -> Option> { + let extracted = + Self::extract_selected_jsonb_entries_from_text( + value, + indexed_paths, + indexed_path_tree, + profiler, + )?; + let mut parsed_values = Vec::with_capacity(extracted.len()); + for (path_index, value) in extracted { + let parsed = match value { + ExtractedJsonbTextValue::ScalarText(value_slice) => parse_json_text(value_slice)?, + ExtractedJsonbTextValue::Parsed(parsed) => parsed, + }; + parsed_values.push((path_index, parsed)); + } + Some(parsed_values) + } + + fn jsonb_scalar_to_index_value(value: &ParsedJsonbValue) -> Option { + match value { + ParsedJsonbValue::Null => Some("null".into()), + ParsedJsonbValue::Bool(b) => Some(if *b { "true" } else { "false" }.into()), + ParsedJsonbValue::Number(n) => Some(format!("{}", n)), + ParsedJsonbValue::String(s) => Some(s.clone()), + ParsedJsonbValue::Object(_) | ParsedJsonbValue::Array(_) => None, + } + } + + fn append_gin_path_segment(path: &mut String, segment: &str) { + if !path.is_empty() { + path.push('.'); + } + + if segment.is_empty() { + path.push('\\'); + path.push('0'); + return; + } + + for ch in segment.chars() { + match ch { + '\\' => { + path.push('\\'); + path.push('\\'); + } + '.' => { + path.push('\\'); + path.push('.'); + } + _ => path.push(ch), + } + } + } + + /// Queries the GIN index by key-value pair. + pub fn gin_index_get_by_key_value( + &self, + index_name: &str, key: &str, value: &str, ) -> Vec> { @@ -1666,6 +3974,104 @@ impl RowStore { }); } + /// Returns whether a row contains the indexed JSON path. + pub fn row_matches_gin_key(&self, index_name: &str, row: &Row, key: &str) -> bool { + self.row_gin_path_value(index_name, row, key).is_some() + } + + /// Returns whether a row contains the indexed JSON path with the expected scalar value. + pub fn row_matches_gin_key_value( + &self, + index_name: &str, + row: &Row, + key: &str, + value: &str, + ) -> bool { + self.row_contains_gin_token_pair(index_name, row, key, value) + } + + /// Returns whether a row satisfies a multi-key GIN predicate. + pub fn row_matches_gin_key_values( + &self, + index_name: &str, + row: &Row, + pairs: &[(&str, &str)], + match_all: bool, + ) -> bool { + if match_all { + pairs + .iter() + .all(|(key, value)| self.row_matches_gin_key_value(index_name, row, key, value)) + } else { + pairs + .iter() + .any(|(key, value)| self.row_matches_gin_key_value(index_name, row, key, value)) + } + } + + /// Visits rows matching a GIN key-value query restricted to a row-id subset. + pub fn visit_gin_index_by_key_value_restricted( + &self, + index_name: &str, + key: &str, + value: &str, + allowed_row_ids: &[RowId], + subset_driven: bool, + mut visitor: F, + ) where + F: FnMut(&Rc) -> bool, + { + if subset_driven { + self.visit_rows_by_ids(allowed_row_ids, |row| { + if self.row_matches_gin_key_value(index_name, row, key, value) { + visitor(row) + } else { + true + } + }); + return; + } + + self.visit_gin_index_by_key_value(index_name, key, value, |row| { + if allowed_row_ids.binary_search(&row.id()).is_ok() { + visitor(row) + } else { + true + } + }); + } + + /// Visits rows matching a GIN key-existence query restricted to a row-id subset. + pub fn visit_gin_index_by_key_restricted( + &self, + index_name: &str, + key: &str, + allowed_row_ids: &[RowId], + subset_driven: bool, + mut visitor: F, + ) where + F: FnMut(&Rc) -> bool, + { + if subset_driven { + self.visit_rows_by_ids(allowed_row_ids, |row| { + if self.row_matches_gin_key(index_name, row, key) { + visitor(row) + } else { + true + } + }); + return; + } + + self.visit_gin_index_by_key(index_name, key, |row| { + if allowed_row_ids.binary_search(&row.id()).is_ok() { + visitor(row) + } else { + true + } + }); + } + /// Queries the GIN index by multiple key-value pairs (AND query). /// Returns rows that match ALL of the given key-value pairs. pub fn gin_index_get_by_key_values_all( @@ -1684,6 +4090,24 @@ impl RowStore { } } + /// Queries the GIN index by multiple key-value pairs (OR query). + /// Returns rows that match ANY of the given key-value pairs. + pub fn gin_index_get_by_key_values_any( + &self, + index_name: &str, + pairs: &[(&str, &str)], + ) -> Vec> { + if let Some(gin_idx) = self.gin_indices.get(index_name) { + let row_ids = gin_idx.get_by_key_values_any(pairs); + row_ids + .iter() + .filter_map(|&id| self.row_ref_by_id(id).cloned()) + .collect() + } else { + Vec::new() + } + } + /// Visits rows matching all GIN key-value pairs without materializing the full row set. /// Return `false` from the visitor to stop early. pub fn visit_gin_index_by_key_values_all( @@ -1702,36 +4126,289 @@ impl RowStore { let Some(row) = self.row_ref_by_id(row_id) else { return true; }; - visitor(row) - }); + visitor(row) + }); + } + + /// Visits rows matching any GIN key-value pairs without materializing the full row set. + /// Return `false` from the visitor to stop early. + pub fn visit_gin_index_by_key_values_any( + &self, + index_name: &str, + pairs: &[(&str, &str)], + mut visitor: F, + ) where + F: FnMut(&Rc) -> bool, + { + let Some(gin_idx) = self.gin_indices.get(index_name) else { + return; + }; + + gin_idx.visit_by_key_values_any(pairs, |row_id| { + let Some(row) = self.row_ref_by_id(row_id) else { + return true; + }; + visitor(row) + }); + } + + /// Visits rows matching a multi-predicate GIN query restricted to a row-id subset. + pub fn visit_gin_index_by_key_values_restricted( + &self, + index_name: &str, + pairs: &[(&str, &str)], + match_all: bool, + allowed_row_ids: &[RowId], + subset_driven: bool, + mut visitor: F, + ) where + F: FnMut(&Rc) -> bool, + { + if subset_driven { + self.visit_rows_by_ids(allowed_row_ids, |row| { + if self.row_matches_gin_key_values(index_name, row, pairs, match_all) { + visitor(row) + } else { + true + } + }); + return; + } + + let mut visit_intersected = |row: &Rc| { + if allowed_row_ids.binary_search(&row.id()).is_ok() { + visitor(row) + } else { + true + } + }; + + if match_all { + self.visit_gin_index_by_key_values_all(index_name, pairs, |row| visit_intersected(row)); + } else { + self.visit_gin_index_by_key_values_any(index_name, pairs, |row| visit_intersected(row)); + } + } + + /// Returns the raw row IDs from the GIN index for a given key. + /// This is useful for testing to detect ghost entries (entries that point to deleted rows). + #[cfg(test)] + pub fn gin_index_get_raw_row_ids(&self, index_name: &str, key: &str) -> Vec { + if let Some(gin_idx) = self.gin_indices.get(index_name) { + gin_idx.get_by_key(key) + } else { + Vec::new() + } + } + + /// Returns the raw row IDs from the GIN index for a given key-value pair. + /// This is useful for testing to detect ghost entries. + #[cfg(test)] + pub fn gin_index_get_raw_row_ids_by_kv( + &self, + index_name: &str, + key: &str, + value: &str, + ) -> Vec { + if let Some(gin_idx) = self.gin_indices.get(index_name) { + gin_idx.get_by_key_value(key, value) + } else { + Vec::new() + } + } + + fn row_gin_path_value( + &self, + index_name: &str, + row: &Row, + key: &str, + ) -> Option { + let config = self.gin_index_configs.get(index_name)?; + let value = row.get(config.column_idx)?; + let parsed = Self::parse_jsonb_value(value)?; + if let Some(paths) = config.compiled_indexed_paths.as_ref() { + if let Some(path) = paths.iter().find(|path| path.encoded_path == key) { + return Self::jsonb_value_at_compiled_gin_path(&parsed, path).cloned(); + } + } + Self::jsonb_value_at_encoded_gin_path(&parsed, key).cloned() + } + + fn row_contains_gin_token_pair( + &self, + index_name: &str, + row: &Row, + key: &str, + value: &str, + ) -> bool { + let Some(config) = self.gin_index_configs.get(index_name) else { + return false; + }; + let Some(row_value) = row.get(config.column_idx) else { + return false; + }; + let Some(parsed) = Self::parse_jsonb_value(row_value) else { + return false; + }; + + let mut current_path = String::new(); + let mut matched = false; + Self::visit_jsonb_gin_pairs( + &parsed, + &mut current_path, + config.indexed_paths.as_deref(), + &mut |candidate_key, candidate_value| { + if candidate_key == key && candidate_value == value { + matched = true; + false + } else { + true + } + }, + ); + matched + } + + fn jsonb_value_at_encoded_gin_path<'a>( + value: &'a ParsedJsonbValue, + path: &str, + ) -> Option<&'a ParsedJsonbValue> { + let segments = decode_gin_path_segments(path); + let mut current = value; + for segment in segments { + current = match current { + ParsedJsonbValue::Object(object) => object.get(&segment)?, + ParsedJsonbValue::Array(items) => { + let index = segment.parse::().ok()?; + items.get(index)? + } + ParsedJsonbValue::Null + | ParsedJsonbValue::Bool(_) + | ParsedJsonbValue::Number(_) + | ParsedJsonbValue::String(_) => return None, + }; + } + Some(current) + } + + fn jsonb_value_at_compiled_gin_path<'a>( + value: &'a ParsedJsonbValue, + path: &CompiledGinPath, + ) -> Option<&'a ParsedJsonbValue> { + let mut current = value; + for segment in &path.segments { + current = match current { + ParsedJsonbValue::Object(object) => object.get(&segment.key)?, + ParsedJsonbValue::Array(items) => items.get(segment.array_index?)?, + ParsedJsonbValue::Null + | ParsedJsonbValue::Bool(_) + | ParsedJsonbValue::Number(_) + | ParsedJsonbValue::String(_) => return None, + }; + } + Some(current) } - /// Returns the raw row IDs from the GIN index for a given key. - /// This is useful for testing to detect ghost entries (entries that point to deleted rows). - #[cfg(test)] - pub fn gin_index_get_raw_row_ids(&self, index_name: &str, key: &str) -> Vec { - if let Some(gin_idx) = self.gin_indices.get(index_name) { - gin_idx.get_by_key(key) - } else { - Vec::new() + fn visit_jsonb_gin_pairs( + value: &ParsedJsonbValue, + current_path: &mut String, + indexed_paths: Option<&[String]>, + visitor: &mut F, + ) -> bool + where + F: FnMut(&str, &str) -> bool, + { + match value { + ParsedJsonbValue::Object(object) => { + for (key, child) in object.iter() { + let saved_len = current_path.len(); + Self::append_gin_path_segment(current_path, key); + if Self::should_index_gin_path(indexed_paths, current_path) { + if let Some(value_str) = Self::jsonb_scalar_to_index_value(child) { + if !visitor(current_path, value_str.as_str()) { + return false; + } + } + for (token_key, gram) in + contains_trigram_pairs(current_path, &child.stringify_for_contains()) + { + if !visitor(token_key.as_str(), gram.as_str()) { + return false; + } + } + } + if Self::should_descend_gin_path(indexed_paths, current_path) + && !Self::visit_jsonb_gin_pairs(child, current_path, indexed_paths, visitor) + { + return false; + } + current_path.truncate(saved_len); + } + true + } + ParsedJsonbValue::Array(items) => { + for (index, child) in items.iter().enumerate() { + let saved_len = current_path.len(); + let segment = index.to_string(); + Self::append_gin_path_segment(current_path, &segment); + if Self::should_index_gin_path(indexed_paths, current_path) { + if let Some(value_str) = Self::jsonb_scalar_to_index_value(child) { + if !visitor(current_path, value_str.as_str()) { + return false; + } + } + for (token_key, gram) in + contains_trigram_pairs(current_path, &child.stringify_for_contains()) + { + if !visitor(token_key.as_str(), gram.as_str()) { + return false; + } + } + } + if Self::should_descend_gin_path(indexed_paths, current_path) + && !Self::visit_jsonb_gin_pairs(child, current_path, indexed_paths, visitor) + { + return false; + } + current_path.truncate(saved_len); + } + true + } + ParsedJsonbValue::Null + | ParsedJsonbValue::Bool(_) + | ParsedJsonbValue::Number(_) + | ParsedJsonbValue::String(_) => true, } } +} - /// Returns the raw row IDs from the GIN index for a given key-value pair. - /// This is useful for testing to detect ghost entries. - #[cfg(test)] - pub fn gin_index_get_raw_row_ids_by_kv( - &self, - index_name: &str, - key: &str, - value: &str, - ) -> Vec { - if let Some(gin_idx) = self.gin_indices.get(index_name) { - gin_idx.get_by_key_value(key, value) - } else { - Vec::new() +fn decode_gin_path_segments(path: &str) -> Vec { + let mut segments = Vec::new(); + let mut current = String::new(); + let mut chars = path.chars(); + + while let Some(ch) = chars.next() { + match ch { + '\\' => { + let Some(escaped) = chars.next() else { + current.push('\\'); + break; + }; + match escaped { + '0' if current.is_empty() => {} + other => current.push(other), + } + } + '.' => { + segments.push(current); + current = String::new(); + } + other => current.push(other), } } + + segments.push(current); + segments } fn parse_json_text(s: &str) -> Option { @@ -1761,7 +4438,11 @@ fn parse_json_text(s: &str) -> Option { } fn unescape_json(s: &str) -> String { - let mut result = String::new(); + if !s.as_bytes().contains(&b'\\') { + return s.into(); + } + + let mut result = String::with_capacity(s.len()); let mut chars = s.chars(); while let Some(c) = chars.next() { if c == '\\' { @@ -1785,6 +4466,55 @@ fn unescape_json(s: &str) -> String { result } +fn is_fast_json_integer_literal(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.is_empty() { + return false; + } + + let digits = if bytes[0] == b'-' { &bytes[1..] } else { bytes }; + + if digits.is_empty() { + return false; + } + + if digits.len() > 1 && digits[0] == b'0' { + return false; + } + + digits.iter().all(|byte| byte.is_ascii_digit()) +} + +fn escape_json_string_fragment(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '"' => { + escaped.push('\\'); + escaped.push('"'); + } + '\\' => { + escaped.push('\\'); + escaped.push('\\'); + } + '\n' => { + escaped.push('\\'); + escaped.push('n'); + } + '\r' => { + escaped.push('\\'); + escaped.push('r'); + } + '\t' => { + escaped.push('\\'); + escaped.push('t'); + } + _ => escaped.push(ch), + } + } + escaped +} + fn parse_json_object(s: &str) -> Option { let s = s.trim(); if !s.starts_with('{') || !s.ends_with('}') { @@ -1873,6 +4603,105 @@ fn split_json_top_level(s: &str, separator: char) -> Vec<&str> { parts } +fn skip_json_whitespace_bytes(bytes: &[u8], mut index: usize) -> usize { + while let Some(byte) = bytes.get(index).copied() { + if !byte.is_ascii_whitespace() { + break; + } + index += 1; + } + index +} + +fn scan_json_string_end_bytes(bytes: &[u8], start: usize) -> Option { + if bytes.get(start).copied() != Some(b'"') { + return None; + } + + let mut index = start + 1; + let mut escape = false; + while let Some(byte) = bytes.get(index).copied() { + if escape { + escape = false; + index += 1; + continue; + } + match byte { + b'\\' => { + escape = true; + index += 1; + } + b'"' => return Some(index + 1), + _ => index += 1, + } + } + + None +} + +fn scan_json_value_end_bytes(bytes: &[u8], start: usize) -> Option { + let start = skip_json_whitespace_bytes(bytes, start); + let first = *bytes.get(start)?; + match first { + b'"' => scan_json_string_end_bytes(bytes, start), + b'{' | b'[' => { + let mut index = start; + let mut depth = 0i32; + let mut in_string = false; + let mut escape = false; + while let Some(byte) = bytes.get(index).copied() { + if escape { + escape = false; + index += 1; + continue; + } + if in_string { + match byte { + b'\\' => escape = true, + b'"' => in_string = false, + _ => {} + } + index += 1; + continue; + } + + match byte { + b'"' => { + in_string = true; + index += 1; + } + b'{' | b'[' => { + depth += 1; + index += 1; + } + b'}' | b']' => { + depth -= 1; + index += 1; + if depth == 0 { + return Some(index); + } + } + _ => index += 1, + } + } + None + } + _ => { + let mut index = start; + while let Some(byte) = bytes.get(index).copied() { + if matches!(byte, b',' | b']' | b'}') { + break; + } + index += 1; + } + while index > start && bytes[index - 1].is_ascii_whitespace() { + index -= 1; + } + Some(index) + } + } +} + fn find_json_colon(s: &str) -> Option { let mut in_string = false; let mut escape = false; @@ -2003,6 +4832,84 @@ mod tests { assert!(store.insert(row2).is_err()); } + #[test] + fn test_row_store_bulk_load_builds_scan_and_secondary_indexes() { + let mut store = RowStore::new(test_schema_with_index()); + let rows = vec![ + Row::new(10, vec![Value::Int64(1), Value::Int64(100)]), + Row::new(11, vec![Value::Int64(2), Value::Int64(200)]), + Row::new(12, vec![Value::Int64(3), Value::Int64(100)]), + ]; + + let loaded = store.bulk_load(rows).unwrap(); + assert_eq!(loaded, 3); + assert_eq!(store.len(), 3); + + let scan_ids: Vec<_> = store.scan().map(|row| row.id()).collect(); + assert_eq!(scan_ids, vec![10, 11, 12]); + + let pk_rows = store.get_by_pk(&Value::Int64(2)); + assert_eq!(pk_rows.len(), 1); + assert_eq!(pk_rows[0].id(), 11); + + let indexed = store.index_scan("idx_value", Some(&KeyRange::only(Value::Int64(100)))); + assert_eq!( + indexed.iter().map(|row| row.id()).collect::>(), + vec![10, 12] + ); + } + + #[test] + fn test_row_store_bulk_load_rejects_duplicate_keys_without_leaking_state() { + let mut store = RowStore::new(test_schema()); + let rows = vec![ + Row::new(10, vec![Value::Int64(1), Value::String("Alice".into())]), + Row::new(11, vec![Value::Int64(1), Value::String("Bob".into())]), + ]; + + assert!(store.bulk_load(rows).is_err()); + assert!(store.is_empty()); + assert!(store.scan().next().is_none()); + } + + #[test] + fn test_row_store_bulk_load_builds_hash_secondary_index() { + let mut store = RowStore::new(test_schema_with_hash_index()); + let rows = vec![ + Row::new(10, vec![Value::Int64(1), Value::Int64(100)]), + Row::new(11, vec![Value::Int64(2), Value::Int64(200)]), + Row::new(12, vec![Value::Int64(3), Value::Int64(100)]), + ]; + + let loaded = store.bulk_load(rows).unwrap(); + assert_eq!(loaded, 3); + + let indexed = store.index_scan("idx_value_hash", Some(&KeyRange::only(Value::Int64(100)))); + assert_eq!( + indexed.iter().map(|row| row.id()).collect::>(), + vec![10, 12] + ); + } + + #[test] + fn test_row_store_bulk_load_rejects_unique_secondary_duplicates_without_leaking_state() { + let mut store = RowStore::new(test_schema_with_unique_index()); + let rows = vec![ + Row::new( + 10, + vec![Value::Int64(1), Value::String("alice@example.com".into())], + ), + Row::new( + 11, + vec![Value::Int64(2), Value::String("alice@example.com".into())], + ), + ]; + + assert!(store.bulk_load(rows).is_err()); + assert!(store.is_empty()); + assert!(store.scan().next().is_none()); + } + #[test] fn test_row_store_scan() { let mut store = RowStore::new(test_schema()); @@ -2105,6 +5012,37 @@ mod tests { assert_eq!(results[1].id(), 3); } + #[test] + fn test_restricted_index_scan_subset_driven_reverse_preserves_subset_order() { + let mut store = RowStore::new(test_schema_with_index()); + store + .insert(Row::new(1, vec![Value::Int64(1), Value::Int64(100)])) + .unwrap(); + store + .insert(Row::new(2, vec![Value::Int64(2), Value::Int64(200)])) + .unwrap(); + store + .insert(Row::new(3, vec![Value::Int64(3), Value::Int64(300)])) + .unwrap(); + + let mut row_ids = Vec::new(); + store.visit_index_scan_with_options_restricted( + "idx_value", + None, + None, + 0, + true, + &[1, 3], + true, + |row| { + row_ids.push(row.id()); + true + }, + ); + + assert_eq!(row_ids, vec![3, 1]); + } + #[test] fn test_row_store_clear() { let mut store = RowStore::new(test_schema()); @@ -2807,6 +5745,40 @@ mod tests { .unwrap() } + fn test_schema_with_path_gin_index() -> Table { + TableBuilder::new("test_jsonb") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("data", DataType::Jsonb) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .add_jsonb_index("idx_data_gin", "data", &["address.city", "status"]) + .unwrap() + .build() + .unwrap() + } + + fn test_schema_with_extended_path_gin_index() -> Table { + TableBuilder::new("test_jsonb") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("data", DataType::Jsonb) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .add_jsonb_index( + "idx_data_gin", + "data", + &["address.city", "status", "tags.1", "history.0.state"], + ) + .unwrap() + .build() + .unwrap() + } + fn make_jsonb(json_str: &str) -> Value { Value::Jsonb(cynos_core::JsonbValue(json_str.as_bytes().to_vec())) } @@ -2827,33 +5799,162 @@ mod tests { grams.insert(gram); } - grams.into_iter().collect() + grams.into_iter().collect() + } + + #[test] + fn test_gin_index_insert_and_query() { + let mut store = RowStore::new(test_schema_with_gin_index()); + + // Insert a row with JSONB data + let row = Row::new( + 1, + vec![ + Value::Int64(1), + make_jsonb(r#"{"name": "Alice", "status": "active"}"#), + ], + ); + store.insert(row).unwrap(); + + // Query by key should find the row + let results = store.gin_index_get_by_key("idx_data_gin", "name"); + assert_eq!(results.len(), 1, "GIN index should find row by key 'name'"); + + // Query by key-value should find the row + let results = store.gin_index_get_by_key_value("idx_data_gin", "status", "active"); + assert_eq!( + results.len(), + 1, + "GIN index should find row by key-value 'status=active'" + ); + } + + #[test] + fn test_gin_index_bulk_load_and_query() { + let mut store = RowStore::new(test_schema_with_gin_index()); + let rows = vec![ + Row::new( + 1, + vec![ + Value::Int64(1), + make_jsonb(r#"{"name":"Alice","status":"active"}"#), + ], + ), + Row::new( + 2, + vec![ + Value::Int64(2), + make_jsonb(r#"{"name":"Bob","status":"inactive"}"#), + ], + ), + ]; + + store.bulk_load(rows).unwrap(); + + assert_eq!(store.gin_index_get_by_key("idx_data_gin", "name").len(), 2); + assert_eq!( + store + .gin_index_get_by_key_value("idx_data_gin", "status", "active") + .iter() + .map(|row| row.id()) + .collect::>(), + vec![1] + ); + } + + #[test] + fn test_gin_index_insert_batch_updates_non_empty_store() { + let mut store = RowStore::new(test_schema_with_gin_index()); + store + .insert(Row::new( + 1, + vec![ + Value::Int64(1), + make_jsonb(r#"{"name":"Alice","status":"active"}"#), + ], + )) + .unwrap(); + + store + .insert_batch(vec![ + Row::new( + 2, + vec![ + Value::Int64(2), + make_jsonb(r#"{"name":"Bob","status":"inactive"}"#), + ], + ), + Row::new( + 3, + vec![ + Value::Int64(3), + make_jsonb(r#"{"name":"Cara","status":"active"}"#), + ], + ), + ]) + .unwrap(); + + assert_eq!(store.gin_index_get_by_key("idx_data_gin", "name").len(), 3); + assert_eq!( + store + .gin_index_get_by_key_value("idx_data_gin", "status", "active") + .iter() + .map(|row| row.id()) + .collect::>(), + vec![1, 3] + ); } #[test] - fn test_gin_index_insert_and_query() { - let mut store = RowStore::new(test_schema_with_gin_index()); - - // Insert a row with JSONB data - let row = Row::new( - 1, - vec![ - Value::Int64(1), - make_jsonb(r#"{"name": "Alice", "status": "active"}"#), - ], - ); - store.insert(row).unwrap(); + fn test_insert_batch_preserves_prefix_rows_and_gin_on_unique_violation() { + let schema = TableBuilder::new("test_batch_insert_unique_gin") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("email", DataType::String) + .unwrap() + .add_column("data", DataType::Jsonb) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .add_index("idx_email", &["email"], true) + .unwrap() + .add_index("idx_data_gin", &["data"], false) + .unwrap() + .build() + .unwrap(); - // Query by key should find the row - let results = store.gin_index_get_by_key("idx_data_gin", "name"); - assert_eq!(results.len(), 1, "GIN index should find row by key 'name'"); + let mut store = RowStore::new(schema); + let result = store.insert_batch(vec![ + Row::new( + 1, + vec![ + Value::Int64(1), + Value::String("alice@test.com".into()), + make_jsonb(r#"{"role":"admin"}"#), + ], + ), + Row::new( + 2, + vec![ + Value::Int64(2), + Value::String("alice@test.com".into()), + make_jsonb(r#"{"role":"user"}"#), + ], + ), + ]); - // Query by key-value should find the row - let results = store.gin_index_get_by_key_value("idx_data_gin", "status", "active"); + assert!(result.is_err()); + assert_eq!(store.len(), 1); + assert!(store.get(1).is_some()); + assert!(store.get(2).is_none()); assert_eq!( - results.len(), - 1, - "GIN index should find row by key-value 'status=active'" + store + .gin_index_get_by_key_value("idx_data_gin", "role", "admin") + .iter() + .map(|row| row.id()) + .collect::>(), + vec![1] ); } @@ -2881,6 +5982,54 @@ mod tests { assert_eq!(visited, vec![1, 2]); } + #[test] + fn test_gin_index_query_any_key_values() { + let mut store = RowStore::new(test_schema_with_gin_index()); + store + .insert(Row::new( + 1, + vec![ + Value::Int64(1), + make_jsonb(r#"{"status":"active","type":"user"}"#), + ], + )) + .unwrap(); + store + .insert(Row::new( + 2, + vec![ + Value::Int64(2), + make_jsonb(r#"{"status":"active","type":"admin"}"#), + ], + )) + .unwrap(); + store + .insert(Row::new( + 3, + vec![ + Value::Int64(3), + make_jsonb(r#"{"status":"inactive","type":"user"}"#), + ], + )) + .unwrap(); + + let any_results = + store.gin_index_get_by_key_values_any("idx_data_gin", &[("status", "active")]); + assert_eq!( + any_results.iter().map(|row| row.id()).collect::>(), + vec![1, 2] + ); + + let any_results = store.gin_index_get_by_key_values_any( + "idx_data_gin", + &[("status", "active"), ("type", "user")], + ); + assert_eq!( + any_results.iter().map(|row| row.id()).collect::>(), + vec![1, 2, 3] + ); + } + #[test] fn test_gin_index_nested_paths_and_scalars() { let mut store = RowStore::new(test_schema_with_gin_index()); @@ -2921,6 +6070,316 @@ mod tests { ); } + #[test] + fn test_gin_index_respects_configured_paths() { + let mut store = RowStore::new(test_schema_with_path_gin_index()); + + store + .insert(Row::new( + 1, + vec![ + Value::Int64(1), + make_jsonb( + r#"{"name":"Alice","status":"active","address":{"city":"Beijing","zip":"100000"},"tags":["vip","premium"]}"#, + ), + ], + )) + .unwrap(); + + assert_eq!( + store + .gin_index_get_by_key_value("idx_data_gin", "address.city", "Beijing") + .len(), + 1, + "configured nested path should be indexed", + ); + assert_eq!( + store + .gin_index_get_by_key_value("idx_data_gin", "status", "active") + .len(), + 1, + "configured top-level path should be indexed", + ); + assert_eq!( + store.gin_index_get_by_key("idx_data_gin", "name").len(), + 0, + "unconfigured sibling path should not be indexed", + ); + assert_eq!( + store + .gin_index_get_by_key_value("idx_data_gin", "tags.1", "premium") + .len(), + 0, + "unconfigured array path should not be indexed", + ); + } + + #[test] + fn test_path_directed_extractor_matches_nested_object_and_array_values() { + let value = make_jsonb( + r#"{"name":"Alice","status":"active","address":{"city":"Beijing","zip":"100000"},"tags":["vip","premium"],"history":[{"state":"new"},{"state":"done"}]}"#, + ); + let paths = vec![ + CompiledGinPath::new("address.city".into()), + CompiledGinPath::new("status".into()), + CompiledGinPath::new("tags.1".into()), + CompiledGinPath::new("history.0.state".into()), + ]; + let path_tree = CompiledGinPathTree::new(&paths); + + let extracted = RowStore::extract_selected_jsonb_values_from_text( + &value, + &paths, + Some(&path_tree), + None, + ) + .expect("text extractor should succeed"); + + let mut extracted_values = BTreeMap::new(); + for (path_index, selected) in extracted { + extracted_values.insert(paths[path_index].encoded_path.clone(), selected); + } + + assert_eq!( + extracted_values.get("address.city"), + Some(&ParsedJsonbValue::String("Beijing".into())) + ); + assert_eq!( + extracted_values.get("status"), + Some(&ParsedJsonbValue::String("active".into())) + ); + assert_eq!( + extracted_values.get("tags.1"), + Some(&ParsedJsonbValue::String("premium".into())) + ); + assert_eq!( + extracted_values.get("history.0.state"), + Some(&ParsedJsonbValue::String("new".into())) + ); + } + + #[test] + fn test_path_directed_extractor_matches_escaped_object_key() { + let value = make_jsonb(r#"{"st\"atus":"active"}"#); + let paths = vec![CompiledGinPath::new("st\"atus".into())]; + let path_tree = CompiledGinPathTree::new(&paths); + + let extracted = RowStore::extract_selected_jsonb_values_from_text( + &value, + &paths, + Some(&path_tree), + None, + ) + .expect("text extractor should succeed"); + + assert_eq!(extracted.len(), 1); + assert_eq!( + extracted[0].1, + ParsedJsonbValue::String("active".into()), + "escaped JSON key should still match compiled path", + ); + } + + #[test] + fn test_path_directed_scalar_fast_path_preserves_scalar_index_values() { + let schema = TableBuilder::new("test_jsonb") + .unwrap() + .add_column("id", DataType::Int64) + .unwrap() + .add_column("data", DataType::Jsonb) + .unwrap() + .add_primary_key(&["id"], false) + .unwrap() + .add_jsonb_index("idx_data_gin", "data", &["score", "flag", "unset", "label"]) + .unwrap() + .build() + .unwrap(); + let mut store = RowStore::new(schema); + + store + .insert(Row::new( + 1, + vec![ + Value::Int64(1), + make_jsonb(r#"{"score":1.0,"flag":true,"unset":null,"label":"hi\nthere"}"#), + ], + )) + .unwrap(); + + assert_eq!( + store + .gin_index_get_by_key_value("idx_data_gin", "score", "1") + .iter() + .map(|row| row.id()) + .collect::>(), + vec![1], + "numeric scalar fast path should preserve f64 formatting semantics", + ); + assert_eq!( + store + .gin_index_get_by_key_value("idx_data_gin", "flag", "true") + .iter() + .map(|row| row.id()) + .collect::>(), + vec![1], + ); + assert_eq!( + store + .gin_index_get_by_key_value("idx_data_gin", "unset", "null") + .iter() + .map(|row| row.id()) + .collect::>(), + vec![1], + ); + assert_eq!( + store + .gin_index_get_by_key_value("idx_data_gin", "label", "hi\nthere") + .iter() + .map(|row| row.id()) + .collect::>(), + vec![1], + "string scalar fast path should still unescape JSON string values", + ); + } + + #[test] + fn test_json_text_scalar_to_index_value_fast_paths_preserve_semantics() { + assert_eq!( + RowStore::json_text_scalar_to_index_value("\"enterprise\""), + Some("enterprise".into()), + "plain strings should not require the slower unescape path", + ); + assert_eq!( + RowStore::json_text_scalar_to_index_value("\"hi\\nthere\""), + Some("hi\nthere".into()), + "escaped strings must still decode correctly", + ); + assert_eq!( + RowStore::json_text_scalar_to_index_value("42"), + Some("42".into()), + "integer literals should preserve their canonical JSON text form", + ); + assert_eq!( + RowStore::json_text_scalar_to_index_value("1.0"), + Some("1".into()), + "floating-point literals must keep the previous f64 formatting semantics", + ); + } + + #[test] + fn test_json_text_scalar_to_index_value_ref_borrows_common_literals() { + match RowStore::json_text_scalar_to_index_value_ref("\"enterprise\"") { + Some(JsonScalarIndexValue::Borrowed("enterprise")) => {} + other => panic!("expected borrowed plain string, got {other:?}"), + } + + match RowStore::json_text_scalar_to_index_value_ref("42") { + Some(JsonScalarIndexValue::Borrowed("42")) => {} + other => panic!("expected borrowed integer literal, got {other:?}"), + } + + match RowStore::json_text_scalar_to_index_value_ref("\"hi\\nthere\"") { + Some(JsonScalarIndexValue::Owned(value)) => assert_eq!(value, "hi\nthere"), + other => panic!("expected owned escaped string, got {other:?}"), + } + + match RowStore::json_text_scalar_to_index_value_ref("1.0") { + Some(JsonScalarIndexValue::Owned(value)) => assert_eq!(value, "1"), + other => panic!("expected owned floating literal, got {other:?}"), + } + } + + #[test] + fn test_gin_index_configured_paths_update_and_delete_remain_correct() { + let mut store = RowStore::new(test_schema_with_extended_path_gin_index()); + + store + .insert(Row::new( + 1, + vec![ + Value::Int64(1), + make_jsonb( + r#"{"status":"active","address":{"city":"Beijing"},"tags":["vip","premium"],"history":[{"state":"new"}]}"#, + ), + ], + )) + .unwrap(); + + assert_eq!( + store + .gin_index_get_by_key_value("idx_data_gin", "address.city", "Beijing") + .iter() + .map(|row| row.id()) + .collect::>(), + vec![1] + ); + assert_eq!( + store + .gin_index_get_by_key_value("idx_data_gin", "tags.1", "premium") + .iter() + .map(|row| row.id()) + .collect::>(), + vec![1] + ); + + store + .update( + 1, + Row::new( + 1, + vec![ + Value::Int64(1), + make_jsonb( + r#"{"status":"inactive","address":{"city":"Shanghai"},"tags":["desk","office"],"history":[{"state":"done"}]}"#, + ), + ], + ), + ) + .unwrap(); + + assert!(store + .gin_index_get_by_key_value("idx_data_gin", "address.city", "Beijing") + .is_empty()); + assert!(store + .gin_index_get_by_key_value("idx_data_gin", "tags.1", "premium") + .is_empty()); + assert_eq!( + store + .gin_index_get_by_key_value("idx_data_gin", "address.city", "Shanghai") + .iter() + .map(|row| row.id()) + .collect::>(), + vec![1] + ); + assert_eq!( + store + .gin_index_get_by_key_value("idx_data_gin", "status", "inactive") + .iter() + .map(|row| row.id()) + .collect::>(), + vec![1] + ); + assert_eq!( + store + .gin_index_get_by_key_value("idx_data_gin", "history.0.state", "done") + .iter() + .map(|row| row.id()) + .collect::>(), + vec![1] + ); + + store.delete(1).unwrap(); + assert!(store + .gin_index_get_by_key_value("idx_data_gin", "address.city", "Shanghai") + .is_empty()); + assert!(store + .gin_index_get_by_key_value("idx_data_gin", "status", "inactive") + .is_empty()); + assert!(store + .gin_index_get_by_key_value("idx_data_gin", "history.0.state", "done") + .is_empty()); + } + #[test] fn test_gin_contains_prefilter_indexes_array_value_trigrams() { let mut store = RowStore::new(test_schema_with_gin_index()); diff --git a/js/.DS_Store b/js/.DS_Store new file mode 100644 index 0000000..d6ed1da Binary files /dev/null and b/js/.DS_Store differ diff --git a/js/packages/.DS_Store b/js/packages/.DS_Store new file mode 100644 index 0000000..86cd36d Binary files /dev/null and b/js/packages/.DS_Store differ diff --git a/js/packages/core/.DS_Store b/js/packages/core/.DS_Store new file mode 100644 index 0000000..3b6a5bf Binary files /dev/null and b/js/packages/core/.DS_Store differ diff --git a/js/packages/core/src/index.ts b/js/packages/core/src/index.ts index 5c831d3..45752de 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -35,8 +35,13 @@ import init, { BinaryResult, } from './wasm.js'; -// Import ResultSet -import { ResultSet } from './result-set.js'; +// Import ResultSet helpers +import { ResultSet, snapshotSchemaLayout } from './result-set.js'; +export type { + ResultSetBufferInput, + ResultSetLayoutInput, + ResultSetLayoutSnapshot, +} from './result-set.js'; export type { JsTableBuilder, @@ -58,7 +63,7 @@ export type { BinaryResult, }; -export { JsDataType, JsSortOrder, ColumnOptions, ForeignKeyOptions, col, ResultSet }; +export { JsDataType, JsSortOrder, ColumnOptions, ForeignKeyOptions, col, ResultSet, snapshotSchemaLayout }; export type { DataType, SortOrder, ChangeSet, Row, SubscriptionCallback, Unsubscribe } from './types.js'; diff --git a/js/packages/core/src/result-set.ts b/js/packages/core/src/result-set.ts index ad6cc25..7b85164 100644 --- a/js/packages/core/src/result-set.ts +++ b/js/packages/core/src/result-set.ts @@ -7,6 +7,22 @@ import type { BinaryResult, SchemaLayout } from './wasm.js'; +type SchemaLayoutMethods = Pick< + SchemaLayout, + 'columnCount' | 'columnName' | 'columnOffset' | 'columnType' | 'nullMaskSize' | 'rowStride' +>; + +export interface ResultSetLayoutSnapshot { + columnNames: string[]; + columnOffsets: number[]; + columnTypes: number[]; + nullMaskSize: number; + rowStride: number; +} + +export type ResultSetLayoutInput = SchemaLayoutMethods | ResultSetLayoutSnapshot; +export type ResultSetBufferInput = BinaryResult | Uint8Array | ArrayBuffer; + // Data type constants (must match Rust BinaryDataType) const DataType = { Boolean: 0, @@ -25,13 +41,69 @@ const HEADER_SIZE = 16; // Shared TextDecoder instance for performance const textDecoder = new TextDecoder(); +function isLayoutSnapshot(layout: ResultSetLayoutInput): layout is ResultSetLayoutSnapshot { + return 'columnNames' in layout; +} + +function normalizeLayout(layout: ResultSetLayoutInput): ResultSetLayoutSnapshot { + if (isLayoutSnapshot(layout)) { + return { + columnNames: [...layout.columnNames], + columnOffsets: [...layout.columnOffsets], + columnTypes: [...layout.columnTypes], + nullMaskSize: layout.nullMaskSize, + rowStride: layout.rowStride, + }; + } + + const columnCount = layout.columnCount(); + const columnNames: string[] = new Array(columnCount); + const columnOffsets: number[] = new Array(columnCount); + const columnTypes: number[] = new Array(columnCount); + + for (let i = 0; i < columnCount; i++) { + columnNames[i] = layout.columnName(i) ?? ''; + columnOffsets[i] = layout.columnOffset(i) ?? 0; + columnTypes[i] = layout.columnType(i) ?? 0; + } + + return { + columnNames, + columnOffsets, + columnTypes, + nullMaskSize: layout.nullMaskSize(), + rowStride: layout.rowStride(), + }; +} + +function normalizeBuffer( + buffer: ResultSetBufferInput +): { view: Uint8Array; release: () => void } { + if (buffer instanceof Uint8Array) { + return { view: buffer, release: () => {} }; + } + + if (buffer instanceof ArrayBuffer) { + return { view: new Uint8Array(buffer), release: () => {} }; + } + + return { + view: (buffer as any).asView(), + release: () => buffer.free(), + }; +} + +export function snapshotSchemaLayout(layout: SchemaLayoutMethods): ResultSetLayoutSnapshot { + return normalizeLayout(layout); +} + /** * Zero-copy result set for binary protocol queries. * Provides lazy decoding of values directly from WASM linear memory. */ export class ResultSet> implements Iterable { - private readonly buffer: BinaryResult; - private readonly layout: SchemaLayout; + private readonly releaseBuffer: () => void; + private readonly layout: ResultSetLayoutSnapshot; private readonly uint8Array: Uint8Array; private readonly dataView: DataView; @@ -53,16 +125,15 @@ export class ResultSet> implements Iterable { /** * Create a ResultSet from a binary buffer and schema layout. * - * @param buffer - Binary result from execBinary() - * @param layout - Schema layout from getSchemaLayout() + * @param buffer - Binary result from execBinary(), or a transferred Uint8Array / ArrayBuffer + * @param layout - Schema layout from getSchemaLayout(), or a serialized layout snapshot */ - constructor(buffer: BinaryResult, layout: SchemaLayout) { - this.buffer = buffer; - this.layout = layout; + constructor(buffer: ResultSetBufferInput, layout: ResultSetLayoutInput) { + this.layout = normalizeLayout(layout); - // Zero-copy: get a view directly into WASM linear memory - // WARNING: This view becomes invalid if WASM memory grows or buffer is freed - this.uint8Array = (buffer as any).asView(); + const normalizedBuffer = normalizeBuffer(buffer); + this.releaseBuffer = normalizedBuffer.release; + this.uint8Array = normalizedBuffer.view; this.dataView = new DataView( this.uint8Array.buffer, this.uint8Array.byteOffset, @@ -74,19 +145,12 @@ export class ResultSet> implements Iterable { this._rowStride = this.dataView.getUint32(4, true); this._varOffset = this.dataView.getUint32(8, true); this._flags = this.dataView.getUint32(12, true); - this._nullMaskSize = layout.nullMaskSize(); + this._nullMaskSize = this.layout.nullMaskSize; // Cache column info - const colCount = layout.columnCount(); - this._columnNames = []; - this._columnTypes = []; - this._columnOffsets = []; - - for (let i = 0; i < colCount; i++) { - this._columnNames.push(layout.columnName(i) ?? ''); - this._columnTypes.push(layout.columnType(i) ?? 0); - this._columnOffsets.push(layout.columnOffset(i) ?? 0); - } + this._columnNames = [...this.layout.columnNames]; + this._columnTypes = [...this.layout.columnTypes]; + this._columnOffsets = [...this.layout.columnOffsets]; // Compile a specialized row decoder function for this schema. // This generates code with literal property names and inlined offsets, @@ -494,7 +558,7 @@ export class ResultSet> implements Iterable { * Free the underlying WASM memory. */ free(): void { - this.buffer.free(); + this.releaseBuffer(); } /** diff --git a/js/packages/core/src/wasm.d.ts b/js/packages/core/src/wasm.d.ts index b4c4152..0bd6a23 100644 --- a/js/packages/core/src/wasm.d.ts +++ b/js/packages/core/src/wasm.d.ts @@ -18,6 +18,13 @@ export class BinaryResult { * Free the buffer memory */ free(): void; + /** + * Copy the buffer into a standalone Uint8Array suitable for `postMessage` + * transfer lists and other ownership-taking APIs. + * + * Unlike `asView()`, the returned bytes are no longer tied to WASM memory. + */ + intoTransferable(): Uint8Array; /** * Check if buffer is empty */ @@ -241,6 +248,11 @@ export class Database { * Returns all table names. */ tableNames(): Array; + takeLastCommitProfile(): any; + takeLastDeltaFlushProfile(): any; + takeLastIvmBridgeProfile(): any; + takeLastSnapshotFlushProfile(): any; + takeLastTraceInitProfile(): any; /** * Returns the total row count across all tables. */ @@ -359,6 +371,16 @@ export class JsChangesStream { * Returns an unsubscribe function. */ subscribe(callback: Function): Function; + /** + * Subscribes to the changes stream using binary snapshots. + * + * The callback receives a `BinaryResult` for the full current result set. + * It is called immediately with the initial data, and again whenever data changes. + * Use `getSchemaLayout()` once and decode with `ResultSet` on the JS side. + * + * Returns an unsubscribe function. + */ + subscribeBinary(callback: Function): Function; } /** @@ -477,6 +499,17 @@ export class JsObservableQuery { * Returns an unsubscribe function. */ subscribe(callback: Function): Function; + /** + * Subscribes to query changes using binary snapshots. + * + * The callback receives a `BinaryResult` for the complete current result set. + * This avoids per-update JS object materialization inside the WASM bridge. + * Call `getSchemaLayout()` once and decode with `ResultSet` on the JS side. + * + * It is called whenever data changes (not immediately - use getResultBinary for initial data). + * Returns an unsubscribe function. + */ + subscribeBinary(callback: Function): Function; /** * Returns the number of active subscriptions. */ @@ -557,7 +590,7 @@ export class JsTableBuilder { /** * Adds a JSONB index for specific paths. */ - jsonbIndex(column: string, _paths: any): JsTableBuilder; + jsonbIndex(column: string, paths: any): JsTableBuilder; /** * Creates a new table builder. */ @@ -882,197 +915,205 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl export interface InitOutput { readonly memory: WebAssembly.Memory; + readonly __wbg_column_free: (a: number, b: number) => void; + readonly __wbg_columnoptions_free: (a: number, b: number) => void; readonly __wbg_database_free: (a: number, b: number) => void; + readonly __wbg_deletebuilder_free: (a: number, b: number) => void; + readonly __wbg_expr_free: (a: number, b: number) => void; + readonly __wbg_foreignkeyoptions_free: (a: number, b: number) => void; + readonly __wbg_get_columnoptions_auto_increment: (a: number) => number; + readonly __wbg_get_columnoptions_nullable: (a: number) => number; + readonly __wbg_get_columnoptions_primary_key: (a: number) => number; + readonly __wbg_get_columnoptions_unique: (a: number) => number; + readonly __wbg_insertbuilder_free: (a: number, b: number) => void; + readonly __wbg_jschangesstream_free: (a: number, b: number) => void; + readonly __wbg_jsgraphqlsubscription_free: (a: number, b: number) => void; + readonly __wbg_jsivmobservablequery_free: (a: number, b: number) => void; + readonly __wbg_jsobservablequery_free: (a: number, b: number) => void; + readonly __wbg_jsonbcolumn_free: (a: number, b: number) => void; + readonly __wbg_jstable_free: (a: number, b: number) => void; + readonly __wbg_jstablebuilder_free: (a: number, b: number) => void; + readonly __wbg_jstransaction_free: (a: number, b: number) => void; readonly __wbg_preparedgraphqlquery_free: (a: number, b: number) => void; - readonly database_new: (a: number, b: number) => number; - readonly database_create: (a: number, b: number) => number; - readonly database_name: (a: number, b: number) => void; - readonly database_createTable: (a: number, b: number, c: number) => number; - readonly database_registerTable: (a: number, b: number, c: number) => void; - readonly database_table: (a: number, b: number, c: number) => number; - readonly database_dropTable: (a: number, b: number, c: number, d: number) => void; - readonly database_tableNames: (a: number) => number; - readonly database_tableCount: (a: number) => number; - readonly database_select: (a: number, b: number) => number; - readonly database_insert: (a: number, b: number, c: number) => number; - readonly database_update: (a: number, b: number, c: number) => number; - readonly database_delete: (a: number, b: number, c: number) => number; - readonly database_transaction: (a: number) => number; - readonly database_clear: (a: number) => void; - readonly database_clearTable: (a: number, b: number, c: number, d: number) => void; - readonly database_totalRowCount: (a: number) => number; - readonly database_hasTable: (a: number, b: number, c: number) => number; - readonly database_graphqlSchema: (a: number, b: number) => void; - readonly database_graphql: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; - readonly database_subscribeGraphql: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; - readonly database_prepareGraphql: (a: number, b: number, c: number, d: number, e: number, f: number) => void; - readonly preparedgraphqlquery_exec: (a: number, b: number, c: number) => void; - readonly preparedgraphqlquery_subscribe: (a: number, b: number, c: number) => void; - readonly __wbg_column_free: (a: number, b: number) => void; - readonly column_new: (a: number, b: number, c: number, d: number) => number; - readonly column_with_index: (a: number, b: number) => number; - readonly column_name: (a: number, b: number) => void; - readonly column_tableName: (a: number, b: number) => void; + readonly __wbg_preparedselectquery_free: (a: number, b: number) => void; + readonly __wbg_selectbuilder_free: (a: number, b: number) => void; + readonly __wbg_set_columnoptions_auto_increment: (a: number, b: number) => void; + readonly __wbg_set_columnoptions_nullable: (a: number, b: number) => void; + readonly __wbg_set_columnoptions_primary_key: (a: number, b: number) => void; + readonly __wbg_set_columnoptions_unique: (a: number, b: number) => void; + readonly __wbg_updatebuilder_free: (a: number, b: number) => void; + readonly col: (a: number, b: number) => number; + readonly column_between: (a: number, b: number, c: number) => number; readonly column_eq: (a: number, b: number) => number; - readonly column_ne: (a: number, b: number) => number; + readonly column_get: (a: number, b: number, c: number) => number; readonly column_gt: (a: number, b: number) => number; readonly column_gte: (a: number, b: number) => number; + readonly column_in: (a: number, b: number) => number; + readonly column_isNotNull: (a: number) => number; + readonly column_isNull: (a: number) => number; + readonly column_like: (a: number, b: number, c: number) => number; readonly column_lt: (a: number, b: number) => number; readonly column_lte: (a: number, b: number) => number; - readonly column_between: (a: number, b: number, c: number) => number; + readonly column_match: (a: number, b: number, c: number) => number; + readonly column_name: (a: number, b: number) => void; + readonly column_ne: (a: number, b: number) => number; + readonly column_new: (a: number, b: number, c: number, d: number) => number; readonly column_notBetween: (a: number, b: number, c: number) => number; - readonly column_in: (a: number, b: number) => number; readonly column_notIn: (a: number, b: number) => number; - readonly column_like: (a: number, b: number, c: number) => number; readonly column_notLike: (a: number, b: number, c: number) => number; - readonly column_match: (a: number, b: number, c: number) => number; readonly column_notMatch: (a: number, b: number, c: number) => number; - readonly column_isNull: (a: number) => number; - readonly column_isNotNull: (a: number) => number; - readonly column_get: (a: number, b: number, c: number) => number; - readonly __wbg_jsonbcolumn_free: (a: number, b: number) => void; - readonly jsonbcolumn_eq: (a: number, b: number) => number; - readonly jsonbcolumn_contains: (a: number, b: number) => number; - readonly jsonbcolumn_exists: (a: number) => number; - readonly __wbg_expr_free: (a: number, b: number) => void; + readonly column_tableName: (a: number, b: number) => void; + readonly column_with_index: (a: number, b: number) => number; + readonly columnoptions_new: () => number; + readonly columnoptions_primaryKey: (a: number, b: number) => number; + readonly columnoptions_setAutoIncrement: (a: number, b: number) => number; + readonly columnoptions_setNullable: (a: number, b: number) => number; + readonly columnoptions_setUnique: (a: number, b: number) => number; + readonly database_clear: (a: number) => void; + readonly database_clearTable: (a: number, b: number, c: number, d: number) => void; + readonly database_create: (a: number, b: number) => number; + readonly database_createTable: (a: number, b: number, c: number) => number; + readonly database_delete: (a: number, b: number, c: number) => number; + readonly database_dropTable: (a: number, b: number, c: number, d: number) => void; + readonly database_graphql: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; + readonly database_graphqlSchema: (a: number, b: number) => void; + readonly database_hasTable: (a: number, b: number, c: number) => number; + readonly database_insert: (a: number, b: number, c: number) => number; + readonly database_name: (a: number, b: number) => void; + readonly database_new: (a: number, b: number) => number; + readonly database_prepareGraphql: (a: number, b: number, c: number, d: number, e: number, f: number) => void; + readonly database_registerTable: (a: number, b: number, c: number) => void; + readonly database_select: (a: number, b: number) => number; + readonly database_subscribeGraphql: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; + readonly database_table: (a: number, b: number, c: number) => number; + readonly database_tableCount: (a: number) => number; + readonly database_tableNames: (a: number) => number; + readonly database_takeLastCommitProfile: (a: number) => number; + readonly database_takeLastDeltaFlushProfile: (a: number) => number; + readonly database_takeLastIvmBridgeProfile: (a: number) => number; + readonly database_takeLastSnapshotFlushProfile: (a: number) => number; + readonly database_takeLastTraceInitProfile: (a: number) => number; + readonly database_totalRowCount: (a: number) => number; + readonly database_transaction: (a: number) => number; + readonly database_update: (a: number, b: number, c: number) => number; + readonly deletebuilder_exec: (a: number) => number; + readonly deletebuilder_where: (a: number, b: number) => number; readonly expr_and: (a: number, b: number) => number; - readonly expr_or: (a: number, b: number) => number; readonly expr_not: (a: number) => number; - readonly __wbg_selectbuilder_free: (a: number, b: number) => void; - readonly __wbg_preparedselectquery_free: (a: number, b: number) => void; - readonly selectbuilder_from: (a: number, b: number, c: number) => number; - readonly selectbuilder_where: (a: number, b: number) => number; - readonly selectbuilder_orderBy: (a: number, b: number, c: number, d: number) => number; - readonly selectbuilder_limit: (a: number, b: number) => number; - readonly selectbuilder_offset: (a: number, b: number) => number; - readonly selectbuilder_union: (a: number, b: number, c: number) => void; - readonly selectbuilder_unionAll: (a: number, b: number, c: number) => void; - readonly selectbuilder_innerJoin: (a: number, b: number, c: number, d: number) => number; - readonly selectbuilder_leftJoin: (a: number, b: number, c: number, d: number) => number; - readonly selectbuilder_groupBy: (a: number, b: number) => number; - readonly selectbuilder_count: (a: number) => number; - readonly selectbuilder_countCol: (a: number, b: number, c: number) => number; - readonly selectbuilder_sum: (a: number, b: number, c: number) => number; - readonly selectbuilder_avg: (a: number, b: number, c: number) => number; - readonly selectbuilder_min: (a: number, b: number, c: number) => number; - readonly selectbuilder_max: (a: number, b: number, c: number) => number; - readonly selectbuilder_stddev: (a: number, b: number, c: number) => number; - readonly selectbuilder_geomean: (a: number, b: number, c: number) => number; - readonly selectbuilder_distinct: (a: number, b: number, c: number) => number; - readonly selectbuilder_exec: (a: number) => number; - readonly selectbuilder_prepare: (a: number, b: number) => void; - readonly selectbuilder_explain: (a: number, b: number) => void; - readonly selectbuilder_observe: (a: number, b: number) => void; - readonly selectbuilder_changes: (a: number, b: number) => void; - readonly selectbuilder_trace: (a: number, b: number) => void; - readonly selectbuilder_getSchemaLayout: (a: number, b: number) => void; - readonly selectbuilder_execBinary: (a: number) => number; - readonly preparedselectquery_exec: (a: number) => number; - readonly preparedselectquery_execBinary: (a: number) => number; - readonly preparedselectquery_getSchemaLayout: (a: number) => number; - readonly __wbg_insertbuilder_free: (a: number, b: number) => void; - readonly insertbuilder_values: (a: number, b: number) => number; + readonly expr_or: (a: number, b: number) => number; + readonly foreignkeyoptions_fieldName: (a: number, b: number, c: number) => number; + readonly foreignkeyoptions_new: () => number; + readonly foreignkeyoptions_reverseFieldName: (a: number, b: number, c: number) => number; + readonly init: () => void; readonly insertbuilder_exec: (a: number) => number; - readonly __wbg_updatebuilder_free: (a: number, b: number) => void; - readonly updatebuilder_set: (a: number, b: number, c: number) => number; - readonly updatebuilder_where: (a: number, b: number) => number; - readonly updatebuilder_exec: (a: number) => number; - readonly __wbg_deletebuilder_free: (a: number, b: number) => void; - readonly deletebuilder_where: (a: number, b: number) => number; - readonly deletebuilder_exec: (a: number) => number; - readonly __wbg_jsobservablequery_free: (a: number, b: number) => void; - readonly jsobservablequery_subscribe: (a: number, b: number) => number; - readonly jsobservablequery_getResult: (a: number) => number; - readonly jsobservablequery_getResultBinary: (a: number) => number; - readonly jsobservablequery_getSchemaLayout: (a: number) => number; - readonly jsobservablequery_length: (a: number) => number; - readonly jsobservablequery_isEmpty: (a: number) => number; - readonly jsobservablequery_subscriptionCount: (a: number) => number; - readonly __wbg_jsivmobservablequery_free: (a: number, b: number) => void; - readonly jsivmobservablequery_subscribe: (a: number, b: number) => number; + readonly insertbuilder_values: (a: number, b: number) => number; + readonly jschangesstream_getResult: (a: number) => number; + readonly jschangesstream_getResultBinary: (a: number) => number; + readonly jschangesstream_getSchemaLayout: (a: number) => number; + readonly jschangesstream_subscribe: (a: number, b: number) => number; + readonly jschangesstream_subscribeBinary: (a: number, b: number) => number; + readonly jsgraphqlsubscription_getResult: (a: number) => number; + readonly jsgraphqlsubscription_subscribe: (a: number, b: number) => number; + readonly jsgraphqlsubscription_subscriptionCount: (a: number) => number; readonly jsivmobservablequery_getResult: (a: number) => number; readonly jsivmobservablequery_getResultBinary: (a: number) => number; readonly jsivmobservablequery_getSchemaLayout: (a: number) => number; - readonly jsivmobservablequery_length: (a: number) => number; readonly jsivmobservablequery_isEmpty: (a: number) => number; + readonly jsivmobservablequery_length: (a: number) => number; + readonly jsivmobservablequery_subscribe: (a: number, b: number) => number; readonly jsivmobservablequery_subscriptionCount: (a: number) => number; - readonly __wbg_jsgraphqlsubscription_free: (a: number, b: number) => void; - readonly jsgraphqlsubscription_getResult: (a: number) => number; - readonly jsgraphqlsubscription_subscribe: (a: number, b: number) => number; - readonly jsgraphqlsubscription_subscriptionCount: (a: number) => number; - readonly __wbg_jschangesstream_free: (a: number, b: number) => void; - readonly jschangesstream_subscribe: (a: number, b: number) => number; - readonly jschangesstream_getResult: (a: number) => number; - readonly jschangesstream_getResultBinary: (a: number) => number; - readonly jschangesstream_getSchemaLayout: (a: number) => number; - readonly __wbg_columnoptions_free: (a: number, b: number) => void; - readonly __wbg_get_columnoptions_primary_key: (a: number) => number; - readonly __wbg_set_columnoptions_primary_key: (a: number, b: number) => void; - readonly __wbg_get_columnoptions_nullable: (a: number) => number; - readonly __wbg_set_columnoptions_nullable: (a: number, b: number) => void; - readonly __wbg_get_columnoptions_unique: (a: number) => number; - readonly __wbg_set_columnoptions_unique: (a: number, b: number) => void; - readonly __wbg_get_columnoptions_auto_increment: (a: number) => number; - readonly __wbg_set_columnoptions_auto_increment: (a: number, b: number) => void; - readonly __wbg_foreignkeyoptions_free: (a: number, b: number) => void; - readonly columnoptions_new: () => number; - readonly columnoptions_primaryKey: (a: number, b: number) => number; - readonly columnoptions_setNullable: (a: number, b: number) => number; - readonly columnoptions_setUnique: (a: number, b: number) => number; - readonly columnoptions_setAutoIncrement: (a: number, b: number) => number; - readonly foreignkeyoptions_new: () => number; - readonly foreignkeyoptions_fieldName: (a: number, b: number, c: number) => number; - readonly foreignkeyoptions_reverseFieldName: (a: number, b: number, c: number) => number; - readonly __wbg_jstablebuilder_free: (a: number, b: number) => void; - readonly jstablebuilder_new: (a: number, b: number) => number; - readonly jstablebuilder_build: (a: number, b: number) => void; - readonly jstablebuilder_column: (a: number, b: number, c: number, d: number, e: number) => number; - readonly jstablebuilder_primaryKey: (a: number, b: number) => number; - readonly jstablebuilder_index: (a: number, b: number, c: number, d: number) => number; - readonly jstablebuilder_uniqueIndex: (a: number, b: number, c: number, d: number) => number; - readonly jstablebuilder_jsonbIndex: (a: number, b: number, c: number, d: number) => number; - readonly jstablebuilder_foreignKey: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number) => number; - readonly jstablebuilder_name: (a: number, b: number) => void; - readonly __wbg_jstable_free: (a: number, b: number) => void; - readonly jstable_name: (a: number, b: number) => void; + readonly jsobservablequery_getResult: (a: number) => number; + readonly jsobservablequery_getResultBinary: (a: number) => number; + readonly jsobservablequery_getSchemaLayout: (a: number) => number; + readonly jsobservablequery_isEmpty: (a: number) => number; + readonly jsobservablequery_length: (a: number) => number; + readonly jsobservablequery_subscribe: (a: number, b: number) => number; + readonly jsobservablequery_subscribeBinary: (a: number, b: number) => number; + readonly jsobservablequery_subscriptionCount: (a: number) => number; + readonly jsonbcolumn_contains: (a: number, b: number) => number; + readonly jsonbcolumn_eq: (a: number, b: number) => number; + readonly jsonbcolumn_exists: (a: number) => number; readonly jstable_col: (a: number, b: number, c: number) => number; - readonly jstable_columnNames: (a: number) => number; readonly jstable_columnCount: (a: number) => number; + readonly jstable_columnNames: (a: number) => number; readonly jstable_getColumnType: (a: number, b: number, c: number) => number; readonly jstable_isColumnNullable: (a: number, b: number, c: number) => number; + readonly jstable_name: (a: number, b: number) => void; readonly jstable_primaryKeyColumns: (a: number) => number; - readonly __wbg_jstransaction_free: (a: number, b: number) => void; - readonly jstransaction_insert: (a: number, b: number, c: number, d: number, e: number) => void; - readonly jstransaction_update: (a: number, b: number, c: number, d: number, e: number, f: number) => void; - readonly jstransaction_delete: (a: number, b: number, c: number, d: number, e: number) => void; + readonly jstablebuilder_build: (a: number, b: number) => void; + readonly jstablebuilder_column: (a: number, b: number, c: number, d: number, e: number) => number; + readonly jstablebuilder_foreignKey: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number) => number; + readonly jstablebuilder_index: (a: number, b: number, c: number, d: number) => number; + readonly jstablebuilder_jsonbIndex: (a: number, b: number, c: number, d: number) => number; + readonly jstablebuilder_name: (a: number, b: number) => void; + readonly jstablebuilder_new: (a: number, b: number) => number; + readonly jstablebuilder_primaryKey: (a: number, b: number) => number; + readonly jstablebuilder_uniqueIndex: (a: number, b: number, c: number, d: number) => number; + readonly jstransaction_active: (a: number) => number; readonly jstransaction_commit: (a: number, b: number) => void; + readonly jstransaction_delete: (a: number, b: number, c: number, d: number, e: number) => void; + readonly jstransaction_insert: (a: number, b: number, c: number, d: number, e: number) => void; readonly jstransaction_rollback: (a: number, b: number) => void; - readonly jstransaction_active: (a: number) => number; readonly jstransaction_state: (a: number, b: number) => void; - readonly init: () => void; - readonly col: (a: number, b: number) => number; + readonly jstransaction_update: (a: number, b: number, c: number, d: number, e: number, f: number) => void; + readonly preparedgraphqlquery_exec: (a: number, b: number, c: number) => void; + readonly preparedgraphqlquery_subscribe: (a: number, b: number, c: number) => void; + readonly preparedselectquery_exec: (a: number) => number; + readonly preparedselectquery_execBinary: (a: number) => number; + readonly preparedselectquery_getSchemaLayout: (a: number) => number; + readonly selectbuilder_avg: (a: number, b: number, c: number) => number; + readonly selectbuilder_changes: (a: number, b: number) => void; + readonly selectbuilder_count: (a: number) => number; + readonly selectbuilder_countCol: (a: number, b: number, c: number) => number; + readonly selectbuilder_distinct: (a: number, b: number, c: number) => number; + readonly selectbuilder_exec: (a: number) => number; + readonly selectbuilder_execBinary: (a: number) => number; + readonly selectbuilder_explain: (a: number, b: number) => void; + readonly selectbuilder_from: (a: number, b: number, c: number) => number; + readonly selectbuilder_geomean: (a: number, b: number, c: number) => number; + readonly selectbuilder_getSchemaLayout: (a: number, b: number) => void; + readonly selectbuilder_groupBy: (a: number, b: number) => number; + readonly selectbuilder_innerJoin: (a: number, b: number, c: number, d: number) => number; + readonly selectbuilder_leftJoin: (a: number, b: number, c: number, d: number) => number; + readonly selectbuilder_limit: (a: number, b: number) => number; + readonly selectbuilder_max: (a: number, b: number, c: number) => number; + readonly selectbuilder_min: (a: number, b: number, c: number) => number; + readonly selectbuilder_observe: (a: number, b: number) => void; + readonly selectbuilder_offset: (a: number, b: number) => number; + readonly selectbuilder_orderBy: (a: number, b: number, c: number, d: number) => number; + readonly selectbuilder_prepare: (a: number, b: number) => void; + readonly selectbuilder_stddev: (a: number, b: number, c: number) => number; + readonly selectbuilder_sum: (a: number, b: number, c: number) => number; + readonly selectbuilder_trace: (a: number, b: number) => void; + readonly selectbuilder_union: (a: number, b: number, c: number) => void; + readonly selectbuilder_unionAll: (a: number, b: number, c: number) => void; + readonly selectbuilder_where: (a: number, b: number) => number; + readonly updatebuilder_exec: (a: number) => number; + readonly updatebuilder_set: (a: number, b: number, c: number) => number; + readonly updatebuilder_where: (a: number, b: number) => number; readonly column_new_simple: (a: number, b: number) => number; + readonly __wbg_binaryresult_free: (a: number, b: number) => void; readonly __wbg_schemalayout_free: (a: number, b: number) => void; + readonly binaryresult_asView: (a: number) => number; + readonly binaryresult_free: (a: number) => void; + readonly binaryresult_intoTransferable: (a: number) => number; + readonly binaryresult_isEmpty: (a: number) => number; + readonly binaryresult_len: (a: number) => number; + readonly binaryresult_ptr: (a: number) => number; + readonly binaryresult_toUint8Array: (a: number) => number; readonly schemalayout_columnCount: (a: number) => number; - readonly schemalayout_columnName: (a: number, b: number, c: number) => void; - readonly schemalayout_columnType: (a: number, b: number) => number; - readonly schemalayout_columnOffset: (a: number, b: number) => number; readonly schemalayout_columnFixedSize: (a: number, b: number) => number; + readonly schemalayout_columnName: (a: number, b: number, c: number) => void; readonly schemalayout_columnNullable: (a: number, b: number) => number; - readonly schemalayout_rowStride: (a: number) => number; + readonly schemalayout_columnOffset: (a: number, b: number) => number; + readonly schemalayout_columnType: (a: number, b: number) => number; readonly schemalayout_nullMaskSize: (a: number) => number; - readonly __wbg_binaryresult_free: (a: number, b: number) => void; - readonly binaryresult_ptr: (a: number) => number; - readonly binaryresult_len: (a: number) => number; - readonly binaryresult_isEmpty: (a: number) => number; - readonly binaryresult_toUint8Array: (a: number) => number; - readonly binaryresult_asView: (a: number) => number; - readonly binaryresult_free: (a: number) => void; - readonly __wasm_bindgen_func_elem_67: (a: number, b: number) => void; - readonly __wasm_bindgen_func_elem_2513: (a: number, b: number) => void; - readonly __wasm_bindgen_func_elem_6953: (a: number, b: number, c: number, d: number) => void; - readonly __wasm_bindgen_func_elem_2520: (a: number, b: number, c: number) => void; - readonly __wasm_bindgen_func_elem_1049: (a: number, b: number) => void; + readonly schemalayout_rowStride: (a: number) => number; + readonly __wasm_bindgen_func_elem_88: (a: number, b: number) => void; + readonly __wasm_bindgen_func_elem_3669: (a: number, b: number) => void; + readonly __wasm_bindgen_func_elem_9043: (a: number, b: number, c: number, d: number) => void; + readonly __wasm_bindgen_func_elem_3676: (a: number, b: number, c: number) => void; + readonly __wasm_bindgen_func_elem_1556: (a: number, b: number) => void; readonly __wbindgen_export: (a: number, b: number) => number; readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number; readonly __wbindgen_export3: (a: number) => void; diff --git a/js/packages/core/src/wasm.js b/js/packages/core/src/wasm.js index 5066026..145ee73 100644 --- a/js/packages/core/src/wasm.js +++ b/js/packages/core/src/wasm.js @@ -38,6 +38,18 @@ export class BinaryResult { const ptr = this.__destroy_into_raw(); wasm.binaryresult_free(ptr); } + /** + * Copy the buffer into a standalone Uint8Array suitable for `postMessage` + * transfer lists and other ownership-taking APIs. + * + * Unlike `asView()`, the returned bytes are no longer tied to WASM memory. + * @returns {Uint8Array} + */ + intoTransferable() { + const ptr = this.__destroy_into_raw(); + const ret = wasm.binaryresult_intoTransferable(ptr); + return takeObject(ret); + } /** * Check if buffer is empty * @returns {boolean} @@ -817,6 +829,41 @@ export class Database { const ret = wasm.database_tableNames(this.__wbg_ptr); return takeObject(ret); } + /** + * @returns {any} + */ + takeLastCommitProfile() { + const ret = wasm.database_takeLastCommitProfile(this.__wbg_ptr); + return takeObject(ret); + } + /** + * @returns {any} + */ + takeLastDeltaFlushProfile() { + const ret = wasm.database_takeLastDeltaFlushProfile(this.__wbg_ptr); + return takeObject(ret); + } + /** + * @returns {any} + */ + takeLastIvmBridgeProfile() { + const ret = wasm.database_takeLastIvmBridgeProfile(this.__wbg_ptr); + return takeObject(ret); + } + /** + * @returns {any} + */ + takeLastSnapshotFlushProfile() { + const ret = wasm.database_takeLastSnapshotFlushProfile(this.__wbg_ptr); + return takeObject(ret); + } + /** + * @returns {any} + */ + takeLastTraceInitProfile() { + const ret = wasm.database_takeLastTraceInitProfile(this.__wbg_ptr); + return takeObject(ret); + } /** * Returns the total row count across all tables. * @returns {number} @@ -1105,6 +1152,21 @@ export class JsChangesStream { const ret = wasm.jschangesstream_subscribe(this.__wbg_ptr, addHeapObject(callback)); return takeObject(ret); } + /** + * Subscribes to the changes stream using binary snapshots. + * + * The callback receives a `BinaryResult` for the full current result set. + * It is called immediately with the initial data, and again whenever data changes. + * Use `getSchemaLayout()` once and decode with `ResultSet` on the JS side. + * + * Returns an unsubscribe function. + * @param {Function} callback + * @returns {Function} + */ + subscribeBinary(callback) { + const ret = wasm.jschangesstream_subscribeBinary(this.__wbg_ptr, addHeapObject(callback)); + return takeObject(ret); + } } if (Symbol.dispose) JsChangesStream.prototype[Symbol.dispose] = JsChangesStream.prototype.free; @@ -1340,6 +1402,22 @@ export class JsObservableQuery { const ret = wasm.jsobservablequery_subscribe(this.__wbg_ptr, addHeapObject(callback)); return takeObject(ret); } + /** + * Subscribes to query changes using binary snapshots. + * + * The callback receives a `BinaryResult` for the complete current result set. + * This avoids per-update JS object materialization inside the WASM bridge. + * Call `getSchemaLayout()` once and decode with `ResultSet` on the JS side. + * + * It is called whenever data changes (not immediately - use getResultBinary for initial data). + * Returns an unsubscribe function. + * @param {Function} callback + * @returns {Function} + */ + subscribeBinary(callback) { + const ret = wasm.jsobservablequery_subscribeBinary(this.__wbg_ptr, addHeapObject(callback)); + return takeObject(ret); + } /** * Returns the number of active subscriptions. * @returns {number} @@ -1567,15 +1645,15 @@ export class JsTableBuilder { /** * Adds a JSONB index for specific paths. * @param {string} column - * @param {any} _paths + * @param {any} paths * @returns {JsTableBuilder} */ - jsonbIndex(column, _paths) { + jsonbIndex(column, paths) { try { const ptr = this.__destroy_into_raw(); const ptr0 = passStringToWasm0(column, wasm.__wbindgen_export, wasm.__wbindgen_export2); const len0 = WASM_VECTOR_LEN; - const ret = wasm.jstablebuilder_jsonbIndex(ptr, ptr0, len0, addBorrowedObject(_paths)); + const ret = wasm.jstablebuilder_jsonbIndex(ptr, ptr0, len0, addBorrowedObject(paths)); return JsTableBuilder.__wrap(ret); } finally { heap[stack_pointer++] = undefined; @@ -2633,6 +2711,10 @@ function __wbg_get_imports() { __wbg__wbg_cb_unref_d9b87ff7982e3b21: function(arg0) { getObject(arg0)._wbg_cb_unref(); }, + __wbg_apply_ada2ee1a60ac7b3c: function() { return handleError(function (arg0, arg1, arg2) { + const ret = getObject(arg0).apply(getObject(arg1), getObject(arg2)); + return addHeapObject(ret); + }, arguments); }, __wbg_binaryresult_new: function(arg0) { const ret = BinaryResult.__wrap(arg0); return addHeapObject(ret); @@ -2744,7 +2826,7 @@ function __wbg_get_imports() { const a = state0.a; state0.a = 0; try { - return __wasm_bindgen_func_elem_6953(a, state0.b, arg0, arg1); + return __wasm_bindgen_func_elem_9043(a, state0.b, arg0, arg1); } finally { state0.a = a; } @@ -2779,6 +2861,10 @@ function __wbg_get_imports() { const ret = new Uint8Array(arg0 >>> 0); return addHeapObject(ret); }, + __wbg_now_a3af9a2f4bbaa4d1: function() { + const ret = Date.now(); + return ret; + }, __wbg_ownKeys_c7100fb5fa376c6f: function() { return handleError(function (arg0) { const ret = Reflect.ownKeys(getObject(arg0)); return addHeapObject(ret); @@ -2815,6 +2901,10 @@ function __wbg_get_imports() { __wbg_set_f43e577aea94465b: function(arg0, arg1, arg2) { getObject(arg0)[arg1 >>> 0] = takeObject(arg2); }, + __wbg_slice_78ac081c3e25cc60: function(arg0, arg1, arg2) { + const ret = getObject(arg0).slice(arg1 >>> 0, arg2 >>> 0); + return addHeapObject(ret); + }, __wbg_static_accessor_GLOBAL_12837167ad935116: function() { const ret = typeof global === 'undefined' ? null : global; return isLikeNone(ret) ? 0 : addHeapObject(ret); @@ -2845,12 +2935,12 @@ function __wbg_get_imports() { }, arguments); }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { // Cast intrinsic for `Closure(Closure { dtor_idx: 1, function: Function { arguments: [], shim_idx: 2, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.__wasm_bindgen_func_elem_67, __wasm_bindgen_func_elem_1049); + const ret = makeMutClosure(arg0, arg1, wasm.__wasm_bindgen_func_elem_88, __wasm_bindgen_func_elem_1556); return addHeapObject(ret); }, __wbindgen_cast_0000000000000002: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 243, function: Function { arguments: [Externref], shim_idx: 244, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.__wasm_bindgen_func_elem_2513, __wasm_bindgen_func_elem_2520); + // Cast intrinsic for `Closure(Closure { dtor_idx: 299, function: Function { arguments: [Externref], shim_idx: 300, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.__wasm_bindgen_func_elem_3669, __wasm_bindgen_func_elem_3676); return addHeapObject(ret); }, __wbindgen_cast_0000000000000003: function(arg0) { @@ -2881,16 +2971,16 @@ function __wbg_get_imports() { }; } -function __wasm_bindgen_func_elem_1049(arg0, arg1) { - wasm.__wasm_bindgen_func_elem_1049(arg0, arg1); +function __wasm_bindgen_func_elem_1556(arg0, arg1) { + wasm.__wasm_bindgen_func_elem_1556(arg0, arg1); } -function __wasm_bindgen_func_elem_2520(arg0, arg1, arg2) { - wasm.__wasm_bindgen_func_elem_2520(arg0, arg1, addHeapObject(arg2)); +function __wasm_bindgen_func_elem_3676(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_3676(arg0, arg1, addHeapObject(arg2)); } -function __wasm_bindgen_func_elem_6953(arg0, arg1, arg2, arg3) { - wasm.__wasm_bindgen_func_elem_6953(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3)); +function __wasm_bindgen_func_elem_9043(arg0, arg1, arg2, arg3) { + wasm.__wasm_bindgen_func_elem_9043(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3)); } const BinaryResultFinalization = (typeof FinalizationRegistry === 'undefined') diff --git a/js/packages/core/tests/binary-subscription.test.ts b/js/packages/core/tests/binary-subscription.test.ts new file mode 100644 index 0000000..9bf25af --- /dev/null +++ b/js/packages/core/tests/binary-subscription.test.ts @@ -0,0 +1,89 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import init, { + ColumnOptions, + Database, + JsDataType, +} from '../wasm/cynos_database.js'; +import { ResultSet, snapshotSchemaLayout } from '../src/result-set.js'; + +beforeAll(async () => { + await init(); +}); + +function nextMicrotask(): Promise { + return Promise.resolve(); +} + +describe('Binary Subscription APIs', () => { + it('changes().subscribeBinary emits transferable snapshots that decode with a serialized layout', async () => { + const db = new Database('binary_changes_stream'); + + const builder = db.createTable('items') + .column('id', JsDataType.Int64, new ColumnOptions().primaryKey(true)) + .column('name', JsDataType.String, null); + db.registerTable(builder); + + await db.insert('items').values([{ id: 1, name: 'Item 1' }]).exec(); + + const stream = db.select('*').from('items').changes(); + const layout = snapshotSchemaLayout(stream.getSchemaLayout()); + const snapshots: Array>> = []; + + const unsubscribe = stream.subscribeBinary((binary: any) => { + const transferable = binary.intoTransferable(); + const hostedBytes = structuredClone(transferable, { + transfer: [transferable.buffer], + }); + const rows = new ResultSet(hostedBytes, layout).toArray(); + snapshots.push(rows); + }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toEqual([{ id: 1, name: 'Item 1' }]); + + await db.insert('items').values([{ id: 2, name: 'Item 2' }]).exec(); + await nextMicrotask(); + + expect(snapshots).toHaveLength(2); + expect(snapshots[1]).toEqual([ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + ]); + + unsubscribe(); + }); + + it('observe().subscribeBinary emits binary updates without an eager initial push', async () => { + const db = new Database('binary_observe_stream'); + + const builder = db.createTable('users') + .column('id', JsDataType.Int64, new ColumnOptions().primaryKey(true)) + .column('name', JsDataType.String, null); + db.registerTable(builder); + + await db.insert('users').values([{ id: 1, name: 'Alice' }]).exec(); + + const observable = db.select('*').from('users').observe(); + const layout = snapshotSchemaLayout(observable.getSchemaLayout()); + const updates: Array>> = []; + + const unsubscribe = observable.subscribeBinary((binary: any) => { + const transferred = binary.intoTransferable(); + const rows = new ResultSet(transferred, layout).toArray(); + updates.push(rows); + }); + + expect(updates).toHaveLength(0); + + await db.insert('users').values([{ id: 2, name: 'Bob' }]).exec(); + await nextMicrotask(); + + expect(updates).toHaveLength(1); + expect(updates[0]).toEqual([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]); + + unsubscribe(); + }); +}); diff --git a/js/packages/core/tests/graphql.test.ts b/js/packages/core/tests/graphql.test.ts index af50499..50b5909 100644 --- a/js/packages/core/tests/graphql.test.ts +++ b/js/packages/core/tests/graphql.test.ts @@ -39,6 +39,80 @@ function createGraphqlDb(): Database { return db; } +function createGraphqlUniqueProfileDb(): Database { + const db = createGraphqlDb(); + + const profiles = db + .createTable('profiles') + .column('id', JsDataType.Int64, new ColumnOptions().primaryKey(true)) + .column('user_id', JsDataType.Int64, new ColumnOptions().setUnique(true)) + .column('bio', JsDataType.String, null) + .foreignKey( + 'fk_profiles_user', + 'user_id', + 'users', + 'id', + new ForeignKeyOptions().fieldName('user').reverseFieldName('profile') + ); + db.registerTable(profiles); + + return db; +} + +function createGraphqlIssueFilterDb(): Database { + dbCounter += 1; + + const db = new Database(`graphql_issue_filter_${dbCounter}`); + const projects = db + .createTable('projects') + .column('id', JsDataType.Int64, new ColumnOptions().primaryKey(true)) + .column('healthScore', JsDataType.Int32, null); + db.registerTable(projects); + + const projectCounters = db + .createTable('projectCounters') + .column('projectId', JsDataType.Int64, new ColumnOptions().primaryKey(true)) + .column('openIssueCount', JsDataType.Int32, null) + .foreignKey( + 'fk_project_counters_project', + 'projectId', + 'projects', + 'id', + new ForeignKeyOptions().fieldName('project').reverseFieldName('counter') + ); + db.registerTable(projectCounters); + + const projectSnapshots = db + .createTable('projectSnapshots') + .column('projectId', JsDataType.Int64, new ColumnOptions().primaryKey(true)) + .column('velocity', JsDataType.Int32, null) + .foreignKey( + 'fk_project_snapshots_project', + 'projectId', + 'projects', + 'id', + new ForeignKeyOptions().fieldName('project').reverseFieldName('snapshot') + ); + db.registerTable(projectSnapshots); + + const issues = db + .createTable('issues') + .column('id', JsDataType.Int64, new ColumnOptions().primaryKey(true)) + .column('projectId', JsDataType.Int64, null) + .column('title', JsDataType.String, null) + .column('status', JsDataType.String, null) + .foreignKey( + 'fk_issues_project', + 'projectId', + 'projects', + 'id', + new ForeignKeyOptions().fieldName('project').reverseFieldName('issues') + ); + db.registerTable(issues); + + return db; +} + async function flushGraphqlReactivity(): Promise { await Promise.resolve(); await Promise.resolve(); @@ -289,6 +363,151 @@ describe('GraphQL', () => { unsubscribe(); }); + it('keeps unique reverse relation limit(1) subscriptions live without snapshot fallback semantics', async () => { + const db = createGraphqlUniqueProfileDb(); + + await db.insert('users').values([ + { id: 1, name: 'Alice' }, + ]).exec(); + + const subscription = db.subscribeGraphql(` + subscription UserCard { + usersByPk(pk: { id: 1 }) { + id + name + profile(limit: 1) { + id + bio + } + } + } + `); + + expect(dataOf(subscription.getResult())).toEqual({ + usersByPk: { + id: 1, + name: 'Alice', + profile: [], + }, + }); + + db.graphql(` + mutation { + insertProfiles(input: [{ id: 10, user_id: 1, bio: "First" }]) { + id + bio + } + } + `); + + await flushGraphqlReactivity(); + expect(dataOf(subscription.getResult())).toEqual({ + usersByPk: { + id: 1, + name: 'Alice', + profile: [{ id: 10, bio: 'First' }], + }, + }); + + db.graphql(` + mutation { + updateProfiles(where: { id: { eq: 10 } }, set: { bio: "Updated" }) { + id + bio + } + } + `); + + await flushGraphqlReactivity(); + expect(dataOf(subscription.getResult())).toEqual({ + usersByPk: { + id: 1, + name: 'Alice', + profile: [{ id: 10, bio: 'Updated' }], + }, + }); + }); + + it('tracks root membership through single-valued relation filters', async () => { + const db = createGraphqlIssueFilterDb(); + + await db.insert('projects').values([{ id: 1, healthScore: 30 }]).exec(); + await db.insert('projectCounters').values([{ projectId: 1, openIssueCount: 6 }]).exec(); + await db.insert('projectSnapshots').values([{ projectId: 1, velocity: 20 }]).exec(); + await db.insert('issues').values([ + { id: 100, projectId: 1, title: 'Issue', status: 'open' }, + ]).exec(); + + const subscription = db.subscribeGraphql(` + subscription IssueFeed { + issues( + where: { + AND: [ + { status: { eq: "open" } } + { + project: { + AND: [ + { healthScore: { gte: 45 } } + { counter: { openIssueCount: { gte: 5 } } } + { snapshot: { velocity: { gte: 18 } } } + ] + } + } + ] + } + ) { + id + title + project { + id + counter(limit: 1) { + openIssueCount + } + snapshot(limit: 1) { + velocity + } + } + } + } + `); + + expect(dataOf(subscription.getResult())).toEqual({ issues: [] }); + + db.graphql(` + mutation { + updateProjects(where: { id: { eq: 1 } }, set: { healthScore: 50 }) { + id + } + } + `); + + await flushGraphqlReactivity(); + expect(dataOf(subscription.getResult())).toEqual({ + issues: [ + { + id: 100, + title: 'Issue', + project: { + id: 1, + counter: [{ openIssueCount: 6 }], + snapshot: [{ velocity: 20 }], + }, + }, + ], + }); + + db.graphql(` + mutation { + updateProjectSnapshots(where: { projectId: { eq: 1 } }, set: { velocity: 5 }) { + projectId + } + } + `); + + await flushGraphqlReactivity(); + expect(dataOf(subscription.getResult())).toEqual({ issues: [] }); + }); + it('pushes nested relation updates for GraphQL subscriptions', async () => { const db = createGraphqlDb(); diff --git a/js/packages/core/tests/join-optimizer-regressions.test.ts b/js/packages/core/tests/join-optimizer-regressions.test.ts new file mode 100644 index 0000000..92e72f3 --- /dev/null +++ b/js/packages/core/tests/join-optimizer-regressions.test.ts @@ -0,0 +1,125 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import init, { + ColumnOptions, + Database, + JsDataType, + JsSortOrder, + col, +} from '../wasm/cynos_database.js' + +beforeAll(async () => { + await init() +}) + +describe('Join optimizer regressions', () => { + it('preserves join-column table identity for WHERE and ORDER BY clauses', async () => { + const db = new Database('join_optimizer_modifier_identity') + + const users = db.createTable('users') + .column('id', JsDataType.Int64, new ColumnOptions().primaryKey(true)) + .column('name', JsDataType.String, null) + .index('idx_users_name', 'name') + db.registerTable(users) + + const orders = db.createTable('orders') + .column('id', JsDataType.Int64, new ColumnOptions().primaryKey(true)) + .column('user_id', JsDataType.Int64, null) + .column('amount', JsDataType.Int64, null) + .index('idx_orders_user_id', 'user_id') + .index('idx_orders_amount', 'amount') + db.registerTable(orders) + + await db.insert('users').values([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Cara' }, + ]).exec() + + await db.insert('orders').values([ + { id: 10, user_id: 1, amount: 50 }, + { id: 11, user_id: 2, amount: 150 }, + { id: 12, user_id: 3, amount: 220 }, + ]).exec() + + const query = db.select(['users.id', 'orders.amount']) + .from('users') + .leftJoin('orders', col('users.id').eq(col('orders.user_id'))) + .where(col('orders.amount').gte(100)) + .orderBy('users.id', JsSortOrder.Asc) + + const plan = query.explain() + const optimized = String(plan.optimized) + const physical = String(plan.physical) + const rows = await query.exec() + + expect(rows).toEqual([ + { id: 2, amount: 150 }, + { id: 3, amount: 220 }, + ]) + + expect(optimized).toContain('join_type: Inner') + expect(optimized).toContain('column: "amount"') + expect(optimized).toContain('table: "orders"') + expect(optimized).toContain('column: "id"') + expect(optimized).toContain('table: "users"') + expect(physical).toContain('join_type: Inner') + }) + + it('removes unused cardinality-preserving left joins from both query and trace paths', async () => { + const db = new Database('join_optimizer_outer_join_removal') + + const users = db.createTable('users') + .column('id', JsDataType.Int64, new ColumnOptions().primaryKey(true)) + .column('name', JsDataType.String, null) + db.registerTable(users) + + const orders = db.createTable('orders') + .column('id', JsDataType.Int64, new ColumnOptions().primaryKey(true)) + .column('amount', JsDataType.Int64, null) + db.registerTable(orders) + + await db.insert('users').values([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]).exec() + + await db.insert('orders').values([ + { id: 1, amount: 50 }, + ]).exec() + + const query = db.select(['users.name']) + .from('users') + .leftJoin('orders', col('users.id').eq(col('orders.id'))) + + const plan = query.explain() + const optimized = String(plan.optimized) + const physical = String(plan.physical) + + expect(optimized).not.toContain('table: "orders"') + expect(physical).not.toContain('table: "orders"') + + const rows = await query.exec() + expect(rows).toEqual([ + { name: 'Alice' }, + { name: 'Bob' }, + ]) + + const observable = query.trace() + const initialTraceResult = observable.getResult() + expect(initialTraceResult).toHaveLength(2) + + let notifications = 0 + const unsubscribe = observable.subscribe(() => { + notifications += 1 + }) + + await db.insert('orders').values([ + { id: 2, amount: 125 }, + ]).exec() + + expect(notifications).toBe(0) + expect(observable.getResult()).toEqual(initialTraceResult) + + unsubscribe() + }) +}) diff --git a/js/packages/core/tests/live-query-benchmark-regressions.test.ts b/js/packages/core/tests/live-query-benchmark-regressions.test.ts new file mode 100644 index 0000000..4b690c7 --- /dev/null +++ b/js/packages/core/tests/live-query-benchmark-regressions.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +const rowShapeModule = import('../../../../scripts/cynos_benchmark_row_shape.mjs') + +describe('live query benchmark project tracking regressions', () => { + it('selects tracked project ids deterministically from the same row set regardless of row order', async () => { + const { extractProjectIds } = await rowShapeModule + const rows = [ + { issueId: 1001, projectId: 42 }, + { issueId: 1002, projectId: 7 }, + { issueId: 1003, projectId: 19 }, + { issueId: 1004, projectId: 42 }, + { issueId: 1005, projectId: 11 }, + { issueId: 1006, projectId: 7 }, + ] + + expect(extractProjectIds(rows, 3)).toEqual([7, 11, 19]) + expect(extractProjectIds([...rows].reverse(), 3)).toEqual([7, 11, 19]) + }) + + it('selects tracked project ids deterministically from binary result-set views too', async () => { + const { extractProjectIdsFromResultSet } = await rowShapeModule + const projectIds = [42, 7, 19, 42, 11, 7] + const reverseProjectIds = [...projectIds].reverse() + + const makeIssueResultSet = (ids: number[]) => ({ + length: ids.length, + getNumber(rowIndex: number, columnIndex: number) { + if (columnIndex !== 1) { + throw new Error(`unexpected column index ${columnIndex}`) + } + return ids[rowIndex] + }, + }) + + expect( + extractProjectIdsFromResultSet( + 'issue_stream_all', + makeIssueResultSet(projectIds), + 3, + ), + ).toEqual([7, 11, 19]) + expect( + extractProjectIdsFromResultSet( + 'issue_stream_all', + makeIssueResultSet(reverseProjectIds), + 3, + ), + ).toEqual([7, 11, 19]) + }) +}) diff --git a/scripts/.DS_Store b/scripts/.DS_Store new file mode 100644 index 0000000..0ef2632 Binary files /dev/null and b/scripts/.DS_Store differ diff --git a/scripts/cynos_benchmark_row_shape.mjs b/scripts/cynos_benchmark_row_shape.mjs new file mode 100644 index 0000000..99ed7c7 --- /dev/null +++ b/scripts/cynos_benchmark_row_shape.mjs @@ -0,0 +1,252 @@ +export function rowValue(row, ...keys) { + for (const key of keys) { + if (key in row) return row[key] + } + return undefined +} + +function rawProjectIdColumn(scenarioId) { + if ( + scenarioId === 'issue_window_500' || + scenarioId === 'issue_window_5000' || + scenarioId === 'issue_stream_all' + ) { + return 1 + } + + if ( + scenarioId === 'project_board_2000' || + scenarioId === 'project_board_stream_all' + ) { + return 0 + } + + throw new Error(`Unknown scenario: ${scenarioId}`) +} + +export function mapIssueRow(row) { + const issueMetadata = rowValue(row, 'issues.metadata', 'metadata') ?? {} + const projectMetadata = row['project.metadata'] ?? {} + + return { + issueId: rowValue(row, 'issues.id', 'id'), + projectId: rowValue(row, 'project.id'), + issueTitle: rowValue(row, 'title', 'issues.title'), + issueStatus: rowValue(row, 'status', 'issues.status'), + issuePriority: rowValue(row, 'priority', 'issues.priority'), + issueSeverityRank: issueMetadata.severityRank ?? null, + issueCustomerTier: issueMetadata.customer?.tier ?? null, + projectName: rowValue(row, 'project.name', 'name'), + projectState: rowValue(row, 'state', 'project.state'), + projectHealth: rowValue(row, 'healthScore', 'project.healthScore'), + projectRiskScore: projectMetadata.risk?.score ?? null, + projectStrategic: projectMetadata.flags?.strategic ?? null, + organizationName: row['org.name'] ?? null, + teamName: row['team.name'] ?? null, + assigneeName: row['assignee.name'] ?? null, + milestoneName: row['milestone.name'] ?? null, + openIssueCount: rowValue(row, 'openIssueCount', 'counter.openIssueCount') ?? 0, + blockerCount: rowValue(row, 'blockerCount', 'counter.blockerCount') ?? 0, + velocity: rowValue(row, 'velocity', 'snapshot.velocity') ?? 0, + updatedAt: rowValue(row, 'updatedAt', 'issues.updatedAt'), + } +} + +export function mapProjectBoardRow(row) { + const projectMetadata = rowValue(row, 'projects.metadata', 'metadata') ?? {} + + return { + projectId: rowValue(row, 'projects.id', 'id'), + projectName: row['projects.name'] ?? null, + projectState: rowValue(row, 'state', 'projects.state'), + projectHealth: rowValue(row, 'healthScore', 'projects.healthScore'), + projectRiskScore: projectMetadata.risk?.score ?? null, + projectStrategic: projectMetadata.flags?.strategic ?? null, + region: rowValue(row, 'region', 'org.region') ?? null, + organizationName: row['org.name'] ?? null, + teamName: row['team.name'] ?? null, + leadName: row['lead.name'] ?? null, + leadRole: rowValue(row, 'role', 'lead.role') ?? null, + milestoneName: row['milestone.name'] ?? null, + milestoneStatus: rowValue(row, 'status', 'milestone.status') ?? null, + openIssueCount: rowValue(row, 'openIssueCount', 'counter.openIssueCount') ?? 0, + blockerCount: rowValue(row, 'blockerCount', 'counter.blockerCount') ?? 0, + staleIssueCount: + rowValue(row, 'staleIssueCount', 'counter.staleIssueCount') ?? 0, + velocity: rowValue(row, 'velocity', 'snapshot.velocity') ?? 0, + blockedRatio: rowValue(row, 'blockedRatio', 'snapshot.blockedRatio') ?? 0, + updatedAt: rowValue(row, 'updatedAt', 'projects.updatedAt'), + } +} + +export function mapRowsForScenario(scenarioId, rows) { + if ( + scenarioId === 'issue_window_500' || + scenarioId === 'issue_window_5000' || + scenarioId === 'issue_stream_all' + ) { + return rows.map(mapIssueRow) + } + + if ( + scenarioId === 'project_board_2000' || + scenarioId === 'project_board_stream_all' + ) { + return rows.map(mapProjectBoardRow) + } + + throw new Error(`Unknown scenario: ${scenarioId}`) +} + +export function scenarioRowKey(scenarioId, row) { + if ( + scenarioId === 'issue_window_500' || + scenarioId === 'issue_window_5000' || + scenarioId === 'issue_stream_all' + ) { + return rowValue(row, 'issues.id', 'issueId', 'id') + } + + if ( + scenarioId === 'project_board_2000' || + scenarioId === 'project_board_stream_all' + ) { + return rowValue(row, 'projects.id', 'projectId', 'id') + } + + throw new Error(`Unknown scenario: ${scenarioId}`) +} + +export function snapshotRowsForScenario(scenarioId, rawRows) { + return mapRowsForScenario(scenarioId, rawRows) +} + +export function extractProjectIds(rows, maxCount) { + const seen = new Set() + for (const row of rows) { + if (row.projectId == null) continue + seen.add(row.projectId) + } + + return sortProjectIds(Array.from(seen), maxCount) +} + +export function extractProjectIdsFromResultSet(scenarioId, resultSet, maxCount) { + const columnIndex = rawProjectIdColumn(scenarioId) + const seen = new Set() + + for (let rowIndex = 0; rowIndex < resultSet.length; rowIndex += 1) { + const projectId = resultSet.getNumber(rowIndex, columnIndex) + if (projectId == null) continue + seen.add(projectId) + } + + return sortProjectIds(Array.from(seen), maxCount) +} + +function compareProjectIds(left, right) { + if (typeof left === 'bigint' || typeof right === 'bigint') { + if (left < right) return -1 + if (left > right) return 1 + return 0 + } + + return Number(left) - Number(right) +} + +function sortProjectIds(projectIds, maxCount) { + projectIds.sort(compareProjectIds) + + if (!Number.isFinite(maxCount)) { + return projectIds + } + + return projectIds.slice(0, Math.max(0, maxCount)) +} + +function materializeIssueRowsFromResultSet(resultSet) { + const rows = new Array(resultSet.length) + + for (let rowIndex = 0; rowIndex < resultSet.length; rowIndex += 1) { + const issueMetadata = resultSet.getJsonb(rowIndex, 9) ?? {} + const projectMetadata = resultSet.getJsonb(rowIndex, 19) ?? {} + + rows[rowIndex] = { + issueId: resultSet.getNumber(rowIndex, 0), + projectId: resultSet.getNumber(rowIndex, 10), + issueTitle: resultSet.getString(rowIndex, 4), + issueStatus: resultSet.getString(rowIndex, 5), + issuePriority: resultSet.getString(rowIndex, 6), + issueSeverityRank: issueMetadata.severityRank ?? null, + issueCustomerTier: issueMetadata.customer?.tier ?? null, + projectName: resultSet.getString(rowIndex, 14), + projectState: resultSet.getString(rowIndex, 15), + projectHealth: resultSet.getNumber(rowIndex, 16), + projectRiskScore: projectMetadata.risk?.score ?? null, + projectStrategic: projectMetadata.flags?.strategic ?? null, + organizationName: resultSet.getString(rowIndex, 21), + teamName: resultSet.getString(rowIndex, 27), + assigneeName: resultSet.getString(rowIndex, 32), + milestoneName: resultSet.getString(rowIndex, 37), + openIssueCount: resultSet.getNumber(rowIndex, 42) ?? 0, + blockerCount: resultSet.getNumber(rowIndex, 43) ?? 0, + velocity: resultSet.getNumber(rowIndex, 47) ?? 0, + updatedAt: resultSet.getNumber(rowIndex, 8), + } + } + + return rows +} + +function materializeProjectBoardRowsFromResultSet(resultSet) { + const rows = new Array(resultSet.length) + + for (let rowIndex = 0; rowIndex < resultSet.length; rowIndex += 1) { + const projectMetadata = resultSet.getJsonb(rowIndex, 9) ?? {} + + rows[rowIndex] = { + projectId: resultSet.getNumber(rowIndex, 0), + projectName: resultSet.getString(rowIndex, 4), + projectState: resultSet.getString(rowIndex, 5), + projectHealth: resultSet.getNumber(rowIndex, 6), + projectRiskScore: projectMetadata.risk?.score ?? null, + projectStrategic: projectMetadata.flags?.strategic ?? null, + region: resultSet.getString(rowIndex, 13), + organizationName: resultSet.getString(rowIndex, 11), + teamName: resultSet.getString(rowIndex, 17), + leadName: resultSet.getString(rowIndex, 22), + leadRole: resultSet.getString(rowIndex, 23), + milestoneName: resultSet.getString(rowIndex, 37), + milestoneStatus: resultSet.getString(rowIndex, 39), + openIssueCount: resultSet.getNumber(rowIndex, 26) ?? 0, + blockerCount: resultSet.getNumber(rowIndex, 27) ?? 0, + staleIssueCount: resultSet.getNumber(rowIndex, 28) ?? 0, + velocity: resultSet.getNumber(rowIndex, 31) ?? 0, + blockedRatio: resultSet.getNumber(rowIndex, 33) ?? 0, + updatedAt: resultSet.getNumber(rowIndex, 7), + } + } + + return rows +} + +export function materializeResultSetForScenario(scenarioId, resultSet) { + let rows + + if ( + scenarioId === 'issue_window_500' || + scenarioId === 'issue_window_5000' || + scenarioId === 'issue_stream_all' + ) { + rows = materializeIssueRowsFromResultSet(resultSet) + } else if ( + scenarioId === 'project_board_2000' || + scenarioId === 'project_board_stream_all' + ) { + rows = materializeProjectBoardRowsFromResultSet(resultSet) + } else { + throw new Error(`Unknown scenario: ${scenarioId}`) + } + + return rows +} diff --git a/scripts/cynos_graphql_worker_runtime.mjs b/scripts/cynos_graphql_worker_runtime.mjs new file mode 100644 index 0000000..1240603 --- /dev/null +++ b/scripts/cynos_graphql_worker_runtime.mjs @@ -0,0 +1,1084 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { parentPort } from 'node:worker_threads' +import { performance } from 'node:perf_hooks' +import initWasm, { + Database, + JsDataType, + ColumnOptions, + ForeignKeyOptions, + col, +} from '../js/packages/core/dist/wasm.js' +import { DATASET_CONFIG } from './tanstack_db_benchmark_shared.mjs' +import { extractProjectIds } from './cynos_benchmark_row_shape.mjs' +import { + buildServerDataset, + summarizeDataset, +} from './live_query_benchmark_dataset.mjs' + +if (!parentPort) { + throw new Error('This module must run inside a worker thread.') +} + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)) +const ROOT_DIR = path.resolve(SCRIPT_DIR, '..') +const WASM_PATH = path.join(ROOT_DIR, 'js', 'packages', 'core', 'dist', 'cynos.wasm') + +const runtime = { + initialized: false, + db: null, + server: null, + prepared: null, + active: null, +} + +function pkOptions() { + return new ColumnOptions().primaryKey(true) +} + +function nullableOptions() { + return new ColumnOptions().setNullable(true) +} + +function fk(fieldName, reverseFieldName) { + return new ForeignKeyOptions() + .fieldName(fieldName) + .reverseFieldName(reverseFieldName) +} + +function post(message) { + parentPort.postMessage(message) +} + +function rowValue(row, ...keys) { + for (const key of keys) { + if (key in row) return row[key] + } + return undefined +} + +function createTables(db) { + db.registerTable( + db.createTable('organizations') + .column('id', JsDataType.Int64, pkOptions()) + .column('name', JsDataType.String, null) + .column('tier', JsDataType.String, null) + .column('region', JsDataType.String, null) + .column('metadata', JsDataType.Jsonb, null) + .index('idx_organizations_region', 'region'), + ) + + db.registerTable( + db.createTable('teams') + .column('id', JsDataType.Int64, pkOptions()) + .column('organizationId', JsDataType.Int64, null) + .column('name', JsDataType.String, null) + .column('function', JsDataType.String, null) + .column('metadata', JsDataType.Jsonb, null) + .foreignKey( + 'fk_teams_organization', + 'organizationId', + 'organizations', + 'id', + fk('organization', 'teams'), + ) + .index('idx_teams_organizationId', 'organizationId'), + ) + + db.registerTable( + db.createTable('users') + .column('id', JsDataType.Int64, pkOptions()) + .column('teamId', JsDataType.Int64, null) + .column('name', JsDataType.String, null) + .column('role', JsDataType.String, null) + .column('metadata', JsDataType.Jsonb, null) + .foreignKey('fk_users_team', 'teamId', 'teams', 'id', fk('team', 'users')) + .index('idx_users_teamId', 'teamId'), + ) + + db.registerTable( + db.createTable('projects') + .column('id', JsDataType.Int64, pkOptions()) + .column('organizationId', JsDataType.Int64, null) + .column('teamId', JsDataType.Int64, null) + .column('leadUserId', JsDataType.Int64, null) + .column('name', JsDataType.String, null) + .column('state', JsDataType.String, null) + .column('healthScore', JsDataType.Int32, null) + .column('updatedAt', JsDataType.Int64, null) + .column('priorityBand', JsDataType.String, null) + .column('metadata', JsDataType.Jsonb, null) + .foreignKey( + 'fk_projects_organization', + 'organizationId', + 'organizations', + 'id', + fk('organization', 'projects'), + ) + .foreignKey('fk_projects_team', 'teamId', 'teams', 'id', fk('team', 'projects')) + .foreignKey( + 'fk_projects_lead', + 'leadUserId', + 'users', + 'id', + fk('lead', 'ledProjects'), + ) + .index('idx_projects_organizationId', 'organizationId') + .index('idx_projects_teamId', 'teamId') + .index('idx_projects_leadUserId', 'leadUserId') + .index('idx_projects_state', 'state') + .index('idx_projects_healthScore', 'healthScore') + .index('idx_projects_updatedAt', 'updatedAt') + .jsonbIndex('metadata', ['$.risk.bucket', '$.risk.score', '$.flags.strategic']), + ) + + db.registerTable( + db.createTable('projectSnapshots') + .column('projectId', JsDataType.Int64, pkOptions()) + .column('velocity', JsDataType.Int32, null) + .column('completionRate', JsDataType.Float64, null) + .column('blockedRatio', JsDataType.Float64, null) + .column('updatedAt', JsDataType.Int64, null) + .foreignKey( + 'fk_project_snapshots_project', + 'projectId', + 'projects', + 'id', + fk('project', 'snapshot'), + ) + .index('idx_projectSnapshots_velocity', 'velocity'), + ) + + db.registerTable( + db.createTable('projectCounters') + .column('projectId', JsDataType.Int64, pkOptions()) + .column('openIssueCount', JsDataType.Int32, null) + .column('blockerCount', JsDataType.Int32, null) + .column('staleIssueCount', JsDataType.Int32, null) + .column('updatedAt', JsDataType.Int64, null) + .foreignKey( + 'fk_project_counters_project', + 'projectId', + 'projects', + 'id', + fk('project', 'counter'), + ) + .index('idx_projectCounters_openIssueCount', 'openIssueCount'), + ) + + db.registerTable( + db.createTable('currentMilestones') + .column('id', JsDataType.Int64, pkOptions()) + .column('projectId', JsDataType.Int64, null) + .column('name', JsDataType.String, null) + .column('dueAt', JsDataType.Int64, null) + .column('status', JsDataType.String, null) + .column('metadata', JsDataType.Jsonb, null) + .foreignKey( + 'fk_current_milestones_project', + 'projectId', + 'projects', + 'id', + fk('project', 'milestone'), + ) + .index('idx_currentMilestones_projectId', 'projectId') + .index('idx_currentMilestones_dueAt', 'dueAt'), + ) + + db.registerTable( + db.createTable('issues') + .column('id', JsDataType.Int64, pkOptions()) + .column('projectId', JsDataType.Int64, null) + .column('assigneeId', JsDataType.Int64, null) + .column('currentMilestoneId', JsDataType.Int64, nullableOptions()) + .column('title', JsDataType.String, null) + .column('status', JsDataType.String, null) + .column('priority', JsDataType.String, null) + .column('estimate', JsDataType.Int32, null) + .column('updatedAt', JsDataType.Int64, null) + .column('metadata', JsDataType.Jsonb, null) + .foreignKey( + 'fk_issues_project', + 'projectId', + 'projects', + 'id', + fk('project', 'issues'), + ) + .foreignKey( + 'fk_issues_assignee', + 'assigneeId', + 'users', + 'id', + fk('assignee', 'assignedIssues'), + ) + .foreignKey( + 'fk_issues_current_milestone', + 'currentMilestoneId', + 'currentMilestones', + 'id', + fk('currentMilestone', 'issues'), + ) + .index('idx_issues_projectId', 'projectId') + .index('idx_issues_assigneeId', 'assigneeId') + .index('idx_issues_currentMilestoneId', 'currentMilestoneId') + .index('idx_issues_status', 'status') + .index('idx_issues_estimate', 'estimate') + .index('idx_issues_updatedAt', 'updatedAt') + .jsonbIndex( + 'metadata', + ['$.customer.tier', '$.severityRank', '$.workflow.lane'], + ), + ) +} + +async function insertTableInBatches(db, tableName, rows) { + const batchSize = + tableName === 'issues' + ? 4_000 + : tableName === 'users' || tableName === 'currentMilestones' + ? 2_000 + : 1_000 + + for (let index = 0; index < rows.length; index += batchSize) { + await db + .insert(tableName) + .values(rows.slice(index, index + batchSize)) + .exec() + } +} + +const ISSUE_FEED_SUBSCRIPTION = ` + subscription IssueFeed($rootLimit: Int!) { + issues( + where: { + AND: [ + { OR: [{ status: { eq: "open" } }, { status: { eq: "in_progress" } }] } + { estimate: { gte: 3 } } + { + OR: [ + { metadata: { path: "$.customer.tier", eq: "enterprise" } } + { metadata: { path: "$.customer.tier", eq: "mid_market" } } + ] + } + { + project: { + AND: [ + { healthScore: { gte: 45 } } + { + OR: [ + { metadata: { path: "$.risk.bucket", eq: "high" } } + { metadata: { path: "$.risk.bucket", eq: "critical" } } + ] + } + { counter: { openIssueCount: { gte: 5 } } } + { snapshot: { velocity: { gte: 18 } } } + ] + } + } + ] + } + orderBy: [{ field: UPDATEDAT, direction: DESC }] + limit: $rootLimit + ) { + id + title + status + priority + estimate + updatedAt + metadata + project { + id + name + state + healthScore + updatedAt + metadata + organization { + name + } + team { + name + } + counter(where: { openIssueCount: { gte: 5 } }, limit: 1) { + openIssueCount + blockerCount + staleIssueCount + updatedAt + } + snapshot(where: { velocity: { gte: 18 } }, limit: 1) { + velocity + blockedRatio + completionRate + updatedAt + } + } + assignee { + name + } + currentMilestone { + name + status + } + } + } +` + +const ISSUE_FEED_QUERY = ISSUE_FEED_SUBSCRIPTION.replace( + 'subscription IssueFeed', + 'query IssueFeed', +) + +const ISSUE_FEED_STREAM_SUBSCRIPTION = ` + subscription IssueFeedStream { + issues( + where: { + AND: [ + { OR: [{ status: { eq: "open" } }, { status: { eq: "in_progress" } }] } + { estimate: { gte: 3 } } + { + OR: [ + { metadata: { path: "$.customer.tier", eq: "enterprise" } } + { metadata: { path: "$.customer.tier", eq: "mid_market" } } + ] + } + { + project: { + AND: [ + { healthScore: { gte: 45 } } + { + OR: [ + { metadata: { path: "$.risk.bucket", eq: "high" } } + { metadata: { path: "$.risk.bucket", eq: "critical" } } + ] + } + { counter: { openIssueCount: { gte: 5 } } } + { snapshot: { velocity: { gte: 18 } } } + ] + } + } + ] + } + ) { + id + title + status + priority + estimate + updatedAt + metadata + project { + id + name + state + healthScore + updatedAt + metadata + organization { + name + } + team { + name + } + counter(where: { openIssueCount: { gte: 5 } }, limit: 1) { + openIssueCount + blockerCount + staleIssueCount + updatedAt + } + snapshot(where: { velocity: { gte: 18 } }, limit: 1) { + velocity + blockedRatio + completionRate + updatedAt + } + } + assignee { + name + } + currentMilestone { + name + status + } + } + } +` + +const ISSUE_FEED_STREAM_QUERY = ISSUE_FEED_STREAM_SUBSCRIPTION.replace( + 'subscription IssueFeedStream', + 'query IssueFeedStream', +) + +const PROJECT_BOARD_SUBSCRIPTION = ` + subscription ProjectBoard($rootLimit: Int!) { + projects( + where: { + AND: [ + { OR: [{ state: { eq: "active" } }, { state: { eq: "at_risk" } }] } + { healthScore: { gte: 45 } } + { + OR: [ + { metadata: { path: "$.risk.bucket", eq: "high" } } + { metadata: { path: "$.risk.bucket", eq: "critical" } } + ] + } + ] + } + orderBy: [{ field: HEALTHSCORE, direction: DESC }] + limit: $rootLimit + ) { + id + name + state + healthScore + updatedAt + metadata + organization { + region + name + } + team { + name + } + lead { + name + role + } + counter(where: { openIssueCount: { gte: 4 } }, limit: 1) { + openIssueCount + blockerCount + staleIssueCount + updatedAt + } + snapshot(where: { velocity: { gte: 20 } }, limit: 1) { + velocity + blockedRatio + completionRate + updatedAt + } + milestone(limit: 1) { + name + status + } + } + } +` + +const PROJECT_BOARD_QUERY = PROJECT_BOARD_SUBSCRIPTION.replace( + 'subscription ProjectBoard', + 'query ProjectBoard', +) + +const PROJECT_BOARD_STREAM_SUBSCRIPTION = ` + subscription ProjectBoardStream { + projects( + where: { + AND: [ + { OR: [{ state: { eq: "active" } }, { state: { eq: "at_risk" } }] } + { healthScore: { gte: 45 } } + { + OR: [ + { metadata: { path: "$.risk.bucket", eq: "high" } } + { metadata: { path: "$.risk.bucket", eq: "critical" } } + ] + } + ] + } + ) { + id + name + state + healthScore + updatedAt + metadata + organization { + region + name + } + team { + name + } + lead { + name + role + } + counter(where: { openIssueCount: { gte: 4 } }, limit: 1) { + openIssueCount + blockerCount + staleIssueCount + updatedAt + } + snapshot(where: { velocity: { gte: 20 } }, limit: 1) { + velocity + blockedRatio + completionRate + updatedAt + } + milestone(limit: 1) { + name + status + } + } + } +` + +const PROJECT_BOARD_STREAM_QUERY = PROJECT_BOARD_STREAM_SUBSCRIPTION.replace( + 'subscription ProjectBoardStream', + 'query ProjectBoardStream', +) + +function issueRootLimit(visibleLimit) { + return visibleLimit +} + +function boardRootLimit() { + return DATASET_CONFIG.projectCount +} + +function scenarioVisibleLimit(scenarioId) { + if (scenarioId === 'issue_window_500') return 500 + if (scenarioId === 'issue_window_5000') return 5_000 + if (scenarioId === 'issue_stream_all') return Number.POSITIVE_INFINITY + if (scenarioId === 'project_board_2000') return 2_000 + if (scenarioId === 'project_board_stream_all') return Number.POSITIVE_INFINITY + throw new Error(`Unknown scenario: ${scenarioId}`) +} + +function scenarioVariables(scenarioId) { + if (scenarioId === 'issue_window_500') { + return { + rootLimit: issueRootLimit(500), + } + } + if (scenarioId === 'issue_window_5000') { + return { + rootLimit: issueRootLimit(5_000), + } + } + if (scenarioId === 'issue_stream_all') { + return {} + } + if (scenarioId === 'project_board_2000') { + return { rootLimit: boardRootLimit() } + } + if (scenarioId === 'project_board_stream_all') { + return {} + } + throw new Error(`Unknown scenario: ${scenarioId}`) +} + +function buildScenarioSubscription(scenarioId, variables) { + if (scenarioId === 'issue_window_500' || scenarioId === 'issue_window_5000') { + return runtime.prepared.issueFeed.subscribe(variables) + } + if (scenarioId === 'issue_stream_all') { + return runtime.prepared.issueFeedStream.subscribe(variables) + } + + if (scenarioId === 'project_board_2000') { + return runtime.prepared.projectBoard.subscribe(variables) + } + if (scenarioId === 'project_board_stream_all') { + return runtime.prepared.projectBoardStream.subscribe(variables) + } + + throw new Error(`Unknown scenario: ${scenarioId}`) +} + +function executeScenarioQuery(scenarioId, variables) { + if (scenarioId === 'issue_window_500' || scenarioId === 'issue_window_5000') { + return runtime.prepared.issueFeedQuery.exec(variables) + } + if (scenarioId === 'issue_stream_all') { + return runtime.prepared.issueFeedStreamQuery.exec(variables) + } + + if (scenarioId === 'project_board_2000') { + return runtime.prepared.projectBoardQuery.exec(variables) + } + if (scenarioId === 'project_board_stream_all') { + return runtime.prepared.projectBoardStreamQuery.exec(variables) + } + + throw new Error(`Unknown scenario: ${scenarioId}`) +} + +function mapIssuePayloadRows(rows, visibleLimit) { + const result = [] + + for (const issue of rows ?? []) { + const project = issue.project + const counter = project?.counter?.[0] + const snapshot = project?.snapshot?.[0] + if (!project || !counter || !snapshot) continue + + result.push({ + issueId: issue.id, + projectId: project.id, + issueTitle: issue.title, + issueStatus: issue.status, + issuePriority: issue.priority, + issueSeverityRank: issue.metadata?.severityRank ?? null, + issueCustomerTier: issue.metadata?.customer?.tier ?? null, + projectName: project.name, + projectState: project.state, + projectHealth: project.healthScore, + projectRiskScore: project.metadata?.risk?.score ?? null, + projectStrategic: project.metadata?.flags?.strategic ?? null, + organizationName: project.organization?.name ?? null, + teamName: project.team?.name ?? null, + assigneeName: issue.assignee?.name ?? null, + milestoneName: issue.currentMilestone?.name ?? null, + openIssueCount: counter.openIssueCount ?? 0, + blockerCount: counter.blockerCount ?? 0, + velocity: snapshot.velocity ?? 0, + updatedAt: issue.updatedAt, + }) + } + + if (Number.isFinite(visibleLimit)) { + result.sort((left, right) => right.updatedAt - left.updatedAt) + return result.slice(0, visibleLimit) + } + + return result +} + +function mapProjectBoardPayloadRows(rows, visibleLimit) { + const result = [] + + for (const project of rows ?? []) { + const counter = project.counter?.[0] + const snapshot = project.snapshot?.[0] + const milestone = project.milestone?.[0] + if (!counter || !snapshot) continue + + result.push({ + projectId: project.id, + projectName: project.name, + projectState: project.state, + projectHealth: project.healthScore, + projectRiskScore: project.metadata?.risk?.score ?? null, + projectStrategic: project.metadata?.flags?.strategic ?? null, + region: project.organization?.region ?? null, + organizationName: project.organization?.name ?? null, + teamName: project.team?.name ?? null, + leadName: project.lead?.name ?? null, + leadRole: project.lead?.role ?? null, + milestoneName: milestone?.name ?? null, + milestoneStatus: milestone?.status ?? null, + openIssueCount: counter.openIssueCount ?? 0, + blockerCount: counter.blockerCount ?? 0, + staleIssueCount: counter.staleIssueCount ?? 0, + velocity: snapshot.velocity ?? 0, + blockedRatio: snapshot.blockedRatio ?? 0, + updatedAt: project.updatedAt, + }) + } + + if (Number.isFinite(visibleLimit)) { + result.sort((left, right) => right.projectHealth - left.projectHealth) + return result.slice(0, visibleLimit) + } + + return result +} + +function mapPayloadForScenario(scenarioId, payload, visibleLimit) { + const data = payload?.data ?? {} + + if ( + scenarioId === 'issue_window_500' || + scenarioId === 'issue_window_5000' || + scenarioId === 'issue_stream_all' + ) { + return mapIssuePayloadRows(data.issues ?? [], visibleLimit) + } + + if (scenarioId === 'project_board_2000' || scenarioId === 'project_board_stream_all') { + return mapProjectBoardPayloadRows(data.projects ?? [], visibleLimit) + } + + throw new Error(`Unknown scenario: ${scenarioId}`) +} + +function syncActiveRows(active, payload) { + const rows = mapPayloadForScenario(active.scenarioId, payload, active.visibleLimit) + active.currentRows = rows + + const nextProjectIds = extractProjectIds(rows, Number.POSITIVE_INFINITY) + if (nextProjectIds.length > 0) { + active.lastProjectIds = nextProjectIds + } + + return rows +} + +function flushPendingSnapshot(active, rows) { + const pending = active.pending + if (!pending) return + post(createSnapshotMessage(active, pending.phase, rows)) + active.pending = null +} + +function postMutationProfile(active, phase, mutationProfile) { + post({ + type: 'mutation-profile', + scenarioId: active.scenarioId, + phase, + mutationProfile, + }) +} + +function takeCommitProfile() { + const profile = runtime.db?.takeLastCommitProfile?.() + return profile && typeof profile === 'object' ? profile : null +} + +function takeDeltaFlushProfile() { + const profile = runtime.db?.takeLastDeltaFlushProfile?.() + return profile && typeof profile === 'object' ? profile : null +} + +function takeSnapshotFlushProfile() { + const profile = runtime.db?.takeLastSnapshotFlushProfile?.() + return profile && typeof profile === 'object' ? profile : null +} + +function buildCommitProfiles() { + return { + commitProfile: takeCommitProfile(), + deltaFlushProfile: takeDeltaFlushProfile(), + snapshotFlushProfile: takeSnapshotFlushProfile(), + } +} + +function cancelScheduledReattach(active) { + if (active.reattachHandle == null) return + clearTimeout(active.reattachHandle) + active.reattachHandle = null +} + +function detachSubscription(active) { + cancelScheduledReattach(active) + active.unsubscribe?.() + active.subscription?.free?.() + active.unsubscribe = null + active.subscription = null +} + +function attachSubscription(active, variables = scenarioVariables(active.scenarioId)) { + detachSubscription(active) + + active.variables = variables + active.subscriptionGeneration = (active.subscriptionGeneration ?? 0) + 1 + const generation = active.subscriptionGeneration + const subscription = buildScenarioSubscription(active.scenarioId, variables) + + active.subscription = subscription + active.unsubscribe = subscription.subscribe((payload) => { + if (runtime.active !== active) return + if (active.subscriptionGeneration !== generation) return + if (active.subscriptionMuted) return + + const rows = syncActiveRows(active, payload) + flushPendingSnapshot(active, rows) + }) +} + +function scheduleAttachSubscription(active, variables) { + cancelScheduledReattach(active) + active.reattachHandle = setTimeout(() => { + active.reattachHandle = null + if (runtime.active !== active) return + attachSubscription(active, variables) + }, 0) +} + +async function initRuntime() { + if (runtime.initialized) { + return { + type: 'ready', + dataset: summarizeDataset(runtime.server.tables), + } + } + + const startedAt = performance.now() + await initWasm({ module_or_path: await fs.readFile(WASM_PATH) }) + + runtime.server = buildServerDataset(DATASET_CONFIG) + runtime.db = new Database(`cynos_bench_${Date.now()}`) + createTables(runtime.db) + + for (const [tableName, rows] of Object.entries(runtime.server.tables)) { + await insertTableInBatches(runtime.db, tableName, rows) + } + + runtime.prepared = { + issueFeed: runtime.db.prepareGraphql(ISSUE_FEED_SUBSCRIPTION, 'IssueFeed'), + issueFeedQuery: runtime.db.prepareGraphql(ISSUE_FEED_QUERY, 'IssueFeed'), + issueFeedStream: runtime.db.prepareGraphql( + ISSUE_FEED_STREAM_SUBSCRIPTION, + 'IssueFeedStream', + ), + issueFeedStreamQuery: runtime.db.prepareGraphql( + ISSUE_FEED_STREAM_QUERY, + 'IssueFeedStream', + ), + projectBoard: runtime.db.prepareGraphql( + PROJECT_BOARD_SUBSCRIPTION, + 'ProjectBoard', + ), + projectBoardQuery: runtime.db.prepareGraphql( + PROJECT_BOARD_QUERY, + 'ProjectBoard', + ), + projectBoardStream: runtime.db.prepareGraphql( + PROJECT_BOARD_STREAM_SUBSCRIPTION, + 'ProjectBoardStream', + ), + projectBoardStreamQuery: runtime.db.prepareGraphql( + PROJECT_BOARD_STREAM_QUERY, + 'ProjectBoardStream', + ), + } + + runtime.initialized = true + return { + type: 'ready', + initMs: performance.now() - startedAt, + dataset: summarizeDataset(runtime.server.tables), + } +} + +function ensureInitialized() { + if (!runtime.initialized || !runtime.db || !runtime.server) { + throw new Error('Worker runtime is not initialized.') + } +} + +function unsubscribeActive() { + if (!runtime.active) return + detachSubscription(runtime.active) + runtime.active = null +} + +function createSnapshotMessage(active, phase, rows) { + return { + type: 'snapshot', + scenarioId: active.scenarioId, + phase, + workerLatencyMs: performance.now() - active.pending.startedAt, + rowCount: rows.length, + changeCount: 1, + rows: active.includeRows ? rows : undefined, + } +} + +function subscribeScenario(message) { + ensureInitialized() + unsubscribeActive() + + const active = { + scenarioId: message.scenarioId, + includeRows: message.includeRows !== false, + visibleLimit: scenarioVisibleLimit(message.scenarioId), + subscription: null, + currentRows: [], + lastProjectIds: [], + pending: { + phase: 'initial', + startedAt: performance.now(), + }, + unsubscribe: null, + variables: null, + subscriptionGeneration: 0, + subscriptionMuted: false, + reattachHandle: null, + } + + runtime.active = active + attachSubscription(active) +} + +function collectActiveProjectIds(maxCount) { + if (!runtime.active) { + throw new Error('No active live query subscription.') + } + + const currentIds = extractProjectIds(runtime.active.currentRows, maxCount) + if (currentIds.length > 0) return currentIds + + if (runtime.active.lastProjectIds.length > 0) { + return runtime.active.lastProjectIds.slice(0, maxCount) + } + + return runtime.server.tables.projects.slice(0, maxCount).map((row) => row.id) +} + +function applyProjectPatch(projectId, transform) { + const projects = runtime.server.tables.projects + const index = projects.findIndex((row) => row.id === projectId) + if (index < 0) return null + const current = projects[index] + const next = transform(current) + projects[index] = next + runtime.server.revisions.projects += 1 + return next +} + +function runSocketPatchBurst(message) { + ensureInitialized() + if (!runtime.active) { + throw new Error('Subscribe before running socket patches.') + } + + const pending = { + phase: 'socket', + startedAt: performance.now(), + } + runtime.active.pending = pending + const projectIds = collectActiveProjectIds(message.patchCount) + const projectIdsCollectedAt = performance.now() + + const tx = runtime.db.transaction() + const patchLoopStartedAt = performance.now() + for (const projectId of projectIds) { + const next = applyProjectPatch(projectId, (current) => { + const healthScore = current.healthScore >= 45 ? 24 : 82 + return { + ...current, + state: current.state === 'active' ? 'at_risk' : 'active', + healthScore, + updatedAt: current.updatedAt + 60_000, + } + }) + if (!next) continue + tx.update( + 'projects', + { + state: next.state, + healthScore: next.healthScore, + updatedAt: next.updatedAt, + }, + col('id').eq(next.id), + ) + } + const patchLoopCompletedAt = performance.now() + const commitStartedAt = performance.now() + tx.commit() + const commitCompletedAt = performance.now() + + postMutationProfile(runtime.active, 'socket', { + projectIdsCount: projectIds.length, + collectProjectIdsMs: projectIdsCollectedAt - pending.startedAt, + patchLoopMs: patchLoopCompletedAt - patchLoopStartedAt, + commitCallMs: commitCompletedAt - commitStartedAt, + ...buildCommitProfiles(), + }) +} + +function runApiRefresh(message) { + ensureInitialized() + if (!runtime.active) { + throw new Error('Subscribe before running API refresh.') + } + + const pending = { + phase: 'api', + startedAt: performance.now(), + } + runtime.active.pending = pending + const projectIds = collectActiveProjectIds(message.patchCount) + const projectIdsCollectedAt = performance.now() + + const tx = runtime.db.transaction() + const patchLoopStartedAt = performance.now() + for (const projectId of projectIds) { + const next = applyProjectPatch(projectId, (current) => { + const nextRisk = current.healthScore >= 45 ? 18 : 76 + const nextHealth = current.healthScore >= 45 ? 28 : 88 + return { + ...current, + healthScore: nextHealth, + updatedAt: current.updatedAt + 120_000, + metadata: { + ...current.metadata, + risk: { + ...current.metadata.risk, + score: nextRisk, + bucket: nextRisk >= 70 ? 'critical' : nextRisk >= 45 ? 'high' : 'medium', + }, + flags: { + ...current.metadata.flags, + strategic: !current.metadata.flags.strategic, + }, + }, + } + }) + if (!next) continue + tx.update( + 'projects', + { + healthScore: next.healthScore, + updatedAt: next.updatedAt, + metadata: next.metadata, + }, + col('id').eq(next.id), + ) + } + const patchLoopCompletedAt = performance.now() + const commitStartedAt = performance.now() + tx.commit() + const commitCompletedAt = performance.now() + + postMutationProfile(runtime.active, 'api', { + projectIdsCount: projectIds.length, + collectProjectIdsMs: projectIdsCollectedAt - pending.startedAt, + patchLoopMs: patchLoopCompletedAt - patchLoopStartedAt, + commitCallMs: commitCompletedAt - commitStartedAt, + ...buildCommitProfiles(), + }) +} + +function handleError(error, context) { + post({ + type: 'error', + context, + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) +} + +parentPort.on('message', async (message) => { + try { + switch (message.type) { + case 'init': + post(await initRuntime()) + return + case 'subscribe': + subscribeScenario(message) + return + case 'socket-patch': + runSocketPatchBurst(message) + return + case 'api-refresh': + runApiRefresh(message) + return + case 'unsubscribe': + unsubscribeActive() + post({ type: 'unsubscribed', scenarioId: message.scenarioId }) + return + case 'shutdown': + unsubscribeActive() + runtime.db?.free?.() + post({ type: 'shutdown-complete' }) + return + default: + throw new Error(`Unknown worker message type: ${message.type}`) + } + } catch (error) { + handleError(error, message.type) + } +}) diff --git a/scripts/cynos_query_plan_probe.mjs b/scripts/cynos_query_plan_probe.mjs new file mode 100644 index 0000000..8ea8881 --- /dev/null +++ b/scripts/cynos_query_plan_probe.mjs @@ -0,0 +1,552 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { performance } from 'node:perf_hooks' +import initWasm, { + Database, + JsDataType, + JsSortOrder, + ColumnOptions, + col, +} from '../js/packages/core/dist/wasm.js' +import { ResultSet, snapshotSchemaLayout } from '../js/packages/core/dist/index.js' +import { DATASET_CONFIG } from './tanstack_db_benchmark_shared.mjs' +import { + buildServerDataset, + summarizeDataset, +} from './live_query_benchmark_dataset.mjs' + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)) +const ROOT_DIR = path.resolve(SCRIPT_DIR, '..') +const WASM_PATH = path.join(ROOT_DIR, 'js', 'packages', 'core', 'dist', 'cynos.wasm') +const REPORT_PATH = path.join(ROOT_DIR, 'tmp', 'cynos_query_plan_probe.md') +const JSON_REPORT_PATH = path.join(ROOT_DIR, 'tmp', 'cynos_query_plan_probe.json') +const ROUNDS = Number.parseInt(process.env.CYNOS_QUERY_PROBE_ROUNDS ?? '3', 10) + +function pkOptions() { + return new ColumnOptions().primaryKey(true) +} + +function nullableOptions() { + return new ColumnOptions().setNullable(true) +} + +function createTables(db) { + db.registerTable( + db.createTable('organizations') + .column('id', JsDataType.Int64, pkOptions()) + .column('name', JsDataType.String, null) + .column('tier', JsDataType.String, null) + .column('region', JsDataType.String, null) + .column('metadata', JsDataType.Jsonb, null) + .index('idx_organizations_region', 'region'), + ) + + db.registerTable( + db.createTable('teams') + .column('id', JsDataType.Int64, pkOptions()) + .column('organizationId', JsDataType.Int64, null) + .column('name', JsDataType.String, null) + .column('function', JsDataType.String, null) + .column('metadata', JsDataType.Jsonb, null) + .index('idx_teams_organizationId', 'organizationId'), + ) + + db.registerTable( + db.createTable('users') + .column('id', JsDataType.Int64, pkOptions()) + .column('teamId', JsDataType.Int64, null) + .column('name', JsDataType.String, null) + .column('role', JsDataType.String, null) + .column('metadata', JsDataType.Jsonb, null) + .index('idx_users_teamId', 'teamId'), + ) + + db.registerTable( + db.createTable('projects') + .column('id', JsDataType.Int64, pkOptions()) + .column('organizationId', JsDataType.Int64, null) + .column('teamId', JsDataType.Int64, null) + .column('leadUserId', JsDataType.Int64, null) + .column('name', JsDataType.String, null) + .column('state', JsDataType.String, null) + .column('healthScore', JsDataType.Int32, null) + .column('updatedAt', JsDataType.Int64, null) + .column('priorityBand', JsDataType.String, null) + .column('metadata', JsDataType.Jsonb, null) + .index('idx_projects_organizationId', 'organizationId') + .index('idx_projects_teamId', 'teamId') + .index('idx_projects_leadUserId', 'leadUserId') + .index('idx_projects_state', 'state') + .index('idx_projects_healthScore', 'healthScore') + .index('idx_projects_updatedAt', 'updatedAt') + .jsonbIndex( + 'metadata', + ['$.risk.bucket', '$.risk.score', '$.flags.strategic'], + ), + ) + + db.registerTable( + db.createTable('projectSnapshots') + .column('projectId', JsDataType.Int64, pkOptions()) + .column('velocity', JsDataType.Int32, null) + .column('completionRate', JsDataType.Float64, null) + .column('blockedRatio', JsDataType.Float64, null) + .column('updatedAt', JsDataType.Int64, null) + .index('idx_projectSnapshots_velocity', 'velocity'), + ) + + db.registerTable( + db.createTable('projectCounters') + .column('projectId', JsDataType.Int64, pkOptions()) + .column('openIssueCount', JsDataType.Int32, null) + .column('blockerCount', JsDataType.Int32, null) + .column('staleIssueCount', JsDataType.Int32, null) + .column('updatedAt', JsDataType.Int64, null) + .index('idx_projectCounters_openIssueCount', 'openIssueCount'), + ) + + db.registerTable( + db.createTable('currentMilestones') + .column('id', JsDataType.Int64, pkOptions()) + .column('projectId', JsDataType.Int64, null) + .column('name', JsDataType.String, null) + .column('dueAt', JsDataType.Int64, null) + .column('status', JsDataType.String, null) + .column('metadata', JsDataType.Jsonb, null) + .index('idx_currentMilestones_projectId', 'projectId') + .index('idx_currentMilestones_dueAt', 'dueAt'), + ) + + db.registerTable( + db.createTable('issues') + .column('id', JsDataType.Int64, pkOptions()) + .column('projectId', JsDataType.Int64, null) + .column('assigneeId', JsDataType.Int64, null) + .column('currentMilestoneId', JsDataType.Int64, nullableOptions()) + .column('title', JsDataType.String, null) + .column('status', JsDataType.String, null) + .column('priority', JsDataType.String, null) + .column('estimate', JsDataType.Int32, null) + .column('updatedAt', JsDataType.Int64, null) + .column('metadata', JsDataType.Jsonb, null) + .index('idx_issues_projectId', 'projectId') + .index('idx_issues_assigneeId', 'assigneeId') + .index('idx_issues_currentMilestoneId', 'currentMilestoneId') + .index('idx_issues_status', 'status') + .index('idx_issues_estimate', 'estimate') + .index('idx_issues_updatedAt', 'updatedAt') + .jsonbIndex( + 'metadata', + ['$.severityRank', '$.customer.tier', '$.workflow.lane'], + ), + ) +} + +async function insertTableInBatches(db, tableName, rows) { + const batchSize = + tableName === 'issues' + ? 4_000 + : tableName === 'users' || tableName === 'currentMilestones' + ? 2_000 + : 1_000 + + for (let index = 0; index < rows.length; index += batchSize) { + await db + .insert(tableName) + .values(rows.slice(index, index + batchSize)) + .exec() + } +} + +function issueRootPredicate() { + return col('issues.status') + .eq('open') + .or(col('issues.status').eq('in_progress')) + .and(col('issues.estimate').gte(3)) + .and( + col('issues.metadata') + .get('$.customer.tier') + .eq('enterprise') + .or(col('issues.metadata').get('$.customer.tier').eq('mid_market')), + ) +} + +function issueJoinedPredicate() { + return issueRootPredicate() + .and(col('project.healthScore').gte(45)) + .and( + col('project.metadata') + .get('$.risk.bucket') + .eq('high') + .or(col('project.metadata').get('$.risk.bucket').eq('critical')), + ) + .and(col('counter.openIssueCount').gte(5)) + .and(col('snapshot.velocity').gte(18)) +} + +function issueJoinBase(db) { + return db + .select([ + 'issues.id', + 'issues.updatedAt', + 'issues.status', + 'issues.estimate', + 'project.id', + 'project.healthScore', + 'project.metadata', + 'counter.openIssueCount', + 'snapshot.velocity', + ]) + .from('issues') + .leftJoin('projects as project', col('issues.projectId').eq(col('project.id'))) + .leftJoin( + 'organizations as org', + col('project.organizationId').eq(col('org.id')), + ) + .leftJoin('teams as team', col('project.teamId').eq(col('team.id'))) + .leftJoin( + 'users as assignee', + col('issues.assigneeId').eq(col('assignee.id')), + ) + .leftJoin( + 'currentMilestones as milestone', + col('issues.currentMilestoneId').eq(col('milestone.id')), + ) + .leftJoin( + 'projectCounters as counter', + col('project.id').eq(col('counter.projectId')), + ) + .leftJoin( + 'projectSnapshots as snapshot', + col('project.id').eq(col('snapshot.projectId')), + ) +} + +function projectRootPredicate() { + return col('projects.state') + .eq('active') + .or(col('projects.state').eq('at_risk')) + .and(col('projects.healthScore').gte(45)) + .and( + col('projects.metadata') + .get('$.risk.bucket') + .eq('high') + .or(col('projects.metadata').get('$.risk.bucket').eq('critical')), + ) +} + +function projectJoinedPredicate() { + return projectRootPredicate() + .and(col('counter.openIssueCount').gte(4)) + .and(col('snapshot.velocity').gte(20)) +} + +function projectJoinBase(db) { + return db + .select([ + 'projects.id', + 'projects.healthScore', + 'projects.updatedAt', + 'projects.metadata', + 'counter.openIssueCount', + 'snapshot.velocity', + 'milestone.name', + ]) + .from('projects') + .leftJoin( + 'organizations as org', + col('projects.organizationId').eq(col('org.id')), + ) + .leftJoin('teams as team', col('projects.teamId').eq(col('team.id'))) + .leftJoin('users as lead', col('projects.leadUserId').eq(col('lead.id'))) + .leftJoin( + 'projectCounters as counter', + col('projects.id').eq(col('counter.projectId')), + ) + .leftJoin( + 'projectSnapshots as snapshot', + col('projects.id').eq(col('snapshot.projectId')), + ) + .leftJoin( + 'currentMilestones as milestone', + col('projects.id').eq(col('milestone.projectId')), + ) +} + +function median(values) { + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid] +} + +function mean(values) { + return values.reduce((sum, value) => sum + value, 0) / values.length +} + +function formatMs(value) { + return value < 1 ? `${value.toFixed(3)} ms` : `${value.toFixed(2)} ms` +} + +function normalizePlan(plan) { + return { + logical: String(plan.logical ?? ''), + optimized: String(plan.optimized ?? ''), + physical: String(plan.physical ?? ''), + } +} + +async function benchmarkPrepared(prepared, decodeBinary = false) { + const layout = snapshotSchemaLayout(prepared.getSchemaLayout()) + const execBinaryMs = [] + const decodeBinaryMs = [] + const execObjectMs = [] + let lastRowCount = 0 + + for (let round = 0; round < ROUNDS; round += 1) { + let startedAt = performance.now() + const binary = await prepared.execBinary() + execBinaryMs.push(performance.now() - startedAt) + + startedAt = performance.now() + const resultSet = new ResultSet(binary, layout) + lastRowCount = resultSet.length + if (decodeBinary) { + resultSet.toArray() + } + decodeBinaryMs.push(performance.now() - startedAt) + + startedAt = performance.now() + const objectRows = await prepared.exec() + execObjectMs.push(performance.now() - startedAt) + lastRowCount = Array.isArray(objectRows) ? objectRows.length : lastRowCount + } + + return { + rowCount: lastRowCount, + execBinaryMs, + decodeBinaryMs, + execObjectMs, + binaryMedianMs: median(execBinaryMs), + binaryMeanMs: mean(execBinaryMs), + decodeMedianMs: median(decodeBinaryMs), + decodeMeanMs: mean(decodeBinaryMs), + objectMedianMs: median(execObjectMs), + objectMeanMs: mean(execObjectMs), + } +} + +function analyzeDataset(server) { + const projectById = new Map(server.tables.projects.map((row) => [row.id, row])) + const counterByProjectId = new Map( + server.tables.projectCounters.map((row) => [row.projectId, row]), + ) + const snapshotByProjectId = new Map( + server.tables.projectSnapshots.map((row) => [row.projectId, row]), + ) + + const issueRootMatches = server.tables.issues.filter((issue) => { + const statusMatch = issue.status === 'open' || issue.status === 'in_progress' + const estimateMatch = issue.estimate >= 3 + const tier = issue.metadata?.customer?.tier + const tierMatch = tier === 'enterprise' || tier === 'mid_market' + return statusMatch && estimateMatch && tierMatch + }) + + const projectJoinedMatches = server.tables.projects.filter((project) => { + const riskBucket = project.metadata?.risk?.bucket + const counter = counterByProjectId.get(project.id) + const snapshot = snapshotByProjectId.get(project.id) + return ( + (project.state === 'active' || project.state === 'at_risk') && + project.healthScore >= 45 && + (riskBucket === 'high' || riskBucket === 'critical') && + (counter?.openIssueCount ?? 0) >= 4 && + (snapshot?.velocity ?? 0) >= 20 + ) + }) + + const issueJoinedMatches = issueRootMatches.filter((issue) => { + const project = projectById.get(issue.projectId) + if (!project) return false + const riskBucket = project.metadata?.risk?.bucket + const counter = counterByProjectId.get(project.id) + const snapshot = snapshotByProjectId.get(project.id) + return ( + project.healthScore >= 45 && + (riskBucket === 'high' || riskBucket === 'critical') && + (counter?.openIssueCount ?? 0) >= 5 && + (snapshot?.velocity ?? 0) >= 18 + ) + }) + + return { + issueRootMatches: issueRootMatches.length, + issueJoinedMatches: issueJoinedMatches.length, + projectJoinedMatches: projectJoinedMatches.length, + } +} + +function issueVariants(db) { + return [ + { + id: 'issue_root_filter_only', + label: 'Issues root filter only', + build: () => + db + .select(['issues.id', 'issues.updatedAt']) + .from('issues') + .where(issueRootPredicate()), + }, + { + id: 'issue_root_filter_topn_5000', + label: 'Issues root filter + ORDER BY/LIMIT', + build: () => + db + .select(['issues.id', 'issues.updatedAt']) + .from('issues') + .where(issueRootPredicate()) + .orderBy('issues.updatedAt', JsSortOrder.Desc) + .limit(5_000), + }, + { + id: 'issue_join_root_filter_only', + label: '7-way join + root-table predicates only', + build: () => issueJoinBase(db).where(issueRootPredicate()), + }, + { + id: 'issue_join_full_filter', + label: '7-way join + full benchmark predicates', + build: () => issueJoinBase(db).where(issueJoinedPredicate()), + }, + { + id: 'issue_join_full_filter_topn_5000', + label: '7-way join + full predicates + ORDER BY/LIMIT', + build: () => + issueJoinBase(db) + .where(issueJoinedPredicate()) + .orderBy('issues.updatedAt', JsSortOrder.Desc) + .limit(5_000), + }, + ] +} + +function projectVariants(db) { + return [ + { + id: 'project_root_filter_only', + label: 'Projects root filter only', + build: () => + db + .select(['projects.id', 'projects.healthScore']) + .from('projects') + .where(projectRootPredicate()), + }, + { + id: 'project_join_full_filter', + label: '6-way join + full board predicates', + build: () => projectJoinBase(db).where(projectJoinedPredicate()), + }, + { + id: 'project_join_full_filter_topn_2000', + label: '6-way join + full predicates + ORDER BY/LIMIT', + build: () => + projectJoinBase(db) + .where(projectJoinedPredicate()) + .orderBy('projects.healthScore', JsSortOrder.Desc) + .limit(2_000), + }, + ] +} + +function summarizePlan(plan) { + const text = `${plan.optimized}\n${plan.physical}` + return { + hasTopN: text.includes('TopN'), + hasHashJoin: text.includes('HashJoin'), + hasIndexNestedLoopJoin: text.includes('IndexNestedLoopJoin'), + hasGinIndexScan: text.includes('GinIndexScan'), + hasGinIndexScanMulti: text.includes('GinIndexScanMulti'), + hasFilterAboveJoin: text.includes('Filter') && text.includes('Join'), + } +} + +async function main() { + await initWasm({ module_or_path: await fs.readFile(WASM_PATH) }) + + const server = buildServerDataset(DATASET_CONFIG) + const db = new Database(`cynos_query_probe_${Date.now()}`) + createTables(db) + + const insertStartedAt = performance.now() + for (const [tableName, rows] of Object.entries(server.tables)) { + await insertTableInBatches(db, tableName, rows) + } + const insertMs = performance.now() - insertStartedAt + + const results = [] + for (const variant of [...issueVariants(db), ...projectVariants(db)]) { + const query = variant.build() + const plan = normalizePlan(query.explain()) + const prepared = query.prepare() + const measurement = await benchmarkPrepared(prepared) + results.push({ + id: variant.id, + label: variant.label, + plan, + planFlags: summarizePlan(plan), + measurement, + }) + } + + const summary = { + generatedAt: new Date().toISOString(), + dataset: summarizeDataset(server.tables), + datasetSelectivity: analyzeDataset(server), + insertMs, + rounds: ROUNDS, + results, + } + + const lines = [] + lines.push('# Cynos Query Plan Probe') + lines.push('') + lines.push(`Generated at: ${summary.generatedAt}`) + lines.push(`Dataset: ${JSON.stringify(summary.dataset)}`) + lines.push(`Dataset selectivity: ${JSON.stringify(summary.datasetSelectivity)}`) + lines.push(`Insert time: ${formatMs(summary.insertMs)}`) + lines.push('') + + for (const result of results) { + lines.push(`## ${result.label}`) + lines.push('') + lines.push( + `Rows: ${result.measurement.rowCount}, execBinary median: ${formatMs(result.measurement.binaryMedianMs)}, exec() median: ${formatMs(result.measurement.objectMedianMs)}, binary decode median: ${formatMs(result.measurement.decodeMedianMs)}`, + ) + lines.push(`Plan flags: ${JSON.stringify(result.planFlags)}`) + lines.push('') + lines.push('### Physical') + lines.push('```') + lines.push(result.plan.physical) + lines.push('```') + lines.push('') + lines.push('### Optimized') + lines.push('```') + lines.push(result.plan.optimized) + lines.push('```') + lines.push('') + } + + await fs.writeFile(REPORT_PATH, `${lines.join('\n')}\n`) + await fs.writeFile(JSON_REPORT_PATH, `${JSON.stringify(summary, null, 2)}\n`) + + console.log(`Wrote report to ${REPORT_PATH}`) + console.log(`Wrote JSON to ${JSON_REPORT_PATH}`) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/cynos_worker_runtime.mjs b/scripts/cynos_worker_runtime.mjs new file mode 100644 index 0000000..c974c3b --- /dev/null +++ b/scripts/cynos_worker_runtime.mjs @@ -0,0 +1,1112 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { parentPort } from 'node:worker_threads' +import { performance } from 'node:perf_hooks' +import initWasm, { + Database, + JsDataType, + JsSortOrder, + ColumnOptions, + col, +} from '../js/packages/core/dist/wasm.js' +import { + ResultSet, + snapshotSchemaLayout, +} from '../js/packages/core/dist/index.js' +import { + API_REFRESH_COUNT, + DATASET_CONFIG, + SOCKET_PATCH_COUNT, +} from './tanstack_db_benchmark_shared.mjs' +import { + extractProjectIds, + extractProjectIdsFromResultSet, + scenarioRowKey, + snapshotRowsForScenario, +} from './cynos_benchmark_row_shape.mjs' +import { + buildServerDataset, + summarizeDataset, +} from './live_query_benchmark_dataset.mjs' + +if (!parentPort) { + throw new Error('This module must run inside a worker thread.') +} + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)) +const ROOT_DIR = path.resolve(SCRIPT_DIR, '..') +const WASM_PATH = path.join(ROOT_DIR, 'js', 'packages', 'core', 'dist', 'cynos.wasm') +const MAX_TRACKED_PROJECT_IDS = Math.max( + 256, + SOCKET_PATCH_COUNT, + API_REFRESH_COUNT, +) + +const runtime = { + initialized: false, + db: null, + server: null, + active: null, + queryMode: 'changes', + scenarioVariant: 'default', + outputMode: 'object', +} + +function usesAlignedFilters(scenarioVariant) { + return scenarioVariant === 'trace_aligned' +} + +function normalizeScenarioVariant(scenarioVariant) { + if (scenarioVariant === 'trace_aligned') { + return 'trace_aligned' + } + + if (scenarioVariant === 'trace_capability_aligned') { + return 'trace_capability_aligned' + } + + return 'default' +} + +function pkOptions() { + return new ColumnOptions().primaryKey(true) +} + +function nullableOptions() { + return new ColumnOptions().setNullable(true) +} + +function post(message, transferList) { + parentPort.postMessage(message, transferList) +} + +function createTables(db) { + db.registerTable( + db.createTable('organizations') + .column('id', JsDataType.Int64, pkOptions()) + .column('name', JsDataType.String, null) + .column('tier', JsDataType.String, null) + .column('region', JsDataType.String, null) + .column('metadata', JsDataType.Jsonb, null) + .index('idx_organizations_region', 'region'), + ) + + db.registerTable( + db.createTable('teams') + .column('id', JsDataType.Int64, pkOptions()) + .column('organizationId', JsDataType.Int64, null) + .column('name', JsDataType.String, null) + .column('function', JsDataType.String, null) + .column('metadata', JsDataType.Jsonb, null) + .index('idx_teams_organizationId', 'organizationId'), + ) + + db.registerTable( + db.createTable('users') + .column('id', JsDataType.Int64, pkOptions()) + .column('teamId', JsDataType.Int64, null) + .column('name', JsDataType.String, null) + .column('role', JsDataType.String, null) + .column('metadata', JsDataType.Jsonb, null) + .index('idx_users_teamId', 'teamId'), + ) + + db.registerTable( + db.createTable('projects') + .column('id', JsDataType.Int64, pkOptions()) + .column('organizationId', JsDataType.Int64, null) + .column('teamId', JsDataType.Int64, null) + .column('leadUserId', JsDataType.Int64, null) + .column('name', JsDataType.String, null) + .column('state', JsDataType.String, null) + .column('healthScore', JsDataType.Int32, null) + .column('updatedAt', JsDataType.Int64, null) + .column('priorityBand', JsDataType.String, null) + .column('metadata', JsDataType.Jsonb, null) + .index('idx_projects_organizationId', 'organizationId') + .index('idx_projects_teamId', 'teamId') + .index('idx_projects_leadUserId', 'leadUserId') + .index('idx_projects_state', 'state') + .index('idx_projects_healthScore', 'healthScore') + .index('idx_projects_updatedAt', 'updatedAt') + .jsonbIndex( + 'metadata', + ['$.risk.bucket', '$.risk.score', '$.flags.strategic'], + ), + ) + + db.registerTable( + db.createTable('projectSnapshots') + .column('projectId', JsDataType.Int64, pkOptions()) + .column('velocity', JsDataType.Int32, null) + .column('completionRate', JsDataType.Float64, null) + .column('blockedRatio', JsDataType.Float64, null) + .column('updatedAt', JsDataType.Int64, null) + .index('idx_projectSnapshots_velocity', 'velocity'), + ) + + db.registerTable( + db.createTable('projectCounters') + .column('projectId', JsDataType.Int64, pkOptions()) + .column('openIssueCount', JsDataType.Int32, null) + .column('blockerCount', JsDataType.Int32, null) + .column('staleIssueCount', JsDataType.Int32, null) + .column('updatedAt', JsDataType.Int64, null) + .index('idx_projectCounters_openIssueCount', 'openIssueCount'), + ) + + db.registerTable( + db.createTable('currentMilestones') + .column('id', JsDataType.Int64, pkOptions()) + .column('projectId', JsDataType.Int64, null) + .column('name', JsDataType.String, null) + .column('dueAt', JsDataType.Int64, null) + .column('status', JsDataType.String, null) + .column('metadata', JsDataType.Jsonb, null) + .index('idx_currentMilestones_projectId', 'projectId') + .index('idx_currentMilestones_dueAt', 'dueAt'), + ) + + db.registerTable( + db.createTable('issues') + .column('id', JsDataType.Int64, pkOptions()) + .column('projectId', JsDataType.Int64, null) + .column('assigneeId', JsDataType.Int64, null) + .column('currentMilestoneId', JsDataType.Int64, nullableOptions()) + .column('title', JsDataType.String, null) + .column('status', JsDataType.String, null) + .column('priority', JsDataType.String, null) + .column('estimate', JsDataType.Int32, null) + .column('updatedAt', JsDataType.Int64, null) + .column('metadata', JsDataType.Jsonb, null) + .index('idx_issues_projectId', 'projectId') + .index('idx_issues_assigneeId', 'assigneeId') + .index('idx_issues_currentMilestoneId', 'currentMilestoneId') + .index('idx_issues_status', 'status') + .index('idx_issues_estimate', 'estimate') + .index('idx_issues_updatedAt', 'updatedAt') + .jsonbIndex( + 'metadata', + ['$.severityRank', '$.customer.tier', '$.workflow.lane'], + ), + ) +} + +async function insertTableInBatches(db, tableName, rows) { + const batchSize = + tableName === 'issues' + ? 4_000 + : tableName === 'users' || tableName === 'currentMilestones' + ? 2_000 + : 1_000 + + for (let index = 0; index < rows.length; index += batchSize) { + await db + .insert(tableName) + .values(rows.slice(index, index + batchSize)) + .exec() + } +} + +function buildIssueWindowQuery(queryMode, limit, scenarioVariant) { + const alignedFilters = usesAlignedFilters(scenarioVariant) + const query = runtime.db + .select([ + 'issues.id', + 'project.id', + 'issues.title', + 'issues.status', + 'issues.priority', + 'issues.updatedAt', + 'issues.metadata', + 'project.name', + 'project.state', + 'project.healthScore', + 'project.metadata', + 'org.name', + 'team.name', + 'assignee.name', + 'milestone.name', + 'counter.openIssueCount', + 'counter.blockerCount', + 'snapshot.velocity', + ]) + .from('issues') + .leftJoin('projects as project', col('issues.projectId').eq(col('project.id'))) + .leftJoin( + 'organizations as org', + col('project.organizationId').eq(col('org.id')), + ) + .leftJoin('teams as team', col('project.teamId').eq(col('team.id'))) + .leftJoin( + 'users as assignee', + col('issues.assigneeId').eq(col('assignee.id')), + ) + .leftJoin( + 'currentMilestones as milestone', + col('issues.currentMilestoneId').eq(col('milestone.id')), + ) + .leftJoin( + 'projectCounters as counter', + col('project.id').eq(col('counter.projectId')), + ) + .leftJoin( + 'projectSnapshots as snapshot', + col('project.id').eq(col('snapshot.projectId')), + ) + .where( + alignedFilters + ? col('issues.status') + .eq('open') + .or(col('issues.status').eq('in_progress')) + .and(col('issues.estimate').gte(3)) + .and( + col('issues.metadata') + .get('$.customer.tier') + .eq('enterprise') + .or(col('issues.metadata').get('$.customer.tier').eq('mid_market')), + ) + : col('issues.status') + .eq('open') + .or(col('issues.status').eq('in_progress')) + .and(col('issues.estimate').gte(3)) + .and( + col('issues.metadata') + .get('$.customer.tier') + .eq('enterprise') + .or(col('issues.metadata').get('$.customer.tier').eq('mid_market')), + ) + .and(col('project.healthScore').gte(45)) + .and( + col('project.metadata') + .get('$.risk.bucket') + .eq('high') + .or(col('project.metadata').get('$.risk.bucket').eq('critical')), + ) + .and(col('counter.openIssueCount').gte(5)) + .and(col('snapshot.velocity').gte(18)), + ) + + if (queryMode === 'trace') { + return query.trace() + } + + if (!Number.isFinite(limit)) { + return query.changes() + } + + return query + .orderBy('issues.updatedAt', JsSortOrder.Desc) + .limit(limit) + .changes() +} + +function buildProjectBoardQuery(queryMode, limit, scenarioVariant) { + const alignedFilters = usesAlignedFilters(scenarioVariant) + const query = runtime.db + .select([ + 'projects.id', + 'projects.name', + 'projects.state', + 'projects.healthScore', + 'projects.updatedAt', + 'projects.metadata', + 'org.region', + 'org.name', + 'team.name', + 'lead.name', + 'lead.role', + 'milestone.name', + 'milestone.status', + 'counter.openIssueCount', + 'counter.blockerCount', + 'counter.staleIssueCount', + 'snapshot.velocity', + 'snapshot.blockedRatio', + ]) + .from('projects') + .leftJoin( + 'organizations as org', + col('projects.organizationId').eq(col('org.id')), + ) + .leftJoin('teams as team', col('projects.teamId').eq(col('team.id'))) + .leftJoin('users as lead', col('projects.leadUserId').eq(col('lead.id'))) + .leftJoin( + 'projectCounters as counter', + col('projects.id').eq(col('counter.projectId')), + ) + .leftJoin( + 'projectSnapshots as snapshot', + col('projects.id').eq(col('snapshot.projectId')), + ) + .leftJoin( + 'currentMilestones as milestone', + col('projects.id').eq(col('milestone.projectId')), + ) + .where( + alignedFilters + ? col('projects.state') + .eq('active') + .or(col('projects.state').eq('at_risk')) + .and(col('projects.healthScore').gte(45)) + .and( + col('projects.metadata') + .get('$.risk.bucket') + .eq('high') + .or(col('projects.metadata').get('$.risk.bucket').eq('critical')), + ) + : col('projects.state') + .eq('active') + .or(col('projects.state').eq('at_risk')) + .and(col('projects.healthScore').gte(45)) + .and( + col('projects.metadata') + .get('$.risk.bucket') + .eq('high') + .or(col('projects.metadata').get('$.risk.bucket').eq('critical')), + ) + .and(col('counter.openIssueCount').gte(4)) + .and(col('snapshot.velocity').gte(20)), + ) + + if (queryMode === 'trace') { + return query.trace() + } + + if (!Number.isFinite(limit)) { + return query.changes() + } + + return query + .orderBy('projects.healthScore', JsSortOrder.Desc) + .limit(limit) + .changes() +} + +function buildScenarioStream(scenarioId, queryMode, scenarioVariant) { + if (scenarioId === 'issue_window_500') { + return buildIssueWindowQuery(queryMode, 500, scenarioVariant) + } + + if (scenarioId === 'issue_window_5000') { + return buildIssueWindowQuery(queryMode, 5_000, scenarioVariant) + } + + if (scenarioId === 'issue_stream_all') { + return buildIssueWindowQuery(queryMode, Number.NaN, scenarioVariant) + } + + if (scenarioId === 'project_board_2000') { + return buildProjectBoardQuery(queryMode, 2_000, scenarioVariant) + } + + if (scenarioId === 'project_board_stream_all') { + return buildProjectBoardQuery(queryMode, Number.NaN, scenarioVariant) + } + + throw new Error(`Unknown scenario: ${scenarioId}`) +} + +async function initRuntime(message = {}) { + if (runtime.initialized) { + return { + type: 'ready', + queryMode: runtime.queryMode, + scenarioVariant: runtime.scenarioVariant, + outputMode: runtime.outputMode, + dataset: summarizeDataset(runtime.server.tables), + } + } + + const startedAt = performance.now() + const initProfile = { + wasmFileReadMs: 0, + wasmInitMs: 0, + datasetBuildMs: 0, + databaseCreateMs: 0, + schemaRegistrationMs: 0, + insertTotalMs: 0, + insertByTableMs: {}, + } + runtime.queryMode = message.queryMode === 'trace' ? 'trace' : 'changes' + runtime.scenarioVariant = normalizeScenarioVariant(message.scenarioVariant) + runtime.outputMode = + runtime.queryMode === 'changes' && message.outputMode === 'binary' + ? 'binary' + : 'object' + const wasmReadStartedAt = performance.now() + const wasmBytes = await fs.readFile(WASM_PATH) + initProfile.wasmFileReadMs = performance.now() - wasmReadStartedAt + const wasmInitStartedAt = performance.now() + await initWasm({ module_or_path: wasmBytes }) + initProfile.wasmInitMs = performance.now() - wasmInitStartedAt + + const datasetBuildStartedAt = performance.now() + runtime.server = buildServerDataset(DATASET_CONFIG) + initProfile.datasetBuildMs = performance.now() - datasetBuildStartedAt + const databaseCreateStartedAt = performance.now() + runtime.db = new Database(`cynos_bench_${Date.now()}`) + initProfile.databaseCreateMs = performance.now() - databaseCreateStartedAt + const schemaRegistrationStartedAt = performance.now() + createTables(runtime.db) + initProfile.schemaRegistrationMs = + performance.now() - schemaRegistrationStartedAt + + const insertStartedAt = performance.now() + for (const [tableName, rows] of Object.entries(runtime.server.tables)) { + const tableInsertStartedAt = performance.now() + await insertTableInBatches(runtime.db, tableName, rows) + initProfile.insertByTableMs[tableName] = + performance.now() - tableInsertStartedAt + } + initProfile.insertTotalMs = performance.now() - insertStartedAt + + runtime.initialized = true + return { + type: 'ready', + initMs: performance.now() - startedAt, + initProfile, + queryMode: runtime.queryMode, + scenarioVariant: runtime.scenarioVariant, + outputMode: runtime.outputMode, + dataset: summarizeDataset(runtime.server.tables), + } +} + +function ensureInitialized() { + if (!runtime.initialized || !runtime.db || !runtime.server) { + throw new Error('Worker runtime is not initialized.') + } +} + +function unsubscribeActive() { + if (!runtime.active) return + runtime.active.unsubscribe?.() + runtime.active.stream?.free?.() + runtime.active = null +} + +function createSnapshotMessage( + active, + phase, + rows, + changeCount = 1, + phaseProfile = undefined, +) { + return { + type: 'snapshot', + scenarioId: active.scenarioId, + phase, + queryMode: active.queryMode, + payloadKind: 'object', + workerLatencyMs: performance.now() - active.pending.startedAt, + rowCount: rows.length, + changeCount, + phaseProfile, + rows: active.includeRows ? rows : undefined, + } +} + +function createBinarySnapshotMessage(active, phase, payload) { + return { + type: 'snapshot', + scenarioId: active.scenarioId, + phase, + queryMode: active.queryMode, + payloadKind: 'binary', + workerLatencyMs: performance.now() - payload.startedAt, + rowCount: payload.rowCount, + changeCount: payload.changeCount, + layout: payload.layout, + binaryBytes: active.includeRows ? payload.binaryBytes : undefined, + binaryByteLength: payload.binaryByteLength, + } +} + +function updateTrackedProjectIds(active, projectIds) { + if (projectIds.length > 0) { + active.lastProjectIds = projectIds + } +} + +function postMutationProfile(active, phase, mutationProfile) { + post({ + type: 'mutation-profile', + scenarioId: active.scenarioId, + phase, + queryMode: active.queryMode, + mutationProfile, + }) +} + +function takeCommitProfile() { + const profile = runtime.db?.takeLastCommitProfile?.() + return profile && typeof profile === 'object' ? profile : null +} + +function takeTraceInitProfile() { + const profile = runtime.db?.takeLastTraceInitProfile?.() + return profile && typeof profile === 'object' ? profile : null +} + +function takeSnapshotInitProfile() { + const profile = runtime.db?.takeLastSnapshotInitProfile?.() + return profile && typeof profile === 'object' ? profile : null +} + +function takeDeltaFlushProfile() { + const profile = runtime.db?.takeLastDeltaFlushProfile?.() + return profile && typeof profile === 'object' ? profile : null +} + +function takeSnapshotFlushProfile() { + const profile = runtime.db?.takeLastSnapshotFlushProfile?.() + return profile && typeof profile === 'object' ? profile : null +} + +function takeIvmBridgeProfile() { + const profile = runtime.db?.takeLastIvmBridgeProfile?.() + return profile && typeof profile === 'object' ? profile : null +} + +function buildCommitProfiles() { + const commitProfile = takeCommitProfile() + const traceInitProfile = takeTraceInitProfile() + const deltaFlushProfile = takeDeltaFlushProfile() + const snapshotFlushProfile = takeSnapshotFlushProfile() + const ivmBridgeProfile = takeIvmBridgeProfile() + + let observableInternalMs = null + let registryNonDeltaMs = null + let deltaFlushOverheadMs = null + + if (deltaFlushProfile && ivmBridgeProfile) { + observableInternalMs = Math.max( + 0, + Number(deltaFlushProfile.queryOnTableChangeMs ?? 0) - + Number(ivmBridgeProfile.totalMs ?? 0), + ) + deltaFlushOverheadMs = Math.max( + 0, + Number(deltaFlushProfile.totalMs ?? 0) - + Number(deltaFlushProfile.cloneMs ?? 0) - + Number(deltaFlushProfile.queryOnTableChangeMs ?? 0), + ) + } + + if (commitProfile && deltaFlushProfile) { + registryNonDeltaMs = Math.max( + 0, + Number(commitProfile.registryFlushMs ?? 0) - + Number(deltaFlushProfile.totalMs ?? 0), + ) + } + + return { + commitProfile, + traceInitProfile, + deltaFlushProfile, + snapshotFlushProfile, + ivmBridgeProfile, + observableInternalMs, + registryNonDeltaMs, + deltaFlushOverheadMs, + } +} + +function syncTrackedProjectIdsFromBinary(active, binary) { + const resultSet = new ResultSet(binary, active.layoutSnapshot) + updateTrackedProjectIds( + active, + extractProjectIdsFromResultSet( + active.scenarioId, + resultSet, + MAX_TRACKED_PROJECT_IDS, + ), + ) + resultSet.free() +} + +function buildBinarySnapshotPayload( + active, + phase, + startedAt, + binary, + changeCount = 1, +) { + const layout = active.layoutSnapshot + + if (active.includeRows) { + const binaryBytes = binary.intoTransferable() + const resultSet = new ResultSet(binaryBytes, layout) + const rowCount = resultSet.length + updateTrackedProjectIds( + active, + extractProjectIdsFromResultSet(active.scenarioId, resultSet, MAX_TRACKED_PROJECT_IDS), + ) + resultSet.free() + + return { + message: createBinarySnapshotMessage(active, phase, { + startedAt, + rowCount, + changeCount, + layout, + binaryBytes, + binaryByteLength: binaryBytes.byteLength, + }), + transferList: [binaryBytes.buffer], + } + } + + const binaryByteLength = binary.len() + const resultSet = new ResultSet(binary, layout) + const rowCount = resultSet.length + updateTrackedProjectIds( + active, + extractProjectIdsFromResultSet(active.scenarioId, resultSet, MAX_TRACKED_PROJECT_IDS), + ) + resultSet.free() + + return { + message: createBinarySnapshotMessage(active, phase, { + startedAt, + rowCount, + changeCount, + layout, + binaryByteLength, + }), + transferList: undefined, + } +} + +function subscribeScenario(message) { + ensureInitialized() + unsubscribeActive() + + const active = { + scenarioId: message.scenarioId, + includeRows: message.includeRows !== false, + queryMode: runtime.queryMode, + outputMode: runtime.outputMode, + stream: null, + layoutSnapshot: null, + currentRows: [], + rawRowsByKey: null, + lastProjectIds: [], + pending: { + phase: 'initial', + startedAt: performance.now(), + }, + unsubscribe: null, + } + + runtime.active = active + const buildStreamStartedAt = performance.now() + active.stream = buildScenarioStream( + message.scenarioId, + runtime.queryMode, + runtime.scenarioVariant, + ) + const buildStreamMs = performance.now() - buildStreamStartedAt + const traceInitProfile = + runtime.queryMode === 'trace' ? takeTraceInitProfile() : null + const snapshotInitProfile = + runtime.queryMode === 'trace' ? null : takeSnapshotInitProfile() + + if (runtime.queryMode === 'trace') { + active.rawRowsByKey = new Map() + + const getResultStartedAt = performance.now() + const initialRawRows = active.stream.getResult() + const getResultMs = performance.now() - getResultStartedAt + for (const rawRow of initialRawRows) { + const key = scenarioRowKey(active.scenarioId, rawRow) + if (key == null) continue + active.rawRowsByKey.set(String(key), rawRow) + } + + const materializeStartedAt = performance.now() + const initialRows = snapshotRowsForScenario( + active.scenarioId, + Array.from(active.rawRowsByKey.values()), + ) + const materializeRowsMs = performance.now() - materializeStartedAt + active.currentRows = initialRows + + const trackedIdsStartedAt = performance.now() + updateTrackedProjectIds( + active, + extractProjectIds(initialRows, MAX_TRACKED_PROJECT_IDS), + ) + const trackedProjectIdsMs = performance.now() - trackedIdsStartedAt + + const initialPending = active.pending + if (initialPending) { + post( + createSnapshotMessage( + active, + initialPending.phase, + initialRows, + initialRows.length, + { + traceInitProfile, + getResultMs, + materializeRowsMs, + trackedProjectIdsMs, + }, + ), + ) + active.pending = null + } + + active.unsubscribe = active.stream.subscribe((delta) => { + const pending = active.pending + const removed = delta?.removed ?? [] + const added = delta?.added ?? [] + const callbackStartedAt = performance.now() + const mergeStartedAt = callbackStartedAt + + for (const rawRow of removed) { + const key = scenarioRowKey(active.scenarioId, rawRow) + if (key == null) continue + active.rawRowsByKey.delete(String(key)) + } + + for (const rawRow of added) { + const key = scenarioRowKey(active.scenarioId, rawRow) + if (key == null) continue + active.rawRowsByKey.set(String(key), rawRow) + } + const mergeDeltaMs = performance.now() - mergeStartedAt + + const materializeStartedAt = performance.now() + const rows = snapshotRowsForScenario( + active.scenarioId, + Array.from(active.rawRowsByKey.values()), + ) + const materializeRowsMs = performance.now() - materializeStartedAt + active.currentRows = rows + + const trackedIdsStartedAt = performance.now() + updateTrackedProjectIds( + active, + extractProjectIds(rows, MAX_TRACKED_PROJECT_IDS), + ) + const trackedProjectIdsMs = performance.now() - trackedIdsStartedAt + const callbackTotalMs = performance.now() - callbackStartedAt + + if (!pending) return + post( + createSnapshotMessage( + active, + pending.phase, + rows, + added.length + removed.length, + { + deltaAddedCount: added.length, + deltaRemovedCount: removed.length, + mergeDeltaMs, + materializeRowsMs, + trackedProjectIdsMs, + callbackTotalMs, + }, + ), + ) + active.pending = null + }) + return + } + + if (runtime.outputMode === 'binary') { + active.layoutSnapshot = snapshotSchemaLayout(active.stream.getSchemaLayout()) + active.unsubscribe = active.stream.subscribeBinary((binary) => { + try { + const pending = active.pending + if (!pending) { + syncTrackedProjectIdsFromBinary(active, binary) + return + } + + const payload = buildBinarySnapshotPayload( + active, + pending.phase, + pending.startedAt, + binary, + 1, + ) + post(payload.message, payload.transferList) + active.pending = null + } catch (error) { + handleError(error, 'subscribe-binary') + } + }) + return + } + + const captureInitialSubscribeProfile = snapshotInitProfile != null + const subscribeStartedAt = captureInitialSubscribeProfile + ? performance.now() + : 0 + let initialEmission = null + active.unsubscribe = active.stream.subscribe((rawRows) => { + const pending = active.pending + const subscribeBeforeCallbackMs = + captureInitialSubscribeProfile && pending?.phase === 'initial' + ? performance.now() - subscribeStartedAt + : undefined + const materializeStartedAt = performance.now() + const rows = snapshotRowsForScenario( + active.scenarioId, + rawRows, + ) + const materializeRowsMs = performance.now() - materializeStartedAt + active.currentRows = rows + const trackedIdsStartedAt = performance.now() + updateTrackedProjectIds( + active, + extractProjectIds(rows, MAX_TRACKED_PROJECT_IDS), + ) + const trackedProjectIdsMs = performance.now() - trackedIdsStartedAt + if (!pending) return + if (pending.phase === 'initial' && !captureInitialSubscribeProfile) { + post( + createSnapshotMessage(active, pending.phase, rows, 1, { + materializeRowsMs, + trackedProjectIdsMs, + }), + ) + active.pending = null + return + } + + const phaseProfile = { + snapshotInitProfile, + buildStreamMs, + subscribeBeforeCallbackMs, + materializeRowsMs, + trackedProjectIdsMs, + } + + if (pending.phase === 'initial') { + initialEmission = { + rows, + changeCount: 1, + phaseProfile, + } + return + } + + post(createSnapshotMessage(active, pending.phase, rows, 1, phaseProfile)) + active.pending = null + }) + if (captureInitialSubscribeProfile && initialEmission) { + const subscribeReturnMs = performance.now() - subscribeStartedAt + initialEmission.phaseProfile.subscribeReturnMs = subscribeReturnMs + post( + createSnapshotMessage( + active, + 'initial', + initialEmission.rows, + initialEmission.changeCount, + initialEmission.phaseProfile, + ), + ) + active.pending = null + } +} + +function collectActiveProjectIds(maxCount) { + if (!runtime.active) { + throw new Error('No active live query subscription.') + } + + const currentIds = extractProjectIds(runtime.active.currentRows, maxCount) + if (currentIds.length > 0) return currentIds + + if (runtime.active.lastProjectIds.length > 0) { + const result = runtime.active.lastProjectIds.slice(0, maxCount) + if (result.length >= maxCount) { + return result + } + + const seen = new Set(result) + for (const row of runtime.server.tables.projects) { + if (seen.has(row.id)) continue + seen.add(row.id) + result.push(row.id) + if (result.length >= maxCount) break + } + + return result + } + + return runtime.server.tables.projects.slice(0, maxCount).map((row) => row.id) +} + +function applyProjectPatch(projectId, transform) { + const projects = runtime.server.tables.projects + const index = projects.findIndex((row) => row.id === projectId) + if (index < 0) return null + const current = projects[index] + const next = transform(current) + projects[index] = next + runtime.server.revisions.projects += 1 + return next +} + +function runSocketPatchBurst(message) { + ensureInitialized() + if (!runtime.active) { + throw new Error('Subscribe before running socket patches.') + } + + const pending = { + phase: 'socket', + startedAt: performance.now(), + } + runtime.active.pending = pending + const projectIds = collectActiveProjectIds(message.patchCount) + const projectIdsCollectedAt = performance.now() + + const tx = runtime.db.transaction() + const patchLoopStartedAt = performance.now() + for (const projectId of projectIds) { + const next = applyProjectPatch(projectId, (current) => { + const healthScore = current.healthScore >= 45 ? 24 : 82 + return { + ...current, + state: current.state === 'active' ? 'at_risk' : 'active', + healthScore, + updatedAt: current.updatedAt + 60_000, + } + }) + if (!next) continue + tx.update( + 'projects', + { + state: next.state, + healthScore: next.healthScore, + updatedAt: next.updatedAt, + }, + col('id').eq(next.id), + ) + } + const patchLoopCompletedAt = performance.now() + const commitStartedAt = performance.now() + tx.commit() + const commitCompletedAt = performance.now() + + postMutationProfile(runtime.active, 'socket', { + projectIdsCount: projectIds.length, + collectProjectIdsMs: projectIdsCollectedAt - pending.startedAt, + patchLoopMs: patchLoopCompletedAt - patchLoopStartedAt, + commitCallMs: commitCompletedAt - commitStartedAt, + ...buildCommitProfiles(), + }) +} + +function runApiRefresh(message) { + ensureInitialized() + if (!runtime.active) { + throw new Error('Subscribe before running API refresh.') + } + + const pending = { + phase: 'api', + startedAt: performance.now(), + } + runtime.active.pending = pending + const projectIds = collectActiveProjectIds(message.patchCount) + const projectIdsCollectedAt = performance.now() + + const tx = runtime.db.transaction() + const patchLoopStartedAt = performance.now() + for (const projectId of projectIds) { + const next = applyProjectPatch(projectId, (current) => { + const nextRisk = current.healthScore >= 45 ? 18 : 76 + const nextHealth = current.healthScore >= 45 ? 28 : 88 + return { + ...current, + healthScore: nextHealth, + updatedAt: current.updatedAt + 120_000, + metadata: { + ...current.metadata, + risk: { + ...current.metadata.risk, + score: nextRisk, + bucket: nextRisk >= 70 ? 'critical' : nextRisk >= 45 ? 'high' : 'medium', + }, + flags: { + ...current.metadata.flags, + strategic: !current.metadata.flags.strategic, + }, + }, + } + }) + if (!next) continue + tx.update( + 'projects', + { + healthScore: next.healthScore, + updatedAt: next.updatedAt, + metadata: next.metadata, + }, + col('id').eq(next.id), + ) + } + const patchLoopCompletedAt = performance.now() + const commitStartedAt = performance.now() + tx.commit() + const commitCompletedAt = performance.now() + + postMutationProfile(runtime.active, 'api', { + projectIdsCount: projectIds.length, + collectProjectIdsMs: projectIdsCollectedAt - pending.startedAt, + patchLoopMs: patchLoopCompletedAt - patchLoopStartedAt, + commitCallMs: commitCompletedAt - commitStartedAt, + ...buildCommitProfiles(), + }) +} + +function handleError(error, context) { + post({ + type: 'error', + context, + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) +} + +parentPort.on('message', async (message) => { + try { + switch (message.type) { + case 'init': + post(await initRuntime(message)) + return + case 'subscribe': + subscribeScenario(message) + return + case 'socket-patch': + runSocketPatchBurst(message) + return + case 'api-refresh': + runApiRefresh(message) + return + case 'unsubscribe': + unsubscribeActive() + post({ type: 'unsubscribed', scenarioId: message.scenarioId }) + return + case 'shutdown': + unsubscribeActive() + runtime.db?.free?.() + post({ type: 'shutdown-complete' }) + return + default: + throw new Error(`Unknown worker message type: ${message.type}`) + } + } catch (error) { + handleError(error, message.type) + } +}) diff --git a/scripts/live_query_benchmark_dataset.mjs b/scripts/live_query_benchmark_dataset.mjs new file mode 100644 index 0000000..25b1c45 --- /dev/null +++ b/scripts/live_query_benchmark_dataset.mjs @@ -0,0 +1,314 @@ +const PROJECT_STATES = ['active', 'at_risk', 'planned', 'paused', 'archived'] +const ORG_REGIONS = ['na', 'emea', 'apac', 'latam'] +const TEAM_FUNCTIONS = ['product', 'design', 'engineering', 'ops', 'growth'] +const CUSTOMER_TIERS = ['self_serve', 'mid_market', 'enterprise'] +const ISSUE_LANES = ['backlog', 'triage', 'delivery', 'follow_up'] +const PRIMARY_TAGS = ['ux', 'api', 'infra', 'growth', 'security', 'sales'] +const SECONDARY_TAGS = ['mobile', 'web', 'sync', 'billing', 'search', 'ai'] +const RISK_BUCKETS = ['low', 'medium', 'high', 'critical'] + +function createRandom(seed) { + let value = seed >>> 0 + return () => { + value += 0x6d2b79f5 + let result = Math.imul(value ^ (value >>> 15), 1 | value) + result ^= result + Math.imul(result ^ (result >>> 7), 61 | result) + return ((result ^ (result >>> 14)) >>> 0) / 4294967296 + } +} + +function pick(random, values) { + return values[Math.floor(random() * values.length)] ?? values[0] +} + +function maybe(random, threshold = 0.5) { + return random() < threshold +} + +function intBetween(random, min, max) { + return Math.floor(random() * (max - min + 1)) + min +} + +export function deepClone(value) { + return structuredClone(value) +} + +function stableModulo(id, modulo) { + return ((id % modulo) + modulo) % modulo +} + +export function summarizeDataset(tables) { + return Object.fromEntries( + Object.entries(tables).map(([tableName, rows]) => [tableName, rows.length]), + ) +} + +export function buildServerDataset(config) { + const random = createRandom(config.seed) + const now = Date.UTC(2026, 2, 27, 8, 0, 0) + + const organizations = Array.from( + { length: config.organizationCount }, + (_, idx) => { + const id = idx + 1 + return { + id, + name: `Organization ${id}`, + tier: CUSTOMER_TIERS[stableModulo(id, CUSTOMER_TIERS.length)], + region: ORG_REGIONS[stableModulo(id, ORG_REGIONS.length)], + metadata: { + spendBand: intBetween(random, 1, 5), + contract: { + renewed: maybe(random, 0.72), + seats: intBetween(random, 50, 5_000), + }, + }, + } + }, + ) + + const teams = Array.from({ length: config.teamCount }, (_, idx) => { + const id = idx + 1 + const organizationId = stableModulo(id - 1, organizations.length) + 1 + return { + id, + organizationId, + name: `Team ${id}`, + function: TEAM_FUNCTIONS[stableModulo(id, TEAM_FUNCTIONS.length)], + metadata: { + timezoneOffset: stableModulo(id, 12) - 6, + budgetCode: `BGT-${organizationId}-${id}`, + }, + } + }) + + const users = Array.from({ length: config.userCount }, (_, idx) => { + const id = idx + 1 + const teamId = stableModulo(id - 1, teams.length) + 1 + return { + id, + teamId, + name: `User ${id}`, + role: maybe(random, 0.08) + ? 'staff' + : maybe(random, 0.2) + ? 'lead' + : 'member', + metadata: { + locale: stableModulo(id, 2) === 0 ? 'en-US' : 'en-GB', + focus: pick(random, ['product', 'platform', 'growth', 'design']), + seniority: intBetween(random, 1, 6), + }, + } + }) + + const teamUserIds = new Map() + for (const user of users) { + const existing = teamUserIds.get(user.teamId) + if (existing) { + existing.push(user.id) + } else { + teamUserIds.set(user.teamId, [user.id]) + } + } + + const projects = Array.from({ length: config.projectCount }, (_, idx) => { + const id = idx + 1 + const teamId = stableModulo(id - 1, teams.length) + 1 + const organizationId = teams[teamId - 1].organizationId + const candidateUsers = teamUserIds.get(teamId) ?? [1] + const leadUserId = candidateUsers[stableModulo(id, candidateUsers.length)] + const healthScore = intBetween(random, 25, 95) + return { + id, + organizationId, + teamId, + leadUserId, + name: `Project ${id}`, + state: pick(random, PROJECT_STATES), + healthScore, + updatedAt: now - id * 17_000, + priorityBand: healthScore > 75 ? 'p0' : healthScore > 55 ? 'p1' : 'p2', + metadata: { + risk: { + score: intBetween(random, 10, 95), + bucket: pick(random, RISK_BUCKETS), + }, + flags: { + strategic: maybe(random, 0.28), + regulated: maybe(random, 0.14), + }, + topology: { + shard: stableModulo(id, 32), + market: pick(random, ORG_REGIONS), + }, + }, + } + }) + + const milestones = [] + const milestonesByProject = new Map() + let milestoneId = 1 + while (milestones.length < config.milestoneCount) { + const project = projects[stableModulo(milestones.length, projects.length)] + const existing = milestonesByProject.get(project.id) ?? [] + const row = { + id: milestoneId, + projectId: project.id, + name: `Milestone ${milestoneId}`, + dueAt: now + intBetween(random, 1, 180) * 86_400_000, + status: maybe(random, 0.7) ? 'active' : 'planned', + metadata: { + quarter: `2026-Q${stableModulo(milestoneId, 4) + 1}`, + slipDays: intBetween(random, 0, 18), + }, + } + milestones.push(row) + existing.push(row.id) + milestonesByProject.set(project.id, existing) + milestoneId += 1 + } + + const issues = Array.from({ length: config.issueCount }, (_, idx) => { + const id = idx + 1 + const project = projects[Math.floor(random() * projects.length)] + const assigneePool = teamUserIds.get(project.teamId) ?? [project.leadUserId] + const currentMilestoneIds = milestonesByProject.get(project.id) ?? [] + const currentMilestoneId = + currentMilestoneIds.length > 0 && maybe(random, 0.78) + ? currentMilestoneIds[Math.floor(random() * currentMilestoneIds.length)] + : undefined + const status = + random() < 0.52 + ? 'open' + : random() < 0.72 + ? 'in_progress' + : random() < 0.88 + ? 'blocked' + : 'closed' + return { + id, + projectId: project.id, + assigneeId: assigneePool[Math.floor(random() * assigneePool.length)], + currentMilestoneId, + title: `Issue ${id}`, + status, + priority: pick(random, ['low', 'medium', 'high', 'urgent']), + estimate: intBetween(random, 1, 8), + updatedAt: now - intBetween(random, 0, 14 * 24 * 60) * 60_000, + metadata: { + severityRank: intBetween(random, 1, 5), + tags: { + primary: pick(random, PRIMARY_TAGS), + secondary: pick(random, SECONDARY_TAGS), + }, + customer: { + tier: pick(random, CUSTOMER_TIERS), + }, + workflow: { + lane: pick(random, ISSUE_LANES), + slaHours: intBetween(random, 4, 96), + }, + }, + } + }) + + const issueCounters = new Map() + for (const issue of issues) { + const current = issueCounters.get(issue.projectId) ?? { + openIssueCount: 0, + blockerCount: 0, + staleIssueCount: 0, + lastUpdatedAt: 0, + } + if (issue.status !== 'closed') current.openIssueCount += 1 + if (issue.status === 'blocked' || issue.metadata.severityRank >= 4) { + current.blockerCount += 1 + } + if (now - issue.updatedAt > 72 * 60 * 60 * 1000) { + current.staleIssueCount += 1 + } + if (issue.updatedAt > current.lastUpdatedAt) { + current.lastUpdatedAt = issue.updatedAt + } + issueCounters.set(issue.projectId, current) + } + + const projectCounters = projects.map((project) => { + const counters = issueCounters.get(project.id) ?? { + openIssueCount: 0, + blockerCount: 0, + staleIssueCount: 0, + lastUpdatedAt: project.updatedAt, + } + return { + projectId: project.id, + openIssueCount: counters.openIssueCount, + blockerCount: counters.blockerCount, + staleIssueCount: counters.staleIssueCount, + updatedAt: counters.lastUpdatedAt, + } + }) + + const projectSnapshots = projects.map((project) => { + const counters = issueCounters.get(project.id) ?? { + openIssueCount: 0, + blockerCount: 0, + staleIssueCount: 0, + } + return { + projectId: project.id, + velocity: Math.max( + 8, + 80 - counters.blockerCount * 2 - counters.staleIssueCount, + ), + completionRate: Math.max(0.1, Math.min(0.98, project.healthScore / 100)), + blockedRatio: + counters.openIssueCount === 0 + ? 0 + : Math.min(1, counters.blockerCount / counters.openIssueCount), + updatedAt: project.updatedAt, + } + }) + + const currentMilestones = projects + .map((project) => { + const ids = milestonesByProject.get(project.id) ?? [] + if (ids.length === 0) return null + const firstId = ids[0] + return milestones[firstId - 1] + }) + .filter(Boolean) + .map((row) => ({ + id: row.id, + projectId: row.projectId, + name: row.name, + dueAt: row.dueAt, + status: row.status, + metadata: row.metadata, + })) + + return { + generatedAt: now, + tables: { + organizations, + teams, + users, + projects, + projectSnapshots, + projectCounters, + currentMilestones, + issues, + }, + revisions: { + organizations: 1, + teams: 1, + users: 1, + projects: 1, + projectSnapshots: 1, + projectCounters: 1, + currentMilestones: 1, + issues: 1, + }, + } +} diff --git a/scripts/live_query_worker_compare.mjs b/scripts/live_query_worker_compare.mjs new file mode 100644 index 0000000..1006901 --- /dev/null +++ b/scripts/live_query_worker_compare.mjs @@ -0,0 +1,592 @@ +import fs from 'node:fs/promises' +import { existsSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { Worker } from 'node:worker_threads' +import { fileURLToPath } from 'node:url' +import { ResultSet } from '../js/packages/core/dist/index.js' +import { + API_REFRESH_COUNT, + DATASET_CONFIG, + MEASURED_ROUNDS, + MESSAGE_TIMEOUT_MS, + SCENARIO_ORDER, + SCENARIOS, + SOCKET_PATCH_COUNT, + WARMUP_ROUNDS, + formatBytes, + formatCount, + formatMs, + mean, + median, + nowIso, +} from './tanstack_db_benchmark_shared.mjs' +import { materializeResultSetForScenario } from './cynos_benchmark_row_shape.mjs' + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)) +const ROOT_DIR = path.resolve(SCRIPT_DIR, '..') +const TMP_DIR = path.join(ROOT_DIR, 'tmp') +const REPORT_PATH = path.join(TMP_DIR, 'live_query_worker_compare.md') +const JSON_REPORT_PATH = path.join(TMP_DIR, 'live_query_worker_compare.json') +const COMPARE_TIMEOUT_MS = Math.max(MESSAGE_TIMEOUT_MS, 120_000) +const INCLUDE_ROWS = process.env.LIVE_QUERY_INCLUDE_ROWS !== '0' +const SCENARIO_VARIANT = parseScenarioVariant() +const ACTIVE_SCENARIO_ORDER = scenarioOrderForVariant(SCENARIO_VARIANT) + +const ENGINE_DEFS = { + tanstack: { + id: 'tanstack', + label: 'TanStack DB', + workerUrl: new URL('./tanstack_db_worker_runtime.mjs', import.meta.url), + notes: [ + 'Base collections are `queryCollectionOptions(...)` collections backed by mock API responses.', + 'JSON predicates are aligned to the Cynos capability surface: `customer.tier` and `risk.bucket` equality / OR checks.', + 'Socket patches use `projects.utils.writeBatch(...writeUpdate(partial))` with only top-level fields.', + 'API refresh mutates the mock API copy, including nested metadata, then triggers `projects.utils.refetch()`.', + ], + }, + cynos: { + id: 'cynos', + label: 'Cynos (GraphQL)', + workerUrl: new URL('./cynos_graphql_worker_runtime.mjs', import.meta.url), + expectsMutationProfiles: true, + notes: [ + 'Worker initializes local Cynos WASM tables directly from the same synthetic server dataset and subscribes through generated GraphQL documents.', + 'GraphQL subscriptions can route through the engine’s delta-backed live planner when the query shape allows it, while still presenting full payload snapshots to the benchmark host.', + 'Issue-feed filters are expressed directly through GraphQL single-valued relation predicates on `project`, `counter`, and `snapshot`, so membership changes can flow through the planner/live runtime instead of a JS-side root-id workaround.', + ], + }, + 'cynos-query': { + id: 'cynos-query', + label: 'Cynos (Query Builder)', + workerUrl: new URL('./cynos_worker_runtime.mjs', import.meta.url), + initMessage: { + queryMode: 'changes', + }, + expectsMutationProfiles: true, + notes: [ + 'Worker initializes local Cynos WASM tables directly from the same synthetic server dataset.', + 'Socket patches and API refreshes are batched in a single transaction commit to mirror burst delivery.', + 'This is the low-level query-builder path using direct `changes()` subscriptions over explicit joins.', + ], + }, + 'cynos-query-binary': { + id: 'cynos-query-binary', + label: 'Cynos (Query Builder + binary)', + workerUrl: new URL('./cynos_worker_runtime.mjs', import.meta.url), + initMessage: { + queryMode: 'changes', + outputMode: 'binary', + }, + expectsMutationProfiles: true, + notes: [ + 'Worker initializes local Cynos WASM tables directly from the same synthetic server dataset.', + 'Query-builder subscriptions use `changes().subscribeBinary()` and transfer a standalone `Uint8Array` back to the host.', + 'The benchmark host decodes `ResultSet` bytes and attempts to remap them into the same scenario row shapes as object mode, so host timings include real decode/materialization work.', + 'Current multi-join `changes()` binary layouts are still provisional: the decoded snapshots do not yet match object-mode rows exactly, so treat these timings as transport-focused / pre-fix numbers rather than final correctness-validated results.', + ], + }, + 'cynos-trace': { + id: 'cynos-trace', + label: 'Cynos (trace)', + workerUrl: new URL('./cynos_worker_runtime.mjs', import.meta.url), + initMessage: { + queryMode: 'trace', + }, + expectsMutationProfiles: true, + notes: [ + 'Worker initializes local Cynos WASM tables directly from the same synthetic server dataset.', + 'The underlying query is compiled through query-builder `trace()` with no JS-side emulation of ORDER BY / LIMIT or top-N semantics.', + 'Use the dedicated no-order/no-limit control scenarios for apples-to-apples comparisons of pure incremental join/filter maintenance against TanStack.', + ], + }, +} + +function parseScenarioVariant() { + const raw = String(process.env.LIVE_QUERY_BENCH_SCENARIO_VARIANT ?? '') + .trim() + .toLowerCase() + + if (!raw || raw === 'default') { + return 'default' + } + + if (raw === 'trace_aligned') { + return 'trace_aligned' + } + + if (raw === 'trace_capability_aligned') { + return 'trace_capability_aligned' + } + + throw new Error( + `Unknown LIVE_QUERY_BENCH_SCENARIO_VARIANT "${raw}". Expected "default", "trace_aligned", or "trace_capability_aligned".`, + ) +} + +function scenarioOrderForVariant(scenarioVariant) { + if (scenarioVariant === 'trace_capability_aligned') { + return ['issue_stream_all', 'project_board_stream_all'] + } + + return SCENARIO_ORDER +} + +function parseEngines() { + const requested = String(process.env.LIVE_QUERY_BENCH_ENGINES ?? '') + .split(',') + .map((value) => value.trim().toLowerCase()) + .filter(Boolean) + + const engineIds = requested.length > 0 ? requested : ['tanstack', 'cynos'] + for (const engineId of engineIds) { + if (!ENGINE_DEFS[engineId]) { + throw new Error(`Unknown engine "${engineId}". Expected one of: ${Object.keys(ENGINE_DEFS).join(', ')}`) + } + } + return engineIds +} + +function createDeferred() { + let resolve + let reject + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function payloadBytes(rows) { + if (!rows) return 0 + return Buffer.byteLength(JSON.stringify(rows), 'utf8') +} + +function materializeSnapshotRows(message) { + if (message.payloadKind !== 'binary') { + return message.rows + } + + if (!message.binaryBytes || !message.layout) { + return undefined + } + + const resultSet = new ResultSet(message.binaryBytes, message.layout) + const rows = materializeResultSetForScenario( + message.scenarioId, + resultSet, + ) + resultSet.free() + return rows +} + +function snapshotPayloadBytes(message) { + if (message.payloadKind === 'binary') { + return message.binaryByteLength ?? 0 + } + + return payloadBytes(message.rows) +} + +class BenchmarkWorkerClient { + constructor(workerUrl) { + this.worker = new Worker(workerUrl, { type: 'module' }) + this.inbox = [] + this.waiters = [] + + this.worker.on('message', (message) => { + if (message.type === 'error') { + const error = new Error(`${message.context}: ${message.message}`) + error.stack = message.stack + const waiter = this.waiters.shift() + if (waiter) { + waiter.reject(error) + } else { + this.inbox.push({ type: '__error__', error }) + } + return + } + + const matchedIndex = this.waiters.findIndex((waiter) => waiter.match(message)) + if (matchedIndex >= 0) { + const [waiter] = this.waiters.splice(matchedIndex, 1) + waiter.resolve(message) + } else { + this.inbox.push(message) + } + }) + } + + post(message) { + this.worker.postMessage(message) + } + + waitFor(match, timeoutMs = COMPARE_TIMEOUT_MS) { + const bufferedIndex = this.inbox.findIndex( + (message) => message.type === '__error__' || match(message), + ) + if (bufferedIndex >= 0) { + const [message] = this.inbox.splice(bufferedIndex, 1) + if (message.type === '__error__') { + return Promise.reject(message.error) + } + return Promise.resolve(message) + } + + const deferred = createDeferred() + const timer = setTimeout(() => { + const index = this.waiters.findIndex((entry) => entry === waiter) + if (index >= 0) this.waiters.splice(index, 1) + deferred.reject(new Error(`Timed out after ${timeoutMs}ms waiting for worker message.`)) + }, timeoutMs) + + const waiter = { + match, + resolve: (message) => { + clearTimeout(timer) + deferred.resolve(message) + }, + reject: (error) => { + clearTimeout(timer) + deferred.reject(error) + }, + } + + this.waiters.push(waiter) + return deferred.promise + } + + async terminate() { + await this.worker.terminate() + } +} + +async function ensureTmpDir() { + if (!existsSync(TMP_DIR)) { + await fs.mkdir(TMP_DIR, { recursive: true }) + } +} + +async function runRound(client, scenarioId, engineDef) { + const initialSentAt = performance.now() + client.post({ type: 'subscribe', scenarioId, includeRows: INCLUDE_ROWS }) + const initial = await client.waitFor( + (message) => + message.type === 'snapshot' && + message.scenarioId === scenarioId && + message.phase === 'initial', + ) + materializeSnapshotRows(initial) + const initialHostMs = performance.now() - initialSentAt + + const socketSentAt = performance.now() + client.post({ + type: 'socket-patch', + scenarioId, + patchCount: SOCKET_PATCH_COUNT, + }) + const socket = await client.waitFor( + (message) => + message.type === 'snapshot' && + message.scenarioId === scenarioId && + message.phase === 'socket', + ) + materializeSnapshotRows(socket) + const socketHostMs = performance.now() - socketSentAt + const socketMutationProfile = engineDef.expectsMutationProfiles + ? await maybeWaitForMutationProfile(client, scenarioId, 'socket') + : null + + const apiSentAt = performance.now() + client.post({ + type: 'api-refresh', + scenarioId, + patchCount: API_REFRESH_COUNT, + }) + const api = await client.waitFor( + (message) => + message.type === 'snapshot' && + message.scenarioId === scenarioId && + message.phase === 'api', + ) + materializeSnapshotRows(api) + const apiHostMs = performance.now() - apiSentAt + const apiMutationProfile = engineDef.expectsMutationProfiles + ? await maybeWaitForMutationProfile(client, scenarioId, 'api') + : null + + client.post({ type: 'unsubscribe', scenarioId }) + await client.waitFor( + (message) => + message.type === 'unsubscribed' && message.scenarioId === scenarioId, + ) + + return { + initial: { + workerMs: initial.workerLatencyMs, + hostMs: initialHostMs, + rowCount: initial.rowCount, + changeCount: initial.changeCount, + bytes: snapshotPayloadBytes(initial), + phaseProfile: initial.phaseProfile ?? null, + }, + socket: { + workerMs: socket.workerLatencyMs, + hostMs: socketHostMs, + rowCount: socket.rowCount, + changeCount: socket.changeCount, + bytes: snapshotPayloadBytes(socket), + phaseProfile: socket.phaseProfile ?? null, + mutationProfile: socketMutationProfile, + }, + api: { + workerMs: api.workerLatencyMs, + hostMs: apiHostMs, + rowCount: api.rowCount, + changeCount: api.changeCount, + bytes: snapshotPayloadBytes(api), + phaseProfile: api.phaseProfile ?? null, + mutationProfile: apiMutationProfile, + }, + } +} + +async function maybeWaitForMutationProfile(client, scenarioId, phase) { + try { + const message = await client.waitFor( + (candidate) => + candidate.type === 'mutation-profile' && + candidate.scenarioId === scenarioId && + candidate.phase === phase, + 1_000, + ) + return message.mutationProfile ?? null + } catch { + return null + } +} + +function summarizePhase(rounds, phase) { + const workerValues = rounds.map((round) => round[phase].workerMs) + const hostValues = rounds.map((round) => round[phase].hostMs) + const rowCounts = rounds.map((round) => round[phase].rowCount) + const changeCounts = rounds.map((round) => round[phase].changeCount) + const byteSizes = rounds.map((round) => round[phase].bytes) + + return { + workerMsMedian: median(workerValues), + workerMsMean: mean(workerValues), + hostMsMedian: median(hostValues), + hostMsMean: mean(hostValues), + rowCountMedian: median(rowCounts), + changeCountMedian: median(changeCounts), + payloadBytesMedian: median(byteSizes), + } +} + +function summarizeScenario(rounds) { + return { + initial: summarizePhase(rounds, 'initial'), + socket: summarizePhase(rounds, 'socket'), + api: summarizePhase(rounds, 'api'), + } +} + +function buildMarkdownReport({ engineOrder, engineResults }) { + const lines = [] + lines.push('# Live Query Worker Compare') + lines.push('') + lines.push(`Generated: ${nowIso()}`) + lines.push('') + lines.push('## Setup') + lines.push('') + lines.push( + `- Dataset: ${formatCount(DATASET_CONFIG.issueCount)} issues, ${formatCount(DATASET_CONFIG.projectCount)} projects, ${formatCount(DATASET_CONFIG.userCount)} users, ${formatCount(DATASET_CONFIG.teamCount)} teams, ${formatCount(DATASET_CONFIG.organizationCount)} orgs, ${formatCount(DATASET_CONFIG.milestoneCount)} milestones`, + ) + lines.push(`- Engines: ${engineOrder.map((engineId) => ENGINE_DEFS[engineId].label).join(', ')}`) + lines.push(`- Warmup rounds: ${WARMUP_ROUNDS}`) + lines.push(`- Measured rounds: ${MEASURED_ROUNDS}`) + lines.push(`- Scenario variant: ${SCENARIO_VARIANT}`) + if (SCENARIO_VARIANT === 'trace_capability_aligned') { + lines.push( + '- Trace capability alignment: benchmark runs only non-blocking scenarios, and TanStack disables ORDER BY / LIMIT to match Cynos trace semantics.', + ) + } + lines.push(`- Socket patch count per round: ${SOCKET_PATCH_COUNT}`) + lines.push(`- API refresh count per round: ${API_REFRESH_COUNT}`) + lines.push( + `- Host row payloads: ${INCLUDE_ROWS ? 'enabled (full snapshot rows are structured-cloned back to host)' : 'disabled (worker returns counts only)'}`, + ) + lines.push('') + lines.push('## Results') + lines.push('') + lines.push( + '| Scenario | Engine | Init worker | Initial worker | Initial host | Snapshot rows | Payload | Socket worker | Socket host | API worker | API host |', + ) + lines.push( + '| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', + ) + + for (const scenarioId of ACTIVE_SCENARIO_ORDER) { + const scenario = SCENARIOS[scenarioId] + for (const engineId of engineOrder) { + const engineSummary = engineResults[engineId] + const summary = engineSummary.scenarioResults[scenarioId].summary + lines.push( + `| ${scenario.label} | ${ENGINE_DEFS[engineId].label} | ${formatMs(engineSummary.initMsMedian)} | ${formatMs(summary.initial.workerMsMedian)} | ${formatMs(summary.initial.hostMsMedian)} | ${formatCount(summary.initial.rowCountMedian)} | ${formatBytes(summary.initial.payloadBytesMedian)} | ${formatMs(summary.socket.workerMsMedian)} | ${formatMs(summary.socket.hostMsMedian)} | ${formatMs(summary.api.workerMsMedian)} | ${formatMs(summary.api.hostMsMedian)} |`, + ) + } + } + + lines.push('') + lines.push('## Engine Notes') + lines.push('') + for (const engineId of engineOrder) { + lines.push(`### ${ENGINE_DEFS[engineId].label}`) + lines.push('') + lines.push( + `- Worker init median: ${formatMs(engineResults[engineId].initMsMedian)} (mean ${formatMs(engineResults[engineId].initMsMean)}) across ${formatCount(engineResults[engineId].initSamples)} isolated worker runs`, + ) + lines.push('Base collection sizes:') + for (const [tableName, count] of Object.entries( + engineResults[engineId].dataset, + )) { + lines.push(`- ${tableName}: ${formatCount(count)}`) + } + for (const note of ENGINE_DEFS[engineId].notes) { + lines.push(`- ${note}`) + } + lines.push('') + } + + lines.push('## Scenario Details') + lines.push('') + for (const scenarioId of ACTIVE_SCENARIO_ORDER) { + const scenario = SCENARIOS[scenarioId] + lines.push(`### ${scenario.label}`) + lines.push('') + lines.push(`- ${scenario.description}`) + for (const engineId of engineOrder) { + const summary = engineResults[engineId].scenarioResults[scenarioId].summary + lines.push( + `- ${ENGINE_DEFS[engineId].label}: initial ${formatMs(summary.initial.hostMsMedian)} host / ${formatMs(summary.initial.workerMsMedian)} worker; socket ${formatMs(summary.socket.hostMsMedian)} host; api ${formatMs(summary.api.hostMsMedian)} host; payload ${formatBytes(summary.initial.payloadBytesMedian)}`, + ) + } + lines.push('') + } + + return `${lines.join('\n')}\n` +} + +async function runEngine(engineId) { + const initSamples = [] + const initProfiles = [] + let dataset = null + + async function runIsolatedRound(scenarioId) { + const engineDef = ENGINE_DEFS[engineId] + const client = new BenchmarkWorkerClient(engineDef.workerUrl) + + try { + client.post({ + type: 'init', + ...(engineDef.initMessage ?? {}), + scenarioVariant: SCENARIO_VARIANT, + }) + const readyMessage = await client.waitFor( + (message) => message.type === 'ready', + COMPARE_TIMEOUT_MS, + ) + const round = await runRound(client, scenarioId, engineDef) + + client.post({ type: 'shutdown' }) + await client.waitFor( + (message) => message.type === 'shutdown-complete', + COMPARE_TIMEOUT_MS, + ) + + return { + readyMessage, + round, + } + } finally { + await client.terminate() + } + } + + const scenarioResults = {} + for (const scenarioId of ACTIVE_SCENARIO_ORDER) { + const warmupRounds = [] + for (let index = 0; index < WARMUP_ROUNDS; index += 1) { + const { readyMessage, round } = await runIsolatedRound(scenarioId) + initSamples.push(readyMessage.initMs) + initProfiles.push(readyMessage.initProfile ?? null) + dataset ??= readyMessage.dataset + warmupRounds.push(round) + } + + const measuredRounds = [] + for (let index = 0; index < MEASURED_ROUNDS; index += 1) { + const { readyMessage, round } = await runIsolatedRound(scenarioId) + initSamples.push(readyMessage.initMs) + initProfiles.push(readyMessage.initProfile ?? null) + dataset ??= readyMessage.dataset + measuredRounds.push(round) + } + + scenarioResults[scenarioId] = { + warmupRounds, + measuredRounds, + summary: summarizeScenario(measuredRounds), + } + } + + return { + dataset, + initMsMedian: median(initSamples), + initMsMean: mean(initSamples), + initSamples: initSamples.length, + initProfiles, + scenarioResults, + } +} + +async function main() { + await ensureTmpDir() + + const engineOrder = parseEngines() + const engineResults = {} + + for (const engineId of engineOrder) { + process.stdout.write(`Running ${ENGINE_DEFS[engineId].label}...\n`) + engineResults[engineId] = await runEngine(engineId) + } + + const report = buildMarkdownReport({ engineOrder, engineResults }) + await fs.writeFile(REPORT_PATH, report, 'utf8') + await fs.writeFile( + JSON_REPORT_PATH, + JSON.stringify( + { + generatedAt: nowIso(), + datasetConfig: DATASET_CONFIG, + includeRows: INCLUDE_ROWS, + scenarioVariant: SCENARIO_VARIANT, + engineOrder, + engineResults, + }, + null, + 2, + ), + 'utf8', + ) + + process.stdout.write(report) + process.stdout.write(`\nSaved Markdown report to ${REPORT_PATH}\n`) + process.stdout.write(`Saved JSON report to ${JSON_REPORT_PATH}\n`) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/package.json b/scripts/package.json index 5e4a94b..c3f93f1 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -3,10 +3,15 @@ "private": true, "type": "module", "scripts": { - "engine:compare": "node ./engine_compare.mjs" + "engine:compare": "node ./engine_compare.mjs", + "live-query:compare": "node ./live_query_worker_compare.mjs", + "tanstack:worker-bench": "node ./tanstack_db_worker_benchmark.mjs" }, "dependencies": { "@electric-sql/pglite": "^0.4.0", + "@tanstack/db": "^0.6.0", + "@tanstack/query-core": "^5.95.2", + "@tanstack/query-db-collection": "^1.0.31", "rxdb": "^16.21.1", "sql.js": "^1.14.1" } diff --git a/scripts/pnpm-lock.yaml b/scripts/pnpm-lock.yaml index aab1ead..6905367 100644 --- a/scripts/pnpm-lock.yaml +++ b/scripts/pnpm-lock.yaml @@ -11,6 +11,15 @@ importers: '@electric-sql/pglite': specifier: ^0.4.0 version: 0.4.1 + '@tanstack/db': + specifier: ^0.6.0 + version: 0.6.0(typescript@6.0.2) + '@tanstack/query-core': + specifier: ^5.95.2 + version: 5.95.2 + '@tanstack/query-db-collection': + specifier: ^1.0.31 + version: 1.0.31(@tanstack/query-core@5.95.2)(typescript@6.0.2) rxdb: specifier: ^16.21.1 version: 16.21.1(rxjs@7.8.2) @@ -279,6 +288,32 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tanstack/db-ivm@0.1.18': + resolution: {integrity: sha512-+pZJiRKdoKRM5Epq9T7otD9ZJl82pRFauo7LKuJGrarjVKQ7r+QQlPe3kGdN9LEKSnuNGIWjX9OOY4M8kH4eLw==} + peerDependencies: + typescript: '>=4.7' + + '@tanstack/db@0.6.0': + resolution: {integrity: sha512-WlTtRYLdWpNAuH9tLV4Q3XdAiSjyxpofpLAJaEcq5ZKoIZJdJPEYRRE2iMgRfq0W0E2lhtdvER/rHdJYfwsNOA==} + peerDependencies: + typescript: '>=4.7' + + '@tanstack/pacer-lite@0.2.1': + resolution: {integrity: sha512-3PouiFjR4B6x1c969/Pl4ZIJleof1M0n6fNX8NRiC9Sqv1g06CVDlEaXUR4212ycGFyfq4q+t8Gi37Xy+z34iQ==} + engines: {node: '>=18'} + + '@tanstack/query-core@5.95.2': + resolution: {integrity: sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==} + + '@tanstack/query-db-collection@1.0.31': + resolution: {integrity: sha512-BJ2UYXWylixoq65dhy/Q9DGL1bAxGyHVjdvfU/kInpeIuiEV9Kh4pyMHm7cktOkgF1Dc8G/qEB+T+ngfL8pKGQ==} + peerDependencies: + '@tanstack/query-core': ^5.0.0 + typescript: '>=4.7' + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -492,6 +527,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + fractional-indexing@3.2.0: + resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==} + engines: {node: ^14.13.1 || >=16.0.0} + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -757,6 +796,9 @@ packages: simple-peer@9.11.1: resolution: {integrity: sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==} + sorted-btree@1.8.1: + resolution: {integrity: sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ==} + sparse-bitfield@3.0.3: resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} @@ -784,6 +826,11 @@ packages: tweetnacl@1.0.3: resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + typescript@6.0.2: + resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} + engines: {node: '>=14.17'} + hasBin: true + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -1223,6 +1270,32 @@ snapshots: '@protobufjs/utf8@1.1.0': {} + '@standard-schema/spec@1.1.0': {} + + '@tanstack/db-ivm@0.1.18(typescript@6.0.2)': + dependencies: + fractional-indexing: 3.2.0 + sorted-btree: 1.8.1 + typescript: 6.0.2 + + '@tanstack/db@0.6.0(typescript@6.0.2)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@tanstack/db-ivm': 0.1.18(typescript@6.0.2) + '@tanstack/pacer-lite': 0.2.1 + typescript: 6.0.2 + + '@tanstack/pacer-lite@0.2.1': {} + + '@tanstack/query-core@5.95.2': {} + + '@tanstack/query-db-collection@1.0.31(@tanstack/query-core@5.95.2)(typescript@6.0.2)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@tanstack/db': 0.6.0(typescript@6.0.2) + '@tanstack/query-core': 5.95.2 + typescript: 6.0.2 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -1463,6 +1536,8 @@ snapshots: dependencies: is-callable: 1.2.7 + fractional-indexing@3.2.0: {} + function-bind@1.1.2: {} generate-function@2.3.1: @@ -1754,6 +1829,8 @@ snapshots: transitivePeerDependencies: - supports-color + sorted-btree@1.8.1: {} + sparse-bitfield@3.0.3: dependencies: memory-pager: 1.5.0 @@ -1782,6 +1859,8 @@ snapshots: tweetnacl@1.0.3: {} + typescript@6.0.2: {} + undici-types@7.18.2: {} unload@2.4.1: {} diff --git a/scripts/tanstack_db_benchmark_shared.mjs b/scripts/tanstack_db_benchmark_shared.mjs new file mode 100644 index 0000000..b173416 --- /dev/null +++ b/scripts/tanstack_db_benchmark_shared.mjs @@ -0,0 +1,124 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)) +const ROOT_DIR = path.resolve(SCRIPT_DIR, '..') +const TMP_DIR = path.join(ROOT_DIR, 'tmp') + +function readIntEnv(name, fallback) { + const value = Number.parseInt(process.env[name] ?? '', 10) + return Number.isFinite(value) && value > 0 ? value : fallback +} + +function readNonNegativeIntEnv(name, fallback) { + const value = Number.parseInt(process.env[name] ?? '', 10) + return Number.isFinite(value) && value >= 0 ? value : fallback +} + +export const DATASET_CONFIG = { + organizationCount: readIntEnv('TANSTACK_BENCH_ORGS', 200), + teamCount: readIntEnv('TANSTACK_BENCH_TEAMS', 1_000), + userCount: readIntEnv('TANSTACK_BENCH_USERS', 12_000), + projectCount: readIntEnv('TANSTACK_BENCH_PROJECTS', 3_000), + milestoneCount: readIntEnv('TANSTACK_BENCH_MILESTONES', 9_000), + issueCount: readIntEnv('TANSTACK_BENCH_ISSUES', 50_000), + seed: readIntEnv('TANSTACK_BENCH_SEED', 20260327), +} + +export const WARMUP_ROUNDS = readNonNegativeIntEnv('TANSTACK_BENCH_WARMUP', 1) +export const MEASURED_ROUNDS = readIntEnv('TANSTACK_BENCH_ROUNDS', 3) +export const SOCKET_PATCH_COUNT = readIntEnv('TANSTACK_BENCH_SOCKET_PATCHES', 24) +export const API_REFRESH_COUNT = readIntEnv('TANSTACK_BENCH_API_REFRESHES', 24) +export const MESSAGE_TIMEOUT_MS = readIntEnv('TANSTACK_BENCH_TIMEOUT_MS', 20_000) + +export const SCENARIOS = { + issue_window_500: { + id: 'issue_window_500', + label: 'Issue Feed · 7-way left join · limit 500', + limit: 500, + root: 'issues', + updateSource: 'projects', + description: + '50K issues on top of project-management entities. Filters mix scalar and nested metadata predicates; host receives full snapshots from a worker subscription.', + }, + issue_window_5000: { + id: 'issue_window_5000', + label: 'Issue Feed · 7-way left join · limit 5000', + limit: 5_000, + root: 'issues', + updateSource: 'projects', + description: + 'Same query shape as the interactive feed, but with a much larger snapshot to surface worker→host structured-clone costs.', + }, + issue_stream_all: { + id: 'issue_stream_all', + label: 'Issue Feed · 7-way left join · no order/limit', + limit: null, + root: 'issues', + updateSource: 'projects', + description: + 'Control scenario for pure incremental join/filter maintenance: same issue-centric multi-join shape, but with ORDER BY / LIMIT removed so both engines can stay on a non-blocking live path.', + }, + project_board_2000: { + id: 'project_board_2000', + label: 'Project Board · 6-way left join · limit 2000', + limit: 2_000, + root: 'projects', + updateSource: 'projects', + description: + 'Project-centric board shaped around project patches, nested metadata, and one-to-one rollup tables to emulate Linear-like project views.', + }, + project_board_stream_all: { + id: 'project_board_stream_all', + label: 'Project Board · 6-way left join · no order/limit', + limit: null, + root: 'projects', + updateSource: 'projects', + description: + 'Control scenario for pure incremental maintenance on the project board join graph, with the same left joins and predicates but no blocking operators.', + }, +} + +export const SCENARIO_ORDER = Object.keys(SCENARIOS) + +export const REPORT_PATH = path.join(TMP_DIR, 'tanstack_db_worker_benchmark.md') +export const JSON_REPORT_PATH = path.join( + TMP_DIR, + 'tanstack_db_worker_benchmark.json', +) + +export function median(values) { + if (values.length === 0) return NaN + const sorted = [...values].sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle] +} + +export function mean(values) { + if (values.length === 0) return NaN + return values.reduce((sum, value) => sum + value, 0) / values.length +} + +export function formatMs(value) { + if (!Number.isFinite(value)) return 'N/A' + if (value < 1) return `${value.toFixed(3)} ms` + return `${value.toFixed(2)} ms` +} + +export function formatCount(value) { + if (!Number.isFinite(value)) return 'N/A' + return Number(value).toLocaleString() +} + +export function formatBytes(value) { + if (!Number.isFinite(value)) return 'N/A' + if (value < 1024) return `${value} B` + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB` + return `${(value / (1024 * 1024)).toFixed(2)} MiB` +} + +export function nowIso() { + return new Date().toISOString() +} diff --git a/scripts/tanstack_db_worker_benchmark.mjs b/scripts/tanstack_db_worker_benchmark.mjs new file mode 100644 index 0000000..5b9dc38 --- /dev/null +++ b/scripts/tanstack_db_worker_benchmark.mjs @@ -0,0 +1,347 @@ +import fs from 'node:fs/promises' +import { existsSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { Worker } from 'node:worker_threads' +import { fileURLToPath } from 'node:url' +import { + API_REFRESH_COUNT, + DATASET_CONFIG, + JSON_REPORT_PATH, + MEASURED_ROUNDS, + MESSAGE_TIMEOUT_MS, + REPORT_PATH, + SCENARIO_ORDER, + SCENARIOS, + SOCKET_PATCH_COUNT, + WARMUP_ROUNDS, + formatBytes, + formatCount, + formatMs, + mean, + median, + nowIso, +} from './tanstack_db_benchmark_shared.mjs' + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)) +const ROOT_DIR = path.resolve(SCRIPT_DIR, '..') +const TMP_DIR = path.join(ROOT_DIR, 'tmp') +const WORKER_PATH = new URL('./tanstack_db_worker_runtime.mjs', import.meta.url) + +function createDeferred() { + let resolve + let reject + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function payloadBytes(rows) { + if (!rows) return 0 + return Buffer.byteLength(JSON.stringify(rows), 'utf8') +} + +class BenchmarkWorkerClient { + constructor() { + this.worker = new Worker(WORKER_PATH, { type: 'module' }) + this.waiters = [] + + this.worker.on('message', (message) => { + if (message.type === 'error') { + const error = new Error(`${message.context}: ${message.message}`) + error.stack = message.stack + const waiter = this.waiters.shift() + if (waiter) { + waiter.reject(error) + } + return + } + + const matchedIndex = this.waiters.findIndex((waiter) => waiter.match(message)) + if (matchedIndex >= 0) { + const [waiter] = this.waiters.splice(matchedIndex, 1) + waiter.resolve(message) + } + }) + } + + post(message) { + this.worker.postMessage(message) + } + + waitFor(match, timeoutMs = MESSAGE_TIMEOUT_MS) { + const deferred = createDeferred() + const timer = setTimeout(() => { + const index = this.waiters.findIndex((entry) => entry === waiter) + if (index >= 0) this.waiters.splice(index, 1) + deferred.reject(new Error(`Timed out after ${timeoutMs}ms waiting for worker message.`)) + }, timeoutMs) + + const waiter = { + match, + resolve: (message) => { + clearTimeout(timer) + deferred.resolve(message) + }, + reject: (error) => { + clearTimeout(timer) + deferred.reject(error) + }, + } + + this.waiters.push(waiter) + return deferred.promise + } + + async terminate() { + await this.worker.terminate() + } +} + +async function ensureTmpDir() { + if (!existsSync(TMP_DIR)) { + await fs.mkdir(TMP_DIR, { recursive: true }) + } +} + +async function runRound(client, scenarioId) { + const initialSentAt = performance.now() + client.post({ type: 'subscribe', scenarioId, includeRows: true }) + const initial = await client.waitFor( + (message) => + message.type === 'snapshot' && + message.scenarioId === scenarioId && + message.phase === 'initial', + ) + const initialHostMs = performance.now() - initialSentAt + + const socketSentAt = performance.now() + client.post({ + type: 'socket-patch', + scenarioId, + patchCount: SOCKET_PATCH_COUNT, + }) + const socket = await client.waitFor( + (message) => + message.type === 'snapshot' && + message.scenarioId === scenarioId && + message.phase === 'socket', + ) + const socketHostMs = performance.now() - socketSentAt + + const apiSentAt = performance.now() + client.post({ + type: 'api-refresh', + scenarioId, + patchCount: API_REFRESH_COUNT, + }) + const api = await client.waitFor( + (message) => + message.type === 'snapshot' && + message.scenarioId === scenarioId && + message.phase === 'api', + ) + const apiHostMs = performance.now() - apiSentAt + + client.post({ type: 'unsubscribe', scenarioId }) + await client.waitFor( + (message) => + message.type === 'unsubscribed' && message.scenarioId === scenarioId, + ) + + return { + initial: { + workerMs: initial.workerLatencyMs, + hostMs: initialHostMs, + rowCount: initial.rowCount, + changeCount: initial.changeCount, + bytes: payloadBytes(initial.rows), + }, + socket: { + workerMs: socket.workerLatencyMs, + hostMs: socketHostMs, + rowCount: socket.rowCount, + changeCount: socket.changeCount, + bytes: payloadBytes(socket.rows), + }, + api: { + workerMs: api.workerLatencyMs, + hostMs: apiHostMs, + rowCount: api.rowCount, + changeCount: api.changeCount, + bytes: payloadBytes(api.rows), + }, + } +} + +function summarizePhase(rounds, phase) { + const workerValues = rounds.map((round) => round[phase].workerMs) + const hostValues = rounds.map((round) => round[phase].hostMs) + const rowCounts = rounds.map((round) => round[phase].rowCount) + const changeCounts = rounds.map((round) => round[phase].changeCount) + const byteSizes = rounds.map((round) => round[phase].bytes) + + return { + workerMsMedian: median(workerValues), + workerMsMean: mean(workerValues), + hostMsMedian: median(hostValues), + hostMsMean: mean(hostValues), + rowCountMedian: median(rowCounts), + changeCountMedian: median(changeCounts), + payloadBytesMedian: median(byteSizes), + } +} + +function summarizeScenario(rounds) { + return { + initial: summarizePhase(rounds, 'initial'), + socket: summarizePhase(rounds, 'socket'), + api: summarizePhase(rounds, 'api'), + } +} + +function buildMarkdownReport({ readyMessage, scenarioResults }) { + const lines = [] + lines.push('# TanStack DB Worker Benchmark') + lines.push('') + lines.push(`Generated: ${nowIso()}`) + lines.push('') + lines.push('## Setup') + lines.push('') + lines.push( + `- Dataset: ${formatCount(DATASET_CONFIG.issueCount)} issues, ${formatCount(DATASET_CONFIG.projectCount)} projects, ${formatCount(DATASET_CONFIG.userCount)} users, ${formatCount(DATASET_CONFIG.teamCount)} teams, ${formatCount(DATASET_CONFIG.organizationCount)} orgs, ${formatCount(DATASET_CONFIG.milestoneCount)} milestones`, + ) + lines.push(`- Warmup rounds: ${WARMUP_ROUNDS}`) + lines.push(`- Measured rounds: ${MEASURED_ROUNDS}`) + lines.push(`- Socket patch count per round: ${SOCKET_PATCH_COUNT}`) + lines.push(`- API refresh count per round: ${API_REFRESH_COUNT}`) + lines.push(`- Worker init time: ${formatMs(readyMessage.initMs)}`) + lines.push('') + lines.push('Base collection sizes:') + lines.push('') + for (const [tableName, count] of Object.entries(readyMessage.dataset)) { + lines.push(`- ${tableName}: ${formatCount(count)}`) + } + lines.push('') + lines.push('## Results') + lines.push('') + lines.push( + '| Scenario | Initial worker | Initial host | Snapshot rows | Payload | Socket worker | Socket host | API worker | API host |', + ) + lines.push( + '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', + ) + + for (const scenarioId of SCENARIO_ORDER) { + const scenario = SCENARIOS[scenarioId] + const summary = scenarioResults[scenarioId].summary + lines.push( + `| ${scenario.label} | ${formatMs(summary.initial.workerMsMedian)} | ${formatMs(summary.initial.hostMsMedian)} | ${formatCount(summary.initial.rowCountMedian)} | ${formatBytes(summary.initial.payloadBytesMedian)} | ${formatMs(summary.socket.workerMsMedian)} | ${formatMs(summary.socket.hostMsMedian)} | ${formatMs(summary.api.workerMsMedian)} | ${formatMs(summary.api.hostMsMedian)} |`, + ) + } + + lines.push('') + lines.push('## Notes') + lines.push('') + lines.push( + '- Base collections are `queryCollectionOptions(...)` collections fed by mock API responses shaped as `{ items, revision, total }`.', + ) + lines.push( + '- Socket updates are simulated as `projects.utils.writeBatch(...writeUpdate(partial))` calls with only 2-3 top-level fields (`state`, `healthScore`, `updatedAt`).', + ) + lines.push( + '- API refreshes mutate the mock server copy, including nested project metadata (`metadata.risk.score`, `metadata.flags.strategic`), then call `projects.utils.refetch()`.', + ) + lines.push( + '- The host path measures worker compute + incremental maintenance + full snapshot structured-clone back to the parent thread.', + ) + lines.push( + '- Queries intentionally use 6-7 left joins and nested semi-structured fields to approximate Linear-style project/issue views rather than trivial single-table filters.', + ) + lines.push('') + lines.push('## Scenario details') + lines.push('') + for (const scenarioId of SCENARIO_ORDER) { + const scenario = SCENARIOS[scenarioId] + const summary = scenarioResults[scenarioId].summary + lines.push(`### ${scenario.label}`) + lines.push('') + lines.push(`- ${scenario.description}`) + lines.push( + `- Initial rows: ${formatCount(summary.initial.rowCountMedian)}, median payload: ${formatBytes(summary.initial.payloadBytesMedian)}`, + ) + lines.push( + `- Socket change events observed: ${formatCount(summary.socket.changeCountMedian)}`, + ) + lines.push( + `- API change events observed: ${formatCount(summary.api.changeCountMedian)}`, + ) + lines.push('') + } + + return `${lines.join('\n')}\n` +} + +async function main() { + await ensureTmpDir() + + const client = new BenchmarkWorkerClient() + + try { + client.post({ type: 'init' }) + const readyMessage = await client.waitFor((message) => message.type === 'ready') + + const scenarioResults = {} + + for (const scenarioId of SCENARIO_ORDER) { + const warmupRounds = [] + for (let index = 0; index < WARMUP_ROUNDS; index += 1) { + warmupRounds.push(await runRound(client, scenarioId)) + } + + const measuredRounds = [] + for (let index = 0; index < MEASURED_ROUNDS; index += 1) { + measuredRounds.push(await runRound(client, scenarioId)) + } + + scenarioResults[scenarioId] = { + warmupRounds, + measuredRounds, + summary: summarizeScenario(measuredRounds), + } + } + + const report = buildMarkdownReport({ readyMessage, scenarioResults }) + await fs.writeFile(REPORT_PATH, report, 'utf8') + await fs.writeFile( + JSON_REPORT_PATH, + JSON.stringify( + { + generatedAt: nowIso(), + datasetConfig: DATASET_CONFIG, + readyMessage, + scenarioResults, + }, + null, + 2, + ), + 'utf8', + ) + + process.stdout.write(report) + process.stdout.write(`\nSaved Markdown report to ${REPORT_PATH}\n`) + process.stdout.write(`Saved JSON report to ${JSON_REPORT_PATH}\n`) + + client.post({ type: 'shutdown' }) + await client.waitFor((message) => message.type === 'shutdown-complete') + } finally { + await client.terminate() + } +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/tanstack_db_worker_runtime.mjs b/scripts/tanstack_db_worker_runtime.mjs new file mode 100644 index 0000000..48a950f --- /dev/null +++ b/scripts/tanstack_db_worker_runtime.mjs @@ -0,0 +1,901 @@ +import { parentPort } from 'node:worker_threads' +import { performance } from 'node:perf_hooks' +import { QueryClient } from '@tanstack/query-core' +import { + BasicIndex, + BTreeIndex, + and, + createCollection, + createLiveQueryCollection, + eq, + gte, + or, +} from '@tanstack/db' +import { queryCollectionOptions } from '@tanstack/query-db-collection' +import { + API_REFRESH_COUNT, + DATASET_CONFIG, + SOCKET_PATCH_COUNT, +} from './tanstack_db_benchmark_shared.mjs' +import { extractProjectIds } from './cynos_benchmark_row_shape.mjs' + +if (!parentPort) { + throw new Error('This module must run inside a worker thread.') +} + +const PROJECT_STATES = ['active', 'at_risk', 'planned', 'paused', 'archived'] +const ORG_REGIONS = ['na', 'emea', 'apac', 'latam'] +const TEAM_FUNCTIONS = ['product', 'design', 'engineering', 'ops', 'growth'] +const CUSTOMER_TIERS = ['self_serve', 'mid_market', 'enterprise'] +const ISSUE_LANES = ['backlog', 'triage', 'delivery', 'follow_up'] +const PRIMARY_TAGS = ['ux', 'api', 'infra', 'growth', 'security', 'sales'] +const SECONDARY_TAGS = ['mobile', 'web', 'sync', 'billing', 'search', 'ai'] +const RISK_BUCKETS = ['low', 'medium', 'high', 'critical'] +const MAX_TRACKED_PROJECT_IDS = Math.max( + 256, + SOCKET_PATCH_COUNT, + API_REFRESH_COUNT, +) + +function createRandom(seed) { + let value = seed >>> 0 + return () => { + value += 0x6d2b79f5 + let result = Math.imul(value ^ (value >>> 15), 1 | value) + result ^= result + Math.imul(result ^ (result >>> 7), 61 | result) + return ((result ^ (result >>> 14)) >>> 0) / 4294967296 + } +} + +function pick(random, values) { + return values[Math.floor(random() * values.length)] ?? values[0] +} + +function maybe(random, threshold = 0.5) { + return random() < threshold +} + +function intBetween(random, min, max) { + return Math.floor(random() * (max - min + 1)) + min +} + +function deepClone(value) { + return structuredClone(value) +} + +function stableModulo(id, modulo) { + return ((id % modulo) + modulo) % modulo +} + +function buildServerDataset(config) { + const random = createRandom(config.seed) + const now = Date.UTC(2026, 2, 27, 8, 0, 0) + + const organizations = Array.from({ length: config.organizationCount }, (_, idx) => { + const id = idx + 1 + return { + id, + name: `Organization ${id}`, + tier: CUSTOMER_TIERS[stableModulo(id, CUSTOMER_TIERS.length)], + region: ORG_REGIONS[stableModulo(id, ORG_REGIONS.length)], + metadata: { + spendBand: intBetween(random, 1, 5), + contract: { + renewed: maybe(random, 0.72), + seats: intBetween(random, 50, 5_000), + }, + }, + } + }) + + const teams = Array.from({ length: config.teamCount }, (_, idx) => { + const id = idx + 1 + const organizationId = stableModulo(id - 1, organizations.length) + 1 + return { + id, + organizationId, + name: `Team ${id}`, + function: TEAM_FUNCTIONS[stableModulo(id, TEAM_FUNCTIONS.length)], + metadata: { + timezoneOffset: stableModulo(id, 12) - 6, + budgetCode: `BGT-${organizationId}-${id}`, + }, + } + }) + + const users = Array.from({ length: config.userCount }, (_, idx) => { + const id = idx + 1 + const teamId = stableModulo(id - 1, teams.length) + 1 + return { + id, + teamId, + name: `User ${id}`, + role: maybe(random, 0.08) + ? 'staff' + : maybe(random, 0.2) + ? 'lead' + : 'member', + metadata: { + locale: stableModulo(id, 2) === 0 ? 'en-US' : 'en-GB', + focus: pick(random, ['product', 'platform', 'growth', 'design']), + seniority: intBetween(random, 1, 6), + }, + } + }) + + const teamUserIds = new Map() + for (const user of users) { + const existing = teamUserIds.get(user.teamId) + if (existing) { + existing.push(user.id) + } else { + teamUserIds.set(user.teamId, [user.id]) + } + } + + const projects = Array.from({ length: config.projectCount }, (_, idx) => { + const id = idx + 1 + const teamId = stableModulo(id - 1, teams.length) + 1 + const organizationId = teams[teamId - 1].organizationId + const candidateUsers = teamUserIds.get(teamId) ?? [1] + const leadUserId = candidateUsers[stableModulo(id, candidateUsers.length)] + const healthScore = intBetween(random, 25, 95) + return { + id, + organizationId, + teamId, + leadUserId, + name: `Project ${id}`, + state: pick(random, PROJECT_STATES), + healthScore, + updatedAt: now - id * 17_000, + priorityBand: healthScore > 75 ? 'p0' : healthScore > 55 ? 'p1' : 'p2', + metadata: { + risk: { + score: intBetween(random, 10, 95), + bucket: pick(random, RISK_BUCKETS), + }, + flags: { + strategic: maybe(random, 0.28), + regulated: maybe(random, 0.14), + }, + topology: { + shard: stableModulo(id, 32), + market: pick(random, ORG_REGIONS), + }, + }, + } + }) + + const milestones = [] + const milestonesByProject = new Map() + let milestoneId = 1 + while (milestones.length < config.milestoneCount) { + const project = projects[stableModulo(milestones.length, projects.length)] + const existing = milestonesByProject.get(project.id) ?? [] + const row = { + id: milestoneId, + projectId: project.id, + name: `Milestone ${milestoneId}`, + dueAt: now + intBetween(random, 1, 180) * 86_400_000, + status: maybe(random, 0.7) ? 'active' : 'planned', + metadata: { + quarter: `2026-Q${stableModulo(milestoneId, 4) + 1}`, + slipDays: intBetween(random, 0, 18), + }, + } + milestones.push(row) + existing.push(row.id) + milestonesByProject.set(project.id, existing) + milestoneId += 1 + } + + const issues = Array.from({ length: config.issueCount }, (_, idx) => { + const id = idx + 1 + const project = projects[Math.floor(random() * projects.length)] + const assigneePool = teamUserIds.get(project.teamId) ?? [project.leadUserId] + const currentMilestoneIds = milestonesByProject.get(project.id) ?? [] + const currentMilestoneId = + currentMilestoneIds.length > 0 && maybe(random, 0.78) + ? currentMilestoneIds[Math.floor(random() * currentMilestoneIds.length)] + : undefined + const status = + random() < 0.52 + ? 'open' + : random() < 0.72 + ? 'in_progress' + : random() < 0.88 + ? 'blocked' + : 'closed' + return { + id, + projectId: project.id, + assigneeId: assigneePool[Math.floor(random() * assigneePool.length)], + currentMilestoneId, + title: `Issue ${id}`, + status, + priority: pick(random, ['low', 'medium', 'high', 'urgent']), + estimate: intBetween(random, 1, 8), + updatedAt: now - intBetween(random, 0, 14 * 24 * 60) * 60_000, + metadata: { + severityRank: intBetween(random, 1, 5), + tags: { + primary: pick(random, PRIMARY_TAGS), + secondary: pick(random, SECONDARY_TAGS), + }, + customer: { + tier: pick(random, CUSTOMER_TIERS), + }, + workflow: { + lane: pick(random, ISSUE_LANES), + slaHours: intBetween(random, 4, 96), + }, + }, + } + }) + + const issueCounters = new Map() + for (const issue of issues) { + const current = issueCounters.get(issue.projectId) ?? { + openIssueCount: 0, + blockerCount: 0, + staleIssueCount: 0, + lastUpdatedAt: 0, + } + if (issue.status !== 'closed') current.openIssueCount += 1 + if (issue.status === 'blocked' || issue.metadata.severityRank >= 4) { + current.blockerCount += 1 + } + if (now - issue.updatedAt > 72 * 60 * 60 * 1000) { + current.staleIssueCount += 1 + } + if (issue.updatedAt > current.lastUpdatedAt) { + current.lastUpdatedAt = issue.updatedAt + } + issueCounters.set(issue.projectId, current) + } + + const projectCounters = projects.map((project) => { + const counters = issueCounters.get(project.id) ?? { + openIssueCount: 0, + blockerCount: 0, + staleIssueCount: 0, + lastUpdatedAt: project.updatedAt, + } + return { + projectId: project.id, + openIssueCount: counters.openIssueCount, + blockerCount: counters.blockerCount, + staleIssueCount: counters.staleIssueCount, + updatedAt: counters.lastUpdatedAt, + } + }) + + const projectSnapshots = projects.map((project) => { + const counters = issueCounters.get(project.id) ?? { + openIssueCount: 0, + blockerCount: 0, + staleIssueCount: 0, + } + return { + projectId: project.id, + velocity: Math.max(8, 80 - counters.blockerCount * 2 - counters.staleIssueCount), + completionRate: Math.max(0.1, Math.min(0.98, project.healthScore / 100)), + blockedRatio: + counters.openIssueCount === 0 + ? 0 + : Math.min(1, counters.blockerCount / counters.openIssueCount), + updatedAt: project.updatedAt, + } + }) + + const currentMilestones = projects + .map((project) => { + const ids = milestonesByProject.get(project.id) ?? [] + if (ids.length === 0) return null + const firstId = ids[0] + return milestones[firstId - 1] + }) + .filter(Boolean) + .map((row) => ({ + id: row.id, + projectId: row.projectId, + name: row.name, + dueAt: row.dueAt, + status: row.status, + metadata: row.metadata, + })) + + return { + generatedAt: now, + tables: { + organizations, + teams, + users, + projects, + projectSnapshots, + projectCounters, + currentMilestones, + issues, + }, + revisions: { + organizations: 1, + teams: 1, + users: 1, + projects: 1, + projectSnapshots: 1, + projectCounters: 1, + currentMilestones: 1, + issues: 1, + }, + } +} + +function createApi(server) { + return { + async fetchTable(tableName) { + const items = deepClone(server.tables[tableName]) + return { + items, + revision: server.revisions[tableName], + total: items.length, + source: 'mock-api', + } + }, + } +} + +const runtime = { + initialized: false, + queryClient: null, + api: null, + server: null, + collections: null, + active: null, + scenarioVariant: 'default', +} + +function usesAlignedFilters(scenarioVariant) { + return scenarioVariant === 'trace_aligned' +} + +function usesTraceCapabilityAlignment(scenarioVariant) { + return scenarioVariant === 'trace_capability_aligned' +} + +function normalizeScenarioVariant(scenarioVariant) { + if (scenarioVariant === 'trace_aligned') { + return 'trace_aligned' + } + + if (scenarioVariant === 'trace_capability_aligned') { + return 'trace_capability_aligned' + } + + return 'default' +} + +function createBaseCollections(queryClient, api) { + const buildCollection = (id, getKey) => + createCollection( + queryCollectionOptions({ + id, + queryKey: ['tanstack-bench', id], + queryClient, + getKey, + retry: false, + queryFn: async () => api.fetchTable(id), + select: (response) => response.items, + }), + ) + + const collections = { + organizations: buildCollection('organizations', (row) => row.id), + teams: buildCollection('teams', (row) => row.id), + users: buildCollection('users', (row) => row.id), + projects: buildCollection('projects', (row) => row.id), + projectSnapshots: buildCollection('projectSnapshots', (row) => row.projectId), + projectCounters: buildCollection('projectCounters', (row) => row.projectId), + currentMilestones: buildCollection('currentMilestones', (row) => row.id), + issues: buildCollection('issues', (row) => row.id), + } + + collections.teams.createIndex((row) => row.organizationId, { + indexType: BasicIndex, + }) + collections.users.createIndex((row) => row.teamId, { + indexType: BasicIndex, + }) + collections.projects.createIndex((row) => row.organizationId, { + indexType: BasicIndex, + }) + collections.projects.createIndex((row) => row.teamId, { + indexType: BasicIndex, + }) + collections.projects.createIndex((row) => row.leadUserId, { + indexType: BasicIndex, + }) + collections.projects.createIndex((row) => row.state, { + indexType: BasicIndex, + }) + collections.projects.createIndex((row) => row.healthScore, { + indexType: BTreeIndex, + }) + collections.projects.createIndex((row) => row.metadata.risk.bucket, { + indexType: BTreeIndex, + }) + collections.projectSnapshots.createIndex((row) => row.projectId, { + indexType: BasicIndex, + }) + collections.projectSnapshots.createIndex((row) => row.velocity, { + indexType: BTreeIndex, + }) + collections.projectCounters.createIndex((row) => row.projectId, { + indexType: BasicIndex, + }) + collections.projectCounters.createIndex((row) => row.openIssueCount, { + indexType: BTreeIndex, + }) + collections.currentMilestones.createIndex((row) => row.projectId, { + indexType: BasicIndex, + }) + collections.issues.createIndex((row) => row.projectId, { + indexType: BasicIndex, + }) + collections.issues.createIndex((row) => row.assigneeId, { + indexType: BasicIndex, + }) + collections.issues.createIndex((row) => row.currentMilestoneId, { + indexType: BasicIndex, + }) + collections.issues.createIndex((row) => row.status, { + indexType: BasicIndex, + }) + collections.issues.createIndex((row) => row.updatedAt, { + indexType: BTreeIndex, + }) + collections.issues.createIndex((row) => row.estimate, { + indexType: BTreeIndex, + }) + collections.issues.createIndex((row) => row.metadata.customer.tier, { + indexType: BTreeIndex, + }) + + return collections +} + +function buildScenarioCollection(scenarioId, collections, scenarioVariant) { + if ( + scenarioId === 'issue_window_500' || + scenarioId === 'issue_window_5000' || + scenarioId === 'issue_stream_all' + ) { + const limit = + scenarioId === 'issue_window_500' + ? 500 + : scenarioId === 'issue_window_5000' + ? 5_000 + : null + const alignedFilters = usesAlignedFilters(scenarioVariant) + const disableBlockingOps = usesTraceCapabilityAlignment(scenarioVariant) + return createLiveQueryCollection((q) => + { + let query = q + .from({ issue: collections.issues }) + .leftJoin({ project: collections.projects }, ({ issue, project }) => + eq(issue.projectId, project.id), + ) + .leftJoin({ org: collections.organizations }, ({ project, org }) => + eq(project.organizationId, org.id), + ) + .leftJoin({ team: collections.teams }, ({ project, team }) => + eq(project.teamId, team.id), + ) + .leftJoin({ assignee: collections.users }, ({ issue, assignee }) => + eq(issue.assigneeId, assignee.id), + ) + .leftJoin( + { milestone: collections.currentMilestones }, + ({ issue, milestone }) => eq(issue.currentMilestoneId, milestone.id), + ) + .leftJoin({ counter: collections.projectCounters }, ({ project, counter }) => + eq(project.id, counter.projectId), + ) + .leftJoin({ snapshot: collections.projectSnapshots }, ({ project, snapshot }) => + eq(project.id, snapshot.projectId), + ) + .where(({ issue, project, counter, snapshot }) => + alignedFilters + ? and( + or(eq(issue.status, 'open'), eq(issue.status, 'in_progress')), + gte(issue.estimate, 3), + or( + eq(issue.metadata.customer.tier, 'enterprise'), + eq(issue.metadata.customer.tier, 'mid_market'), + ), + ) + : and( + or(eq(issue.status, 'open'), eq(issue.status, 'in_progress')), + gte(issue.estimate, 3), + or( + eq(issue.metadata.customer.tier, 'enterprise'), + eq(issue.metadata.customer.tier, 'mid_market'), + ), + gte(project.healthScore, 45), + or( + eq(project.metadata.risk.bucket, 'high'), + eq(project.metadata.risk.bucket, 'critical'), + ), + gte(counter.openIssueCount, 5), + gte(snapshot.velocity, 18), + ), + ) + .select( + ({ issue, project, org, team, assignee, milestone, counter, snapshot }) => ({ + issueId: issue.id, + projectId: project.id, + issueTitle: issue.title, + issueStatus: issue.status, + issuePriority: issue.priority, + issueSeverityRank: issue.metadata.severityRank, + issueCustomerTier: issue.metadata.customer.tier, + projectName: project.name, + projectState: project.state, + projectHealth: project.healthScore, + projectRiskScore: project.metadata.risk.score, + projectStrategic: project.metadata.flags.strategic, + organizationName: org.name, + teamName: team.name, + assigneeName: assignee.name, + milestoneName: milestone.name, + openIssueCount: counter.openIssueCount, + blockerCount: counter.blockerCount, + velocity: snapshot.velocity, + updatedAt: issue.updatedAt, + }), + ) + if (Number.isFinite(limit) && !disableBlockingOps) { + query = query + .orderBy(({ $selected }) => $selected.updatedAt, 'desc') + .limit(limit) + } + return query + }, + ) + } + + if ( + scenarioId === 'project_board_2000' || + scenarioId === 'project_board_stream_all' + ) { + const limit = scenarioId === 'project_board_2000' ? 2_000 : null + const alignedFilters = usesAlignedFilters(scenarioVariant) + const disableBlockingOps = usesTraceCapabilityAlignment(scenarioVariant) + return createLiveQueryCollection((q) => + { + let query = q + .from({ project: collections.projects }) + .leftJoin({ org: collections.organizations }, ({ project, org }) => + eq(project.organizationId, org.id), + ) + .leftJoin({ team: collections.teams }, ({ project, team }) => + eq(project.teamId, team.id), + ) + .leftJoin({ lead: collections.users }, ({ project, lead }) => + eq(project.leadUserId, lead.id), + ) + .leftJoin({ counter: collections.projectCounters }, ({ project, counter }) => + eq(project.id, counter.projectId), + ) + .leftJoin({ snapshot: collections.projectSnapshots }, ({ project, snapshot }) => + eq(project.id, snapshot.projectId), + ) + .leftJoin( + { milestone: collections.currentMilestones }, + ({ project, milestone }) => eq(project.id, milestone.projectId), + ) + .where(({ project, counter, snapshot }) => + alignedFilters + ? and( + or(eq(project.state, 'active'), eq(project.state, 'at_risk')), + gte(project.healthScore, 45), + or( + eq(project.metadata.risk.bucket, 'high'), + eq(project.metadata.risk.bucket, 'critical'), + ), + ) + : and( + or(eq(project.state, 'active'), eq(project.state, 'at_risk')), + gte(project.healthScore, 45), + or( + eq(project.metadata.risk.bucket, 'high'), + eq(project.metadata.risk.bucket, 'critical'), + ), + gte(counter.openIssueCount, 4), + gte(snapshot.velocity, 20), + ), + ) + .select(({ project, org, team, lead, counter, snapshot, milestone }) => ({ + projectId: project.id, + projectName: project.name, + projectState: project.state, + projectHealth: project.healthScore, + projectRiskScore: project.metadata.risk.score, + projectStrategic: project.metadata.flags.strategic, + region: org.region, + organizationName: org.name, + teamName: team.name, + leadName: lead.name, + leadRole: lead.role, + milestoneName: milestone.name, + milestoneStatus: milestone.status, + openIssueCount: counter.openIssueCount, + blockerCount: counter.blockerCount, + staleIssueCount: counter.staleIssueCount, + velocity: snapshot.velocity, + blockedRatio: snapshot.blockedRatio, + updatedAt: project.updatedAt, + })) + if (Number.isFinite(limit) && !disableBlockingOps) { + query = query + .orderBy(({ $selected }) => $selected.projectHealth, 'desc') + .limit(limit) + } + return query + }, + ) + } + + throw new Error(`Unknown scenario: ${scenarioId}`) +} + +async function initRuntime(message = {}) { + if (runtime.initialized) { + return { + type: 'ready', + scenarioVariant: runtime.scenarioVariant, + dataset: summarizeDataset(runtime.server.tables), + } + } + + const startedAt = performance.now() + runtime.scenarioVariant = normalizeScenarioVariant(message.scenarioVariant) + runtime.server = buildServerDataset(DATASET_CONFIG) + runtime.api = createApi(runtime.server) + runtime.queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + runtime.collections = createBaseCollections(runtime.queryClient, runtime.api) + await Promise.all( + Object.values(runtime.collections).map((collection) => collection.preload()), + ) + runtime.initialized = true + return { + type: 'ready', + initMs: performance.now() - startedAt, + scenarioVariant: runtime.scenarioVariant, + dataset: summarizeDataset(runtime.server.tables), + } +} + +function summarizeDataset(tables) { + return Object.fromEntries( + Object.entries(tables).map(([tableName, rows]) => [tableName, rows.length]), + ) +} + +function ensureInitialized() { + if (!runtime.initialized || !runtime.collections || !runtime.server) { + throw new Error('Worker runtime is not initialized.') + } +} + +function unsubscribeActive() { + if (!runtime.active) return + runtime.active.subscription?.unsubscribe?.() + runtime.active = null +} + +function post(message) { + parentPort.postMessage(message) +} + +function createSnapshotMessage(active, phase, changes, includeRows = true) { + const rows = includeRows ? active.collection.toArray : undefined + return { + type: 'snapshot', + scenarioId: active.scenarioId, + phase, + workerLatencyMs: performance.now() - active.pending.startedAt, + rowCount: active.collection.size, + changeCount: changes.length, + rows, + } +} + +function subscribeScenario(message) { + ensureInitialized() + unsubscribeActive() + + const active = { + scenarioId: message.scenarioId, + includeRows: message.includeRows !== false, + collection: null, + lastProjectIds: [], + pending: { + phase: 'initial', + startedAt: performance.now(), + }, + subscription: null, + } + + active.collection = buildScenarioCollection( + message.scenarioId, + runtime.collections, + runtime.scenarioVariant, + ) + + active.subscription = active.collection.subscribeChanges( + (changes) => { + const pending = active.pending + const nextProjectIds = extractProjectIds( + active.collection.toArray, + MAX_TRACKED_PROJECT_IDS, + ) + if (nextProjectIds.length > 0) { + active.lastProjectIds = nextProjectIds + } + if (!pending) return + post(createSnapshotMessage(active, pending.phase, changes, active.includeRows)) + active.pending = null + }, + { + includeInitialState: true, + }, + ) + + runtime.active = active +} + +function collectActiveProjectIds(maxCount) { + if (!runtime.active) { + throw new Error('No active live query subscription.') + } + + const currentIds = extractProjectIds(runtime.active.collection.toArray, maxCount) + if (currentIds.length > 0) return currentIds + + if (runtime.active.lastProjectIds.length > 0) { + return runtime.active.lastProjectIds.slice(0, maxCount) + } + + return runtime.server.tables.projects.slice(0, maxCount).map((row) => row.id) +} + +function applyProjectPatch(projectId, transform) { + const projects = runtime.server.tables.projects + const index = projects.findIndex((row) => row.id === projectId) + if (index < 0) return null + const current = projects[index] + const next = transform(current) + projects[index] = next + runtime.server.revisions.projects += 1 + return next +} + +function runSocketPatchBurst(message) { + ensureInitialized() + if (!runtime.active) { + throw new Error('Subscribe before running socket patches.') + } + + const projectIds = collectActiveProjectIds(message.patchCount) + runtime.active.pending = { + phase: 'socket', + startedAt: performance.now(), + } + + runtime.collections.projects.utils.writeBatch(() => { + for (const projectId of projectIds) { + const next = applyProjectPatch(projectId, (current) => { + const healthScore = current.healthScore >= 45 ? 24 : 82 + return { + ...current, + state: current.state === 'active' ? 'at_risk' : 'active', + healthScore, + updatedAt: current.updatedAt + 60_000, + } + }) + if (!next) continue + runtime.collections.projects.utils.writeUpdate({ + id: next.id, + state: next.state, + healthScore: next.healthScore, + updatedAt: next.updatedAt, + }) + } + }) +} + +async function runApiRefresh(message) { + ensureInitialized() + if (!runtime.active) { + throw new Error('Subscribe before running API refresh.') + } + + const projectIds = collectActiveProjectIds(message.patchCount) + runtime.active.pending = { + phase: 'api', + startedAt: performance.now(), + } + + for (const projectId of projectIds) { + applyProjectPatch(projectId, (current) => { + const nextRisk = current.healthScore >= 45 ? 18 : 76 + const nextHealth = current.healthScore >= 45 ? 28 : 88 + return { + ...current, + healthScore: nextHealth, + updatedAt: current.updatedAt + 120_000, + metadata: { + ...current.metadata, + risk: { + ...current.metadata.risk, + score: nextRisk, + bucket: nextRisk >= 70 ? 'critical' : nextRisk >= 45 ? 'high' : 'medium', + }, + flags: { + ...current.metadata.flags, + strategic: !current.metadata.flags.strategic, + }, + }, + } + }) + } + + await runtime.collections.projects.utils.refetch({ throwOnError: true }) +} + +function handleError(error, context) { + post({ + type: 'error', + context, + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) +} + +parentPort.on('message', async (message) => { + try { + switch (message.type) { + case 'init': + post(await initRuntime(message)) + return + case 'subscribe': + subscribeScenario(message) + return + case 'socket-patch': + runSocketPatchBurst(message) + return + case 'api-refresh': + await runApiRefresh(message) + return + case 'unsubscribe': + unsubscribeActive() + post({ type: 'unsubscribed', scenarioId: message.scenarioId }) + return + case 'shutdown': + unsubscribeActive() + post({ type: 'shutdown-complete' }) + return + default: + throw new Error(`Unknown worker message type: ${message.type}`) + } + } catch (error) { + handleError(error, message.type) + } +})