From b1526a47857f5ff8669625c032fab5bf77acb21f Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 10:18:06 +0700 Subject: [PATCH 01/45] Integrate the frozen LinearTable API --- docs/crate.md | 8 ++ src/lib.rs | 3 + src/linear_table.rs | 215 ++++++++++++++++++++++++++++++++++++++++++ tests/linear_table.rs | 62 ++++++++++++ 4 files changed, 288 insertions(+) create mode 100644 src/linear_table.rs create mode 100644 tests/linear_table.rs diff --git a/docs/crate.md b/docs/crate.md index 39fd4312..781e52b0 100644 --- a/docs/crate.md +++ b/docs/crate.md @@ -10,6 +10,14 @@ Since 1.9 it also builds without `std`. A consumer with `select_all`; persistence, vacuum and the disk index are the parts that need an operating system, and they are gated out. +For build-once lookup data, [`LinearTable`](crate::LinearTable) keeps duplicate +keys in insertion order and exposes its rows as one contiguous slice. This is +the integrated replacement for `worktable-vec::LinearTable`: callers can sort +while building, freeze behind a shared reference, and use their own +allocation-free binary search. Its optional snapshot operation uses the same +self-contained Vec page codec as generated `vec: true` tables and remains +available in `no_std + alloc` builds. + Three things a declaration can now choose that it could not before: - **The index backend**, with `using`. The default is `arctic`, which takes diff --git a/src/lib.rs b/src/lib.rs index 828d6862..ec48e855 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ mod columnar; pub mod fsx; pub mod in_memory; mod index; +pub mod linear_table; pub mod lock; mod mem_stat; #[cfg(feature = "std")] @@ -47,6 +48,7 @@ pub use columnar::{ ColumnSlotId64, ColumnarColumn, ColumnarRowRef, next_columnar_incarnation, }; pub use index::*; +pub use linear_table::{InsertError as LinearInsertError, LinearTable, VecTable}; #[cfg(feature = "std")] pub use persistence::{ LoadMode, PersistedWorkTable, PersistenceConfig, PersistenceLoadError, UnloadFailure, UnloadReport, @@ -218,6 +220,7 @@ pub mod prelude { }; #[cfg(feature = "s3-support")] pub use crate::{DatabaseS3DiskConfig, DatabaseS3PersistenceEngine, S3Database}; + pub use crate::{LinearInsertError, LinearTable, VecTable}; /// The upstream IndexSet backend, when the `vanilla-index` feature selects it. #[cfg(feature = "vanilla-index")] pub use crate::{UpstreamIndexMap, UpstreamIndexPair}; diff --git a/src/linear_table.rs b/src/linear_table.rs new file mode 100644 index 00000000..074abe9a --- /dev/null +++ b/src/linear_table.rs @@ -0,0 +1,215 @@ +//! Contiguous rows for consumers that build once and search a frozen slice. +//! +//! [`LinearTable`] is deliberately smaller than a generated `vec: true` +//! table. It preserves insertion order and duplicate keys, and [`rows`](LinearTable::rows) +//! exposes the exact contiguous `[(K, V)]` storage. A caller can sort while +//! building, freeze the table behind an immutable reference, and run an +//! allocation-free binary search over that slice. +//! +//! The type is the retired `worktable-vec::LinearTable` moved into WorkTable. +//! Keeping it here lets crash handlers and other `no_std + alloc` consumers +//! use the stable row shape without taking a second table crate or routing a +//! lookup through WorkTable's paged indexes. + +use alloc::vec::Vec; + +use crate::vec_hydrate::{Codec, LoadError, RowTooLarge, from_pages, to_pages}; + +/// Why a uniqueness-checking insert was refused. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InsertError { + /// The key already belongs to a row. + DuplicateKey(K), + /// Reserved for fixed-capacity table implementations that cannot grow. + /// + /// [`LinearTable`] uses `Vec` growth and does not emit this variant. It is + /// retained for source compatibility with the retired standalone type. + OutOfMemory(K), +} + +/// Ordered, contiguous rows with a linear point lookup. +/// +/// [`push`](Self::push) preserves duplicates. [`insert`](Self::insert) is the +/// explicit uniqueness-checking alternative. The type has the same layout as +/// its row vector, so exposing the immutable slice adds no index, lock, or +/// allocation to a read path. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +#[repr(transparent)] +pub struct LinearTable { + rows: Vec<(K, V)>, +} + +/// A name for [`LinearTable`] that emphasizes its exact Vec-backed shape. +pub type VecTable = LinearTable; + +impl LinearTable { + /// An empty table. + #[must_use] + pub const fn new() -> Self { + Self { rows: Vec::new() } + } + + /// An empty table with room for at least `capacity` rows. + #[must_use] + pub fn with_capacity(capacity: usize) -> Self { + Self { + rows: Vec::with_capacity(capacity), + } + } + + /// Append a row without rejecting a duplicate key. + /// + /// Returns the row's insertion-order position. + pub fn push(&mut self, key: K, value: V) -> usize { + let row = self.rows.len(); + self.rows.push((key, value)); + row + } + + /// A row by insertion-order position. + #[must_use] + pub fn get_row(&self, row: usize) -> Option<&(K, V)> { + self.rows.get(row) + } + + /// A mutable row by insertion-order position. + pub fn get_row_mut(&mut self, row: usize) -> Option<&mut (K, V)> { + self.rows.get_mut(row) + } + + /// Every row as one contiguous slice. + #[must_use] + pub fn as_slice(&self) -> &[(K, V)] { + self.rows.as_slice() + } + + /// Every row as one mutable contiguous slice. + pub fn as_mut_slice(&mut self) -> &mut [(K, V)] { + self.rows.as_mut_slice() + } + + /// Every row in insertion order. + pub fn iter(&self) -> core::slice::Iter<'_, (K, V)> { + self.rows.iter() + } + + /// Every row mutably, in insertion order. + pub fn iter_mut(&mut self) -> core::slice::IterMut<'_, (K, V)> { + self.rows.iter_mut() + } + + /// Reserve room for at least `additional` more rows. + pub fn reserve(&mut self, additional: usize) { + self.rows.reserve(additional); + } + + /// The current row capacity. + #[must_use] + pub fn capacity(&self) -> usize { + self.rows.capacity() + } + + /// Consume the table and return its rows without copying. + #[must_use] + pub fn into_rows(self) -> Vec<(K, V)> { + self.rows + } + + /// Every row as one contiguous slice. + /// + /// This is the frozen query seam: a caller that has finished building the + /// table can keep only `&LinearTable` and binary-search this slice without + /// allocation or internal mutation. + #[must_use] + pub fn rows(&self) -> &[(K, V)] { + &self.rows + } + + /// Number of rows, including duplicate keys appended with [`push`](Self::push). + #[must_use] + pub fn len(&self) -> usize { + self.rows.len() + } + + /// Whether the table has no rows. + #[must_use] + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +impl LinearTable +where + K: Eq, +{ + /// Append a row only when its key is absent. + pub fn insert(&mut self, key: K, value: V) -> Result> { + if self.rows.iter().any(|(present, _)| present == &key) { + return Err(InsertError::DuplicateKey(key)); + } + Ok(self.push(key, value)) + } + + /// The first value with this key. + #[inline] + #[must_use] + pub fn select(&self, key: &K) -> Option<&V> { + self.rows + .iter() + .find(|(present, _)| present == key) + .map(|(_, value)| value) + } +} + +impl LinearTable +where + Vec<(K, V)>: Codec, + (K, V): Clone, +{ + /// Encode every row with WorkTable's self-contained Vec page codec. + /// + /// # Errors + /// + /// Refuses a row whose archive does not fit one page body. + pub fn unload(&self) -> Result, RowTooLarge> { + to_pages(&self.rows) + } + + /// Encode rows from `first` onward as an independent append segment. + /// + /// Independent segments restart page numbering at zero. The codec accepts + /// a terminal page followed by such a segment, so callers can concatenate + /// the returned bytes without rewriting the preceding pages. Updates and + /// deletes still require a full snapshot. + /// + /// # Errors + /// + /// Refuses a row whose archive does not fit one page body. + pub fn unload_appending(&self, first: usize) -> Result, RowTooLarge> { + to_pages(&self.rows[first.min(self.rows.len())..]) + } + + /// Rebuild the contiguous rows from WorkTable Vec pages. + /// + /// # Errors + /// + /// Refuses incomplete, corrupt, foreign-version, foreign-schema, or + /// undecodable pages with the codec's page-specific [`LoadError`]. + pub fn load(bytes: &[u8]) -> Result { + Ok(Self { + rows: from_pages(bytes)?, + }) + } +} + +impl From> for LinearTable { + fn from(rows: Vec<(K, V)>) -> Self { + Self { rows } + } +} + +impl AsRef<[(K, V)]> for LinearTable { + fn as_ref(&self) -> &[(K, V)] { + self.as_slice() + } +} diff --git a/tests/linear_table.rs b/tests/linear_table.rs new file mode 100644 index 00000000..06215a8d --- /dev/null +++ b/tests/linear_table.rs @@ -0,0 +1,62 @@ +use worktable::{LinearInsertError, LinearTable}; + +#[derive( + Clone, + Debug, + PartialEq, + worktable::prelude::rkyv::Archive, + worktable::prelude::rkyv::Serialize, + worktable::prelude::rkyv::Deserialize, +)] +#[rkyv(crate = worktable::prelude::rkyv)] +struct SavedRow { + line: u32, +} + +#[test] +fn push_preserves_duplicates_and_exposes_the_exact_contiguous_order() { + let mut table = LinearTable::with_capacity(3); + assert_eq!(table.push(20, "later"), 0); + assert_eq!(table.push(10, "first"), 1); + assert_eq!(table.push(20, "duplicate"), 2); + + assert_eq!(table.rows(), &[(20, "later"), (10, "first"), (20, "duplicate")]); + assert_eq!(table.as_ref(), table.rows()); +} + +#[test] +fn a_frozen_sorted_slice_supports_predecessor_search_without_an_index() { + let table = LinearTable::from(vec![(0_u64, 10_u32), (16, 11), (32, 12)]); + let rows = table.rows(); + let after = rows.partition_point(|(offset, _)| *offset <= 24); + + assert_eq!(after, 2); + assert_eq!(rows.get(after - 1), Some(&(16, 11))); +} + +#[test] +fn insert_is_the_explicit_unique_alternative_to_duplicate_preserving_push() { + let mut table = LinearTable::new(); + assert_eq!(table.insert(7, "first"), Ok(0)); + assert_eq!(table.insert(7, "second"), Err(LinearInsertError::DuplicateKey(7))); + assert_eq!(table.rows(), &[(7, "first")]); +} + +#[test] +fn vec_pages_round_trip_duplicates_and_append_in_insertion_order() { + let first = LinearTable::from(vec![(7_u64, SavedRow { line: 10 }), (7_u64, SavedRow { line: 11 })]); + let second = LinearTable::from(vec![(9_u64, SavedRow { line: 12 })]); + + let mut pages = first.unload().expect("rows fit"); + pages.extend_from_slice(&second.unload_appending(0).expect("rows fit")); + let loaded = LinearTable::::load(&pages).expect("valid pages"); + + assert_eq!( + loaded, + LinearTable::from(vec![ + (7, SavedRow { line: 10 }), + (7, SavedRow { line: 11 }), + (9, SavedRow { line: 12 }), + ]) + ); +} From 404383570e45fbd09eb0c3b06a9e1641fa1dfa9b Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 10:29:47 +0700 Subject: [PATCH 02/45] Finish busy persistence before drop returns --- src/persistence/task.rs | 23 ++++++++------- tests/slotted_page_requirement.rs | 47 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/persistence/task.rs b/src/persistence/task.rs index a50ef6f0..cc11f859 100644 --- a/src/persistence/task.rs +++ b/src/persistence/task.rs @@ -1725,7 +1725,7 @@ pub struct PersistenceTask Drop for PersistenceTask { - /// Aborts the engine task so it cannot outlive the table it persists. + /// Stops the engine task before the table finishes dropping. /// Without this the detached task keeps running on the runtime after the /// table is dropped, and a re-opened table can read the same files while /// the old engine is still writing them. @@ -1733,13 +1733,12 @@ impl Drop /// The abort only happens when the engine is provably idle (queue and /// analyzer empty, no operation in flight) — that is the normal state /// after `wait_for_ops`, and an idle task is parked at the queue pop, an - /// await point where cancellation is clean. Aborting a *busy* engine - /// would cancel persistence futures that are not cancellation-safe (a - /// data page could be left half-written while its index events are - /// abandoned), so a busy engine is left running and reported instead: - /// callers must drain with `wait_for_ops` before dropping. A proper - /// `close()` lifecycle (drain, join, surface terminal errors) is the - /// long-term replacement for this heuristic. + /// await point where cancellation is clean. Aborting a *busy* engine would + /// cancel persistence futures that are not cancellation-safe (a data page + /// could be left half-written while its index events are abandoned). Its + /// worker is private to this task, so a busy drop instead joins that worker + /// after requesting close. This keeps an immediate same-path reopen from + /// racing the last writes. fn drop(&mut self) { match self.engine_task_handle.as_ref() { None => return, @@ -1772,10 +1771,10 @@ impl Drop if let Some(handle) = self.engine_task_handle.take() { handle.cancel(); } - } else { - tracing::error!( - "PersistenceTask dropped with work in flight; the engine task keeps draining detached and then stops, but its errors can no longer be observed. Call close() (or wait_for_ops() before dropping) to guarantee a clean shutdown." - ); + } else if let Some(handle) = self.engine_task_handle.take() { + // The engine owns a dedicated one-worker runtime. Joining here + // cannot occupy the worker that must make this task progress. + let _ = nagoya::block_on(handle); } } } diff --git a/tests/slotted_page_requirement.rs b/tests/slotted_page_requirement.rs index 4b039bff..323ba462 100644 --- a/tests/slotted_page_requirement.rs +++ b/tests/slotted_page_requirement.rs @@ -194,3 +194,50 @@ async fn a_store_reopens_without_being_rebuilt() { reopened.close().await.expect("the table closes"); let _ = std::fs::remove_dir_all(dir); } + +/// Dropping a busy table requests a close. That close must finish before the +/// last owner disappears, or an immediate same-path reopen races a detached +/// writer and sees either a partial table or torn page headers. +#[tokio::test] +async fn a_busy_drop_finishes_before_an_immediate_reopen() { + const ACCEPTED: u64 = 300; + let dir = "tests/data/slotted_page/busy_drop_reopen"; + let _ = std::fs::remove_dir_all(dir); + + let engine = SlottedRowPersistenceEngine::new(DiskConfig::new_with_table_name( + dir, + SlottedRowWorkTable::name_snake_case(), + SlottedRowWorkTable::version(), + )) + .await + .expect("an engine"); + let table = SlottedRowWorkTable::load(engine).await.expect("a table"); + for n in 0..ACCEPTED { + table + .insert(SlottedRowRow { + id: table.get_next_pk().into(), + blob: format!("accepted row {n}"), + }) + .await + .expect("an accepted row"); + } + drop(table); + + let engine = SlottedRowPersistenceEngine::new(DiskConfig::new_with_table_name( + dir, + SlottedRowWorkTable::name_snake_case(), + SlottedRowWorkTable::version(), + )) + .await + .expect("the immediate reopen does not observe torn files"); + let reopened = SlottedRowWorkTable::load(engine) + .await + .expect("the immediately reopened table"); + assert_eq!( + reopened.select_all().execute().expect("a read").len(), + ACCEPTED as usize, + "every operation accepted before drop is durable when reopen begins" + ); + reopened.close().await.expect("the reopened table closes"); + let _ = std::fs::remove_dir_all(dir); +} From 2c83d6de44553b156524090cd28af84e6509e306 Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 10:56:18 +0700 Subject: [PATCH 03/45] Avoid generated table startup stack overflow --- codegen/src/generators/persist/table/impls.rs | 6 +- .../src/generators/read_only/table/impls.rs | 6 +- .../persist_table/generator/space_file/mod.rs | 32 ++++--- src/in_memory/data.rs | 85 +++++++++++++++++- src/in_memory/pages.rs | 87 ++++++++++++++++++- src/table/mod.rs | 2 +- 6 files changed, 193 insertions(+), 25 deletions(-) diff --git a/codegen/src/generators/persist/table/impls.rs b/codegen/src/generators/persist/table/impls.rs index 0e84ee3c..0d7babf2 100644 --- a/codegen/src/generators/persist/table/impls.rs +++ b/codegen/src/generators/persist/table/impls.rs @@ -323,7 +323,7 @@ impl PersistGenerator { } async fn load(engine: E) -> worktable::prelude::eyre::Result { - Self::load_with(engine, LoadMode::Strict).await + worktable::prelude::Box::pin(Self::load_with(engine, LoadMode::Strict)).await } async fn load_with(mut engine: E, mode: LoadMode) -> worktable::prelude::eyre::Result { @@ -337,10 +337,10 @@ impl PersistGenerator { .await?; let table_path = engine.config().table_path().to_owned(); if !std::path::Path::new(&table_path).exists() { - return Self::new(engine).await; + return worktable::prelude::Box::pin(Self::new(engine)).await; }; let table = load_persisted_state(&table_path, async { - let space = #space_ident::parse_file(&table_path).await?; + let space = worktable::prelude::Box::pin(#space_ident::parse_file(&table_path)).await?; Ok::<_, worktable::prelude::eyre::Report>(space.into_worktable_with_mode(engine, &table_path, mode).await?) }).await?; Ok(table) diff --git a/codegen/src/generators/read_only/table/impls.rs b/codegen/src/generators/read_only/table/impls.rs index 033d328e..fdd6a8a1 100644 --- a/codegen/src/generators/read_only/table/impls.rs +++ b/codegen/src/generators/read_only/table/impls.rs @@ -287,16 +287,16 @@ impl ReadOnlyGenerator { } async fn load(engine: E) -> worktable::prelude::eyre::Result { - Self::load_with(engine, LoadMode::Strict).await + worktable::prelude::Box::pin(Self::load_with(engine, LoadMode::Strict)).await } async fn load_with(engine: E, mode: LoadMode) -> worktable::prelude::eyre::Result { let table_path = engine.config().table_path().to_owned(); if !std::path::Path::new(&table_path).exists() { - return Self::new(engine).await; + return worktable::prelude::Box::pin(Self::new(engine)).await; }; let table = load_persisted_state(&table_path, async { - let space = #space_ident::parse_file(&table_path).await?; + let space = worktable::prelude::Box::pin(#space_ident::parse_file(&table_path)).await?; Ok::<_, worktable::prelude::eyre::Report>(space.into_worktable_with_mode(&table_path, mode)?) }).await?; Ok(table) diff --git a/codegen/src/persist_table/generator/space_file/mod.rs b/codegen/src/persist_table/generator/space_file/mod.rs index cec3a1fa..0da5952f 100644 --- a/codegen/src/persist_table/generator/space_file/mod.rs +++ b/codegen/src/persist_table/generator/space_file/mod.rs @@ -48,7 +48,7 @@ impl Generator { pub struct #space_file_ident { #primary_index pub indexes: #index_persisted_ident, - pub data: Vec>>, + pub data: Vec>>>, pub data_info: GeneralPage::Generator as PrimaryKeyGeneratorState>::State>>, } } @@ -239,22 +239,24 @@ impl Generator { ) -> Result<#wt_ident, PersistenceLoadError> { let mut page_id = 1; let data = self.data.into_iter().map(|p| { - let mut data = Data::from_data_page(p); - data.set_page_id(page_id.into()); + let mut data = Data::from_data_page_ref_arc(&p); + worktable::prelude::Arc::get_mut(&mut data) + .expect("a newly restored page is uniquely owned") + .set_page_id(page_id.into()); page_id += 1; - worktable::prelude::Arc::new(data) + data }) .collect(); - let data = DataPages::from_data(data) - .with_empty_links(self.data_info.inner.empty_links_list) + let data = DataPages::from_data_arc(data) + .with_empty_links_arc(self.data_info.inner.empty_links_list) .map_err(|error| PersistenceLoadError::corrupt(path, error))?; let indexes = #index_ident::from_persisted(self.indexes); #primary_index_init let table = WorkTable { - data: worktable::prelude::Arc::new(data), + data, primary_index: worktable::prelude::Arc::new(primary_index), indexes: worktable::prelude::Arc::new(indexes), pk_gen: PrimaryKeyGeneratorState::from_state(self.data_info.inner.pk_gen_state), @@ -309,22 +311,24 @@ impl Generator { { let mut page_id = 1; let data = self.data.into_iter().map(|p| { - let mut data = Data::from_data_page(p); - data.set_page_id(page_id.into()); + let mut data = Data::from_data_page_ref_arc(&p); + worktable::prelude::Arc::get_mut(&mut data) + .expect("a newly restored page is uniquely owned") + .set_page_id(page_id.into()); page_id += 1; - worktable::prelude::Arc::new(data) + data }) .collect(); - let data = DataPages::from_data(data) - .with_empty_links(self.data_info.inner.empty_links_list) + let data = DataPages::from_data_arc(data) + .with_empty_links_arc(self.data_info.inner.empty_links_list) .map_err(|error| PersistenceLoadError::corrupt(path, error))?; let indexes = #index_ident::from_persisted(self.indexes); #primary_index_init let table = WorkTable { - data: worktable::prelude::Arc::new(data), + data, primary_index: worktable::prelude::Arc::new(primary_index), indexes: worktable::prelude::Arc::new(indexes), pk_gen: PrimaryKeyGeneratorState::from_state(self.data_info.inner.pk_gen_state), @@ -416,7 +420,7 @@ impl Generator { let count = file_length.div_ceil(#page_const_name as u64); for page_id in 1..count { let index = parse_data_page::<{ #page_const_name as u32}, { #inner_const_name as usize }, { #page_const_name as u32 }>(&mut data_file, page_id as u32).await?; - data.push(index); + data.push(Box::new(index)); } (data, info) }; diff --git a/src/in_memory/data.rs b/src/in_memory/data.rs index 48539796..91949143 100644 --- a/src/in_memory/data.rs +++ b/src/in_memory/data.rs @@ -1,4 +1,4 @@ -use alloc::vec::Vec; +use alloc::{sync::Arc, vec::Vec}; use core::cell::UnsafeCell; use core::fmt::Debug; use core::marker::PhantomData; @@ -52,6 +52,29 @@ impl Default for CellLocks { } } +impl CellLocks { + /// Initialize the lock table directly in its final allocation. + /// + /// Building both fixed-size atomic arrays as a return value makes the + /// compiler reserve another copy on the caller's stack. `Data` uses this + /// when it is created inside an `Arc`, where that copy is unnecessary. + unsafe fn initialize_at(target: *mut Self) { + unsafe { + let states = core::ptr::addr_of_mut!((*target).states).cast::(); + for index in 0..CELL_LOCK_SLOTS { + states.add(index).write(CellState::new(0)); + } + + let owners = core::ptr::addr_of_mut!((*target).owners).cast::(); + for index in 0..CELL_LOCK_SLOTS { + owners.add(index).write(CellOwner::new(0)); + } + + core::ptr::addr_of_mut!((*target).nested_reads).write(CellState::new(0)); + } + } +} + #[inline] fn current_owner() -> usize { #[cfg(wt_loom)] @@ -291,6 +314,66 @@ pub struct Data { unsafe impl Sync for Data {} impl Data { + unsafe fn initialize_arc_at(target: *mut Self, id: PageId, free_offset: u32, source: Option<*const u8>) { + unsafe { + core::ptr::addr_of_mut!((*target).id).write(id); + core::ptr::addr_of_mut!((*target).free_offset).write(AtomicU32::new(free_offset)); + core::ptr::addr_of_mut!((*target).access).write(parking_lot::RwLock::new(())); + CellLocks::initialize_at(core::ptr::addr_of_mut!((*target).cell_locks)); + core::ptr::addr_of_mut!((*target).live_cells).write(AtomicU32::new(0)); + + let destination = core::ptr::addr_of_mut!((*target).inner_data).cast::(); + if let Some(source) = source { + core::ptr::copy_nonoverlapping(source, destination, DATA_LENGTH); + } else { + core::ptr::write_bytes(destination, 0, DATA_LENGTH); + } + + core::ptr::addr_of_mut!((*target)._phantom).write(PhantomData); + } + } + + /// Creates a new page directly in its `Arc` allocation. + /// + /// A default page owns a roughly 16 KiB inline byte image. Initializing it + /// through `Arc::new(Data::new(...))` can copy that image through several + /// stack return slots, which compounds when a generated table constructs + /// several indexed components on a normal 2 MiB thread stack. + pub fn new_arc(id: PageId) -> Arc { + let mut page = Arc::::new_uninit(); + let target = Arc::get_mut(&mut page) + .expect("a newly allocated Arc is uniquely owned") + .as_mut_ptr(); + + // SAFETY: every field is initialized exactly once in the allocation + // above. The byte image is an array of u8, for which all-zero is valid. + unsafe { + Self::initialize_arc_at(target, id, 0, None); + page.assume_init() + } + } + + /// Restores a persisted page by reference without moving its inline byte + /// image through the caller's stack. + pub fn from_data_page_ref_arc(page: &GeneralPage>) -> Arc { + let mut restored = Arc::::new_uninit(); + let target = Arc::get_mut(&mut restored) + .expect("a newly allocated Arc is uniquely owned") + .as_mut_ptr(); + + // SAFETY: the boxed page remains alive for the copy, and the helper + // initializes every field before the Arc is exposed. + unsafe { + Self::initialize_arc_at( + target, + page.header.page_id, + page.header.data_length, + Some(page.inner.data.as_ptr()), + ); + restored.assume_init() + } + } + fn validate_link(&self, link: Link) -> Result<(), ExecutionError> { let start = link.offset as usize; let end = start diff --git a/src/in_memory/pages.rs b/src/in_memory/pages.rs index ff21d4f3..db9564a1 100644 --- a/src/in_memory/pages.rs +++ b/src/in_memory/pages.rs @@ -225,6 +225,24 @@ struct PageDirectory { } impl PageDirectory { + unsafe fn initialize_at(target: *mut Self, pages: &[Arc]) { + unsafe { + let roots = core::ptr::addr_of_mut!((*target).roots).cast::>>(); + for index in 0..PAGE_DIRECTORY_ROOTS { + roots.add(index).write(AtomicPtr::new(core::ptr::null_mut())); + } + core::ptr::addr_of_mut!((*target).chunks).write(Mutex::new(Vec::new())); + } + + // SAFETY: both fields used by `publish` are initialized above, the + // allocation is stable, and it is not observable until its owner is + // returned from construction. + let directory = unsafe { &*target }; + for (index, page) in pages.iter().enumerate() { + directory.publish(index, page); + } + } + fn new(pages: &[Arc]) -> Self { let directory = Self { roots: core::array::from_fn(|_| AtomicPtr::new(core::ptr::null_mut())), @@ -426,6 +444,57 @@ where Row: StorableRow, ::WrappedRow: RowWrapper, { + unsafe fn initialize_arc_at( + target: *mut Self, + mut pages: Vec::WrappedRow, DATA_LENGTH>>>, + ) { + if pages.is_empty() { + pages.push(Data::new_arc(1.into())); + } + let last_page_id = pages.len() as u32; + + unsafe { + core::ptr::addr_of_mut!((*target).epoch).write(EpochDomain::new()); + core::ptr::addr_of_mut!((*target).retired).write(Mutex::new(VecDeque::new())); + core::ptr::addr_of_mut!((*target).reclaimable).write(Arc::new(AtomicUsize::new(0))); + core::ptr::addr_of_mut!((*target).pending_retirements).write(AtomicUsize::new(0)); + core::ptr::addr_of_mut!((*target).queued_page_retirements).write(AtomicUsize::new(0)); + PageDirectory::initialize_at(core::ptr::addr_of_mut!((*target).page_directory), &pages); + core::ptr::addr_of_mut!((*target).pages).write(PageList::from_pages(pages)); + core::ptr::addr_of_mut!((*target).pages_write).write(Mutex::new(())); + core::ptr::addr_of_mut!((*target).empty_links).write(EmptyLinkRegistry::::default()); + core::ptr::addr_of_mut!((*target).empty_pages).write(Default::default()); + core::ptr::addr_of_mut!((*target).row_count).write(AtomicU64::new(0)); + core::ptr::addr_of_mut!((*target).last_page_id).write(AtomicU32::new(last_page_id)); + core::ptr::addr_of_mut!((*target).current_page_id).write(AtomicU32::new(last_page_id)); + } + } + + /// Creates the page collection directly in its `Arc` allocation. + /// + /// The fixed page directory deliberately keeps 1,024 roots inline for a + /// pointer-only read path. Constructing `Self` on the stack before moving + /// it into an Arc needlessly reserves that full array in nested generated + /// table-load futures. + pub fn new_arc() -> Arc { + Self::from_data_arc(Vec::new()) + } + + /// Restores a page collection directly in its `Arc` allocation. + pub fn from_data_arc(pages: Vec::WrappedRow, DATA_LENGTH>>>) -> Arc { + let mut collection = Arc::::new_uninit(); + let target = Arc::get_mut(&mut collection) + .expect("a newly allocated Arc is uniquely owned") + .as_mut_ptr(); + + // SAFETY: the helper initializes every field exactly once and the Arc + // remains uniquely owned until initialization is complete. + unsafe { + Self::initialize_arc_at(target, pages); + collection.assume_init() + } + } + fn page_ref( &self, page_id: PageId, @@ -700,7 +769,7 @@ where } pub fn new() -> Self { - let page = Arc::new(Data::new(1.into())); + let page = Data::new_arc(1.into()); let pages = vec![page]; Self { epoch: EpochDomain::new(), @@ -877,7 +946,7 @@ where let _write = self.pages_write.lock(); if tried_page == page_id_mapper(self.current_page_id.load(Ordering::Acquire) as usize) { let index = self.last_page_id.fetch_add(1, Ordering::AcqRel) + 1; - let page = Arc::new(Data::new(index.into())); + let page = Data::new_arc(index.into()); self.pages.push(page.clone()); self.publish_page(&page); self.current_page_id.store(index, Ordering::Release); @@ -910,7 +979,7 @@ where let _write = self.pages_write.lock(); let index = self.last_page_id.fetch_add(1, Ordering::AcqRel) + 1; - let page = Arc::new(Data::new(index.into())); + let page = Data::new_arc(index.into()); self.pages.push(page.clone()); self.publish_page(&page); @@ -1412,6 +1481,18 @@ where Ok(self) } + pub fn with_empty_links_arc(mut self: Arc, links: Vec) -> Result, ExecutionError> { + let registry = EmptyLinkRegistry::default(); + for link in links { + self.page_ref(link.page_id)?.reserve_restored_range(link)?; + registry.push(link); + } + Arc::get_mut(&mut self) + .expect("restored page collection is uniquely owned") + .empty_links = registry; + Ok(self) + } + pub fn current_page_id(&self) -> PageId { self.current_page_id.load(Ordering::Acquire).into() } diff --git a/src/table/mod.rs b/src/table/mod.rs index 8a76f110..0d430a08 100644 --- a/src/table/mod.rs +++ b/src/table/mod.rs @@ -109,7 +109,7 @@ where { fn default() -> Self { Self { - data: Arc::new(DataPages::new()), + data: DataPages::new_arc(), primary_index: Arc::new(PrimaryIndex::::default()), indexes: Arc::new(SecondaryIndexes::default()), pk_gen: Default::default(), From a9cbab2c3c9b325bb888ab8f3a84d1e667ec226a Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 10:56:18 +0700 Subject: [PATCH 04/45] Prepare WorkTable 1.9.0 beta1 --- CHANGELOG.md | 23 +++++++++++++++++++++++ Cargo.toml | 4 ++-- README.md | 2 +- codegen/Cargo.toml | 2 +- docs/paper-2-plan.md | 2 +- docs/why-worktables.typ | 4 ++-- docs/wt-user-guide.typ | 10 +++++----- 7 files changed, 35 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec079e55..22f21836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,29 @@ Change Log ========== +## [1.9.0-beta1] + +### Added + +- The frozen `LinearTable` and `VecTable` API is now part of WorkTable itself, + replacing the retired `worktable-vec` compatibility crate. PathDB's indexed + lookup compiles to instruction-for-instruction identical AArch64 code before + and after the move. + +### Fixed + +- Dropping a persisted table with queued writes now joins its private writer + before the final handle disappears. An immediate same-path reopen can no + longer race detached writes and observe a partial store or torn index header. + +- Generated persisted-table startup now constructs its page storage directly + in the final `Arc` allocation and pins nested load futures before awaiting + them. This removes roughly 16 KiB page and directory temporaries from normal + thread stacks. In AgentCode's eight-table empty-store startup, the generated + load future shrank from 17,832 bytes to 2,392 bytes and the restart path no + longer overflows Tokio's default 2 MiB worker stack. Table read and mutation + paths are unchanged. + ## [1.9.0-alpha1] diff --git a/Cargo.toml b/Cargo.toml index 2ecc1abf..19ca3d09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["codegen", "dsl", "examples", "performance_measurement", "performance [package] name = "worktable" -version = "1.9.0-alpha1" +version = "1.9.0-beta1" edition = "2024" authors = ["Handy-caT"] license = "MIT" @@ -147,7 +147,7 @@ walkdir = { version = "2", optional = true } # These pre-release workspace crates move as one train. The explicit caret # keeps the dependency policy consistent while the local path selects this # checkout during validation. -worktable_codegen = { path = "codegen", version = "^1.9.0-alpha1" } +worktable_codegen = { path = "codegen", version = "^1.9.0-beta1" } # Re-exported below. Each generated table carries its declaration as a const # whose documentation says to read it with `worktable_dsl::Schema::parse`; that # instruction is only true if a plain `worktable` dependency can reach the diff --git a/README.md b/README.md index f7d15787..a894b657 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ for table in database.catalog().system_tables() { ```toml [dependencies] -worktable = { version = "^1.9.0-alpha1", features = ["s3-support"] } # S3 sync, optional +worktable = { version = "^1.9.0-beta1", features = ["s3-support"] } # S3 sync, optional ``` Persisted indexes default to WorkTablesIndex. Vanilla IndexSet can be selected explicitly with `using indexset` while retaining the existing disk/S3 representation. Congee and Arctic persistence is experimental and uses their native checkpoint/WAL adapters; declarations using either backend must state `persist: true` or `persist: false` explicitly. The full syntax and capability matrix are documented in [Per-index backends with `using`](docs/index-backend-dsl-proposal.md). diff --git a/codegen/Cargo.toml b/codegen/Cargo.toml index 2db7b380..6dcb13e2 100644 --- a/codegen/Cargo.toml +++ b/codegen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "worktable_codegen" -version = "1.9.0-alpha1" +version = "1.9.0-beta1" edition = "2024" license = "MIT" description = "Proc-macro companion crate for worktable: the worktable! macro and its derives." diff --git a/docs/paper-2-plan.md b/docs/paper-2-plan.md index 89f97794..36f97ca1 100644 --- a/docs/paper-2-plan.md +++ b/docs/paper-2-plan.md @@ -104,7 +104,7 @@ SIGMOD R4 (2026-10-17). DaMoN 2027 CFP not posted. ## Update 2026-09-11: release evidence and paper scope -The release checkout is WorkTable 1.9.0-alpha1. Its runtime and row-lock +The release checkout is WorkTable 1.9.0-beta1. Its runtime and row-lock implementation differs from the earlier paper: inspect `src/lock/map.rs`, `src/runtime/` and the selected dependency graph at the pinned evaluation commit before describing them. diff --git a/docs/why-worktables.typ b/docs/why-worktables.typ index dc9ae9bf..a10ce624 100644 --- a/docs/why-worktables.typ +++ b/docs/why-worktables.typ @@ -92,7 +92,7 @@ as well as memory; memory reclamation does not imply file truncation. Persistence is opt-in for the paged shape, with local disk and an S3-backed tier. Its completion boundaries are explicit. A successful mutation is accepted and queued; orderly `close().await` drains and joins the engine. -The current alpha does not promise transaction journaling or fsync durability. +The current beta does not promise transaction journaling or fsync durability. That makes it a fit for application-owned working state whose recovery contract is designed deliberately. @@ -105,7 +105,7 @@ leaving the consequential choices inspectable. Start with the #link("wt-user-guide.pdf")[WorkTable user guide]: declarations, every storage shape, queries, callsites, runtimes, persistence and lifecycle -examples. It describes 1.9.0-alpha1; use the reviewed checkout until publication. +examples. It describes 1.9.0-beta1; use the reviewed checkout until publication. #v(0.35cm) #text(size: 8pt, fill: rgb("#526873"))[ diff --git a/docs/wt-user-guide.typ b/docs/wt-user-guide.typ index 13ee00bb..69de1182 100644 --- a/docs/wt-user-guide.typ +++ b/docs/wt-user-guide.typ @@ -22,7 +22,7 @@ #text(size: 11pt, style: "italic")[Absolutely not a database.] #v(0.6em) #text(size: 9.5pt)[A user's guide to the `worktable!` macro, its queries, its indexes and its - persistence tier. Written against 1.9.0-alpha1.] + persistence tier. Written against 1.9.0-beta1.] ] #v(1.2em) @@ -39,10 +39,10 @@ See #link()[Persistence].] = Getting started ```sh -cargo add worktable@1.9.0-alpha1 +cargo add worktable@1.9.0-beta1 ``` -Until this alpha is published, depend on the reviewed checkout with +Until this beta is published, depend on the reviewed checkout with `worktable = { path = "../WorkTable" }`. A plain `cargo add worktable` selects the published release and may not include the APIs described here. @@ -911,7 +911,7 @@ more than halved randomized leaf lookup and was faster for four-client in-memory insertion. Select that tradeoff at the Cargo callsite: ```toml -worktable = { version = "^1.9.0-alpha1", default-features = false, +worktable = { version = "^1.9.0-beta1", default-features = false, features = ["std", "vanilla-index", "wti-predictable-search"] } ``` @@ -1160,7 +1160,7 @@ opened as ordinary WorkTable space files. `worktable_version!` and `migration_engine!` describe explicit versioned conversions; see `docs/migration.md` and the executable `tests/migration` fixtures for each required trait and transformation. They do not automatically infer data migration from a changed -schema. For this 1.9 alpha, a planned rebuild/data wipe is supported by the release plan; +schema. For this 1.9 beta, a planned rebuild/data wipe is supported by the release plan; do not infer cross-version file compatibility from a successful same-version reopen. `worktable::worktable_dsl` exposes parsing, checking and canonical schema emission for tools; the TypeScript emitter is tested against that Rust source of truth. From a8a2e32e83dc02ab1fecb59ecf2512801ba10738 Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 11:09:43 +0700 Subject: [PATCH 05/45] Clarify the beta performance boundary --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f21836..3ba5df21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,9 @@ Change Log them. This removes roughly 16 KiB page and directory temporaries from normal thread stacks. In AgentCode's eight-table empty-store startup, the generated load future shrank from 17,832 bytes to 2,392 bytes and the restart path no - longer overflows Tokio's default 2 MiB worker stack. Table read and mutation - paths are unchanged. + longer overflows Tokio's default 2 MiB worker stack. Existing-page reads and + mutations are unchanged; fresh construction and page-growing inserts use the + new final-allocation initializer. ## [1.9.0-alpha1] From c770e676d1f7c8f98633ef379d9a69fa03f42b9c Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 11:22:49 +0700 Subject: [PATCH 06/45] Contain persistence worker panics during drop --- src/persistence/task.rs | 69 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/src/persistence/task.rs b/src/persistence/task.rs index cc11f859..d124f13a 100644 --- a/src/persistence/task.rs +++ b/src/persistence/task.rs @@ -725,12 +725,16 @@ mod lifecycle_tests { failure: TestFailure, } - #[derive(Clone, Copy)] + #[derive(Clone)] enum TestFailure { None, Engine, IndexCorruption, Panic, + GatedPanic { + entered: Arc, + release: Arc, + }, } impl PersistenceEngine<(), u64, TestEvents, TestIndex> for TestEngine { @@ -753,7 +757,7 @@ mod lifecycle_tests { &mut self, _batch_op: BatchOperation<(), u64, TestEvents, TestIndex>, ) -> eyre::Result<()> { - match self.failure { + match &self.failure { TestFailure::None => {} TestFailure::Engine => return Err(eyre::eyre!("injected batch failure")), TestFailure::IndexCorruption => { @@ -762,6 +766,11 @@ mod lifecycle_tests { ); } TestFailure::Panic => panic!("injected persistence worker panic"), + TestFailure::GatedPanic { entered, release } => { + entered.wait(); + release.wait(); + panic!("injected persistence worker panic"); + } } self.batches.fetch_add(1, Ordering::Relaxed); self.events.lock().push("batch"); @@ -1258,6 +1267,55 @@ mod lifecycle_tests { assert!(Arc::ptr_eq(&wait_error, &intake_error)); } + /// A worker panic must not escape `Drop`. + /// + /// In particular, a destructor that resumes the worker's panic can abort + /// the process when the table is itself being dropped during unwinding. + /// Hold the worker inside the engine until `Drop` has entered `Closing`, + /// proving that the drop path is awaiting this still-busy worker when the + /// injected panic lands. + #[test] + fn busy_drop_contains_a_worker_panic() { + let entered = Arc::new(std::sync::Barrier::new(2)); + let release = Arc::new(std::sync::Barrier::new(2)); + let task = PersistenceTask::run_engine(TestEngine { + batches: Arc::new(AtomicUsize::new(0)), + events: Arc::new(ParkingMutex::new(Vec::new())), + config: TestConfig, + failure: TestFailure::GatedPanic { + entered: entered.clone(), + release: release.clone(), + }, + }); + + task.apply_operation(insert_operation(1)).unwrap(); + entered.wait(); + + let monitor = task.monitor(); + let dropping = + std::thread::spawn(move || std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(task)))); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while !matches!(monitor.lifecycle.state(), PersistenceState::Closing) { + assert!( + std::time::Instant::now() < deadline, + "Drop did not begin joining the busy persistence worker" + ); + std::thread::yield_now(); + } + release.wait(); + + let drop_result = dropping.join().expect("drop thread itself must remain joinable"); + assert!(drop_result.is_ok(), "a persistence worker panic escaped Drop"); + match monitor.lifecycle.state() { + PersistenceState::Failed(error) => assert_eq!( + error.to_string(), + "persistence engine failed: persistence worker panicked" + ), + state => panic!("worker panic did not become terminal: {state:?}"), + } + } + /// A worker that stops publishes a terminal state instead of leaving its /// waiters parked, and refuses operations afterwards. /// @@ -1773,8 +1831,11 @@ impl Drop } } else if let Some(handle) = self.engine_task_handle.take() { // The engine owns a dedicated one-worker runtime. Joining here - // cannot occupy the worker that must make this task progress. - let _ = nagoya::block_on(handle); + // cannot occupy the worker that must make this task progress. A + // join rethrows a worker panic, which must not escape a destructor + // (and would abort the process if this drop is already unwinding). + // `WorkerCompletionGuard` records that panic in the lifecycle. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| nagoya::block_on(handle))); } } } From c631e0bf5b369aaa6891fc7ceff53304a0264fa7 Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 11:24:22 +0700 Subject: [PATCH 07/45] Correct the page directory capacity note --- src/in_memory/pages.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/in_memory/pages.rs b/src/in_memory/pages.rs index db9564a1..afa14c5d 100644 --- a/src/in_memory/pages.rs +++ b/src/in_memory/pages.rs @@ -212,7 +212,7 @@ impl PageDirectoryChunk { } } -/// Non-owning, stable page pointers for the first 4,096 pages (64 MiB at the +/// Non-owning, stable page pointers for the first 65,536 pages (1 GiB at the /// default page size). `DataPages::pages` owns every allocation; this directory /// exists solely to avoid shared ArcSwap snapshot accounting on point access. #[derive(Debug)] From 0a46d35dd51b60c1a20edb610af34a7911927c4b Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 12:04:15 +0700 Subject: [PATCH 08/45] Reduce all-feature test linker load --- .github/workflows/rust.yml | 7 +++++++ scripts/ci-local.sh | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 60a66f05..231607d0 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -38,10 +38,15 @@ jobs: include: - name: default args: "" + test_debug: 2 - name: versioned-publication args: "--features versioned-row-publication" + test_debug: 2 - name: all-features args: "--all-features" + # Full DWARF makes Rust 1.98's bundled rust-lld crash while linking + # the large integration-test binary on a two-core runner. + test_debug: 0 steps: - uses: actions/checkout@v4 @@ -53,6 +58,8 @@ jobs: run: cargo build --workspace --all-targets ${{ matrix.args }} --verbose - name: Run tests run: cargo test --workspace --all-targets ${{ matrix.args }} --verbose + env: + CARGO_PROFILE_TEST_DEBUG: ${{ matrix.test_debug }} clippy_check: diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 2976e045..c51a0ff2 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -55,7 +55,7 @@ run cargo test --workspace --all-targets --features versioned-row-publication echo "=== build and test (all-features) ===" run cargo build --workspace --all-targets --all-features -run cargo test --workspace --all-targets --all-features +run env CARGO_PROFILE_TEST_DEBUG=0 cargo test --workspace --all-targets --all-features echo "=== cell-lock concurrency models ===" run env "RUSTFLAGS=--cfg wt_loom" CARGO_TARGET_DIR=target/cell-lock-loom cargo test --release --lib cell_lock_models From dc35d62bd67125fbeb2d2b60e9ae831f2ba3c8be Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 12:46:38 +0700 Subject: [PATCH 09/45] Keep opaque archived updates within row storage --- codegen/src/common/name_generator.rs | 65 +++++- .../generators/in_memory/queries/update.rs | 160 ++++++++++++--- .../src/generators/persist/queries/update.rs | 192 +++++++++++++++--- codegen/src/worktable/mod.rs | 107 ++++++++++ src/in_memory/pages.rs | 13 +- tests/persistence/sync/mod.rs | 1 + .../persistence/sync/opaque_unsized_update.rs | 154 ++++++++++++++ tests/worktable/update_in_place_unsized.rs | 119 +++++++++++ 8 files changed, 756 insertions(+), 55 deletions(-) create mode 100644 tests/persistence/sync/opaque_unsized_update.rs diff --git a/codegen/src/common/name_generator.rs b/codegen/src/common/name_generator.rs index b1865a12..caeca0db 100644 --- a/codegen/src/common/name_generator.rs +++ b/codegen/src/common/name_generator.rs @@ -1,6 +1,7 @@ use convert_case::{Case, Casing}; -use proc_macro2::{Ident, Literal}; +use proc_macro2::{Ident, Literal, TokenStream}; use quote::__private::Span; +use syn::{GenericArgument, PathArguments, Type}; pub fn is_unsized(ty_: &str) -> bool { matches!(ty_, "String") @@ -14,6 +15,51 @@ pub fn is_float(ty_: &str) -> bool { matches!(ty_, "f64" | "f32") } +/// Whether moving only this field's archived bytes can leave a relative +/// pointer referring to the temporary query buffer. +/// +/// The macro cannot inspect a user type's `Archive::Archived` layout. Keep the +/// field-swap fast path for Rust's known scalar shapes and make every opaque +/// user type take the full-row serialize/overwrite path. `String` is handled +/// by the existing variable-size path; wrapping it in `Option` still requires +/// the conservative full-row path because the option's archived payload can +/// contain the same relative pointer. +pub fn archived_field_requires_rebuild(ty: &TokenStream) -> bool { + fn type_requires_rebuild(ty: &Type) -> bool { + let Type::Path(type_path) = ty else { + return true; + }; + let Some(segment) = type_path.path.segments.last() else { + return true; + }; + + match segment.ident.to_string().as_str() { + "String" => false, + "bool" | "char" | "u8" | "u16" | "u32" | "u64" | "u128" | "usize" | "i8" | "i16" | "i32" | "i64" + | "i128" | "isize" | "f32" | "f64" | "Uuid" => false, + "Option" => match &segment.arguments { + PathArguments::AngleBracketed(arguments) => arguments + .args + .iter() + .find_map(|argument| match argument { + GenericArgument::Type(inner) => { + let is_string = matches!(inner, Type::Path(path) if path.path.segments.last().is_some_and(|segment| segment.ident == "String")); + Some(is_string || type_requires_rebuild(inner)) + } + _ => None, + }) + .unwrap_or(true), + _ => true, + }, + _ => true, + } + } + + syn::parse2::(ty.clone()) + .map(|ty| type_requires_rebuild(&ty)) + .unwrap_or(true) +} + pub struct WorktableNameGenerator { pub(crate) name: String, } @@ -171,3 +217,20 @@ impl WorktableNameGenerator { ) } } + +#[cfg(test)] +mod tests { + use super::archived_field_requires_rebuild; + use quote::quote; + + #[test] + fn archived_field_classification_is_conservative_for_opaque_types() { + assert!(!archived_field_requires_rebuild("e!(u64))); + assert!(!archived_field_requires_rebuild("e!(Option))); + assert!(!archived_field_requires_rebuild("e!(String))); + assert!(!archived_field_requires_rebuild("e!(Uuid))); + assert!(!archived_field_requires_rebuild("e!(Option))); + assert!(archived_field_requires_rebuild("e!(Option))); + assert!(archived_field_requires_rebuild("e!(EncryptedSecret))); + } +} diff --git a/codegen/src/generators/in_memory/queries/update.rs b/codegen/src/generators/in_memory/queries/update.rs index 3001fdd3..5c74cada 100644 --- a/codegen/src/generators/in_memory/queries/update.rs +++ b/codegen/src/generators/in_memory/queries/update.rs @@ -1,5 +1,5 @@ use crate::common::model::{Index, Operation}; -use crate::common::name_generator::{WorktableNameGenerator, is_float}; +use crate::common::name_generator::{WorktableNameGenerator, archived_field_requires_rebuild, is_float}; use crate::generators::in_memory::InMemoryGenerator; use convert_case::{Case, Casing}; use indexmap::IndexMap; @@ -7,6 +7,11 @@ use proc_macro2::Literal; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; +struct UpdateStorage<'a> { + string_fields: Option>, + requires_rebuild: bool, +} + impl InMemoryGenerator { pub fn gen_query_update_impl(&mut self) -> syn::Result { let custom_updates = if let Some(q) = &self.queries { @@ -66,6 +71,7 @@ impl InMemoryGenerator { let persist_op = self.gen_persist_op(); let full_row_lock = self.gen_full_lock_for_update(); let columnar_dirty = crate::generators::columnar::table_mark_dirty(&self.columns); + let requires_rebuild = self.columns.columns_map.values().any(archived_field_requires_rebuild); // A full-row `update(row)` replaces EVERY column, so it inherently // rewrites every secondary index. The in-place fast path only applies // when no updated field is indexed (it emits no index diff), so a @@ -76,8 +82,9 @@ impl InMemoryGenerator { // gen_size_check; gen_non_unique_update updates a non-unique-indexed // column and therefore always reinserts, correctly.) let const_name = name_generator.get_page_inner_size_const_ident(); - let full_row_in_place_eligible = !self.columns.is_sized && self.columns.indexes.is_empty(); - let update_body = if self.columns.is_sized { + let full_row_in_place_eligible = + (!self.columns.is_sized || requires_rebuild) && self.columns.indexes.is_empty(); + let update_body = if self.columns.is_sized && !requires_rebuild { quote! { let mut bytes = worktable::prelude::rkyv::to_bytes::(&row) .map_err(|_| WorkTableError::SerializeError)?; @@ -208,18 +215,19 @@ impl InMemoryGenerator { .collect::>(); if fields.is_empty() { None } else { Some(fields) } }; + let requires_rebuild = op + .columns + .iter() + .any(|column| archived_field_requires_rebuild(self.columns.columns_map.get(column).unwrap())); + let storage = UpdateStorage { + string_fields: unsized_columns, + requires_rebuild, + }; let idents = &op.columns; if let Some(index) = index { if index.is_unique { - self.gen_unique_update( - snake_case_name, - name, - index, - idents, - indexes_columns.as_ref(), - unsized_columns, - ) + self.gen_unique_update(snake_case_name, name, index, idents, indexes_columns.as_ref(), storage) } else { self.gen_non_unique_update( snake_case_name, @@ -227,12 +235,12 @@ impl InMemoryGenerator { index, idents, indexes_columns.as_ref(), - unsized_columns, + storage, ) } } else if self.columns.primary_keys.len() == 1 { if *self.columns.primary_keys.first().unwrap() == op.by { - self.gen_pk_update(snake_case_name, name, idents, indexes_columns.as_ref(), unsized_columns) + self.gen_pk_update(snake_case_name, name, idents, indexes_columns.as_ref(), storage) } else { todo!() } @@ -303,6 +311,7 @@ impl InMemoryGenerator { unsized_fields: Option>, idents: &[Ident], idx_idents: Option<&Vec>, + requires_rebuild: bool, ) -> TokenStream { // The in-place fast path re-serializes the row directly into its slot and // republishes it, bypassing the generated secondary-index diff. That is @@ -311,7 +320,62 @@ impl InMemoryGenerator { // path (and its unique-constraint check). Fall back to always-reinsert in // that case. let touches_index = idx_idents.map(|v| !v.is_empty()).unwrap_or(false); - if let (Some(f), false) = (unsized_fields, touches_index) { + if requires_rebuild { + let row_updates = idents + .iter() + .map(|i| quote! { row_new.#i = row.#i.clone(); }) + .collect::>(); + let full_row_lock = self.gen_full_lock_for_update(); + let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); + let const_name = name_generator.get_page_inner_size_const_ident(); + + if touches_index { + quote! { + { + drop(_guard); + let pending_lock = { #full_row_lock }; + let _guard = pending_lock.into_guard_with_mutation(); + + let row_old = self.0.select(pk.clone()).ok_or(WorkTableError::NotFound)?; + let mut row_new = row_old.clone(); + #(#row_updates)* + self.reinsert(row_old, row_new).await?; + return core::result::Result::Ok(()); + } + } + } else { + quote! { + { + // An opaque archived field may contain relative pointers. + // Rebuild the complete row under its full lock so every + // pointer is based in the destination slot, then retain the + // current link when the serialized length still fits. + drop(_guard); + let pending_lock = { #full_row_lock }; + let _guard = pending_lock.into_guard_with_mutation(); + + let row_old = self.0.select(pk.clone()).ok_or(WorkTableError::NotFound)?; + let mut row_new = row_old.clone(); + #(#row_updates)* + let current_link: Link = self.0 + .primary_index + .pk_map + .get_value(&pk) + .map(Into::into) + .ok_or(WorkTableError::NotFound)?; + let in_place_ok = unsafe { + self.0.data.update_in_place::<{ #const_name }>(row_new.clone(), current_link).is_ok() + }; + if in_place_ok { + return core::result::Result::Ok(()); + } + + self.reinsert(row_old, row_new).await?; + return core::result::Result::Ok(()); + } + } + } + } else if let (Some(f), false) = (unsized_fields, touches_index) { let fields_check: Vec<_> = f .iter() .map(|f| { @@ -635,8 +699,12 @@ impl InMemoryGenerator { name: &Ident, idents: &[Ident], idx_idents: Option<&Vec>, - unsized_fields: Option>, + storage: UpdateStorage<'_>, ) -> TokenStream { + let UpdateStorage { + string_fields: unsized_fields, + requires_rebuild, + } = storage; let pk_ident = &self.pk.as_ref().unwrap().ident; let method_ident = Ident::new(format!("update_{snake_case_name}").as_str(), Span::mixed_site()); let query_ident = Ident::new(format!("{name}Query").as_str(), Span::mixed_site()); @@ -651,8 +719,9 @@ impl InMemoryGenerator { }) .collect::>(); - let archived_swap_is_safe = self.columns.is_sized || (unsized_fields.is_none() && idx_idents.is_none()); - let size_check = self.gen_size_check(unsized_fields, idents, idx_idents); + let archived_swap_is_safe = + !requires_rebuild && (self.columns.is_sized || (unsized_fields.is_none() && idx_idents.is_none())); + let size_check = self.gen_size_check(unsized_fields, idents, idx_idents, requires_rebuild); let diff_process_insert = self.gen_process_diffs_insert_on_index(idents, idx_idents); let diff_process_remove = self.gen_process_diffs_remove_on_index(idx_idents); let persist_call = self.gen_persist_call(); @@ -711,8 +780,12 @@ impl InMemoryGenerator { index: &Index, idents: &[Ident], idx_idents: Option<&Vec>, - unsized_fields: Option>, + storage: UpdateStorage<'_>, ) -> TokenStream { + let UpdateStorage { + string_fields: unsized_fields, + requires_rebuild, + } = storage; let by_field = &index.field; let index = &index.name; let method_ident = Ident::new(format!("update_{snake_case_name}").as_str(), Span::mixed_site()); @@ -733,8 +806,44 @@ impl InMemoryGenerator { // every loop iteration itself (in-place or reinsert, then continue), // so the archived-swap tail is only emitted otherwise; emitting both // would leave unreachable code after the size_check block. - let has_unsized = unsized_fields.is_some(); - let size_check = if let Some(f) = unsized_fields { + let has_unsized = unsized_fields.is_some() || requires_rebuild; + let size_check = if requires_rebuild { + let row_updates = idents + .iter() + .map(|i| quote! { row_new.#i = row.#i.clone(); }) + .collect::>(); + let touches_index = idx_idents.map(|v| !v.is_empty()).unwrap_or(false); + let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); + let const_name = name_generator.get_page_inner_size_const_ident(); + if touches_index { + quote! { + { + let row_old = self.0.select(pk.clone()).ok_or(WorkTableError::NotFound)?; + let mut row_new = row_old.clone(); + #(#row_updates)* + self.reinsert(row_old, row_new).await?; + guards.remove(&pk); + continue; + } + } + } else { + quote! { + { + let row_old = self.0.select(pk.clone()).ok_or(WorkTableError::NotFound)?; + let mut row_new = row_old.clone(); + #(#row_updates)* + let in_place_ok = unsafe { + self.0.data.update_in_place::<{ #const_name }>(row_new.clone(), link).is_ok() + }; + if !in_place_ok { + self.reinsert(row_old, row_new).await?; + } + guards.remove(&pk); + continue; + } + } + } + } else if let Some(f) = unsized_fields { let fields_check: Vec<_> = f .iter() .map(|f| { @@ -926,8 +1035,12 @@ impl InMemoryGenerator { index: &Index, idents: &[Ident], idx_idents: Option<&Vec>, - unsized_fields: Option>, + storage: UpdateStorage<'_>, ) -> TokenStream { + let UpdateStorage { + string_fields: unsized_fields, + requires_rebuild, + } = storage; let by_field = &index.field; let by_is_float = is_float( self.columns @@ -957,8 +1070,9 @@ impl InMemoryGenerator { // nothing and the archived in-place swap below is both safe and the // only body this fn gets - gating on is_sized alone emitted a fn with // no tail expression for exactly that schema. - let archived_swap_is_safe = self.columns.is_sized || (unsized_fields.is_none() && idx_idents.is_none()); - let size_check = self.gen_size_check(unsized_fields, idents, idx_idents); + let archived_swap_is_safe = + !requires_rebuild && (self.columns.is_sized || (unsized_fields.is_none() && idx_idents.is_none())); + let size_check = self.gen_size_check(unsized_fields, idents, idx_idents, requires_rebuild); let diff_process_insert = self.gen_process_diffs_insert_on_index(idents, idx_idents); let diff_process_remove = self.gen_process_diffs_remove_on_index(idx_idents); let persist_call = self.gen_persist_call(); diff --git a/codegen/src/generators/persist/queries/update.rs b/codegen/src/generators/persist/queries/update.rs index ff379424..b5fb03df 100644 --- a/codegen/src/generators/persist/queries/update.rs +++ b/codegen/src/generators/persist/queries/update.rs @@ -1,5 +1,5 @@ use crate::common::model::{Index, Operation}; -use crate::common::name_generator::{WorktableNameGenerator, is_float}; +use crate::common::name_generator::{WorktableNameGenerator, archived_field_requires_rebuild, is_float}; use crate::generators::persist::PersistGenerator; use convert_case::{Case, Casing}; use indexmap::IndexMap; @@ -7,6 +7,11 @@ use proc_macro2::Literal; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; +struct UpdateStorage<'a> { + string_fields: Option>, + requires_rebuild: bool, +} + impl PersistGenerator { pub fn gen_query_update_impl(&mut self) -> syn::Result { let custom_updates = if let Some(q) = &self.queries { @@ -66,13 +71,15 @@ impl PersistGenerator { let persist_op = self.gen_persist_op(); let full_row_lock = self.gen_full_lock_for_update(); let columnar_dirty = crate::generators::columnar::table_mark_dirty(&self.columns); + let requires_rebuild = self.columns.columns_map.values().any(archived_field_requires_rebuild); let const_name = name_generator.get_page_inner_size_const_ident(); let secondary_events_ident = name_generator.get_space_secondary_index_events_ident(); // A full-row update rewrites every column, hence every secondary // index; only a table with NO secondary indexes may take the // same-size in-place path (mirrors the in-memory generator). - let full_row_in_place_eligible = !self.columns.is_sized && self.columns.indexes.is_empty(); - let size_check = if self.columns.is_sized { + let full_row_in_place_eligible = + (!self.columns.is_sized || requires_rebuild) && self.columns.indexes.is_empty(); + let size_check = if self.columns.is_sized && !requires_rebuild { quote! {} } else { let in_place_attempt = if full_row_in_place_eligible { @@ -133,7 +140,7 @@ impl PersistGenerator { // from) every path, so the archived-swap tail is emitted only for // sized rows; emitting both would leave unreachable code behind the // diverging size_check block. - let update_body = if self.columns.is_sized { + let update_body = if self.columns.is_sized && !requires_rebuild { quote! { let mut bytes = worktable::prelude::rkyv::to_bytes::(&row).map_err(|_| WorkTableError::SerializeError)?; let mut archived_row = unsafe { worktable::prelude::rkyv::access_unchecked_mut::<<#row_ident as worktable::prelude::rkyv::Archive>::Archived>(&mut bytes[..]).unseal_unchecked() }; @@ -217,18 +224,19 @@ impl PersistGenerator { .collect::>(); if fields.is_empty() { None } else { Some(fields) } }; + let requires_rebuild = op + .columns + .iter() + .any(|column| archived_field_requires_rebuild(self.columns.columns_map.get(column).unwrap())); + let storage = UpdateStorage { + string_fields: unsized_columns, + requires_rebuild, + }; let idents = &op.columns; if let Some(index) = index { if index.is_unique { - self.gen_unique_update( - snake_case_name, - name, - index, - idents, - indexes_columns.as_ref(), - unsized_columns, - ) + self.gen_unique_update(snake_case_name, name, index, idents, indexes_columns.as_ref(), storage) } else { self.gen_non_unique_update( snake_case_name, @@ -236,12 +244,12 @@ impl PersistGenerator { index, idents, indexes_columns.as_ref(), - unsized_columns, + storage, ) } } else if self.columns.primary_keys.len() == 1 { if *self.columns.primary_keys.first().unwrap() == op.by { - self.gen_pk_update(snake_case_name, name, idents, indexes_columns.as_ref(), unsized_columns) + self.gen_pk_update(snake_case_name, name, idents, indexes_columns.as_ref(), storage) } else { todo!() } @@ -366,6 +374,7 @@ impl PersistGenerator { unsized_fields: Option>, idents: &[Ident], idx_idents: Option<&Vec>, + requires_rebuild: bool, ) -> TokenStream { // Port of the in-memory generator's gen_size_check: the in-place fast // path bypasses the index diff machinery, so it only applies when no @@ -375,7 +384,75 @@ impl PersistGenerator { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let secondary_events_ident = name_generator.get_space_secondary_index_events_ident(); let primary_key_ident = name_generator.get_primary_key_type_ident(); - if let (Some(f), false) = (unsized_fields, touches_index) { + if requires_rebuild { + let row_updates = idents + .iter() + .map(|i| quote! { row_new.#i = row.#i.clone(); }) + .collect::>(); + let full_row_lock = self.gen_full_lock_for_update(); + let const_name = name_generator.get_page_inner_size_const_ident(); + + if touches_index { + quote! { + { + drop(_guard); + let pending_lock = { #full_row_lock }; + let _guard = pending_lock.into_guard_with_mutation(); + + let row_old = self.0.select(pk.clone()).ok_or(WorkTableError::NotFound)?; + let mut row_new = row_old.clone(); + #(#row_updates)* + self.reinsert(row_old, row_new).await?; + return core::result::Result::Ok(()); + } + } + } else { + quote! { + { + // An opaque archived field may contain relative pointers. + // Rebuild the complete row under its full lock so every + // pointer is based in the destination slot, then retain the + // current link when the serialized length still fits. + drop(_guard); + let pending_lock = { #full_row_lock }; + let _guard = pending_lock.into_guard_with_mutation(); + + let row_old = self.0.select(pk.clone()).ok_or(WorkTableError::NotFound)?; + let mut row_new = row_old.clone(); + #(#row_updates)* + let current_link: Link = self.0 + .primary_index + .pk_map + .get_value(&pk) + .map(Into::into) + .ok_or(WorkTableError::NotFound)?; + let in_place_ok = unsafe { + self.0.data.update_in_place::<{ #const_name }>(row_new.clone(), current_link).is_ok() + }; + if in_place_ok { + let secondary_keys_events: #secondary_events_ident = core::default::Default::default(); + let op: Operation< + <<#primary_key_ident as TablePrimaryKey>::Generator as PrimaryKeyGeneratorState>::State, + #primary_key_ident, + #secondary_events_ident + > = Operation::Update(UpdateOperation { + retired_link: None, + id: OperationId::Single(worktable::prelude::uuid::Uuid::now_v7()), + primary_key_events: vec![], + secondary_keys_events, + bytes: self.0.data.select_raw(current_link)?, + link: current_link, + }); + self.1.apply_operation(op)?; + return core::result::Result::Ok(()); + } + + self.reinsert(row_old, row_new).await?; + return core::result::Result::Ok(()); + } + } + } + } else if let (Some(f), false) = (unsized_fields, touches_index) { let fields_check: Vec<_> = f .iter() .map(|f| { @@ -682,8 +759,12 @@ impl PersistGenerator { name: &Ident, idents: &[Ident], idx_idents: Option<&Vec>, - unsized_fields: Option>, + storage: UpdateStorage<'_>, ) -> TokenStream { + let UpdateStorage { + string_fields: unsized_fields, + requires_rebuild, + } = storage; let pk_ident = &self.pk.as_ref().unwrap().ident; let method_ident = Ident::new(format!("update_{snake_case_name}").as_str(), Span::mixed_site()); let query_ident = Ident::new(format!("{name}Query").as_str(), Span::mixed_site()); @@ -701,8 +782,9 @@ impl PersistGenerator { // Same gate as the in-memory generator: when the size_check body // handles (and returns from) every path, emitting the archived-swap // tail too would leave unreachable code. - let archived_swap_is_safe = self.columns.is_sized || (unsized_fields.is_none() && idx_idents.is_none()); - let size_check = self.gen_size_check(unsized_fields, idents, idx_idents); + let archived_swap_is_safe = + !requires_rebuild && (self.columns.is_sized || (unsized_fields.is_none() && idx_idents.is_none())); + let size_check = self.gen_size_check(unsized_fields, idents, idx_idents, requires_rebuild); let diff_process_insert = self.gen_process_diffs_insert_on_index(idents, idx_idents); let diff_process_remove = self.gen_process_diffs_remove_on_index(idx_idents); let persist_call = self.gen_persist_call(); @@ -760,8 +842,12 @@ impl PersistGenerator { index: &Index, idents: &[Ident], idx_idents: Option<&Vec>, - unsized_fields: Option>, + storage: UpdateStorage<'_>, ) -> TokenStream { + let UpdateStorage { + string_fields: unsized_fields, + requires_rebuild, + } = storage; let by_field = &index.field; let index = &index.name; let method_ident = Ident::new(format!("update_{snake_case_name}").as_str(), Span::mixed_site()); @@ -782,8 +868,61 @@ impl PersistGenerator { // every loop iteration itself (in-place or reinsert, then continue), // so the archived-swap tail is only emitted otherwise; emitting both // would leave unreachable code after the size_check block. - let has_unsized = unsized_fields.is_some(); - let size_check = if let Some(f) = unsized_fields { + let has_unsized = unsized_fields.is_some() || requires_rebuild; + let size_check = if requires_rebuild { + let row_updates = idents + .iter() + .map(|i| quote! { row_new.#i = row.#i.clone(); }) + .collect::>(); + let touches_index = idx_idents.map(|v| !v.is_empty()).unwrap_or(false); + let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); + let const_name = name_generator.get_page_inner_size_const_ident(); + let secondary_events_ident = name_generator.get_space_secondary_index_events_ident(); + let primary_key_ident = name_generator.get_primary_key_type_ident(); + if touches_index { + quote! { + { + let row_old = self.0.select(pk.clone()).ok_or(WorkTableError::NotFound)?; + let mut row_new = row_old.clone(); + #(#row_updates)* + self.reinsert(row_old, row_new).await?; + guards.remove(&pk); + continue; + } + } + } else { + quote! { + { + let row_old = self.0.select(pk.clone()).ok_or(WorkTableError::NotFound)?; + let mut row_new = row_old.clone(); + #(#row_updates)* + let in_place_ok = unsafe { + self.0.data.update_in_place::<{ #const_name }>(row_new.clone(), link).is_ok() + }; + if in_place_ok { + let secondary_keys_events: #secondary_events_ident = core::default::Default::default(); + let op: Operation< + <<#primary_key_ident as TablePrimaryKey>::Generator as PrimaryKeyGeneratorState>::State, + #primary_key_ident, + #secondary_events_ident + > = Operation::Update(UpdateOperation { + retired_link: None, + id: OperationId::Single(worktable::prelude::uuid::Uuid::now_v7()), + primary_key_events: vec![], + secondary_keys_events, + bytes: self.0.data.select_raw(link)?, + link, + }); + self.1.apply_operation(op)?; + } else { + self.reinsert(row_old, row_new).await?; + } + guards.remove(&pk); + continue; + } + } + } + } else if let Some(f) = unsized_fields { let fields_check: Vec<_> = f .iter() .map(|f| { @@ -994,8 +1133,12 @@ impl PersistGenerator { index: &Index, idents: &[Ident], idx_idents: Option<&Vec>, - unsized_fields: Option>, + storage: UpdateStorage<'_>, ) -> TokenStream { + let UpdateStorage { + string_fields: unsized_fields, + requires_rebuild, + } = storage; let by_field = &index.field; let by_is_float = is_float( self.columns @@ -1023,8 +1166,9 @@ impl PersistGenerator { // Same gate as the in-memory generator: when the size_check body // handles (and returns from) every path, emitting the archived-swap // tail too would leave unreachable code. - let archived_swap_is_safe = self.columns.is_sized || (unsized_fields.is_none() && idx_idents.is_none()); - let size_check = self.gen_size_check(unsized_fields, idents, idx_idents); + let archived_swap_is_safe = + !requires_rebuild && (self.columns.is_sized || (unsized_fields.is_none() && idx_idents.is_none())); + let size_check = self.gen_size_check(unsized_fields, idents, idx_idents, requires_rebuild); let diff_process_insert = self.gen_process_diffs_insert_on_index(idents, idx_idents); let diff_process_remove = self.gen_process_diffs_remove_on_index(idx_idents); let persist_call = self.gen_persist_call(); diff --git a/codegen/src/worktable/mod.rs b/codegen/src/worktable/mod.rs index 06472e03..e5cfb3dc 100644 --- a/codegen/src/worktable/mod.rs +++ b/codegen/src/worktable/mod.rs @@ -679,6 +679,113 @@ mod tests { ); } + #[test] + fn opaque_archived_update_rebuilds_for_memory_and_persistence() { + for persist in [false, true] { + let output = expand(quote! { + name: OpaqueArchivedUpdate, + persist: #persist, + columns: { + id: u64 primary_key, + secret: EncryptedSecret, + }, + queries: { + update: { + Secret(secret) by id, + } + } + }) + .unwrap() + .to_string(); + + let update = output + .split("pub async fn update_secret") + .nth(1) + .expect("generated opaque-field update"); + assert!( + update.contains("data . update_in_place"), + "opaque fields must be serialized as part of the complete row" + ); + assert!( + update.contains("self . reinsert"), + "a changed serialized length must fall back to reinsert" + ); + assert!( + !update.contains("swap (& mut archived . inner . secret"), + "moving an opaque archived field can retain pointers into the temporary query buffer" + ); + + let full_update = output + .split("async fn update_with_guard") + .nth(1) + .expect("generated full-row update"); + assert!(full_update.contains("data . update_in_place")); + assert!(full_update.contains("self . reinsert")); + assert!(!full_update.contains("swap (& mut archived . inner . secret")); + } + } + + #[test] + fn optional_string_update_rebuilds_for_memory_and_persistence() { + for persist in [false, true] { + let output = expand(quote! { + name: OptionalStringUpdate, + persist: #persist, + columns: { + id: u64 primary_key, + display_name: String optional, + }, + queries: { + update: { + DisplayName(display_name) by id, + } + } + }) + .unwrap() + .to_string(); + + let update = output + .split("pub async fn update_display_name") + .nth(1) + .expect("generated optional-string update"); + assert!(update.contains("data . update_in_place")); + assert!(update.contains("self . reinsert")); + assert!(!update.contains("swap (& mut archived . inner . display_name")); + } + } + + #[test] + fn indexed_opaque_update_uses_index_maintaining_reinsert() { + for persist in [false, true] { + let output = expand(quote! { + name: IndexedOpaqueUpdate, + persist: #persist, + columns: { + id: u64 primary_key, + secret: EncryptedSecret, + }, + indexes: { + secret_idx: secret unique using worktables_index, + }, + queries: { + update: { + Secret(secret) by id, + } + } + }) + .unwrap() + .to_string(); + + let update = output + .split("pub async fn update_secret") + .nth(1) + .expect("generated indexed opaque-field update"); + assert!(update.contains("self . reinsert")); + assert!(!update.contains("data . update_in_place")); + assert!(!update.contains("swap (& mut archived . inner . secret")); + } + } + #[cfg(feature = "logical-index-persistence")] #[test] fn logical_persistence_wraps_explicit_wti_backends() { diff --git a/src/in_memory/pages.rs b/src/in_memory/pages.rs index afa14c5d..0fcf56fa 100644 --- a/src/in_memory/pages.rs +++ b/src/in_memory/pages.rs @@ -1134,13 +1134,12 @@ where /// the current slot (so it fits exactly). /// /// # Persistence - /// This path emits **no** persistence CDC. It is only sound for tables that - /// are not persisted (or on a persistence sink that reconstructs state from - /// the page image on reload). Do NOT route a persisted-table update through - /// this method: the row would change in memory and republish but no change - /// event would reach disk, silently losing durability until reload. The - /// generated persisted update path deliberately keeps the reinsert path for - /// this reason. + /// This storage primitive emits **no** persistence CDC. A persisted caller + /// must read the replacement bytes from this slot and enqueue its durable + /// update before returning success. Omitting that operation changes memory + /// without changing disk, so a cold reload restores the old row. Generated + /// persisted updates follow that contract; memory-only callers need no + /// additional operation. /// /// Serialization and the exact-length check finish before any page byte is /// changed. The exact cell guard excludes readers of this cell during the diff --git a/tests/persistence/sync/mod.rs b/tests/persistence/sync/mod.rs index 38d8ecc5..deeb9f95 100644 --- a/tests/persistence/sync/mod.rs +++ b/tests/persistence/sync/mod.rs @@ -8,6 +8,7 @@ use worktable::worktable; mod failure; mod failure_multi_index; mod many_strings; +mod opaque_unsized_update; mod option; mod repeated_string_upsert; mod string_primary_index; diff --git a/tests/persistence/sync/opaque_unsized_update.rs b/tests/persistence/sync/opaque_unsized_update.rs new file mode 100644 index 00000000..6e959085 --- /dev/null +++ b/tests/persistence/sync/opaque_unsized_update.rs @@ -0,0 +1,154 @@ +use crate::remove_dir_if_exists; + +use worktable::prelude::*; +use worktable::worktable; + +#[derive( + worktable::prelude::rkyv::Archive, + Clone, + Debug, + worktable::prelude::rkyv::Deserialize, + MemStat, + PartialEq, + PartialOrd, + worktable::prelude::rkyv::Serialize, +)] +#[rkyv(crate = worktable::prelude::rkyv)] +#[rkyv(derive(Debug, PartialEq, PartialOrd))] +struct WrappedSecret(String); + +worktable!( + name: OpaqueUnsizedUpdate, + version: 1, + persist: true, + columns: { + id: u64 primary_key, + secret: WrappedSecret, + untouched: u64, + }, + queries: { + update: { + SecretById(secret) by id, + } + } +); + +#[test] +fn targeted_update_of_string_wrapper_survives_read_and_reload() { + const DIR: &str = "tests/data/sync/opaque_unsized_update"; + let config = DiskConfig::new_with_table_name( + DIR, + OpaqueUnsizedUpdateWorkTable::name_snake_case(), + OpaqueUnsizedUpdateWorkTable::version(), + ); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_io() + .enable_time() + .build() + .unwrap(); + + runtime.block_on(async { + remove_dir_if_exists(DIR.to_string()).await; + + fn link_of(table: &OpaqueUnsizedUpdateWorkTable, id: u64) -> Link { + table + .0 + .primary_index + .pk_map + .get_value(&OpaqueUnsizedUpdatePrimaryKey::from(id)) + .map(Into::into) + .expect("row must exist") + } + + { + let engine = OpaqueUnsizedUpdatePersistenceEngine::new(config.clone()).await.unwrap(); + let table = OpaqueUnsizedUpdateWorkTable::load(engine).await.unwrap(); + table + .insert(OpaqueUnsizedUpdateRow { + id: 7, + secret: WrappedSecret("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()), + untouched: 42, + }) + .await + .unwrap(); + let link_before = link_of(&table, 7); + + table + .update_secret_by_id( + SecretByIdQuery { + secret: WrappedSecret("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string()), + }, + 7, + ) + .await + .unwrap(); + let link_after = link_of(&table, 7); + + let row = table.select(7).unwrap(); + assert_eq!( + row.secret, + WrappedSecret("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string()) + ); + assert_eq!(row.untouched, 42); + assert_eq!(link_before, link_after, "same-size opaque update must retain its slot"); + table.wait_for_ops().await.unwrap(); + } + + { + let engine = OpaqueUnsizedUpdatePersistenceEngine::new(config.clone()).await.unwrap(); + let table = OpaqueUnsizedUpdateWorkTable::load(engine).await.unwrap(); + let row = table.select(7).unwrap(); + assert_eq!( + row.secret, + WrappedSecret("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string()) + ); + assert_eq!(row.untouched, 42); + + table + .update_secret_by_id( + SecretByIdQuery { + secret: WrappedSecret( + "a replacement with a deliberately different serialized length".to_string(), + ), + }, + 7, + ) + .await + .unwrap(); + table.wait_for_ops().await.unwrap(); + } + + { + let engine = OpaqueUnsizedUpdatePersistenceEngine::new(config.clone()).await.unwrap(); + let table = OpaqueUnsizedUpdateWorkTable::load(engine).await.unwrap(); + let row = table.select(7).unwrap(); + assert_eq!( + row.secret, + WrappedSecret("a replacement with a deliberately different serialized length".to_string()) + ); + assert_eq!(row.untouched, 42); + + table + .update(OpaqueUnsizedUpdateRow { + id: 7, + secret: WrappedSecret("full-row replacement after targeted updates".to_string()), + untouched: 84, + }) + .await + .unwrap(); + table.wait_for_ops().await.unwrap(); + } + + { + let engine = OpaqueUnsizedUpdatePersistenceEngine::new(config).await.unwrap(); + let table = OpaqueUnsizedUpdateWorkTable::load(engine).await.unwrap(); + let row = table.select(7).unwrap(); + assert_eq!( + row.secret, + WrappedSecret("full-row replacement after targeted updates".to_string()) + ); + assert_eq!(row.untouched, 84); + } + }); +} diff --git a/tests/worktable/update_in_place_unsized.rs b/tests/worktable/update_in_place_unsized.rs index 7b1353d7..a40488ad 100644 --- a/tests/worktable/update_in_place_unsized.rs +++ b/tests/worktable/update_in_place_unsized.rs @@ -240,3 +240,122 @@ macro_rules! unsized_in_place_suite { unsized_in_place_suite!(wti, worktables_index); unsized_in_place_suite!(congee, congee); unsized_in_place_suite!(arctic, arctic); + +mod opaque_wrapper { + use rkyv::{Archive, Deserialize, Serialize}; + use worktable::prelude::*; + use worktable::worktable; + + #[derive(Archive, Clone, Debug, Deserialize, Serialize, PartialEq, PartialOrd, MemStat)] + #[rkyv(compare(PartialEq), derive(Debug))] + struct WrappedString(String); + + worktable!( + name: OpaqueOnly, + persist: false, + columns: { + id: u64 primary_key, + secret: WrappedString, + }, + ); + + worktable!( + name: OpaqueWrapperUpdate, + persist: false, + columns: { + id: u64 primary_key, + padding: String, + nickname: String optional, + secret: WrappedString, + }, + queries: { + update: { + Secret(secret) by id, + Nickname(nickname) by id, + } + } + ); + + #[tokio::test] + async fn custom_archived_string_wrapper_is_rebased_into_row_storage() { + let table = OpaqueWrapperUpdateWorkTable::default(); + table + .insert(OpaqueWrapperUpdateRow { + id: 1, + padding: "keeps the table on variable-size storage".to_string(), + nickname: None, + secret: WrappedString("original out-of-line secret value".to_string()), + }) + .await + .unwrap(); + + table + .update_secret( + SecretQuery { + secret: WrappedString("replacement out-of-line secret!!".to_string()), + }, + 1, + ) + .await + .unwrap(); + + assert_eq!( + table.select(1).unwrap().secret, + WrappedString("replacement out-of-line secret!!".to_string()) + ); + } + + #[tokio::test] + async fn optional_string_update_is_rebased_into_row_storage() { + let table = OpaqueWrapperUpdateWorkTable::default(); + table + .insert(OpaqueWrapperUpdateRow { + id: 1, + padding: "keeps the table on variable-size storage".to_string(), + nickname: Some("original out-of-line nickname".to_string()), + secret: WrappedString("unchanged out-of-line secret".to_string()), + }) + .await + .unwrap(); + + table + .update_nickname( + NicknameQuery { + nickname: Some("replacement out-of-line name".to_string()), + }, + 1, + ) + .await + .unwrap(); + + assert_eq!( + table.select(1).unwrap().nickname, + Some("replacement out-of-line name".to_string()) + ); + } + + #[tokio::test] + async fn full_row_update_rebases_an_opaque_only_table() { + let table = OpaqueOnlyWorkTable::default(); + table + .insert(OpaqueOnlyRow { + id: 1, + secret: WrappedString("original out-of-line secret value".to_string()), + }) + .await + .unwrap(); + + table + .update(OpaqueOnlyRow { + id: 1, + secret: WrappedString("replacement out-of-line secret value".to_string()), + }) + .await + .unwrap(); + + assert_eq!( + table.select(1).unwrap().secret, + WrappedString("replacement out-of-line secret value".to_string()) + ); + } +} From b1b9546635faad3c453ee712bee16c256ffc001a Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 15:43:54 +0700 Subject: [PATCH 10/45] Document WorkTable mutation choices --- docs/wt-user-guide.typ | 43 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/docs/wt-user-guide.typ b/docs/wt-user-guide.typ index 69de1182..f2a57117 100644 --- a/docs/wt-user-guide.typ +++ b/docs/wt-user-guide.typ @@ -151,7 +151,27 @@ let many = table.select_by_country(44).execute()?; // Vec optional column must say `using worktables_index`; Arctic supports `String` keys, but does not support optional keys. -== 5. Declared queries +== 5. Mutations and declared queries + +These operations are deliberately different. Choose by how much of the row the caller +owns and whether an absent key is valid: + +#text(size: 7.5pt)[ +#table( + columns: (1.4fr, 1.1fr, 0.9fr, 3fr), + stroke: 0.4pt + rgb("#cccccc"), + inset: 5pt, + [*operation*], [*input*], [*key absent*], [*meaning*], + [`insert(row)`], [complete `Row`], [insert], [Create a new row. An existing primary key returns `PrimaryAlreadyExists`; the caller does not authorize replacement.], + [`upsert(row)`], [complete `Row`], [insert], [Insert or replace. The caller declares the complete row authoritative. A row selected earlier can overwrite newer fields if it is later passed here.], + [`update(row)`], [complete `Row`], [`NotFound`], [Replace every field of an existing row. It never creates a missing row, but the supplied row is still a complete authoritative snapshot.], + [`update_`\ `(Query, key)`], [declared fields], [`NotFound`], [Change only the named fields. WorkTable rereads under its mutation lock when safe reconstruction needs the complete row, preserving concurrent changes to other fields.], + [`update_`\ `_in_place(closure, key)`], [mutable archived fields], [`NotFound`], [Directly mutate declared, unindexed fields of an existing row. This is the lowest-work path and is restricted to primary-key lookup.], + [`reinsert(old, new)`], [two complete rows], [`NotFound` or mismatch], [Advanced explicit replacement that moves storage and repairs indexes. Ordinary application updates should use one of the methods above.], +) +] + +Declare targeted updates, deletes and direct archived-field mutations with the table: ```rust worktable! ( @@ -183,8 +203,25 @@ table.delete_by_id(1).await?; table.update_state_by_id_in_place(|state| *state = 2.into(), 1).await?; ``` -`update` reads, changes and writes. `in_place` mutates without selecting first and locks -internally, so it is safe from several threads without the caller holding anything. +The generated suffix describes the declared operation. `StatusById(status) by id` emits +`update_status_by_id(StatusByIdQuery { status }, id)`; placing that declaration under +`in_place` emits `update_status_by_id_in_place(|status| ..., id)`. `status` is the column +name, not a storage type. + +A declared `update` reads, changes and writes only its named fields. Normal declared +updates accept owned Rust values and are the safe default for strings, options and +application-defined wrappers. An archived string contains a relative pointer, so +WorkTable may reconstruct the complete row rather than move only that field's archived +bytes. It rereads under the full mutation lock first, preserving concurrent changes to +other fields. The macro cannot inspect an external type such as `EncryptedSecret` and +prove whether its archived form contains relative pointers, so unknown custom types take +that conservative path. + +`in_place` mutates without selecting first and locks internally. Use it when the +application can safely edit the archived representation directly, as with a scalar or a +fixed `#[repr(u8)]` enum. Do not copy an archived string, vector or pointer-bearing wrapper +from another buffer into an `in_place` closure. Persisted in-place queries enqueue the +changed slot bytes; they do not skip durability. == 6. Selects you do not declare From d1d58b38bcaa8a19bcd1c917a56fad15287ae20c Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 15:57:34 +0700 Subject: [PATCH 11/45] Rename declared partial update APIs --- CHANGELOG.md | 18 +++-- README.md | 10 +-- benches/cases/full_featured.rs | 11 +-- benches/cases/update_contention.rs | 2 +- benches/common/mod.rs | 4 +- codegen/src/common/name_generator.rs | 4 +- codegen/src/generators/dense_table.rs | 34 ++++----- .../generators/in_memory/queries/in_place.rs | 8 +-- .../src/generators/in_memory/queries/locks.rs | 8 +-- .../src/generators/in_memory/queries/type.rs | 6 +- .../generators/in_memory/queries/unsized_.rs | 2 +- .../generators/in_memory/queries/update.rs | 18 ++--- .../generators/persist/queries/in_place.rs | 8 +-- .../src/generators/persist/queries/locks.rs | 8 +-- .../src/generators/persist/queries/type.rs | 6 +- .../generators/persist/queries/unsized_.rs | 2 +- .../src/generators/persist/queries/update.rs | 18 ++--- codegen/src/generators/runtime_backend.rs | 2 +- codegen/src/generators/vec_table/mod.rs | 18 ++--- codegen/src/worktable/mod.rs | 50 +++++++------ docs/TODO.md | 2 +- docs/beta17-validation.md | 2 +- docs/magic.md | 24 +++---- docs/partition-by-one-pager.md | 2 +- docs/partitioned-tables-worked-example.md | 10 +-- docs/pr46-review-findings.md | 2 +- docs/queries.md | 32 ++++----- docs/vec-persistence-design.md | 2 +- docs/wt-user-guide.typ | 44 ++++++------ dsl/src/check.rs | 2 +- dsl/src/model/queries.rs | 15 ++-- dsl/src/parser/queries/delete.rs | 4 +- dsl/src/parser/queries/in_place.rs | 23 +++--- dsl/src/parser/queries/mod.rs | 70 ++++++++++++------- dsl/src/parser/queries/select.rs | 4 +- dsl/src/parser/queries/update.rs | 14 ++-- dsl/src/parser/runtime.rs | 18 ++--- dsl/src/schema/emit_dsl.rs | 12 ++-- dsl/src/schema/emit_uml.rs | 4 +- dsl/src/schema/mod.rs | 30 ++++---- dsl/src/validate.rs | 22 +++--- dsl/tests/diff.rs | 2 +- dsl/tests/never_panics.rs | 4 +- dsl/tests/query_storage.rs | 10 +-- dsl/tests/round_trip.rs | 4 +- dsl/tests/schema.rs | 6 +- dsl/tests/trailing_commas.rs | 12 ++-- dsl/tests/uml.rs | 2 +- examples/guide_check.rs | 4 +- paper-bench/scripts/compile_cost.sh | 4 +- paper-bench/src/bin/ablation.rs | 6 +- paper-bench/src/bin/contention.rs | 12 ++-- paper-bench/src/dynamic.rs | 2 +- paper-bench/src/lib.rs | 4 +- src/persistence/operation/batch.rs | 8 +-- tests/persistence/concurrent/mod.rs | 2 +- .../persistence/duplicate_key_index_reload.rs | 6 +- tests/persistence/failure/mod.rs | 4 +- .../persistence/failure/update_non_unique.rs | 4 +- tests/persistence/failure/update_unsized.rs | 8 +-- tests/persistence/in_place_durability.rs | 4 +- tests/persistence/mod.rs | 2 +- tests/persistence/multi_row_backend_order.rs | 4 +- tests/persistence/same_size_in_place.rs | 8 +-- tests/persistence/sync/failure.rs | 8 +-- tests/persistence/sync/many_strings.rs | 6 +- tests/persistence/sync/mod.rs | 8 +-- .../persistence/sync/opaque_unsized_update.rs | 6 +- tests/persistence/sync/option.rs | 30 ++++---- .../persistence/sync/string_primary_index.rs | 8 +-- .../sync/string_secondary_index.rs | 8 +-- .../persistence/sync/string_update_timeout.rs | 2 +- tests/runtime_execution.rs | 22 +++--- tests/ui/in_place_over_indexed_column.rs | 4 +- tests/ui/in_place_over_indexed_column.stderr | 2 +- tests/ui/query_over_unknown_column.rs | 2 +- tests/ui/runtime_pinned_and_call_site.rs | 2 +- tests/ui/runtime_profile_backend_mismatch.rs | 2 +- tests/ui/runtime_unknown_profile.rs | 2 +- tests/worktable/array.rs | 8 +-- tests/worktable/base.rs | 16 ++--- tests/worktable/bench.rs | 6 +- tests/worktable/borrowed_primary_key.rs | 8 +-- tests/worktable/cancel_safety.rs | 6 +- tests/worktable/columnar.rs | 8 +-- tests/worktable/count.rs | 2 +- tests/worktable/delete.rs | 2 +- tests/worktable/in_place.rs | 40 +++++------ tests/worktable/index/mod.rs | 12 ++-- tests/worktable/index/update_by_pk.rs | 8 +-- tests/worktable/index/update_query.rs | 8 +-- tests/worktable/leak_probe.rs | 10 +-- tests/worktable/lock_order.rs | 6 +- tests/worktable/multi_row_deadlock.rs | 6 +- tests/worktable/mutation_gate_deadlock.rs | 10 +-- tests/worktable/nonunique_arctic.rs | 6 +- tests/worktable/option.rs | 36 +++++----- tests/worktable/partitioned.rs | 10 +-- tests/worktable/runtime_backends.rs | 20 +++--- tests/worktable/unique_fixed_unsized.rs | 4 +- tests/worktable/unsized_.rs | 42 +++++------ tests/worktable/update_delete_race.rs | 4 +- tests/worktable/update_in_place_unsized.rs | 18 ++--- tests/worktable/vec_table.rs | 17 +++-- tests/worktable/with_enum.rs | 2 +- tests/worktable/wrong_row_update.rs | 8 ++- 106 files changed, 592 insertions(+), 540 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ba5df21..85acbab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ Change Log ## [1.9.0-beta1] +### Changed + +- Declared mutations now use `update_partial:` and + `update_partial_in_place:`. They generate `update_partial_` and + `update_partial_in_place_`, making their field-level semantics distinct + from the complete-row `update(row)` method. + ### Added - The frozen `LinearTable` and `VecTable` API is now part of WorkTable itself, @@ -63,7 +70,8 @@ Change Log - **`queries:` on a `vec: true` table.** It was refused wholesale; it now - generates `update_`, `delete_` and `update__in_place` under + generates `update_partial_`, `delete_` and + `update_partial_in_place_` under the same names the paged table uses, so a declaration reads the same either way. @@ -222,15 +230,15 @@ Change Log declares 65,536 rows into a partition that holds 256), and `persist: true`, which a dense partition has no engine to honour. - It carries `queries:`. An `update` or `delete` query keyed by the primary key + It carries `queries:`. An `update_partial` or `delete` query keyed by the primary key generates the same method name against the same `Query` struct the paged table generates, so a call reads identically; the signature does not, deliberately, because there is no `.await` and no `WorkTableError`, and a call that moved between the shapes should fail to compile rather than quietly change what it guarantees. A query keyed by any other column is refused: a dense partition has no secondary index, and scanning it instead - would be a keyed operation silently becoming a linear one. `in_place` is - refused as a synonym, because every update here is already in place. + would be a keyed operation silently becoming a linear one. + `update_partial_in_place` is refused, because every update here is already in place. Note that `memory_by_key` and `memory_total` **cannot see this saving**. They report `used_bytes`, which is rows plus indexes and excludes the fixed floor @@ -1090,7 +1098,7 @@ rather than new surface. ### Fixed - Re-reading a table from file. -- Index difference logic for `update` queries. +- Index difference logic for `update_partial` queries. ## [0.5.1] diff --git a/README.md b/README.md index a894b657..91dbf74f 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ worktable!( exchnage_idx: exchange, } queries: { - update: { + update_partial: { AnotherByExchange(another) by exchange, AnotherByTest(another) by test, AnotherById(another) by id, @@ -436,7 +436,7 @@ There are some default query implementations that are available for all `WorkTab ``` queries: { - update: { + update_partial: { AnotherByExchange(another) by exchange, AnotherByTest(another) by test, AnotherById(another) by id, @@ -454,10 +454,10 @@ Default query declaration is `(*) by `. It For each query `Query` and `By` structs are generated. They will be used by user to call the query. -#### `update` query declaration +#### `update_partial` query declaration -`update` queries are used to update row's data partially. Default generated `update` allows only full update of the row. -But if user's logic needs some simultaneous update of row parts from different code parts. `update` logic supports +`update_partial` queries update only the declared fields. The generated `update(row)` method replaces the full row. +When application logic updates disjoint parts of a row concurrently, `update_partial` supports smart lock logic that allows simultaneous update of not overlapping row fields. #### `select_all` query declaration diff --git a/benches/cases/full_featured.rs b/benches/cases/full_featured.rs index 69c95bfa..dacc990a 100644 --- a/benches/cases/full_featured.rs +++ b/benches/cases/full_featured.rs @@ -151,7 +151,7 @@ fn update_by_pk_query(c: &mut Criterion) { let query = AnotherByIdQuery { another: format!("upd_{}", fastrand::u64(..)), }; - black_box(table.update_another_by_id(query, id).await) + black_box(table.update_partial_another_by_id(query, id).await) }) }); } @@ -179,7 +179,7 @@ fn update_by_unique_index_query(c: &mut Criterion) { let query = AnotherByVal1Query { another: format!("upd_{}", fastrand::u64(..)), }; - black_box(table.update_another_by_val_1(query, val1).await) + black_box(table.update_partial_another_by_val_1(query, val1).await) }) }); } @@ -200,8 +200,11 @@ fn in_place_update(c: &mut Criterion) { }; c.bench_function("full_featured_in_place_update_val", |b| { - b.to_async(&rt) - .iter(|| async { table.update_val_by_id_in_place(|val| *val += 1, black_box(pk)).await }) + b.to_async(&rt).iter(|| async { + table + .update_partial_in_place_val_by_id(|val| *val += 1, black_box(pk)) + .await + }) }); } diff --git a/benches/cases/update_contention.rs b/benches/cases/update_contention.rs index eb3ed8c0..f6f9cb18 100644 --- a/benches/cases/update_contention.rs +++ b/benches/cases/update_contention.rs @@ -81,7 +81,7 @@ fn single_row_in_place_contention(c: &mut Criterion) { for _ in 0..level { let table_clone = table.clone(); join_set.spawn(async move { - black_box(table_clone.update_val_by_id_in_place(|val| *val += 1, pk).await) + black_box(table_clone.update_partial_in_place_val_by_id(|val| *val += 1, pk).await) }); } while join_set.join_next().await.is_some() {} diff --git a/benches/common/mod.rs b/benches/common/mod.rs index 13920717..10f42c8c 100644 --- a/benches/common/mod.rs +++ b/benches/common/mod.rs @@ -53,10 +53,10 @@ worktable!( another_idx: another, }, queries: { - in_place: { + update_partial_in_place: { ValById(val) by id, } - update: { + update_partial: { AnotherById(another) by id, SomethingById(something) by id, AnotherByVal1(another) by val1, diff --git a/codegen/src/common/name_generator.rs b/codegen/src/common/name_generator.rs index caeca0db..5e62524f 100644 --- a/codegen/src/common/name_generator.rs +++ b/codegen/src/common/name_generator.rs @@ -83,11 +83,11 @@ impl WorktableNameGenerator { self.name.from_case(Case::Pascal).to_case(Case::Snake) } - pub fn get_update_query_lock_ident(snake_case_name: &String) -> Ident { + pub fn get_update_partial_query_lock_ident(snake_case_name: &String) -> Ident { Ident::new(format!("lock_update_{snake_case_name}").as_str(), Span::mixed_site()) } - pub fn get_update_in_place_query_lock_ident(snake_case_name: &String) -> Ident { + pub fn get_update_partial_in_place_query_lock_ident(snake_case_name: &String) -> Ident { Ident::new( format!("lock_update_in_place_{snake_case_name}").as_str(), Span::mixed_site(), diff --git a/codegen/src/generators/dense_table.rs b/codegen/src/generators/dense_table.rs index 985da4c5..58239fe3 100644 --- a/codegen/src/generators/dense_table.rs +++ b/codegen/src/generators/dense_table.rs @@ -22,12 +22,12 @@ use worktable_dsl::model::{Columns, Operation, PartitionMaxSize}; /// dense payload is emitted after them and `Queries` is not `Clone`. #[derive(Debug, Default)] pub struct DenseQueries { - /// `update (columns) by `. - pub updates: Vec<(Ident, Operation)>, + /// `update_partial (columns) by `. + pub update_partials: Vec<(Ident, Operation)>, /// `delete () by `. pub deletes: Vec<(Ident, Operation)>, - /// `in_place (columns) by `. Refused: see [`expand`]. - pub in_place: Vec<(Ident, Operation)>, + /// `update_partial_in_place (columns) by `. Refused: see [`expand`]. + pub update_partials_in_place: Vec<(Ident, Operation)>, } impl DenseQueries { @@ -40,14 +40,14 @@ impl DenseQueries { map.iter().map(|(name, op)| (name.clone(), op.clone())).collect() }; Self { - updates: lift(&queries.updates), + update_partials: lift(&queries.update_partials), deletes: lift(&queries.deletes), - in_place: lift(&queries.in_place), + update_partials_in_place: lift(&queries.update_partials_in_place), } } fn is_empty(&self) -> bool { - self.updates.is_empty() && self.deletes.is_empty() && self.in_place.is_empty() + self.update_partials.is_empty() && self.deletes.is_empty() && self.update_partials_in_place.is_empty() } } @@ -372,17 +372,17 @@ fn gen_queries( return Ok(Vec::new()); } - // `in_place` exists on the paged table because a write there is async and + // `update_partial_in_place` exists on the paged table because a write there is async and // has to hold a column across a suspension point. Nothing here is async and - // `update` is already in place, so generating both would be two names for + // `update_partial` is already in place, so generating both would be two names for // one method. - if let Some((query, _)) = queries.in_place.first() { + if let Some((query, _)) = queries.update_partials_in_place.first() { return Err(Error::new( query.span(), format!( - "`in_place {query}` has no meaning on a dense partition: every update here is \ + "`update_partial_in_place {query}` has no meaning on a dense partition: every update here is \ already in place, because there is no page to rewrite and no await to hold a \ - column across. Declare it as `update {query}`, or use `partition_max_size: u64` \ + column across. Declare it as `update_partial {query}`, or use `partition_max_size: u64` \ for the full table." ), )); @@ -390,9 +390,9 @@ fn gen_queries( let mut out = Vec::new(); - for (query, op) in &queries.updates { - by_must_be_the_key(pk, query, op, "update")?; - let method = format_ident!("update_{}", snake(query)); + for (query, op) in &queries.update_partials { + by_must_be_the_key(pk, query, op, "update_partial")?; + let method = format_ident!("update_partial_{}", snake(query)); let query_ty = format_ident!("{}Query", query); let fields = &op.columns; for column in fields { @@ -403,7 +403,7 @@ fn gen_queries( return Err(Error::new( column.span(), format!( - "`update {query}` cannot update primary key `{pk}` in a dense partition: the key is \ + "`update_partial {query}` cannot update primary key `{pk}` in a dense partition: the key is \ the row's physical position. Remove `{pk}` from the update, or delete and insert the row \ at its new key." ), @@ -411,7 +411,7 @@ fn gen_queries( } } let doc = format!( - "`update {query}`, by position.\n\n\ + "`update_partial {query}`, by position.\n\n\ Edits {} in place on the row at `{pk}`, without cloning the row. \ `None` means that key holds no row and nothing was written.\n\n\ The paged table's method of this name is `async` and returns \ diff --git a/codegen/src/generators/in_memory/queries/in_place.rs b/codegen/src/generators/in_memory/queries/in_place.rs index 06450463..e0e197a2 100644 --- a/codegen/src/generators/in_memory/queries/in_place.rs +++ b/codegen/src/generators/in_memory/queries/in_place.rs @@ -11,8 +11,8 @@ impl InMemoryGenerator { let table_ident = name_generator.get_work_table_ident(); let custom_in_place = if let Some(q) = &self.queries { - let profile = q.in_place_runtime.clone(); - let custom_in_place = self.gen_in_place_queries(q.in_place.clone()); + let profile = q.update_partial_in_place_runtime.clone(); + let custom_in_place = self.gen_in_place_queries(q.update_partials_in_place.clone()); let custom_in_place = crate::generators::profile_dispatch::wrap( custom_in_place, profile.as_ref(), @@ -58,10 +58,10 @@ impl InMemoryGenerator { fn gen_primary_key_in_place(&self, snake_case_name: String, columns: &[Ident]) -> TokenStream { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let pk_type = name_generator.get_primary_key_type_ident(); - let lock_ident = WorktableNameGenerator::get_update_in_place_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_partial_in_place_query_lock_ident(&snake_case_name); let method_ident = Ident::new( - format!("update_{snake_case_name}_in_place").as_str(), + format!("update_partial_in_place_{snake_case_name}").as_str(), Span::mixed_site(), ); diff --git a/codegen/src/generators/in_memory/queries/locks.rs b/codegen/src/generators/in_memory/queries/locks.rs index 96522cd8..9ae33f10 100644 --- a/codegen/src/generators/in_memory/queries/locks.rs +++ b/codegen/src/generators/in_memory/queries/locks.rs @@ -13,8 +13,8 @@ impl InMemoryGenerator { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let lock_type_ident = name_generator.get_lock_type_ident(); - let update_fns = Self::gen_update_query_locks(&q.updates); - let update_in_place_fns = Self::gen_in_place_update_query_locks(&q.in_place); + let update_fns = Self::gen_update_query_locks(&q.update_partials); + let update_in_place_fns = Self::gen_in_place_update_query_locks(&q.update_partials_in_place); Ok(quote! { impl #lock_type_ident { @@ -33,7 +33,7 @@ impl InMemoryGenerator { .map(|name| { let snake_case_name = name.to_string().from_case(Case::Pascal).to_case(Case::Snake); - let lock_ident = WorktableNameGenerator::get_update_in_place_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_partial_in_place_query_lock_ident(&snake_case_name); let columns = &updates.get(name).as_ref().expect("exists").columns; let lock_fn = Self::gen_rows_lock_fn(columns, lock_ident); @@ -55,7 +55,7 @@ impl InMemoryGenerator { .map(|name| { let snake_case_name = name.to_string().from_case(Case::Pascal).to_case(Case::Snake); - let lock_ident = WorktableNameGenerator::get_update_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_partial_query_lock_ident(&snake_case_name); let op = updates.get(name).expect("exists"); // The lock set covers the updated columns AND the predicate diff --git a/codegen/src/generators/in_memory/queries/type.rs b/codegen/src/generators/in_memory/queries/type.rs index 12cbf007..351e1b1c 100644 --- a/codegen/src/generators/in_memory/queries/type.rs +++ b/codegen/src/generators/in_memory/queries/type.rs @@ -92,12 +92,12 @@ impl InMemoryGenerator { if let Some(queries) = &self.queries { let query_defs = queries - .updates + .update_partials .keys() .map(|v| { let ident = Ident::new(format!("{v}Query").as_str(), Span::mixed_site()); let (rows, updates): (Vec<_>, Vec<_>) = queries - .updates + .update_partials .get(v) .expect("exists") .columns @@ -159,7 +159,7 @@ impl InMemoryGenerator { .collect::, _>>()?; let by_defs = queries - .updates + .update_partials .values() .map(|op| { let ident = Ident::new(format!("{}By", op.name).as_str(), Span::mixed_site()); diff --git a/codegen/src/generators/in_memory/queries/unsized_.rs b/codegen/src/generators/in_memory/queries/unsized_.rs index 4e6664a0..98b74e40 100644 --- a/codegen/src/generators/in_memory/queries/unsized_.rs +++ b/codegen/src/generators/in_memory/queries/unsized_.rs @@ -49,7 +49,7 @@ impl InMemoryGenerator { fn gen_get_unsized_field_len_query_fn(&self) -> TokenStream { if let Some(q) = &self.queries { let query_impls: Vec<_> = q - .updates + .update_partials .iter() .filter(|(_, op)| { op.columns diff --git a/codegen/src/generators/in_memory/queries/update.rs b/codegen/src/generators/in_memory/queries/update.rs index 5c74cada..8a24ba6a 100644 --- a/codegen/src/generators/in_memory/queries/update.rs +++ b/codegen/src/generators/in_memory/queries/update.rs @@ -15,8 +15,8 @@ struct UpdateStorage<'a> { impl InMemoryGenerator { pub fn gen_query_update_impl(&mut self) -> syn::Result { let custom_updates = if let Some(q) = &self.queries { - let profile = q.update_runtime.clone(); - let custom_updates = self.gen_custom_updates(q.updates.clone()); + let profile = q.update_partial_runtime.clone(); + let custom_updates = self.gen_custom_updates(q.update_partials.clone()); let custom_updates = crate::generators::profile_dispatch::wrap( custom_updates, profile.as_ref(), @@ -706,9 +706,9 @@ impl InMemoryGenerator { requires_rebuild, } = storage; let pk_ident = &self.pk.as_ref().unwrap().ident; - let method_ident = Ident::new(format!("update_{snake_case_name}").as_str(), Span::mixed_site()); + let method_ident = Ident::new(format!("update_partial_{snake_case_name}").as_str(), Span::mixed_site()); let query_ident = Ident::new(format!("{name}Query").as_str(), Span::mixed_site()); - let lock_ident = WorktableNameGenerator::get_update_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_partial_query_lock_ident(&snake_case_name); let row_updates = idents .iter() @@ -788,7 +788,7 @@ impl InMemoryGenerator { } = storage; let by_field = &index.field; let index = &index.name; - let method_ident = Ident::new(format!("update_{snake_case_name}").as_str(), Span::mixed_site()); + let method_ident = Ident::new(format!("update_partial_{snake_case_name}").as_str(), Span::mixed_site()); let query_ident = Ident::new(format!("{name}Query").as_str(), Span::mixed_site()); let by_ident = Ident::new(format!("{name}By").as_str(), Span::mixed_site()); @@ -1051,11 +1051,11 @@ impl InMemoryGenerator { .as_str(), ); let index = &index.name; - let method_ident = Ident::new(format!("update_{snake_case_name}").as_str(), Span::mixed_site()); + let method_ident = Ident::new(format!("update_partial_{snake_case_name}").as_str(), Span::mixed_site()); let query_ident = Ident::new(format!("{name}Query").as_str(), Span::mixed_site()); let by_ident = Ident::new(format!("{name}By").as_str(), Span::mixed_site()); - let lock_ident = WorktableNameGenerator::get_update_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_partial_query_lock_ident(&snake_case_name); let row_updates = idents .iter() @@ -1256,9 +1256,9 @@ mod tests { }, ); generator.queries = Some(Queries { - updates, + update_partials: updates, deletes: IndexMap::new(), - in_place: IndexMap::new(), + update_partials_in_place: IndexMap::new(), ..Default::default() }); generator.gen_primary_key_def().unwrap(); diff --git a/codegen/src/generators/persist/queries/in_place.rs b/codegen/src/generators/persist/queries/in_place.rs index 4b6d6289..4bc31c48 100644 --- a/codegen/src/generators/persist/queries/in_place.rs +++ b/codegen/src/generators/persist/queries/in_place.rs @@ -13,8 +13,8 @@ impl PersistGenerator { let table_ident = name_generator.get_work_table_ident(); let custom_in_place = if let Some(q) = &self.queries { - let profile = q.in_place_runtime.clone(); - let custom_in_place = self.gen_in_place_queries(q.in_place.clone()); + let profile = q.update_partial_in_place_runtime.clone(); + let custom_in_place = self.gen_in_place_queries(q.update_partials_in_place.clone()); let custom_in_place = crate::generators::profile_dispatch::wrap( custom_in_place, profile.as_ref(), @@ -61,10 +61,10 @@ impl PersistGenerator { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let pk_type = name_generator.get_primary_key_type_ident(); let secondary_events_ident = name_generator.get_space_secondary_index_events_ident(); - let lock_ident = WorktableNameGenerator::get_update_in_place_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_partial_in_place_query_lock_ident(&snake_case_name); let method_ident = Ident::new( - format!("update_{snake_case_name}_in_place").as_str(), + format!("update_partial_in_place_{snake_case_name}").as_str(), Span::mixed_site(), ); diff --git a/codegen/src/generators/persist/queries/locks.rs b/codegen/src/generators/persist/queries/locks.rs index 6739cd86..fff1ce1b 100644 --- a/codegen/src/generators/persist/queries/locks.rs +++ b/codegen/src/generators/persist/queries/locks.rs @@ -13,8 +13,8 @@ impl PersistGenerator { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let lock_type_ident = name_generator.get_lock_type_ident(); - let update_fns = Self::gen_update_query_locks(&q.updates); - let update_in_place_fns = Self::gen_in_place_update_query_locks(&q.in_place); + let update_fns = Self::gen_update_query_locks(&q.update_partials); + let update_in_place_fns = Self::gen_in_place_update_query_locks(&q.update_partials_in_place); Ok(quote! { impl #lock_type_ident { @@ -33,7 +33,7 @@ impl PersistGenerator { .map(|name| { let snake_case_name = name.to_string().from_case(Case::Pascal).to_case(Case::Snake); - let lock_ident = WorktableNameGenerator::get_update_in_place_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_partial_in_place_query_lock_ident(&snake_case_name); let columns = &updates.get(name).as_ref().expect("exists").columns; let lock_fn = Self::gen_rows_lock_fn(columns, lock_ident); @@ -55,7 +55,7 @@ impl PersistGenerator { .map(|name| { let snake_case_name = name.to_string().from_case(Case::Pascal).to_case(Case::Snake); - let lock_ident = WorktableNameGenerator::get_update_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_partial_query_lock_ident(&snake_case_name); let op = updates.get(name).expect("exists"); // The lock set covers the updated columns AND the predicate diff --git a/codegen/src/generators/persist/queries/type.rs b/codegen/src/generators/persist/queries/type.rs index 55987206..3c632cbe 100644 --- a/codegen/src/generators/persist/queries/type.rs +++ b/codegen/src/generators/persist/queries/type.rs @@ -92,12 +92,12 @@ impl PersistGenerator { if let Some(queries) = &self.queries { let query_defs = queries - .updates + .update_partials .keys() .map(|v| { let ident = Ident::new(format!("{v}Query").as_str(), Span::mixed_site()); let (rows, updates): (Vec<_>, Vec<_>) = queries - .updates + .update_partials .get(v) .expect("exists") .columns @@ -159,7 +159,7 @@ impl PersistGenerator { .collect::, _>>()?; let by_defs = queries - .updates + .update_partials .values() .map(|op| { let ident = Ident::new(format!("{}By", op.name).as_str(), Span::mixed_site()); diff --git a/codegen/src/generators/persist/queries/unsized_.rs b/codegen/src/generators/persist/queries/unsized_.rs index d66f9c9e..9e663a38 100644 --- a/codegen/src/generators/persist/queries/unsized_.rs +++ b/codegen/src/generators/persist/queries/unsized_.rs @@ -49,7 +49,7 @@ impl PersistGenerator { fn gen_get_unsized_field_len_query_fn(&self) -> TokenStream { if let Some(q) = &self.queries { let query_impls: Vec<_> = q - .updates + .update_partials .iter() .filter(|(_, op)| { op.columns diff --git a/codegen/src/generators/persist/queries/update.rs b/codegen/src/generators/persist/queries/update.rs index b5fb03df..f98b93b4 100644 --- a/codegen/src/generators/persist/queries/update.rs +++ b/codegen/src/generators/persist/queries/update.rs @@ -15,8 +15,8 @@ struct UpdateStorage<'a> { impl PersistGenerator { pub fn gen_query_update_impl(&mut self) -> syn::Result { let custom_updates = if let Some(q) = &self.queries { - let profile = q.update_runtime.clone(); - let custom_updates = self.gen_custom_updates(q.updates.clone()); + let profile = q.update_partial_runtime.clone(); + let custom_updates = self.gen_custom_updates(q.update_partials.clone()); let custom_updates = crate::generators::profile_dispatch::wrap( custom_updates, profile.as_ref(), @@ -766,9 +766,9 @@ impl PersistGenerator { requires_rebuild, } = storage; let pk_ident = &self.pk.as_ref().unwrap().ident; - let method_ident = Ident::new(format!("update_{snake_case_name}").as_str(), Span::mixed_site()); + let method_ident = Ident::new(format!("update_partial_{snake_case_name}").as_str(), Span::mixed_site()); let query_ident = Ident::new(format!("{name}Query").as_str(), Span::mixed_site()); - let lock_ident = WorktableNameGenerator::get_update_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_partial_query_lock_ident(&snake_case_name); let row_updates = idents .iter() @@ -850,7 +850,7 @@ impl PersistGenerator { } = storage; let by_field = &index.field; let index = &index.name; - let method_ident = Ident::new(format!("update_{snake_case_name}").as_str(), Span::mixed_site()); + let method_ident = Ident::new(format!("update_partial_{snake_case_name}").as_str(), Span::mixed_site()); let query_ident = Ident::new(format!("{name}Query").as_str(), Span::mixed_site()); let by_ident = Ident::new(format!("{name}By").as_str(), Span::mixed_site()); @@ -1149,11 +1149,11 @@ impl PersistGenerator { .as_str(), ); let index = &index.name; - let method_ident = Ident::new(format!("update_{snake_case_name}").as_str(), Span::mixed_site()); + let method_ident = Ident::new(format!("update_partial_{snake_case_name}").as_str(), Span::mixed_site()); let query_ident = Ident::new(format!("{name}Query").as_str(), Span::mixed_site()); let by_ident = Ident::new(format!("{name}By").as_str(), Span::mixed_site()); - let lock_ident = WorktableNameGenerator::get_update_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_partial_query_lock_ident(&snake_case_name); let row_updates = idents .iter() @@ -1345,9 +1345,9 @@ mod tests { }, ); generator.set_queries(Queries { - updates, + update_partials: updates, deletes: IndexMap::new(), - in_place: IndexMap::new(), + update_partials_in_place: IndexMap::new(), ..Default::default() }); generator.gen_primary_key_def().unwrap(); diff --git a/codegen/src/generators/runtime_backend.rs b/codegen/src/generators/runtime_backend.rs index d0642d03..d81f10c4 100644 --- a/codegen/src/generators/runtime_backend.rs +++ b/codegen/src/generators/runtime_backend.rs @@ -33,7 +33,7 @@ pub(crate) fn runtime_type(backend: RuntimeBackend) -> TokenStream { /// /// The chain is: a section's own annotation, then the table's `runtime:`, then /// the built-in default. The middle step is the one worth stating: a table that -/// declares `runtime: tokio` and has an unannotated `update` section must give +/// declares `runtime: tokio` and has an unannotated `update_partial` section must give /// that section tokio, not the built-in nagoya, or the table would silently run /// two runtimes. /// diff --git a/codegen/src/generators/vec_table/mod.rs b/codegen/src/generators/vec_table/mod.rs index 29edd331..798ee909 100644 --- a/codegen/src/generators/vec_table/mod.rs +++ b/codegen/src/generators/vec_table/mod.rs @@ -1418,7 +1418,7 @@ fn gen_queries( } }; - for (name, op) in &queries.updates { + for (name, op) in &queries.update_partials { let (by_type, unique) = resolve_by(&op.by)?; let query_ty = format_ident!("{}Query", name); let fields = &op.columns; @@ -1440,12 +1440,12 @@ fn gen_queries( }); // The declared name already carries the key — `AmountById` becomes - // `update_amount_by_id` — which is the paged table's convention and the + // `update_partial_amount_by_id` — which is the paged table's convention and the // whole point of generating these. - let method = format_ident!("update_{}", snake_of(name)); + let method = format_ident!("update_partial_{}", snake_of(name)); let pick = selected(&op.by, unique); let doc = format!( - "`update {name}` keyed by `{}`.\n\n\ + "`update_partial {name}` keyed by `{}`.\n\n\ Sets {} and repairs every index the change moved a row under.\n\n\ The paged table's method of this name is `async` and returns \ `Result<(), WorkTableError>`. This one is neither, so a call cannot \ @@ -1496,13 +1496,13 @@ fn gen_queries( }); } - for (name, op) in &queries.in_place { + for (name, op) in &queries.update_partials_in_place { let (by_type, unique) = resolve_by(&op.by)?; if op.columns.len() != 1 { return Err(syn::Error::new( name.span(), - "an `in_place` query edits exactly one column through a closure. \ - For several columns at once use an `update` query, which takes a \ + "an `update_partial_in_place` query edits exactly one column through a closure. \ + For several columns at once use an `update_partial` query, which takes a \ struct of them.", )); } @@ -1511,10 +1511,10 @@ fn gen_queries( .columns_map .get(column) .ok_or_else(|| syn::Error::new(column.span(), format!("no column `{column}`")))?; - let method = format_ident!("update_{}_in_place", snake_of(name)); + let method = format_ident!("update_partial_in_place_{}", snake_of(name)); let pick = selected(&op.by, unique); let doc = format!( - "`in_place {name}` keyed by `{}`.\n\n\ + "`update_partial_in_place {name}` keyed by `{}`.\n\n\ Hands a cloned candidate's `{column}` to the closure, then validates \ unique keys before replacing the row. Returns how many rows it reached.", op.by diff --git a/codegen/src/worktable/mod.rs b/codegen/src/worktable/mod.rs index e5cfb3dc..bb8f57f0 100644 --- a/codegen/src/worktable/mod.rs +++ b/codegen/src/worktable/mod.rs @@ -653,7 +653,7 @@ mod tests { balance: f64, }, queries: { - update: { + update_partial: { Balance(balance) by id, } } @@ -662,7 +662,7 @@ mod tests { .to_string(); let update = output - .split("pub async fn update_balance") + .split("pub async fn update_partial_balance") .nth(1) .expect("generated balance update"); assert!( @@ -690,7 +690,7 @@ mod tests { secret: EncryptedSecret, }, queries: { - update: { + update_partial: { Secret(secret) by id, } } @@ -699,7 +699,7 @@ mod tests { .to_string(); let update = output - .split("pub async fn update_secret") + .split("pub async fn update_partial_secret") .nth(1) .expect("generated opaque-field update"); assert!( @@ -736,7 +736,7 @@ mod tests { display_name: String optional, }, queries: { - update: { + update_partial: { DisplayName(display_name) by id, } } @@ -745,7 +745,7 @@ mod tests { .to_string(); let update = output - .split("pub async fn update_display_name") + .split("pub async fn update_partial_display_name") .nth(1) .expect("generated optional-string update"); assert!(update.contains("data . update_in_place")); @@ -768,7 +768,7 @@ mod tests { secret_idx: secret unique using worktables_index, }, queries: { - update: { + update_partial: { Secret(secret) by id, } } @@ -777,7 +777,7 @@ mod tests { .to_string(); let update = output - .split("pub async fn update_secret") + .split("pub async fn update_partial_secret") .nth(1) .expect("generated indexed opaque-field update"); assert!(update.contains("self . reinsert")); @@ -1343,7 +1343,7 @@ mod position_tests { partition_max_size: u8, columns: { exchange_id: u8 primary_key, bid: f64, ask: f64 }, queries: { - update: { TopPrice(bid, ask) by exchange_id, }, + update_partial: { TopPrice(bid, ask) by exchange_id, }, delete: { Stale() by exchange_id, } } }) @@ -1353,7 +1353,10 @@ mod position_tests { expanded.contains("impl PriceDenseTable"), "the dense payload must be emitted: {expanded}" ); - assert!(expanded.contains("fn update_top_price"), "missing the update query"); + assert!( + expanded.contains("fn update_partial_top_price"), + "missing the update query" + ); assert!(expanded.contains("fn delete_stale"), "missing the delete query"); } @@ -1368,7 +1371,7 @@ mod position_tests { columns: { exchange_id: u8 primary_key, venue: u32, bid: f64 }, indexes: { venue_idx: venue }, queries: { - update: { ByVenue(bid) by venue, } + update_partial: { ByVenue(bid) by venue, } } }) .expect_err("a dense partition has no secondary index") @@ -1392,7 +1395,7 @@ mod position_tests { partition_max_size: u8, columns: { exchange_id: u8 primary_key, bid: f64 }, queries: { - update: { ReKey(exchange_id, bid) by exchange_id, } + update_partial: { ReKey(exchange_id, bid) by exchange_id, } } }) .expect_err("changing a dense primary key would separate identity from position") @@ -1404,23 +1407,26 @@ mod position_tests { ); } - /// `in_place` is a synonym here, so it says so rather than generating a + /// `update_partial_in_place` is redundant here, so it says so rather than generating a /// second name for one method. #[test] - fn in_place_on_a_dense_partition_is_refused_as_a_synonym() { + fn update_partial_in_place_on_a_dense_partition_is_refused() { let error = expand(quote! { name: Price, partition_by: symbol_id: u16, partition_max_size: u8, columns: { exchange_id: u8 primary_key, bid: f64 }, queries: { - in_place: { Bump(bid) by exchange_id, } + update_partial_in_place: { Bump(bid) by exchange_id, } } }) - .expect_err("in_place has no meaning on a dense partition") + .expect_err("update_partial_in_place has no meaning on a dense partition") .to_string(); assert!(error.contains("already in place"), "must say why: {error}"); - assert!(error.contains("update Bump"), "must name the replacement: {error}"); + assert!( + error.contains("update_partial Bump"), + "must name the replacement: {error}" + ); } /// A dense partition cannot persist, and says so rather than pretending. @@ -1552,14 +1558,14 @@ mod emitted_declarations { tenant_idx: tenant, }, queries: { - update: { + update_partial: { Nickname(nickname) by id, Email(email) by tenant, } delete: { ById() by id, } - in_place: { + update_partial_in_place: { Balance(balance) by id, } } @@ -1641,7 +1647,7 @@ mod generator_determinism { tenant_idx: tenant, }, queries: { - update: { + update_partial: { SetBalance(balance) by id, MoveTenant(tenant) by email, }, @@ -1708,7 +1714,7 @@ mod schema_const { nickname: String optional, }, indexes: { email_idx: email unique }, - queries: { update: { Nickname(nickname) by id } } + queries: { update_partial: { Nickname(nickname) by id } } }; let baked = baked_schema(expand(declaration.clone()).expect("expands"), "ACCOUNT_SCHEMA"); @@ -2010,7 +2016,7 @@ mod runtime_tests { runtime: tokio, columns: { id: u64 primary_key, value: u64 }, queries: { - update: { Value(value) by id, }, + update_partial: { Value(value) by id, }, delete: { ById() by id, }, } }); diff --git a/docs/TODO.md b/docs/TODO.md index 1ae18543..85cd1b96 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -66,7 +66,7 @@ results were rerun across all three local trees before drawing conclusions. ### WT DSL expansion and trailing commas are deterministic The trailing-comma parser fix was already present on the beta.17 branch and is -covered for `config`, `delete`, `in_place`, block order, and the no-comma form. +covered for `config`, `delete`, `update_partial_in_place`, block order, and the no-comma form. Expansion is now deterministic too. `columns_map`, query maps, and generated unique-type sets preserve declaration order with `IndexMap`/`IndexSet`. The diff --git a/docs/beta17-validation.md b/docs/beta17-validation.md index a3f6d15f..6dd5aaaa 100644 --- a/docs/beta17-validation.md +++ b/docs/beta17-validation.md @@ -45,7 +45,7 @@ checked as follows. ## WT DSL findings and fixes The trailing-comma fix already on the branch passes five focused cases: -`config`, `delete`, `in_place`, reordered blocks, and the existing no-comma +`config`, `delete`, `update_partial_in_place`, reordered blocks, and the existing no-comma form. Validation found a separate real beta.17 defect: macro expansion could vary diff --git a/docs/magic.md b/docs/magic.md index c00ab8b5..5cb3dd67 100644 --- a/docs/magic.md +++ b/docs/magic.md @@ -119,7 +119,7 @@ A **fixed, ordered prefix**, then a free-order section list. | 5, required with 4 | `partition_max_size:` | rows per partition, as an index width | | any order | `columns:` | the row and its primary key | | any order | `indexes:` | secondary indexes | -| any order | `queries:` | generated `update` / `delete` / `in_place` | +| any order | `queries:` | generated `update_partial` / `delete` / `update_partial_in_place` | | any order | `config:` | `page_size`, `row_derives` | The prefix is genuinely ordered: `parse_name` reads the first token and errors if @@ -143,7 +143,7 @@ worktable!( another_idx: another, }, queries: { - update: { + update_partial: { AnotherByExchange(another) by exchange, AnotherByTest(another) by test, AnotherById(another) by id, @@ -206,28 +206,28 @@ Three kinds. CamelCase in the declaration, snake_case in the generated method. ```rust queries: { - update: { + update_partial: { AmountById(amount) by id, }, delete: { ByName() by name, }, - in_place: { + update_partial_in_place: { SomeValueById(some_value) by id, } } ``` -**`update`** generates `update_amount_by_id(AmountByIdQuery { amount }, id)`. The +**`update_partial`** generates `update_partial_amount_by_id(AmountByIdQuery { amount }, id)`. The query struct is the name plus `Query`. **`delete`** generates `delete_by_name(name)`. Empty parentheses because a delete names no columns. -**`in_place`** generates `update_some_value_by_id_in_place(id, |value| ...)`, which +**`update_partial_in_place`** generates `update_partial_in_place_some_value_by_id(|value| ..., id)`, which mutates without selecting first. Its locking is internal, so it is safe from several threads without the caller holding anything — which is also why it is a -*different concurrency point* from `update`. **Only `by {pk_field}` is +*different concurrency point* from `update_partial`. **Only `by {pk_field}` is supported.** ## Selects, which are not declared @@ -366,7 +366,7 @@ concurrency points and they want opposite things: | query fan-out | does not exist yet | independent chunks | 1.99x-2.84x on orderbook upsert/delete | The `queries:` sections already group by concurrency point: everything in -`update:` goes through the same lock path, and `in_place:` is a different path by +`update_partial:` goes through the same lock path, and `update_partial_in_place:` is a different path by design. So the annotation belongs on the section. ## Three positions, one keyword @@ -374,7 +374,7 @@ design. So the annotation belongs on the section. | position | scope | cost | |---|---|---| | `runtime: nagoya(spread)` at the top level | the table's sync types and default pool | changes the generated type | -| `update runtime fast_local:` on a section | that concurrency point | compile time, free | +| `update_partial runtime fast_local:` on a section | that concurrency point | compile time, free | | `.runtime(wide)` on a builder | one call | runtime, opt-in | ## Named profiles @@ -403,11 +403,11 @@ worktable!( symbol_idx: symbol using congee, }, queries: { - update runtime fast_local: { // `runtime` = scheduler + update_partial runtime fast_local: { // `runtime` = scheduler Fill(qty) by id, Cancel(qty) by symbol, }, - in_place runtime fast_local: { + update_partial_in_place runtime fast_local: { Bump(qty) by id, }, delete runtime wide: { @@ -524,7 +524,7 @@ runtime does this query use" and it is visible at the place you are reading. **The cost of that choice**, recorded so it is not a surprise: adding a section annotation becomes a breaking change for callers already using `.runtime()`. The window is narrow today — `.runtime()` exists only on select builders, and selects -are not declared in `queries:`, so `update` / `delete` / `in_place` annotations +are not declared in `queries:`, so `update_partial` / `delete` / `update_partial_in_place` annotations can never collide with it. It only bites if a `select` section annotation is added later. diff --git a/docs/partition-by-one-pager.md b/docs/partition-by-one-pager.md index bbf73d25..e08b7621 100644 --- a/docs/partition-by-one-pager.md +++ b/docs/partition-by-one-pager.md @@ -72,7 +72,7 @@ let books = OrderBookPartitions::new(); // hot path: array index, no hashing, no allocation let book = books.partition(symbol_id).ok_or(...)?; -book.update_top_price(feed.into(), exchange_id).await?; +book.update_partial_top_price(feed.into(), exchange_id).await?; // across partitions: for sweeps and maintenance, not the hot path let stale = books.select_all().filter(|r| r.ts < cutoff).execute()?; diff --git a/docs/partitioned-tables-worked-example.md b/docs/partitioned-tables-worked-example.md index d8c2879f..c1d4e406 100644 --- a/docs/partitioned-tables-worked-example.md +++ b/docs/partitioned-tables-worked-example.md @@ -52,7 +52,7 @@ worktable!( rest_ask_sizes: OrderBookRestDepth, }, queries: { - update: { + update_partial: { TopPrice(best_bid_price, best_bid_size, best_ask_price, best_ask_size) by exchange_id, RestPrices(bids_size, rest_bid_prices, rest_bid_sizes, asks_size, rest_ask_prices, rest_ask_sizes) by exchange_id, @@ -138,7 +138,7 @@ worktable!( rest_ask_sizes: OrderBookRestDepth, }, queries: { - update: { + update_partial: { TopPrice(best_bid_price, best_bid_size, best_ask_price, best_ask_size) by exchange_id, RestPrices(bids_size, rest_bid_prices, rest_bid_sizes, asks_size, rest_ask_prices, rest_ask_sizes) by exchange_id, @@ -225,16 +225,16 @@ concept. See section 7. ```rust // today: hashes the symbol string on every tick let book = manager.get_order_book(&feed.symbol).ok_or_else(|| eyre!("unknown symbol"))?; -book.table.update_top_price(feed.into(), row_id).await?; +book.table.update_partial_top_price(feed.into(), row_id).await?; // with partition_by: array index let book = books.partition(feed.symbol_id).ok_or_else(|| eyre!("unknown symbol"))?; -book.update_top_price(feed.into(), row_id).await?; +book.update_partial_top_price(feed.into(), row_id).await?; ``` One indirection fewer, because there is no wrapper struct holding the key alongside the table, and 9.5 ns fewer, because the lookup stopped being a hash -of a heap string. Everything else is identical: `update_top_price` is the +of a heap string. Everything else is identical: `update_partial_top_price` is the generated query it always was, and it runs against a 23-row table. The feed handler resolves `symbol_id` once when the subscription is opened, diff --git a/docs/pr46-review-findings.md b/docs/pr46-review-findings.md index cae1954b..669f5cbe 100644 --- a/docs/pr46-review-findings.md +++ b/docs/pr46-review-findings.md @@ -7,7 +7,7 @@ Work lands on branch `fix/pr46-review-findings`. ## F1 — Mutation gate held across `.await` (was rated P1) — NOT REPRODUCED `LockMap::mutation_guard` (`src/lock/map.rs`) is a blocking spin/yield ticket -lock. The generated `update`/`in_place`/`delete` paths keep the `MutationGuard` +lock. The generated `update_partial`/`update_partial_in_place`/`delete` paths keep the `MutationGuard` inside `LockGuard` and hold it across `.await` (`update_with_guard(...).await`, `reinsert(...).await`). The concern: two keys colliding on the same 1-of-64 stripe, guard-holder parked at its await while the other task spins. diff --git a/docs/queries.md b/docs/queries.md index b43a2097..2434d732 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -1,6 +1,6 @@ # Queries -WorkTable support query definition feature. Users can add custom `update`, `delete` and `update_in_place` queries. +WorkTable supports declared `update_partial`, `delete`, and `update_partial_in_place` queries. ```rust worktable!( @@ -18,45 +18,45 @@ worktable!( }, // Queries declaration section. queries: { - // `update` queries - update: { + // `update_partial` queries + update_partial: { AmountById(amount) by id, }, // `delete` queries delete: { ByName() by name, }, - in_place: { + update_partial_in_place: { SomeValueById(some_value) by id, } } ); ``` -### `update` queries +### `update_partial` queries `TODO` -### `update_in_place` queries +### `update_partial_in_place` queries -`update_in_place` queries are special update queries that allow you to update field's value +`update_partial_in_place` queries allow you to update a field's value without need to select it before query. It is useful for counters, as example, because with internal mutation queries locking logic user's don't need to add explicit locks over `WorkTable` -object. So you can safely use `update_in_place` queries in multiple threads simultaneously. +object. So you can safely use `update_partial_in_place` queries in multiple threads simultaneously. !!! For now only `by {pk_field}` queries are supported !!! -To declare `update_in_place` query you need to add `in_place` section to `queries`. Query definition is -same to `update`: `{YourQueryNameCamelCase}({fields_you_want_to_update}) by {by_field_name}`. -For example, in declaration above `update_in_place` is declared like this: +To declare an `update_partial_in_place` query, add an `update_partial_in_place` section to `queries`. Its definition is +the same shape as `update_partial`: `{YourQueryNameCamelCase}({fields_you_want_to_update}) by {by_field_name}`. +For example: ``` -in_place: { +update_partial_in_place: { SomeValueById(some_value) by id, } ``` -It will generate `update_some_value_by_id_in_place` method for `WorkTable` object (name generation logic is same +It will generate `update_partial_in_place_some_value_by_id` method for `WorkTable` object (name generation logic is same as for other queries). It will have two arguments: your `by` field value and closure, where you can use mutable field value itself. @@ -75,7 +75,7 @@ async fn main() -> eyre::Result<()> { let pk = table.insert(row)?; // This will lead to `some_value` field update by adding 100 to it value. table - .update_some_value_by_id_in_place(|some_value| *some_value += 100, pk.0) + .update_partial_in_place_some_value_by_id(|some_value| *some_value += 100, pk.0) .await?; let row = table.select(pk)?; assert_eq!(row.some_value, 100); @@ -84,8 +84,8 @@ async fn main() -> eyre::Result<()> { } ``` -You can find tests that covers `update_in_place` queries [here](../tests/worktable/in_place.rs). +You can find tests that cover `update_partial_in_place` queries [here](../tests/worktable/in_place.rs). ### `delete` queries -`TODO` \ No newline at end of file +`TODO` diff --git a/docs/vec-persistence-design.md b/docs/vec-persistence-design.md index cff351c7..f96cbef5 100644 --- a/docs/vec-persistence-design.md +++ b/docs/vec-persistence-design.md @@ -129,7 +129,7 @@ The restriction today is blunter than any of that: `queries:` is refused on `vec: true` for every backend, and the dense table accepts them only `by ` because it has no secondary index. Enabling them on `vec: true` is parity work rather than performance work — a generated -`update_amount_by_id` would be a named wrapper over `update(&key, |row| ..)`, +`update_partial_amount_by_id` would be a named wrapper over `update(&key, |row| ..)`, which already exists and already takes a closure. Mixed backends already work and are tested: a `fxhash` primary key with `arctic` diff --git a/docs/wt-user-guide.typ b/docs/wt-user-guide.typ index f2a57117..5f470a21 100644 --- a/docs/wt-user-guide.typ +++ b/docs/wt-user-guide.typ @@ -165,8 +165,8 @@ owns and whether an absent key is valid: [`insert(row)`], [complete `Row`], [insert], [Create a new row. An existing primary key returns `PrimaryAlreadyExists`; the caller does not authorize replacement.], [`upsert(row)`], [complete `Row`], [insert], [Insert or replace. The caller declares the complete row authoritative. A row selected earlier can overwrite newer fields if it is later passed here.], [`update(row)`], [complete `Row`], [`NotFound`], [Replace every field of an existing row. It never creates a missing row, but the supplied row is still a complete authoritative snapshot.], - [`update_`\ `(Query, key)`], [declared fields], [`NotFound`], [Change only the named fields. WorkTable rereads under its mutation lock when safe reconstruction needs the complete row, preserving concurrent changes to other fields.], - [`update_`\ `_in_place(closure, key)`], [mutable archived fields], [`NotFound`], [Directly mutate declared, unindexed fields of an existing row. This is the lowest-work path and is restricted to primary-key lookup.], + [`update_partial_`\ `(Query, key)`], [declared fields], [`NotFound`], [Change only the named fields. WorkTable rereads under its mutation lock when safe reconstruction needs the complete row, preserving concurrent changes to other fields.], + [`update_partial_in_place_`\ `(closure, key)`], [mutable archived fields], [`NotFound`], [Directly mutate declared, unindexed fields of an existing row. This is the lowest-work path and is restricted to primary-key lookup.], [`reinsert(old, new)`], [two complete rows], [`NotFound` or mismatch], [Advanced explicit replacement that moves storage and repairs indexes. Ordinary application updates should use one of the methods above.], ) ] @@ -182,13 +182,13 @@ worktable! ( state: u8, }, queries: { - update: { + update_partial: { AmountById(amount) by id, // () by }, delete: { ById() by id, // empty parens: names no columns }, - in_place: { + update_partial_in_place: { StateById(state) by id, // only `by ` is supported }, }, @@ -198,17 +198,17 @@ worktable! ( CamelCase declared, snake_case generated: ```rust -table.update_amount_by_id(AmountByIdQuery { amount: 900 }, 1).await?; // name + "Query" +table.update_partial_amount_by_id(AmountByIdQuery { amount: 900 }, 1).await?; // name + "Query" table.delete_by_id(1).await?; -table.update_state_by_id_in_place(|state| *state = 2.into(), 1).await?; +table.update_partial_in_place_state_by_id(|state| *state = 2.into(), 1).await?; ``` The generated suffix describes the declared operation. `StatusById(status) by id` emits -`update_status_by_id(StatusByIdQuery { status }, id)`; placing that declaration under -`in_place` emits `update_status_by_id_in_place(|status| ..., id)`. `status` is the column +`update_partial_status_by_id(StatusByIdQuery { status }, id)`; placing that declaration under +`update_partial_in_place` emits `update_partial_in_place_status_by_id(|status| ..., id)`. `status` is the column name, not a storage type. -A declared `update` reads, changes and writes only its named fields. Normal declared +A declared `update_partial` reads, changes and writes only its named fields. Normal declared updates accept owned Rust values and are the safe default for strings, options and application-defined wrappers. An archived string contains a relative pointer, so WorkTable may reconstruct the complete row rather than move only that field's archived @@ -217,10 +217,10 @@ other fields. The macro cannot inspect an external type such as `EncryptedSecret prove whether its archived form contains relative pointers, so unknown custom types take that conservative path. -`in_place` mutates without selecting first and locks internally. Use it when the +`update_partial_in_place` mutates without selecting first and locks internally. Use it when the application can safely edit the archived representation directly, as with a scalar or a fixed `#[repr(u8)]` enum. Do not copy an archived string, vector or pointer-bearing wrapper -from another buffer into an `in_place` closure. Persisted in-place queries enqueue the +from another buffer into an `update_partial_in_place` closure. Persisted in-place queries enqueue the changed slot bytes; they do not skip durability. == 6. Selects you do not declare @@ -392,7 +392,7 @@ method name and takes the same `Query` struct as the paged table, so the c the same; it is not `async` and does not return `WorkTableError`, so a call cannot move between the shapes by accident. A query keyed by any other column is refused, because a dense partition has no secondary index and scanning instead would turn a keyed operation -into a linear one without saying so. `in_place` is refused as a synonym: every update +into a linear one without saying so. `update_partial_in_place` is refused: every update here is already in place. Note that `memory_by_key` and `memory_total` cannot see any of this. They report @@ -463,10 +463,10 @@ error naming what to use instead, rather than being accepted and ignored. Declared `queries` are supported. They use equality on a primary or secondary index, including `fxhash`, and run synchronously through `&mut self`. An update declaration such as `StateById(state) by id` emits -`update_state_by_id(StateByIdQuery { state: 7 }, &id) -> usize`; a delete declaration +`update_partial_state_by_id(StateByIdQuery { state: 7 }, &id) -> usize`; a delete declaration `ByOwner() by owner` emits `delete_by_owner(&owner) -> usize`. The return value counts -affected rows. `in_place: { Status(state) by id }` emits -`update_status_in_place(|state| *state = 42, &id) -> usize` and accepts one column. +affected rows. `update_partial_in_place: { Status(state) by id }` emits +`update_partial_in_place_status(|state| *state = 42, &id) -> usize` and accepts one column. These methods belong to the table, not mutable wrappers on the shared partition set. Vec edits validate a cloned candidate before replacing a row. A primary or unique @@ -474,7 +474,7 @@ secondary-key collision panics with that row and its indexes unchanged; a panick edit closure also leaves the stored row unchanged. Replacing an existing row through `upsert` checks unique secondary keys first. Multi-row queries apply one row at a time and are not transactions: earlier successful edits remain if a later edit fails. -Cloning owned fields is part of this mutation cost, including Vec `in_place` queries. +Cloning owned fields is part of this mutation cost, including Vec `update_partial_in_place` queries. === Bytes and back: `unload` and `load` @@ -612,14 +612,14 @@ worktable! { runtime: nagoya(shared_slot), columns: { id: u64 primary_key, total: u64 }, queries: { - update runtime scheduled: { TotalById(total) by id }, - in_place runtime scheduled: { TotalById(total) by id }, + update_partial runtime scheduled: { TotalById(total) by id }, + update_partial_in_place runtime scheduled: { TotalById(total) by id }, } } let table = Arc::new(OrdersWorkTable::default()); table.insert(OrdersRow { id: 1, total: 10 }).await?; -table.update_total_by_id(TotalByIdQuery { total: 20 }, 1u64).await?; -table.update_total_by_id_in_place(|total| *total = 21.into(), 1u64).await?; +table.update_partial_total_by_id(TotalByIdQuery { total: 20 }, 1u64).await?; +table.update_partial_in_place_total_by_id(|total| *total = 21.into(), 1u64).await?; let rows = table.select_all() .order_on(OrdersRowFields::Total, Order::Desc) .limit(100).runtime(wide).execute_async().await?; @@ -702,9 +702,9 @@ worktable! ( by_bucket: { cluster_by: [bucket] }, }, queries: { - update: { ScoreById(score) by id }, + update_partial: { ScoreById(score) by id }, delete: { ById() by id }, - in_place: { ScoreById(score) by id }, + update_partial_in_place: { ScoreById(score) by id }, }, config: { page_size: 4096, diff --git a/dsl/src/check.rs b/dsl/src/check.rs index 1677db0d..da91c36c 100644 --- a/dsl/src/check.rs +++ b/dsl/src/check.rs @@ -308,7 +308,7 @@ mod dispatch_agreement { }, indexes: { qty_idx: qty }, columnar_indexes: { host_order: { cluster_by: [host_id] } }, - queries: { update: { Fill(qty) by id } }, + queries: { update_partial: { Fill(qty) by id } }, config: { page_size: 4096 }, "; diff --git a/dsl/src/model/queries.rs b/dsl/src/model/queries.rs index 46e66c08..d6f6e9bd 100644 --- a/dsl/src/model/queries.rs +++ b/dsl/src/model/queries.rs @@ -5,10 +5,10 @@ use crate::model::Operation; #[derive(Debug, Default)] pub struct Queries { - pub updates: IndexMap, + pub update_partials: IndexMap, pub deletes: IndexMap, - pub in_place: IndexMap, - /// The profile named by `update runtime :`, when the section was + pub update_partials_in_place: IndexMap, + /// The profile named by `update_partial runtime :`, when the section was /// annotated. `None` is not a default: it means the section falls back to /// the table's `runtime`, and the table's own default only after that. /// @@ -16,9 +16,10 @@ pub struct Queries { /// profile is declared by `runtimes!` somewhere else in the crate, so /// whether it exists, and whether its backend matches the table's, is a /// question for code generation. - pub update_runtime: Option, - /// The profile named by `delete runtime :`. See `update_runtime`. + pub update_partial_runtime: Option, + /// The profile named by `delete runtime :`. See `update_partial_runtime`. pub delete_runtime: Option, - /// The profile named by `in_place runtime :`. See `update_runtime`. - pub in_place_runtime: Option, + /// The profile named by `update_partial_in_place runtime :`. See + /// `update_partial_runtime`. + pub update_partial_in_place_runtime: Option, } diff --git a/dsl/src/parser/queries/delete.rs b/dsl/src/parser/queries/delete.rs index 42b4ac3f..99beac70 100644 --- a/dsl/src/parser/queries/delete.rs +++ b/dsl/src/parser/queries/delete.rs @@ -53,13 +53,13 @@ mod tests { #[test] fn test_update() { let tokens = quote! { - update: { + update_partial: { TestQuery(id, test) by name, Test1Query(id, name) by test, } }; let mut parser = Parser::new(tokens); - let (_, ops) = parser.parse_updates().unwrap(); + let (_, ops) = parser.parse_update_partials().unwrap(); assert_eq!(ops.len(), 2); let op = ops.get(&Ident::new("TestQuery", Span::mixed_site())).unwrap(); diff --git a/dsl/src/parser/queries/in_place.rs b/dsl/src/parser/queries/in_place.rs index c10f2f94..893b7f53 100644 --- a/dsl/src/parser/queries/in_place.rs +++ b/dsl/src/parser/queries/in_place.rs @@ -6,17 +6,20 @@ use crate::Parser; use crate::model::Operation; impl Parser { - /// The `in_place` block, and the profile it was annotated with. See - /// [`Parser::parse_updates`] for why the annotation rides beside the + /// The `update_partial_in_place` block, and the profile it was annotated with. See + /// [`Parser::parse_update_partials`] for why the annotation rides beside the /// operations. - pub fn parse_in_place(&mut self) -> syn::Result<(Option, IndexMap)> { + pub fn parse_update_partials_in_place(&mut self) -> syn::Result<(Option, IndexMap)> { let ident = self.input_iter.next().ok_or(syn::Error::new( self.input.span(), - "Expected `in_place` field in declaration", + "Expected `update_partial_in_place` field in declaration", ))?; if let TokenTree::Ident(ident) = ident { - if ident.to_string().as_str() != "in_place" { - return Err(syn::Error::new(ident.span(), "Expected `in_place` field")); + if ident.to_string().as_str() != "update_partial_in_place" { + return Err(syn::Error::new( + ident.span(), + "Expected `update_partial_in_place` field", + )); } } else { return Err(syn::Error::new(ident.span(), "Expected field name identifier.")); @@ -33,8 +36,8 @@ impl Parser { if let TokenTree::Group(ops) = ops { let mut parser = Parser::new(ops.stream()); let operations = parser.parse_operations()?; - // Symmetry with `parse_updates`: consume a comma after the block, - // so a `in_place` block is not required to be written last. + // Symmetry with `parse_update_partials`: consume a comma after the block, + // so an `update_partial_in_place` block is not required to be written last. self.try_parse_comma()?; Ok((runtime, operations)) } else { @@ -53,12 +56,12 @@ mod tests { #[test] fn test_update() { let tokens = quote! { - in_place: { + update_partial_in_place: { TestQuery(id) by name, } }; let mut parser = Parser::new(tokens); - let (_, ops) = parser.parse_in_place().unwrap(); + let (_, ops) = parser.parse_update_partials_in_place().unwrap(); assert_eq!(ops.len(), 1); let op = ops.get(&Ident::new("TestQuery", Span::mixed_site())).unwrap(); diff --git a/dsl/src/parser/queries/mod.rs b/dsl/src/parser/queries/mod.rs index 0664b6da..756a61f1 100644 --- a/dsl/src/parser/queries/mod.rs +++ b/dsl/src/parser/queries/mod.rs @@ -38,25 +38,27 @@ impl Parser { let mut parser = Parser::new(ops.stream()); while let Some(ident) = parser.peek_next() { match ident.to_string().as_str() { - "update" => { - let (runtime, updates) = parser.parse_updates()?; - queries.updates = updates; - queries.update_runtime = runtime; + "update_partial" => { + let (runtime, updates) = parser.parse_update_partials()?; + queries.update_partials = updates; + queries.update_partial_runtime = runtime; } "delete" => { let (runtime, deletes) = parser.parse_deletes()?; queries.deletes = deletes; queries.delete_runtime = runtime; } - "in_place" => { - let (runtime, in_place) = parser.parse_in_place()?; - queries.in_place = in_place; - queries.in_place_runtime = runtime; + "update_partial_in_place" => { + let (runtime, updates) = parser.parse_update_partials_in_place()?; + queries.update_partials_in_place = updates; + queries.update_partial_in_place_runtime = runtime; } other => { return Err(syn::Error::new( ident.span(), - format!("Unexpected token `{other}`; expected one of `update`, `delete`, `in_place`"), + format!( + "Unexpected token `{other}`; expected one of `update_partial`, `delete`, `update_partial_in_place`" + ), )); } } @@ -81,56 +83,56 @@ mod tests { fn sections_are_unannotated_by_default() { let tokens = quote! { queries: { - update: { Fill(qty) by id }, + update_partial: { Fill(qty) by id }, delete: { BySymbol() by symbol }, - in_place: { Bump(qty) by id }, + update_partial_in_place: { Bump(qty) by id }, } }; let queries = Parser::new(tokens).parse_queries().unwrap(); - assert!(queries.update_runtime.is_none()); + assert!(queries.update_partial_runtime.is_none()); assert!(queries.delete_runtime.is_none()); - assert!(queries.in_place_runtime.is_none()); + assert!(queries.update_partial_in_place_runtime.is_none()); } #[test] fn each_section_takes_a_runtime_annotation() { let tokens = quote! { queries: { - update runtime fast_local: { Fill(qty) by id }, + update_partial runtime fast_local: { Fill(qty) by id }, delete runtime wide: { BySymbol() by symbol }, - in_place runtime bulk: { Bump(qty) by id }, + update_partial_in_place runtime bulk: { Bump(qty) by id }, } }; let queries = Parser::new(tokens).parse_queries().unwrap(); - assert_eq!(queries.update_runtime.unwrap(), "fast_local"); + assert_eq!(queries.update_partial_runtime.unwrap(), "fast_local"); assert_eq!(queries.delete_runtime.unwrap(), "wide"); - assert_eq!(queries.in_place_runtime.unwrap(), "bulk"); - assert_eq!(queries.updates.len(), 1); + assert_eq!(queries.update_partial_in_place_runtime.unwrap(), "bulk"); + assert_eq!(queries.update_partials.len(), 1); assert_eq!(queries.deletes.len(), 1); - assert_eq!(queries.in_place.len(), 1); + assert_eq!(queries.update_partials_in_place.len(), 1); } #[test] fn an_annotated_section_sits_beside_an_unannotated_one() { let tokens = quote! { queries: { - update runtime fast_local: { Fill(qty) by id }, - in_place: { Bump(qty) by id }, + update_partial runtime fast_local: { Fill(qty) by id }, + update_partial_in_place: { Bump(qty) by id }, } }; let queries = Parser::new(tokens).parse_queries().unwrap(); - assert_eq!(queries.update_runtime.unwrap(), "fast_local"); - assert!(queries.in_place_runtime.is_none()); + assert_eq!(queries.update_partial_runtime.unwrap(), "fast_local"); + assert!(queries.update_partial_in_place_runtime.is_none()); } #[test] fn a_section_rejects_a_backend_in_place_of_a_profile() { let tokens = quote! { queries: { - update runtime nagoya: { Fill(qty) by id }, + update_partial runtime nagoya: { Fill(qty) by id }, } }; let error = Parser::new(tokens).parse_queries().unwrap_err().to_string(); @@ -140,4 +142,24 @@ mod tests { "{error}" ); } + + #[test] + fn legacy_update_section_is_rejected() { + let tokens = quote! { + queries: { update: { Fill(qty) by id } } + }; + let error = Parser::new(tokens).parse_queries().unwrap_err().to_string(); + + assert!(error.contains("Unexpected token `update`"), "{error}"); + } + + #[test] + fn legacy_in_place_section_is_rejected() { + let tokens = quote! { + queries: { in_place: { Bump(qty) by id } } + }; + let error = Parser::new(tokens).parse_queries().unwrap_err().to_string(); + + assert!(error.contains("Unexpected token `in_place`"), "{error}"); + } } diff --git a/dsl/src/parser/queries/select.rs b/dsl/src/parser/queries/select.rs index 0140a10b..2df60ecb 100644 --- a/dsl/src/parser/queries/select.rs +++ b/dsl/src/parser/queries/select.rs @@ -44,13 +44,13 @@ mod tests { #[test] fn test_update() { let tokens = quote! { - update: { + update_partial: { TestQuery(id, test) by name, Test1Query(id, name) by test, } }; let mut parser = Parser::new(tokens); - let (_, ops) = parser.parse_updates().unwrap(); + let (_, ops) = parser.parse_update_partials().unwrap(); assert_eq!(ops.len(), 2); let op = ops.get(&Ident::new("TestQuery", Span::mixed_site())).unwrap(); diff --git a/dsl/src/parser/queries/update.rs b/dsl/src/parser/queries/update.rs index c7563a53..12adca2c 100644 --- a/dsl/src/parser/queries/update.rs +++ b/dsl/src/parser/queries/update.rs @@ -6,20 +6,20 @@ use crate::Parser; use crate::model::Operation; impl Parser { - /// The `update` block, and the profile it was annotated with. + /// The `update_partial` block, and the profile it was annotated with. /// /// The annotation is returned beside the operations rather than folded /// into them because it applies to the block: every query in it runs on /// the same runtime, and saying so once is the point of writing it at the /// section rather than on each query. - pub fn parse_updates(&mut self) -> syn::Result<(Option, IndexMap)> { + pub fn parse_update_partials(&mut self) -> syn::Result<(Option, IndexMap)> { let ident = self.input_iter.next().ok_or(syn::Error::new( self.input.span(), - "Expected `update` field in declaration", + "Expected `update_partial` field in declaration", ))?; if let TokenTree::Ident(ident) = ident { - if ident.to_string().as_str() != "update" { - return Err(syn::Error::new(ident.span(), "Expected `update` field")); + if ident.to_string().as_str() != "update_partial" { + return Err(syn::Error::new(ident.span(), "Expected `update_partial` field")); } } else { return Err(syn::Error::new(ident.span(), "Expected field name identifier.")); @@ -54,13 +54,13 @@ mod tests { #[test] fn test_update() { let tokens = quote! { - update: { + update_partial: { TestQuery(id, test) by name, Test1Query(id, name) by test, } }; let mut parser = Parser::new(tokens); - let (_, ops) = parser.parse_updates().unwrap(); + let (_, ops) = parser.parse_update_partials().unwrap(); assert_eq!(ops.len(), 2); let op = ops.get(&Ident::new("TestQuery", Span::mixed_site())).unwrap(); diff --git a/dsl/src/parser/runtime.rs b/dsl/src/parser/runtime.rs index e744be5a..2a5e7074 100644 --- a/dsl/src/parser/runtime.rs +++ b/dsl/src/parser/runtime.rs @@ -43,7 +43,7 @@ fn expected_flavor() -> String { const TOKIO_HAS_NO_FLAVORS: &str = "`tokio` has no flavors; write `runtime: tokio`, or select a flavored runtime with `runtime: nagoya(spread)`"; -const EXPECTED_PROFILE: &str = "expected a profile name after `runtime`, as in `update runtime fast_local:`; \ +const EXPECTED_PROFILE: &str = "expected a profile name after `runtime`, as in `update_partial runtime fast_local:`; \ profiles are declared with `runtimes!`"; impl Parser { @@ -152,7 +152,7 @@ impl Parser { } /// The optional `runtime ` between a query section's keyword and - /// its colon, as in `update runtime fast_local: { .. }`. + /// its colon, as in `update_partial runtime fast_local: { .. }`. /// /// The token after `runtime` is a profile name, never a backend literal. /// A section names a profile because a profile carries tuning as well as a @@ -393,7 +393,7 @@ mod tests { " name: Last, columns: { id: u64 primary_key, qty: u64 }, - queries: { update: { Fill(qty) by id } }, + queries: { update_partial: { Fill(qty) by id } }, runtime: tokio, ", ); @@ -439,15 +439,15 @@ mod tests { name: Annotated, columns: { id: u64 primary_key, qty: u64, symbol: u64 }, queries: { - update runtime fast_local: { Fill(qty) by id }, + update_partial runtime fast_local: { Fill(qty) by id }, delete runtime wide: { BySymbol() by symbol }, - in_place: { Bump(qty) by id }, + update_partial_in_place: { Bump(qty) by id }, }, ", ); - assert_eq!(schema.queries.update_runtime.as_deref(), Some("fast_local")); + assert_eq!(schema.queries.update_partial_runtime.as_deref(), Some("fast_local")); assert_eq!(schema.queries.delete_runtime.as_deref(), Some("wide")); - assert_eq!(schema.queries.in_place_runtime, None); + assert_eq!(schema.queries.update_partial_in_place_runtime, None); } #[test] @@ -456,13 +456,13 @@ mod tests { name: RoundTrip, columns: { id: u64 primary_key, qty: u64 }, runtime: nagoya(spread), - queries: { update runtime wide: { Fill(qty) by id } }, + queries: { update_partial runtime wide: { Fill(qty) by id } }, "; let once = schema(source); let twice = schema(&once.to_dsl()); assert_eq!(once, twice); assert_eq!(twice.runtime, RuntimeBackend::Nagoya(Flavor::Spread)); - assert_eq!(twice.queries.update_runtime.as_deref(), Some("wide")); + assert_eq!(twice.queries.update_partial_runtime.as_deref(), Some("wide")); } #[test] diff --git a/dsl/src/schema/emit_dsl.rs b/dsl/src/schema/emit_dsl.rs index fe73a108..a08c38f0 100644 --- a/dsl/src/schema/emit_dsl.rs +++ b/dsl/src/schema/emit_dsl.rs @@ -93,9 +93,9 @@ impl Schema { let _ = writeln!(out, "queries: {{"); write_query_block( &mut out, - "update", - self.queries.update_runtime.as_deref(), - &self.queries.updates, + "update_partial", + self.queries.update_partial_runtime.as_deref(), + &self.queries.update_partials, ); write_query_block( &mut out, @@ -105,9 +105,9 @@ impl Schema { ); write_query_block( &mut out, - "in_place", - self.queries.in_place_runtime.as_deref(), - &self.queries.in_place, + "update_partial_in_place", + self.queries.update_partial_in_place_runtime.as_deref(), + &self.queries.update_partials_in_place, ); let _ = writeln!(out, "}},"); } diff --git a/dsl/src/schema/emit_uml.rs b/dsl/src/schema/emit_uml.rs index 1f185921..51a25ddd 100644 --- a/dsl/src/schema/emit_uml.rs +++ b/dsl/src/schema/emit_uml.rs @@ -54,9 +54,9 @@ impl Schema { } for (kind, operations) in [ - ("update", &self.queries.updates), + ("update_partial", &self.queries.update_partials), ("delete", &self.queries.deletes), - ("in_place", &self.queries.in_place), + ("update_partial_in_place", &self.queries.update_partials_in_place), ] { for operation in operations { let _ = writeln!( diff --git a/dsl/src/schema/mod.rs b/dsl/src/schema/mod.rs index 701f05e0..91f802af 100644 --- a/dsl/src/schema/mod.rs +++ b/dsl/src/schema/mod.rs @@ -196,26 +196,26 @@ pub struct PartitionKeySpec { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(default))] pub struct QueriesSpec { - /// `update:` operations. - pub updates: Vec, + /// `update_partial:` operations. + pub update_partials: Vec, /// `delete:` operations. pub deletes: Vec, - /// `in_place:` operations. - pub in_place: Vec, - /// The profile named by `update runtime :`, if written. Unresolved: - /// see [`crate::model::Queries::update_runtime`]. - pub update_runtime: Option, + /// `update_partial_in_place:` operations. + pub update_partials_in_place: Vec, + /// The profile named by `update_partial runtime :`, if written. Unresolved: + /// see [`crate::model::Queries::update_partial_runtime`]. + pub update_partial_runtime: Option, /// The profile named by `delete runtime :`, if written. pub delete_runtime: Option, - /// The profile named by `in_place runtime :`, if written. - pub in_place_runtime: Option, + /// The profile named by `update_partial_in_place runtime :`, if written. + pub update_partial_in_place_runtime: Option, } impl QueriesSpec { /// Whether any query was declared. An empty block and an absent one are /// the same thing to the macro, so the emitter writes neither. pub fn is_empty(&self) -> bool { - self.updates.is_empty() && self.deletes.is_empty() && self.in_place.is_empty() + self.update_partials.is_empty() && self.deletes.is_empty() && self.update_partials_in_place.is_empty() } } @@ -496,12 +496,14 @@ fn queries_from_model(queries: Queries) -> QueriesSpec { } QueriesSpec { - update_runtime: queries.update_runtime.map(|profile| profile.to_string()), + update_partial_runtime: queries.update_partial_runtime.map(|profile| profile.to_string()), delete_runtime: queries.delete_runtime.map(|profile| profile.to_string()), - in_place_runtime: queries.in_place_runtime.map(|profile| profile.to_string()), - updates: convert(queries.updates), + update_partial_in_place_runtime: queries + .update_partial_in_place_runtime + .map(|profile| profile.to_string()), + update_partials: convert(queries.update_partials), deletes: convert(queries.deletes), - in_place: convert(queries.in_place), + update_partials_in_place: convert(queries.update_partials_in_place), } } diff --git a/dsl/src/validate.rs b/dsl/src/validate.rs index 44980c1b..675ffcfe 100644 --- a/dsl/src/validate.rs +++ b/dsl/src/validate.rs @@ -87,20 +87,20 @@ pub fn validate_arctic_page_size(columns: &Columns, config: Option<&crate::model Ok(()) } -/// `in_place` queries hand the caller a mutable reference to the archived +/// `update_partial_in_place` queries hand the caller a mutable reference to the archived /// column bytes and bypass all index maintenance, so a column that any index /// is built over cannot be mutated in place: the index would keep resolving /// the old value. pub fn validate_in_place_queries(columns: &Columns, queries: &crate::model::Queries) -> syn::Result<()> { - for (name, op) in &queries.in_place { + for (name, op) in &queries.update_partials_in_place { for column in &op.columns { if columns.indexes.values().any(|index| &index.field == column) { return Err(syn::Error::new( column.span(), format!( - "in_place query `{name}` mutates column `{column}`, which is covered by an index; \ + "update_partial_in_place query `{name}` mutates column `{column}`, which is covered by an index; \ indexed columns cannot be updated in place because secondary indexes are not \ - maintained on this path. Use an `update` query instead" + maintained on this path. Use an `update_partial` query instead" ), )); } @@ -379,10 +379,10 @@ pub fn validate_query_storage( ) -> syn::Result<()> { if storage.is_vec() { if let Some(profile) = queries - .update_runtime + .update_partial_runtime .as_ref() .or(queries.delete_runtime.as_ref()) - .or(queries.in_place_runtime.as_ref()) + .or(queries.update_partial_in_place_runtime.as_ref()) { return Err(syn::Error::new( profile.span(), @@ -391,32 +391,32 @@ pub fn validate_query_storage( } return Ok(()); } - for (name, op) in &queries.updates { + for (name, op) in &queries.update_partials { let by_primary = columns.primary_keys.len() == 1 && columns.primary_keys.first() == Some(&op.by); let by_index = columns.indexes.values().any(|index| index.field == op.by); if !by_primary && !by_index { return Err(syn::Error::new( op.by.span(), format!( - "update query `{name}` requires a single-column primary key or a secondary index on `{}`", + "update_partial query `{name}` requires a single-column primary key or a secondary index on `{}`", op.by ), )); } } - for (name, op) in &queries.in_place { + for (name, op) in &queries.update_partials_in_place { if columns.primary_keys.len() != 1 || columns.primary_keys.first() != Some(&op.by) { return Err(syn::Error::new( op.by.span(), format!( - "in_place query `{name}` requires selection by the single-column primary key; use an update query for an indexed predicate" + "update_partial_in_place query `{name}` requires selection by the single-column primary key; use an update_partial query for an indexed predicate" ), )); } if op.columns.iter().any(|column| columns.primary_keys.contains(column)) { return Err(syn::Error::new( name.span(), - "in_place queries cannot mutate primary key columns; use an update query to maintain indexes", + "update_partial_in_place queries cannot mutate primary key columns; use an update_partial query to maintain indexes", )); } } diff --git a/dsl/tests/diff.rs b/dsl/tests/diff.rs index b072349b..15a55bb6 100644 --- a/dsl/tests/diff.rs +++ b/dsl/tests/diff.rs @@ -329,7 +329,7 @@ fn queries_and_config_never_reach_the_data() { name: User, version: 2, persist: true, columns: { id: u64 primary_key autoincrement, email: String, age: u8 }, indexes: { email_idx: email unique }, - queries: { update: { Age(age) by id } }, + queries: { update_partial: { Age(age) by id } }, config: { row_derives: Clone } ", ); diff --git a/dsl/tests/never_panics.rs b/dsl/tests/never_panics.rs index caa2b166..ca295ec3 100644 --- a/dsl/tests/never_panics.rs +++ b/dsl/tests/never_panics.rs @@ -15,7 +15,7 @@ const SEEDS: &[&str] = &[ "worktable!(name: T, columns: { id: u64 primary_key });", "worktable!(name: T, persist: true, columns: { id: u64 primary_key autoincrement, v: String }, indexes: { v_idx: v unique });", "worktable!(name: T, columns: { id: u32 primary_key using arctic, v: i64 }, indexes: { v_idx: v });", - "worktable!(name: T, columns: { id: u64 primary_key }, queries: { update: { ById(v) by id } });", + "worktable!(name: T, columns: { id: u64 primary_key }, queries: { update_partial: { ById(v) by id } });", "worktable!(name: T, persist: false, columns: { id: u8 primary_key using congee }, config: { page_size: 4096 });", ]; @@ -159,7 +159,7 @@ fn check_reports_semantic_errors_rather_than_panicking() { ), ( "a query over a column that does not exist", - "worktable!(name: T, columns: { id: u64 primary_key }, queries: { update: { ById(nope) by id } });", + "worktable!(name: T, columns: { id: u64 primary_key }, queries: { update_partial: { ById(nope) by id } });", ), ( "a page size that is not a number", diff --git a/dsl/tests/query_storage.rs b/dsl/tests/query_storage.rs index 48fa48b6..d3622c72 100644 --- a/dsl/tests/query_storage.rs +++ b/dsl/tests/query_storage.rs @@ -3,9 +3,9 @@ use worktable_dsl::check::check; #[test] fn paged_mutation_shapes_fail_before_emission() { for query in [ - "update: { Change(value) by value }", - "in_place: { Change(value) by value }", - "in_place: { Change(id) by id }", + "update_partial: { Change(value) by value }", + "update_partial_in_place: { Change(value) by value }", + "update_partial_in_place: { Change(id) by id }", ] { let checked = check(&format!( "name: T, columns: {{ id: u64 primary_key, value: u64 }}, queries: {{ {query} }}" @@ -16,14 +16,14 @@ fn paged_mutation_shapes_fail_before_emission() { #[test] fn a_vec_query_cannot_silently_ignore_a_runtime_profile() { let checked = check( - "name: T, vec: true, columns: { id: u64 primary_key, value: u64 }, queries: { update runtime scheduled: { Change(value) by id } }", + "name: T, vec: true, columns: { id: u64 primary_key, value: u64 }, queries: { update_partial runtime scheduled: { Change(value) by id } }", ); assert!(checked.diagnostics.iter().any(|d| d.message.contains("synchronous"))); } #[test] fn supported_paged_index_updates_remain_valid() { let checked = check( - "name: T, columns: { id: u64 primary_key, value: u64, amount: u64 }, indexes: { value_idx: value }, queries: { update: { Change(amount) by value } }", + "name: T, columns: { id: u64 primary_key, value: u64, amount: u64 }, indexes: { value_idx: value }, queries: { update_partial: { Change(amount) by value } }", ); assert!(checked.diagnostics.is_empty(), "{:?}", checked.diagnostics); } diff --git a/dsl/tests/round_trip.rs b/dsl/tests/round_trip.rs index 6fc0d934..139104c2 100644 --- a/dsl/tests/round_trip.rs +++ b/dsl/tests/round_trip.rs @@ -115,7 +115,7 @@ fn reading_the_same_declaration_twice_gives_the_same_schema() { let source = " name: Repeatable, columns: { id: u64 primary_key, a: u64, b: u64, c: String }, - queries: { update: { A(a) by id, B(b) by id, C(c) by id } } + queries: { update_partial: { A(a) by id, B(b) by id, C(c) by id } } "; let first = Schema::parse(source).expect("parses"); let second = Schema::parse(source).expect("parses"); @@ -152,7 +152,7 @@ fn every_top_level_block_survives_the_emitter() { ), ( "queries", - "name: A, columns: { id: u64 primary_key, x: u64 }, queries: { update: { X(x) by id } }", + "name: A, columns: { id: u64 primary_key, x: u64 }, queries: { update_partial: { X(x) by id } }", ), ( "config", diff --git a/dsl/tests/schema.rs b/dsl/tests/schema.rs index aeeaa9fc..7b64937a 100644 --- a/dsl/tests/schema.rs +++ b/dsl/tests/schema.rs @@ -32,10 +32,10 @@ fn queries_are_in_declaration_order() { " name: Sorted, columns: { id: u64 primary_key, a: u64, b: u64, c: u64 }, - queries: { update: { Charlie(c) by id, Alpha(a) by id, Bravo(b) by id } } + queries: { update_partial: { Charlie(c) by id, Alpha(a) by id, Bravo(b) by id } } ", ); - let names: Vec<&str> = schema.queries.updates.iter().map(|q| q.name.as_str()).collect(); + let names: Vec<&str> = schema.queries.update_partials.iter().map(|q| q.name.as_str()).collect(); assert_eq!(names, ["Charlie", "Alpha", "Bravo"]); } @@ -113,7 +113,7 @@ fn a_schema_survives_a_trip_through_serde() { payload: String optional, }, indexes: { payload_idx: payload unique }, - queries: { update: { Payload(payload) by id } }, + queries: { update_partial: { Payload(payload) by id } }, config: { page_size: 16384, row_derives: Clone, Debug } ", ); diff --git a/dsl/tests/trailing_commas.rs b/dsl/tests/trailing_commas.rs index e6f27674..e3b0bfef 100644 --- a/dsl/tests/trailing_commas.rs +++ b/dsl/tests/trailing_commas.rs @@ -37,12 +37,12 @@ fn config_does_not_have_to_be_written_last() { "name: Ordered, columns: { id: u64 primary_key, name: String }, config: { page_size: 8192 }, - queries: { update: { Renamed(name) by id, } }", + queries: { update_partial: { Renamed(name) by id, } }", ) .expect("block order should not depend on which parser eats a comma"); assert_eq!(schema.config.page_size, Some(8192)); - assert_eq!(schema.queries.updates.len(), 1); + assert_eq!(schema.queries.update_partials.len(), 1); } /// The same asymmetry inside `queries`, where `delete` and `in_place` sat. @@ -53,15 +53,15 @@ fn a_comma_after_delete_or_in_place_is_accepted() { columns: { id: u64 primary_key, name: String }, queries: { delete: { ByName() by name, }, - in_place: { SetName(name) by id, }, - update: { Renamed(name) by id, } + update_partial_in_place: { SetName(name) by id, }, + update_partial: { Renamed(name) by id, } }", ) .expect("`delete` and `in_place` should not have to be written last either"); assert_eq!(schema.queries.deletes.len(), 1); - assert_eq!(schema.queries.in_place.len(), 1); - assert_eq!(schema.queries.updates.len(), 1); + assert_eq!(schema.queries.update_partials_in_place.len(), 1); + assert_eq!(schema.queries.update_partials.len(), 1); } /// Omitting the comma stays valid. The fix is permissive, not a new rule. diff --git a/dsl/tests/uml.rs b/dsl/tests/uml.rs index 2989df3e..2071fb7a 100644 --- a/dsl/tests/uml.rs +++ b/dsl/tests/uml.rs @@ -52,7 +52,7 @@ fn mermaid_draws_queries_as_operations() { name: Ledger, columns: { id: u64 primary_key, balance: f64, note: String }, queries: { - update: { Balance(balance) by id } + update_partial: { Balance(balance) by id } delete: { ById() by id } } ", diff --git a/examples/guide_check.rs b/examples/guide_check.rs index b853013a..7ac2784a 100644 --- a/examples/guide_check.rs +++ b/examples/guide_check.rs @@ -14,7 +14,7 @@ worktable! ( symbol_idx: symbol, }, queries: { - update: { QuantityById(quantity) by id, } + update_partial: { QuantityById(quantity) by id, } } ); @@ -54,7 +54,7 @@ async fn main() -> eyre::Result<()> { }]) .await?; table - .update_quantity_by_id(QuantityByIdQuery { quantity: 7 }, 100) + .update_partial_quantity_by_id(QuantityByIdQuery { quantity: 7 }, 100) .await?; assert_eq!(table.select(100).unwrap().quantity, 7); assert_eq!(table.select_all().limit(1).execute()?.len(), 1); diff --git a/paper-bench/scripts/compile_cost.sh b/paper-bench/scripts/compile_cost.sh index 09f96a13..729d3206 100644 --- a/paper-bench/scripts/compile_cost.sh +++ b/paper-bench/scripts/compile_cost.sh @@ -39,8 +39,8 @@ worktable!( }, indexes: { a_idx_$i: a, }, queries: { - update: { UpdA$i(a) by id, }, - in_place: { IncB$i(b) by id, } + update_partial: { UpdA$i(a) by id, }, + update_partial_in_place: { IncB$i(b) by id, } } ); EOF diff --git a/paper-bench/src/bin/ablation.rs b/paper-bench/src/bin/ablation.rs index f9f04b65..4d758152 100644 --- a/paper-bench/src/bin/ablation.rs +++ b/paper-bench/src/bin/ablation.rs @@ -57,7 +57,7 @@ fn main() { rt.block_on(async { for _ in 0..n { let pk = rng.below(rows); - table.update_upd_b(UpdBQuery { b: pk }, pk).await.unwrap(); + table.update_partial_upd_b(UpdBQuery { b: pk }, pk).await.unwrap(); } }); n @@ -74,7 +74,7 @@ fn main() { rt.block_on(async { for _ in 0..n { let pk = rng.below(rows); - table.update_upd_a(UpdAQuery { a: pk }, pk).await.unwrap(); + table.update_partial_upd_a(UpdAQuery { a: pk }, pk).await.unwrap(); } }); n @@ -91,7 +91,7 @@ fn main() { rt.block_on(async { for _ in 0..n { let pk = rng.below(rows); - table.update_inc_b_in_place(|b| *b += 1, pk).await.unwrap(); + table.update_partial_in_place_inc_b(|b| *b += 1, pk).await.unwrap(); } }); n diff --git a/paper-bench/src/bin/contention.rs b/paper-bench/src/bin/contention.rs index dd083c6a..b986323e 100644 --- a/paper-bench/src/bin/contention.rs +++ b/paper-bench/src/bin/contention.rs @@ -38,24 +38,24 @@ async fn run(mode: &'static str, tasks: usize) -> f64 { match mode { "disjoint" => { if i % 2 == 0 { - table.update_upd_b(UpdBQuery { b: n }, pk_val).await.unwrap(); + table.update_partial_upd_b(UpdBQuery { b: n }, pk_val).await.unwrap(); } else { - table.update_upd_e(UpdEQuery { e: n }, pk_val).await.unwrap(); + table.update_partial_upd_e(UpdEQuery { e: n }, pk_val).await.unwrap(); } } "overlap" => { - table.update_upd_be(UpdBEQuery { b: n, e: n }, pk_val).await.unwrap(); + table.update_partial_upd_be(UpdBEQuery { b: n, e: n }, pk_val).await.unwrap(); } "mutex" => { let _g = big_lock.lock().await; if i % 2 == 0 { - table.update_upd_b(UpdBQuery { b: n }, pk_val).await.unwrap(); + table.update_partial_upd_b(UpdBQuery { b: n }, pk_val).await.unwrap(); } else { - table.update_upd_e(UpdEQuery { e: n }, pk_val).await.unwrap(); + table.update_partial_upd_e(UpdEQuery { e: n }, pk_val).await.unwrap(); } } "inplace" => { - table.update_inc_b_in_place(|b| *b += 1, pk_val).await.unwrap(); + table.update_partial_in_place_inc_b(|b| *b += 1, pk_val).await.unwrap(); } _ => unreachable!(), } diff --git a/paper-bench/src/dynamic.rs b/paper-bench/src/dynamic.rs index f6e61a18..912005d8 100644 --- a/paper-bench/src/dynamic.rs +++ b/paper-bench/src/dynamic.rs @@ -127,7 +127,7 @@ impl DynTable { } /// Field update through the catalog — the dynamic path a specialized - /// `update_upd_b` avoids: lock table, hash lookup, decode, dispatch, + /// `update_partial_upd_b` avoids: lock table, hash lookup, decode, dispatch, /// re-encode, write back. pub fn update_field(&self, pk: u64, col: &str, v: Value) -> Option<()> { let lock = { diff --git a/paper-bench/src/lib.rs b/paper-bench/src/lib.rs index 5b19bd88..95ec7587 100644 --- a/paper-bench/src/lib.rs +++ b/paper-bench/src/lib.rs @@ -22,13 +22,13 @@ worktable!( a_idx: a, }, queries: { - update: { + update_partial: { UpdA(a) by id, UpdB(b) by id, UpdE(e) by id, UpdBE(b, e) by id, }, - in_place: { + update_partial_in_place: { IncB(b) by id, } } diff --git a/src/persistence/operation/batch.rs b/src/persistence/operation/batch.rs index fd28f970..d494f76b 100644 --- a/src/persistence/operation/batch.rs +++ b/src/persistence/operation/batch.rs @@ -55,7 +55,7 @@ worktable! ( pos_idx: pos unique, }, queries: { - update: { + update_partial: { PosById(pos) by id, } } @@ -261,7 +261,7 @@ where .select_by_pos(old_pos) .ok_or_else(|| eyre::eyre!("batch metadata position {old_pos} is missing during reindex"))?; self.info_wt - .update_pos_by_id(PosByIdQuery { pos: old_pos - 1 }, row.id) + .update_partial_pos_by_id(PosByIdQuery { pos: old_pos - 1 }, row.id) .await?; } Ok(()) @@ -867,10 +867,10 @@ mod tests { // into fixed UUID positions used to produce A, update-B, B and discard // the update as superseded by B's older row image. let insert_b = event_insert(1, link, vec![2; 4], vec![1]); - let update_b = eventless_update(2, link, vec![3; 4]); + let update_partial_b = eventless_update(2, link, vec![3; 4]); let insert_a = event_insert(3, link, vec![1; 4], vec![0]); - let batch = latest_data_writes(&[insert_b, update_b, insert_a]); + let batch = latest_data_writes(&[insert_b, update_partial_b, insert_a]); assert_eq!(batch.get(&PageId::from(1u32)).unwrap(), &vec![(link, vec![3; 4])]); } diff --git a/tests/persistence/concurrent/mod.rs b/tests/persistence/concurrent/mod.rs index 4ea6a997..381c3c80 100644 --- a/tests/persistence/concurrent/mod.rs +++ b/tests/persistence/concurrent/mod.rs @@ -40,7 +40,7 @@ worktable! ( value_idx: value unique, }, queries: { - update: { + update_partial: { AnotherById(another) by id, }, delete: { diff --git a/tests/persistence/duplicate_key_index_reload.rs b/tests/persistence/duplicate_key_index_reload.rs index 3a73b531..dced4639 100644 --- a/tests/persistence/duplicate_key_index_reload.rs +++ b/tests/persistence/duplicate_key_index_reload.rs @@ -23,7 +23,7 @@ worktable!( bucket_idx: bucket, }, queries: { - update: { + update_partial: { ScoreById(score) by id, } } @@ -273,7 +273,7 @@ fn test_duplicate_key_secondary_index_survives_reload() { if model.by_score.values().any(|ids| ids.contains(&id)) { let new_score = (id % KEYS) + 1_000; table - .update_score_by_id(ScoreByIdQuery { score: new_score }, id) + .update_partial_score_by_id(ScoreByIdQuery { score: new_score }, id) .await .unwrap(); model.move_score(id, new_score); @@ -461,7 +461,7 @@ fn test_duplicate_key_mutations_without_reload() { if model.by_score.values().any(|ids| ids.contains(&id)) { let new_score = (id % KEYS) + 1_000; table - .update_score_by_id(ScoreByIdQuery { score: new_score }, id) + .update_partial_score_by_id(ScoreByIdQuery { score: new_score }, id) .await .unwrap(); model.move_score(id, new_score); diff --git a/tests/persistence/failure/mod.rs b/tests/persistence/failure/mod.rs index 58e0ad58..ecd26e01 100644 --- a/tests/persistence/failure/mod.rs +++ b/tests/persistence/failure/mod.rs @@ -53,7 +53,7 @@ worktable!( unique_value_idx: unique_value unique, }, queries: { - update: { UniqueValueByCategory(unique_value) by category }, + update_partial: { UniqueValueByCategory(unique_value) by category }, }, ); @@ -80,7 +80,7 @@ worktable!( unique_value_idx: unique_value unique, }, queries: { - update: { NameAndValueByCategory(name, unique_value) by category }, + update_partial: { NameAndValueByCategory(name, unique_value) by category }, }, ); diff --git a/tests/persistence/failure/update_non_unique.rs b/tests/persistence/failure/update_non_unique.rs index 109daac8..a7f1a079 100644 --- a/tests/persistence/failure/update_non_unique.rs +++ b/tests/persistence/failure/update_non_unique.rs @@ -72,7 +72,7 @@ fn test_update_non_unique_middle_fail() { tokio::time::sleep(Duration::from_millis(100)).await; let query = UniqueValueByCategoryQuery { unique_value: 99 }; - let result = table.update_unique_value_by_category(query, 1).await; + let result = table.update_partial_unique_value_by_category(query, 1).await; assert!(result.is_err()); let valid_row3 = MixedIdxRow { @@ -180,7 +180,7 @@ fn test_update_non_unique_last_fail() { tokio::time::sleep(Duration::from_millis(100)).await; let query = UniqueValueByCategoryQuery { unique_value: 99 }; - let result = table.update_unique_value_by_category(query, 1).await; + let result = table.update_partial_unique_value_by_category(query, 1).await; assert!(result.is_err()); let valid_row3 = MixedIdxRow { diff --git a/tests/persistence/failure/update_unsized.rs b/tests/persistence/failure/update_unsized.rs index da29b215..e0991f67 100644 --- a/tests/persistence/failure/update_unsized.rs +++ b/tests/persistence/failure/update_unsized.rs @@ -75,7 +75,7 @@ fn test_update_unsized_same_size() { name: "xxx".to_string(), unique_value: 99, }; - let result = table.update_name_and_value_by_category(query, 1).await; + let result = table.update_partial_name_and_value_by_category(query, 1).await; assert!(result.is_err()); let valid_row3 = NonUniqueUnsizedRow { @@ -162,7 +162,7 @@ fn test_update_unsized_larger_all_success() { unique_value: 20, }; - let result = table.update_name_and_value_by_category(query, 1).await; + let result = table.update_partial_name_and_value_by_category(query, 1).await; assert!(result.is_ok()); let valid_row3 = NonUniqueUnsizedRow { @@ -273,7 +273,7 @@ fn test_update_unsized_larger_middle_fail() { unique_value: 99, }; - let result = table.update_name_and_value_by_category(query, 1).await; + let result = table.update_partial_name_and_value_by_category(query, 1).await; assert!(result.is_err()); let valid_row3 = NonUniqueUnsizedRow { @@ -376,7 +376,7 @@ fn test_update_unsized_larger_last_fail() { unique_value: 99, }; - let result = table.update_name_and_value_by_category(query, 1).await; + let result = table.update_partial_name_and_value_by_category(query, 1).await; assert!(result.is_err()); let valid_row3 = NonUniqueUnsizedRow { diff --git a/tests/persistence/in_place_durability.rs b/tests/persistence/in_place_durability.rs index 154dc6b1..0dd6c8c5 100644 --- a/tests/persistence/in_place_durability.rs +++ b/tests/persistence/in_place_durability.rs @@ -13,7 +13,7 @@ worktable!( note: String, }, queries: { - in_place: { + update_partial_in_place: { CounterById(counter) by id, } } @@ -57,7 +57,7 @@ fn in_place_update_survives_reload() { .await .unwrap(); table - .update_counter_by_id_in_place(|counter| *counter = 42u64.into(), 1) + .update_partial_in_place_counter_by_id(|counter| *counter = 42u64.into(), 1) .await .unwrap(); table.wait_for_ops().await.unwrap(); diff --git a/tests/persistence/mod.rs b/tests/persistence/mod.rs index 157b644f..0039a7ad 100644 --- a/tests/persistence/mod.rs +++ b/tests/persistence/mod.rs @@ -46,7 +46,7 @@ worktable! ( another_idx: another, }, queries: { - update: { + update_partial: { AnotherById(another) by id, }, delete: { diff --git a/tests/persistence/multi_row_backend_order.rs b/tests/persistence/multi_row_backend_order.rs index cbd71a8d..420a8a36 100644 --- a/tests/persistence/multi_row_backend_order.rs +++ b/tests/persistence/multi_row_backend_order.rs @@ -28,7 +28,7 @@ macro_rules! persisted_multi_row_backend_case { group_idx: group_id, }, queries: { - update: { + update_partial: { PayloadByGroup(payload) by group_id, } } @@ -56,7 +56,7 @@ macro_rules! persisted_multi_row_backend_case { let replacement = "new-payload".repeat(64); table - .update_payload_by_group( + .update_partial_payload_by_group( PayloadByGroupQuery { payload: replacement.clone(), }, diff --git a/tests/persistence/same_size_in_place.rs b/tests/persistence/same_size_in_place.rs index 8c36463f..b6479f79 100644 --- a/tests/persistence/same_size_in_place.rs +++ b/tests/persistence/same_size_in_place.rs @@ -13,7 +13,7 @@ worktable!( note: String, }, queries: { - update: { + update_partial: { AmountById(amount) by id, NoteById(note) by id, } @@ -59,7 +59,7 @@ fn same_size_updates_keep_the_row_link() { // Fixed-size column update: archived in-place swap, same slot. table - .update_amount_by_id(AmountByIdQuery { amount: 2 }, 1) + .update_partial_amount_by_id(AmountByIdQuery { amount: 2 }, 1) .await .unwrap(); let link_after_amount = table.0.primary_index.pk_map.get_value(&pk).unwrap().0; @@ -70,7 +70,7 @@ fn same_size_updates_keep_the_row_link() { // Same-length String update: same-size in-place path, same slot. table - .update_note_by_id( + .update_partial_note_by_id( NoteByIdQuery { note: "bbbb".to_string(), }, @@ -86,7 +86,7 @@ fn same_size_updates_keep_the_row_link() { // A size-changing String update must still reinsert correctly. table - .update_note_by_id( + .update_partial_note_by_id( NoteByIdQuery { note: "a-considerably-longer-note".to_string(), }, diff --git a/tests/persistence/sync/failure.rs b/tests/persistence/sync/failure.rs index 70d11c88..39de85f2 100644 --- a/tests/persistence/sync/failure.rs +++ b/tests/persistence/sync/failure.rs @@ -44,14 +44,14 @@ fn test_failed_update_by_pk_doesnt_corrupt_persistence() { let table = TestSyncWorkTable::load(engine).await.unwrap(); let result = table - .update_another_by_id(AnotherByIdQuery { another: 9999 }, 9999) + .update_partial_another_by_id(AnotherByIdQuery { another: 9999 }, 9999) .await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), WorkTableError::NotFound)); for (i, pk) in pks.iter().enumerate() { table - .update_another_by_id( + .update_partial_another_by_id( AnotherByIdQuery { another: i as u64 + 1000, }, @@ -117,14 +117,14 @@ fn test_failed_update_by_unique_index_doesnt_corrupt_persistence() { let table = TestSyncWorkTable::load(engine).await.unwrap(); let result = table - .update_field_by_another(FieldByAnotherQuery { field: 9999.0 }, 9999) + .update_partial_field_by_another(FieldByAnotherQuery { field: 9999.0 }, 9999) .await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), WorkTableError::NotFound)); for (i, _pk) in pks.iter().enumerate() { table - .update_field_by_another( + .update_partial_field_by_another( FieldByAnotherQuery { field: i as f64 + 1000.0, }, diff --git a/tests/persistence/sync/many_strings.rs b/tests/persistence/sync/many_strings.rs index d9082675..f26d9b63 100644 --- a/tests/persistence/sync/many_strings.rs +++ b/tests/persistence/sync/many_strings.rs @@ -12,7 +12,7 @@ worktable! ( another: u64, }, queries: { - update: { + update_partial: { FieldAnotherById(field, another) by id, }, } @@ -63,7 +63,7 @@ fn test_space_update_query_pk_sync() { field: "Some field value".to_string(), another: 0, }; - table.update_field_another_by_id(q, pk.clone()).await.unwrap(); + table.update_partial_field_another_by_id(q, pk.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); } { @@ -122,7 +122,7 @@ fn test_space_update_query_pk_many_times_sync() { field: "Some field value".to_string(), another: i, }; - table.update_field_another_by_id(q, pk.clone()).await.unwrap(); + table.update_partial_field_another_by_id(q, pk.clone()).await.unwrap(); } table.wait_for_ops().await.unwrap(); diff --git a/tests/persistence/sync/mod.rs b/tests/persistence/sync/mod.rs index deeb9f95..f38f7eaf 100644 --- a/tests/persistence/sync/mod.rs +++ b/tests/persistence/sync/mod.rs @@ -31,7 +31,7 @@ worktable! ( non_unique_idx: non_unique }, queries: { - update: { + update_partial: { AnotherById(another) by id, FieldByAnother(field) by another, AnotherByNonUnique(another) by non_unique @@ -236,7 +236,7 @@ fn test_space_update_query_pk_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update_another_by_id(AnotherByIdQuery { another: 13 }, row.id) + .update_partial_another_by_id(AnotherByIdQuery { another: 13 }, row.id) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -281,7 +281,7 @@ fn test_space_update_query_unique_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update_field_by_another(FieldByAnotherQuery { field: 1.0 }, 42) + .update_partial_field_by_another(FieldByAnotherQuery { field: 1.0 }, 42) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -326,7 +326,7 @@ fn test_space_update_query_non_unique_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update_another_by_non_unique(AnotherByNonUniqueQuery { another: 13 }, 10) + .update_partial_another_by_non_unique(AnotherByNonUniqueQuery { another: 13 }, 10) .await .unwrap(); table.wait_for_ops().await.unwrap(); diff --git a/tests/persistence/sync/opaque_unsized_update.rs b/tests/persistence/sync/opaque_unsized_update.rs index 6e959085..217b4007 100644 --- a/tests/persistence/sync/opaque_unsized_update.rs +++ b/tests/persistence/sync/opaque_unsized_update.rs @@ -27,7 +27,7 @@ worktable!( untouched: u64, }, queries: { - update: { + update_partial: { SecretById(secret) by id, } } @@ -75,7 +75,7 @@ fn targeted_update_of_string_wrapper_survives_read_and_reload() { let link_before = link_of(&table, 7); table - .update_secret_by_id( + .update_partial_secret_by_id( SecretByIdQuery { secret: WrappedSecret("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string()), }, @@ -106,7 +106,7 @@ fn targeted_update_of_string_wrapper_survives_read_and_reload() { assert_eq!(row.untouched, 42); table - .update_secret_by_id( + .update_partial_secret_by_id( SecretByIdQuery { secret: WrappedSecret( "a replacement with a deliberately different serialized length".to_string(), diff --git a/tests/persistence/sync/option.rs b/tests/persistence/sync/option.rs index 98e8031a..6beca4c8 100644 --- a/tests/persistence/sync/option.rs +++ b/tests/persistence/sync/option.rs @@ -18,7 +18,7 @@ worktable! ( exchnage_idx: exchange, }, queries: { - update: { + update_partial: { TestById(test) by id, TestByAnother(test) by another, TestByExchange(test) by exchange, @@ -165,7 +165,7 @@ fn test_option_update_full_sync() { #[test] fn test_option_update_by_id_sync() { let config = DiskConfig::new_with_table_name( - "tests/data/option_sync/update_by_id", + "tests/data/option_sync/update_partial_by_id", TestOptionSyncWorkTable::name_snake_case(), TestOptionSyncWorkTable::version(), ); @@ -178,7 +178,7 @@ fn test_option_update_by_id_sync() { .unwrap(); runtime.block_on(async { - remove_dir_if_exists("tests/data/option_sync/update_by_id".to_string()).await; + remove_dir_if_exists("tests/data/option_sync/update_partial_by_id".to_string()).await; let pk = { let engine = TestOptionSyncPersistenceEngine::new(config.clone()).await.unwrap(); @@ -192,7 +192,7 @@ fn test_option_update_by_id_sync() { table.insert(row.clone()).await.unwrap(); table - .update_test_by_id(TestByIdQuery { test: Some(42) }, row.id) + .update_partial_test_by_id(TestByIdQuery { test: Some(42) }, row.id) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -239,7 +239,7 @@ fn test_option_update_none_to_some_sync() { table.insert(row.clone()).await.unwrap(); table - .update_test_by_id(TestByIdQuery { test: Some(55) }, row.id) + .update_partial_test_by_id(TestByIdQuery { test: Some(55) }, row.id) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -286,7 +286,7 @@ fn test_option_update_some_to_none_sync() { table.insert(row.clone()).await.unwrap(); table - .update_test_by_id(TestByIdQuery { test: None }, row.id) + .update_partial_test_by_id(TestByIdQuery { test: None }, row.id) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -333,7 +333,7 @@ fn test_option_update_by_another_sync() { table.insert(row.clone()).await.unwrap(); table - .update_test_by_another(TestByAnotherQuery { test: Some(77) }, 123) + .update_partial_test_by_another(TestByAnotherQuery { test: Some(77) }, 123) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -380,7 +380,7 @@ fn test_option_update_by_exchange_sync() { table.insert(row.clone()).await.unwrap(); table - .update_test_by_exchange(TestByExchangeQuery { test: Some(88) }, 456) + .update_partial_test_by_exchange(TestByExchangeQuery { test: Some(88) }, 456) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -436,7 +436,7 @@ fn test_option_multiple_rows_sync() { let pk2 = table.insert(row2).await.unwrap(); table - .update_test_by_id(TestByIdQuery { test: Some(30) }, pk1.clone()) + .update_partial_test_by_id(TestByIdQuery { test: Some(30) }, pk1.clone()) .await .unwrap(); @@ -468,7 +468,7 @@ worktable! ( exchnage_idx: exchange, }, queries: { - update: { + update_partial: { IndexTestById(test) by id, IndexTestByAnother(test) by another, IndexTestByExchange(test) by exchange, @@ -590,7 +590,7 @@ fn test_option_indexed_update_none_to_some_by_id_sync() { table.insert(row.clone()).await.unwrap(); table - .update_index_test_by_id(IndexTestByIdQuery { test: Some(55) }, row.id) + .update_partial_index_test_by_id(IndexTestByIdQuery { test: Some(55) }, row.id) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -637,7 +637,7 @@ fn test_option_indexed_update_some_to_none_by_id_sync() { table.insert(row.clone()).await.unwrap(); table - .update_index_test_by_id(IndexTestByIdQuery { test: None }, row.id) + .update_partial_index_test_by_id(IndexTestByIdQuery { test: None }, row.id) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -684,7 +684,7 @@ fn test_option_indexed_update_by_another_sync() { table.insert(row.clone()).await.unwrap(); table - .update_index_test_by_another(IndexTestByAnotherQuery { test: Some(77) }, 123) + .update_partial_index_test_by_another(IndexTestByAnotherQuery { test: Some(77) }, 123) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -748,12 +748,12 @@ fn test_option_indexed_multiple_rows_sync() { let pk3 = table.insert(row3).await.unwrap(); table - .update_index_test_by_id(IndexTestByIdQuery { test: Some(40) }, pk1.clone()) + .update_partial_index_test_by_id(IndexTestByIdQuery { test: Some(40) }, pk1.clone()) .await .unwrap(); table - .update_index_test_by_id(IndexTestByIdQuery { test: Some(50) }, pk2.clone()) + .update_partial_index_test_by_id(IndexTestByIdQuery { test: Some(50) }, pk2.clone()) .await .unwrap(); diff --git a/tests/persistence/sync/string_primary_index.rs b/tests/persistence/sync/string_primary_index.rs index 58019b28..64294026 100644 --- a/tests/persistence/sync/string_primary_index.rs +++ b/tests/persistence/sync/string_primary_index.rs @@ -18,7 +18,7 @@ worktable! ( non_unique_idx: non_unique }, queries: { - update: { + update_partial: { AnotherById(another) by id, FieldByAnother(field) by another, AnotherByNonUnique(another) by non_unique @@ -194,7 +194,7 @@ fn test_space_update_query_pk_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update_another_by_id(AnotherByIdQuery { another: 13 }, row.id.clone()) + .update_partial_another_by_id(AnotherByIdQuery { another: 13 }, row.id.clone()) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -238,7 +238,7 @@ fn test_space_update_query_unique_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update_field_by_another(FieldByAnotherQuery { field: 1.0 }, 42) + .update_partial_field_by_another(FieldByAnotherQuery { field: 1.0 }, 42) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -282,7 +282,7 @@ fn test_space_update_query_non_unique_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update_another_by_non_unique(AnotherByNonUniqueQuery { another: 13 }, 10) + .update_partial_another_by_non_unique(AnotherByNonUniqueQuery { another: 13 }, 10) .await .unwrap(); table.wait_for_ops().await.unwrap(); diff --git a/tests/persistence/sync/string_secondary_index.rs b/tests/persistence/sync/string_secondary_index.rs index e4b65ffc..bcd16777 100644 --- a/tests/persistence/sync/string_secondary_index.rs +++ b/tests/persistence/sync/string_secondary_index.rs @@ -19,7 +19,7 @@ worktable! ( non_unique_idx: non_unique }, queries: { - update: { + update_partial: { AnotherById(another) by id, FieldByAnother(field) by another, AnotherByNonUnique(another) by non_unique @@ -302,7 +302,7 @@ fn test_space_update_query_pk_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update_another_by_id( + .update_partial_another_by_id( AnotherByIdQuery { another: "Some string to test updated".to_string(), }, @@ -355,7 +355,7 @@ fn test_space_update_query_unique_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update_field_by_another(FieldByAnotherQuery { field: 1.0 }, "Some string before".to_string()) + .update_partial_field_by_another(FieldByAnotherQuery { field: 1.0 }, "Some string before".to_string()) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -400,7 +400,7 @@ fn test_space_update_query_non_unique_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update_another_by_non_unique( + .update_partial_another_by_non_unique( AnotherByNonUniqueQuery { another: "Some string to test updated".to_string(), }, diff --git a/tests/persistence/sync/string_update_timeout.rs b/tests/persistence/sync/string_update_timeout.rs index 05bab4c0..907aadc2 100644 --- a/tests/persistence/sync/string_update_timeout.rs +++ b/tests/persistence/sync/string_update_timeout.rs @@ -26,7 +26,7 @@ worktable!( fk_app_public_id_idx: fk_app_pub_id, }, queries: { - update: { + update_partial: { DisplayNameByPublicId(display_name) by public_id, UsernameByPublicId(username) by public_id, StatusByPublicId(status) by public_id, diff --git a/tests/runtime_execution.rs b/tests/runtime_execution.rs index 36f438c5..769a724b 100644 --- a/tests/runtime_execution.rs +++ b/tests/runtime_execution.rs @@ -10,9 +10,9 @@ worktable! { columns: { id: u64 primary_key, value: u64, group: u64 }, indexes: { group_idx: group }, queries: { - update runtime scheduled: { ValueById(value) by id }, + update_partial runtime scheduled: { ValueById(value) by id }, delete runtime scheduled: { ByGroup() by group }, - in_place runtime scheduled: { ValueById(value) by id }, + update_partial_in_place runtime scheduled: { ValueById(value) by id }, } } @@ -48,12 +48,12 @@ fn generated_profiles_dispatch_mutations_and_owned_selects() { .unwrap(); } table - .update_value_by_id(ValueByIdQuery { value: 100 }, 9u64) + .update_partial_value_by_id(ValueByIdQuery { value: 100 }, 9u64) .await .unwrap(); let caller = std::thread::current().id(); table - .update_value_by_id_in_place( + .update_partial_in_place_value_by_id( move |value| { assert_ne!(std::thread::current().id(), caller); *value = 101.into(); @@ -103,7 +103,7 @@ fn nested_dispatch_progresses_on_one_worker() { .await .unwrap(); table - .update_value_by_id(ValueByIdQuery { value: 4 }, 1u64) + .update_partial_value_by_id(ValueByIdQuery { value: 4 }, 1u64) .await .unwrap(); table.select_all().runtime(scheduled).execute_async().await.unwrap()[0].value @@ -162,7 +162,7 @@ worktable! { persist: true, runtime: nagoya(shared_slot), columns: { id: u64 primary_key, value: u64 }, - queries: { update runtime scheduled: { DiskValueById(value) by id } } + queries: { update_partial runtime scheduled: { DiskValueById(value) by id } } } #[test] @@ -180,7 +180,7 @@ fn scheduled_mutation_is_persisted_and_reopened() { let table = Arc::new(ScheduledDiskWorkTable::load(engine).await.unwrap()); table.insert(ScheduledDiskRow { id: 1, value: 2 }).await.unwrap(); table - .update_disk_value_by_id(DiskValueByIdQuery { value: 99 }, 1u64) + .update_partial_disk_value_by_id(DiskValueByIdQuery { value: 99 }, 1u64) .await .unwrap(); assert_eq!( @@ -205,7 +205,7 @@ mod tokio_execution { name: TokioScheduled, runtime: tokio, columns: { id: u64 primary_key, value: u64 }, - queries: { in_place runtime on_tokio: { TokioValueById(value) by id } } + queries: { update_partial_in_place runtime on_tokio: { TokioValueById(value) by id } } } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn tokio_profiles_dispatch_on_the_entered_runtime() { @@ -213,7 +213,7 @@ mod tokio_execution { let table = Arc::new(TokioScheduledWorkTable::default()); table.insert(TokioScheduledRow { id: 1, value: 0 }).await.unwrap(); table - .update_tokio_value_by_id_in_place( + .update_partial_in_place_tokio_value_by_id( move |value| { assert_ne!(std::thread::current().id(), caller); *value = 8.into(); @@ -270,7 +270,7 @@ runtimes! { on_spread: nagoya(spread), } worktable! { name: Tunable, columns: { id: u64 primary_key, value: u64 }, - queries: { in_place runtime on_spread: { TunedValue(value) by id } } + queries: { update_partial_in_place runtime on_spread: { TunedValue(value) by id } } } #[test] fn callsites_can_tune_nagoya_without_changing_the_table_default() { @@ -279,7 +279,7 @@ fn callsites_can_tune_nagoya_without_changing_the_table_default() { table.insert(TunableRow { id: 1, value: 2 }).await.unwrap(); let caller = std::thread::current().id(); table - .update_tuned_value_in_place( + .update_partial_in_place_tuned_value( move |value| { assert_ne!(std::thread::current().id(), caller); *value = 3.into(); diff --git a/tests/ui/in_place_over_indexed_column.rs b/tests/ui/in_place_over_indexed_column.rs index b982ac51..36a2295b 100644 --- a/tests/ui/in_place_over_indexed_column.rs +++ b/tests/ui/in_place_over_indexed_column.rs @@ -1,4 +1,4 @@ -// Rule: an `in_place` query writes the archived column bytes and maintains no +// Rule: an `update_partial_in_place` query writes the archived column bytes and maintains no // index, so a column any index is built over cannot be mutated on that path. // The index would keep resolving the old value. use worktable::worktable; @@ -14,7 +14,7 @@ worktable! { value_idx: value unique, }, queries: { - in_place: { + update_partial_in_place: { ValueById(value) by id, } }, diff --git a/tests/ui/in_place_over_indexed_column.stderr b/tests/ui/in_place_over_indexed_column.stderr index 9b615ad9..f1f7a835 100644 --- a/tests/ui/in_place_over_indexed_column.stderr +++ b/tests/ui/in_place_over_indexed_column.stderr @@ -1,4 +1,4 @@ -error: in_place query `ValueById` mutates column `value`, which is covered by an index; indexed columns cannot be updated in place because secondary indexes are not maintained on this path. Use an `update` query instead +error: update_partial_in_place query `ValueById` mutates column `value`, which is covered by an index; indexed columns cannot be updated in place because secondary indexes are not maintained on this path. Use an `update_partial` query instead --> tests/ui/in_place_over_indexed_column.rs:18:23 | 18 | ValueById(value) by id, diff --git a/tests/ui/query_over_unknown_column.rs b/tests/ui/query_over_unknown_column.rs index 4e754589..c0b7db62 100644 --- a/tests/ui/query_over_unknown_column.rs +++ b/tests/ui/query_over_unknown_column.rs @@ -11,7 +11,7 @@ worktable! { value: u64, }, queries: { - update: { + update_partial: { MissingById(missing) by id, } }, diff --git a/tests/ui/runtime_pinned_and_call_site.rs b/tests/ui/runtime_pinned_and_call_site.rs index ddefac6a..e5308c67 100644 --- a/tests/ui/runtime_pinned_and_call_site.rs +++ b/tests/ui/runtime_pinned_and_call_site.rs @@ -28,7 +28,7 @@ worktable! { qty: u64, }, queries: { - update runtime wide: { + update_partial runtime wide: { Fill(qty) by id, } }, diff --git a/tests/ui/runtime_profile_backend_mismatch.rs b/tests/ui/runtime_profile_backend_mismatch.rs index 3baf363b..58d35340 100644 --- a/tests/ui/runtime_profile_backend_mismatch.rs +++ b/tests/ui/runtime_profile_backend_mismatch.rs @@ -23,7 +23,7 @@ worktable! { qty: u64, }, queries: { - update runtime tokio_max: { + update_partial runtime tokio_max: { Fill(qty) by id, } }, diff --git a/tests/ui/runtime_unknown_profile.rs b/tests/ui/runtime_unknown_profile.rs index 9b06c180..8fc8e1e9 100644 --- a/tests/ui/runtime_unknown_profile.rs +++ b/tests/ui/runtime_unknown_profile.rs @@ -22,7 +22,7 @@ worktable! { qty: u64, }, queries: { - update runtime nope: { + update_partial runtime nope: { Fill(qty) by id, } }, diff --git a/tests/worktable/array.rs b/tests/worktable/array.rs index 3927c4ec..68b167c0 100644 --- a/tests/worktable/array.rs +++ b/tests/worktable/array.rs @@ -10,7 +10,7 @@ worktable! ( test: Arr }, queries: { - update: { + update_partial: { TestById(test) by id, } } @@ -60,7 +60,7 @@ async fn update_query() { let row = TestRow { id: 1, test: [1; 20] }; let pk = table.insert(row.clone()).await.unwrap(); let q = TestByIdQuery { test: [2; 20] }; - table.update_test_by_id(q.clone(), pk.clone()).await.unwrap(); + table.update_partial_test_by_id(q.clone(), pk.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row.test, q.test); @@ -76,7 +76,7 @@ worktable! ( test: ArrI }, queries: { - update: { + update_partial: { TestIById(test) by id, } } @@ -126,7 +126,7 @@ async fn update_query_i() { let row = TestIRow { id: 1, test: [1; 20] }; let pk = table.insert(row.clone()).await.unwrap(); let q = TestIByIdQuery { test: [2; 20] }; - table.update_test_i_by_id(q.clone(), pk.clone()).await.unwrap(); + table.update_partial_test_i_by_id(q.clone(), pk.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row.test, q.test); diff --git a/tests/worktable/base.rs b/tests/worktable/base.rs index 1cd957a9..7a9886f2 100644 --- a/tests/worktable/base.rs +++ b/tests/worktable/base.rs @@ -24,7 +24,7 @@ worktable! ( another_idx: another, } queries: { - update: { + update_partial: { AnotherByExchange(another) by exchange, AnotherByTest(another) by test, AnotherById(another) by id, @@ -238,7 +238,7 @@ async fn update_parallel() { let val = fastrand::u64(..); let id_to_update = fastrand::i64(1..=100); shared - .update_another_by_test(AnotherByTestQuery { another: val }, id_to_update) + .update_partial_another_by_test(AnotherByTestQuery { another: val }, id_to_update) .await .unwrap(); { @@ -253,7 +253,7 @@ async fn update_parallel() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); table - .update_another_by_id(AnotherByIdQuery { another: val }, id_to_update) + .update_partial_another_by_id(AnotherByIdQuery { another: val }, id_to_update) .await .unwrap(); { @@ -288,7 +288,7 @@ async fn secondary_update_follows_concurrent_row_relocation() { let writer = tokio::spawn(async move { writer_barrier.wait().await; for revision in 1..=2000 { - writer_table.update_exchange_by_id(ExchangeByIdQuery { + writer_table.update_partial_exchange_by_id(ExchangeByIdQuery { exchange: format!("relocated-{revision}-{}", "x".repeat(revision % 64)), }, 0).await.unwrap(); tokio::task::yield_now().await; @@ -296,7 +296,7 @@ async fn secondary_update_follows_concurrent_row_relocation() { }); barrier.wait().await; for revision in 1..=2000 { - table.update_another_by_test(AnotherByTestQuery { another: revision }, 1) + table.update_partial_another_by_test(AnotherByTestQuery { another: revision }, 1) .await.unwrap(); tokio::task::yield_now().await; } @@ -1152,7 +1152,7 @@ async fn test_update_by_non_unique() { let _ = table.insert(row2.clone()).await.unwrap(); let row = AnotherByExchangeQuery { another: 3 }; - table.update_another_by_exchange(row, "test".to_string()).await.unwrap(); + table.update_partial_another_by_exchange(row, "test".to_string()).await.unwrap(); let all = table.select_all().execute().unwrap(); @@ -1189,7 +1189,7 @@ async fn test_update_by_unique() { let _ = table.insert(row.clone()).await.unwrap(); let row = AnotherByTestQuery { another: 3 }; - table.update_another_by_test(row, 1).await.unwrap(); + table.update_partial_another_by_test(row, 1).await.unwrap(); let row = table.select_by_test(1).unwrap(); @@ -1216,7 +1216,7 @@ async fn test_update_by_pk() { let pk = table.insert(row.clone()).await.unwrap(); let row = AnotherByIdQuery { another: 3 }; - table.update_another_by_id(row, pk).await.unwrap(); + table.update_partial_another_by_id(row, pk).await.unwrap(); let row = table.select_by_test(1).unwrap(); diff --git a/tests/worktable/bench.rs b/tests/worktable/bench.rs index d7d24524..ff68f62d 100644 --- a/tests/worktable/bench.rs +++ b/tests/worktable/bench.rs @@ -14,7 +14,7 @@ worktable!( value: String }, queries: { - update: { + update_partial: { ValueById(value) by id, } } @@ -66,13 +66,13 @@ async fn _rw_lock_hash_map_vs_wt() { for i in 0..100000u64 { let s: String = Alphanumeric.sample_string(&mut rand::rng(), 8); let q = ValueByIdQuery { value: s }; - task_wt.update_value_by_id(q, (i % 50) * 2).await.unwrap(); + task_wt.update_partial_value_by_id(q, (i % 50) * 2).await.unwrap(); } }); for i in 0..100000u64 { let s: String = Alphanumeric.sample_string(&mut rand::rng(), 8); let q = ValueByIdQuery { value: s }; - wt.update_value_by_id(q, (i % 50) * 2 + 1).await.unwrap(); + wt.update_partial_value_by_id(q, (i % 50) * 2 + 1).await.unwrap(); } h.await.unwrap(); println!("wt update in {} μs", wt_start.elapsed().as_micros()); diff --git a/tests/worktable/borrowed_primary_key.rs b/tests/worktable/borrowed_primary_key.rs index aef33b0f..bcc53da7 100644 --- a/tests/worktable/borrowed_primary_key.rs +++ b/tests/worktable/borrowed_primary_key.rs @@ -8,10 +8,10 @@ worktable!( value: u64, }, queries: { - update: { + update_partial: { BorrowedValueById(value) by id, } - in_place: { + update_partial_in_place: { BorrowedValueById(value) by id, } } @@ -43,11 +43,11 @@ async fn string_primary_key_accepts_borrowed_forms() { assert_eq!(table.select(&generated), Some(row)); table - .update_borrowed_value_by_id(BorrowedValueByIdQuery { value: 8 }, &id) + .update_partial_borrowed_value_by_id(BorrowedValueByIdQuery { value: 8 }, &id) .await .unwrap(); table - .update_borrowed_value_by_id_in_place(|value| *value += 1, &id) + .update_partial_in_place_borrowed_value_by_id(|value| *value += 1, &id) .await .unwrap(); assert_eq!(table.select(&id).unwrap().value, 9); diff --git a/tests/worktable/cancel_safety.rs b/tests/worktable/cancel_safety.rs index 0ada7eb6..bcc9caf3 100644 --- a/tests/worktable/cancel_safety.rs +++ b/tests/worktable/cancel_safety.rs @@ -12,7 +12,7 @@ worktable!( other: u64, }, queries: { - update: { + update_partial: { ValueById(value) by id, } } @@ -109,7 +109,7 @@ async fn cancelled_custom_update_releases_registered_lock() { let cancelled = tokio::time::timeout( Duration::from_millis(200), - table.update_value_by_id(ValueByIdQuery { value: 5 }, 3), + table.update_partial_value_by_id(ValueByIdQuery { value: 5 }, 3), ) .await; assert!( @@ -121,7 +121,7 @@ async fn cancelled_custom_update_releases_registered_lock() { tokio::time::timeout( Duration::from_secs(5), - table.update_value_by_id(ValueByIdQuery { value: 11 }, 3), + table.update_partial_value_by_id(ValueByIdQuery { value: 11 }, 3), ) .await .expect("update after a cancelled predecessor must not hang") diff --git a/tests/worktable/columnar.rs b/tests/worktable/columnar.rs index b4ea942a..ac1c6556 100644 --- a/tests/worktable/columnar.rs +++ b/tests/worktable/columnar.rs @@ -40,10 +40,10 @@ worktable!( }, }, queries: { - update: { + update_partial: { TemperatureById(temperature) by id, }, - in_place: { + update_partial_in_place: { TimestampById(timestamp) by id, } }, @@ -158,13 +158,13 @@ async fn columnar_fields_and_clustered_index_follow_mutations() { assert_eq!(table.columnar_project_temperature(&updated).unwrap()[0].1, 75); table - .update_temperature_by_id(TemperatureByIdQuery { temperature: 76 }, 1) + .update_partial_temperature_by_id(TemperatureByIdQuery { temperature: 76 }, 1) .await .unwrap(); assert_eq!(table.columnar_project_temperature(&updated).unwrap()[0].1, 76); table - .update_timestamp_by_id_in_place(|value| *value = 40.into(), 1) + .update_partial_in_place_timestamp_by_id(|value| *value = 40.into(), 1) .await .unwrap(); assert!(table.columnar_is_dirty()); diff --git a/tests/worktable/count.rs b/tests/worktable/count.rs index 7398304b..418e691a 100644 --- a/tests/worktable/count.rs +++ b/tests/worktable/count.rs @@ -19,7 +19,7 @@ worktable!( idx2: attr2 unique, }, queries: { - update: { + update_partial: { ThreeAttrById(attr1, attr2) by id, }, delete: { diff --git a/tests/worktable/delete.rs b/tests/worktable/delete.rs index fb3aaf4b..d58834c3 100644 --- a/tests/worktable/delete.rs +++ b/tests/worktable/delete.rs @@ -13,7 +13,7 @@ worktable!( val2_idx: val2, }, queries: { - update: { + update_partial: { Val1ByToken(val1) by token, }, delete: { diff --git a/tests/worktable/in_place.rs b/tests/worktable/in_place.rs index 5265e524..69b8d167 100644 --- a/tests/worktable/in_place.rs +++ b/tests/worktable/in_place.rs @@ -21,11 +21,11 @@ worktable!( something: u64, }, queries: { - in_place: { + update_partial_in_place: { ValById(val) by id, Val2ById(val2) by id, } - update: { + update_partial: { AnotherById(another) by id, SomethingById(something) by id, } @@ -45,7 +45,7 @@ async fn test_update_val_by_id() -> eyre::Result<()> { }; let pk = table.insert(row).await?; for _ in 0..10000 { - table.update_val_by_id_in_place(|val| *val += 1, pk.0).await? + table.update_partial_in_place_val_by_id(|val| *val += 1, pk.0).await? } let row = table.select(pk).unwrap(); assert_eq!(row.val, 10000); @@ -65,7 +65,7 @@ async fn test_update_val2_by_id() -> eyre::Result<()> { }; let pk = table.insert(row).await?; for _ in 0..100 { - table.update_val_2_by_id_in_place(|val| *val += 1, pk.0).await? + table.update_partial_in_place_val_2_by_id(|val| *val += 1, pk.0).await? } let row = table.select(pk).unwrap(); assert_eq!(row.val2, 100); @@ -88,13 +88,13 @@ async fn test_update_val_by_id_two_thread() -> eyre::Result<()> { let h = tokio::spawn(async move { for _ in 0..10_000 { shared_table - .update_val_by_id_in_place(|val| *val += 1, pk.0) + .update_partial_in_place_val_by_id(|val| *val += 1, pk.0) .await .unwrap() } }); for _ in 0..10_000 { - table.update_val_by_id_in_place(|val| *val += 1, pk.0).await? + table.update_partial_in_place_val_by_id(|val| *val += 1, pk.0).await? } h.await?; let row = table.select(pk).unwrap(); @@ -118,7 +118,7 @@ async fn test_update_val_and_val2_by_id_four_thread() -> eyre::Result<()> { let h1 = tokio::spawn(async move { for _ in 0..10_000 { shared_table - .update_val_by_id_in_place(|val| *val += 1, pk.0) + .update_partial_in_place_val_by_id(|val| *val += 1, pk.0) .await .unwrap() } @@ -127,7 +127,7 @@ async fn test_update_val_and_val2_by_id_four_thread() -> eyre::Result<()> { let h2 = tokio::spawn(async move { for _ in 0..10_000 { shared_table - .update_val_2_by_id_in_place(|val| *val += 1, pk.0) + .update_partial_in_place_val_2_by_id(|val| *val += 1, pk.0) .await .unwrap() } @@ -136,13 +136,13 @@ async fn test_update_val_and_val2_by_id_four_thread() -> eyre::Result<()> { let h3 = tokio::spawn(async move { for _ in 0..10_000 { shared_table - .update_val_by_id_in_place(|val| *val += 1, pk.0) + .update_partial_in_place_val_by_id(|val| *val += 1, pk.0) .await .unwrap() } }); for _ in 0..10_000 { - table.update_val_2_by_id_in_place(|val| *val += 1, pk.0).await? + table.update_partial_in_place_val_2_by_id(|val| *val += 1, pk.0).await? } h1.await?; h2.await?; @@ -169,7 +169,7 @@ async fn test_update_val_by_id_four_thread() -> eyre::Result<()> { let h1 = tokio::spawn(async move { for _ in 0..10_000 { shared_table - .update_val_by_id_in_place(|val| *val += 1, pk.0) + .update_partial_in_place_val_by_id(|val| *val += 1, pk.0) .await .unwrap() } @@ -178,7 +178,7 @@ async fn test_update_val_by_id_four_thread() -> eyre::Result<()> { let h2 = tokio::spawn(async move { for _ in 0..10_000 { shared_table - .update_val_by_id_in_place(|val| *val += 1, pk.0) + .update_partial_in_place_val_by_id(|val| *val += 1, pk.0) .await .unwrap() } @@ -187,13 +187,13 @@ async fn test_update_val_by_id_four_thread() -> eyre::Result<()> { let h3 = tokio::spawn(async move { for _ in 0..10_000 { shared_table - .update_val_by_id_in_place(|val| *val += 1, pk.0) + .update_partial_in_place_val_by_id(|val| *val += 1, pk.0) .await .unwrap() } }); for _ in 0..10_000 { - table.update_val_by_id_in_place(|val| *val += 1, pk.0).await? + table.update_partial_in_place_val_by_id(|val| *val += 1, pk.0).await? } h1.await?; h2.await?; @@ -227,7 +227,7 @@ async fn test_update_in_place_and_update_sized_multithread() -> eyre::Result<()> let val = fastrand::i64(..); let id_to_update = fastrand::u64(0..=99); shared - .update_val_by_id_in_place(|v| *v = val.into(), id_to_update) + .update_partial_in_place_val_by_id(|v| *v = val.into(), id_to_update) .await .unwrap(); { @@ -243,7 +243,7 @@ async fn test_update_in_place_and_update_sized_multithread() -> eyre::Result<()> let val = fastrand::i16(..); let id_to_update = fastrand::u64(0..=99); shared - .update_val_2_by_id_in_place(|v| *v = val.into(), id_to_update) + .update_partial_in_place_val_2_by_id(|v| *v = val.into(), id_to_update) .await .unwrap(); { @@ -257,7 +257,7 @@ async fn test_update_in_place_and_update_sized_multithread() -> eyre::Result<()> let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); table - .update_something_by_id(SomethingByIdQuery { something: val }, id_to_update) + .update_partial_something_by_id(SomethingByIdQuery { something: val }, id_to_update) .await?; { let mut guard = i_state.lock(); @@ -306,7 +306,7 @@ async fn test_update_in_place_and_update_unsized_multithread() -> eyre::Result<( let val = fastrand::i64(..); let id_to_update = fastrand::u64(0..=99); shared - .update_val_by_id_in_place(|v| *v = val.into(), id_to_update) + .update_partial_in_place_val_by_id(|v| *v = val.into(), id_to_update) .await .unwrap(); { @@ -322,7 +322,7 @@ async fn test_update_in_place_and_update_unsized_multithread() -> eyre::Result<( let val = fastrand::i16(..); let id_to_update = fastrand::u64(0..=99); shared - .update_val_2_by_id_in_place(|v| *v = val.into(), id_to_update) + .update_partial_in_place_val_2_by_id(|v| *v = val.into(), id_to_update) .await .unwrap(); { @@ -336,7 +336,7 @@ async fn test_update_in_place_and_update_unsized_multithread() -> eyre::Result<( let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); table - .update_another_by_id( + .update_partial_another_by_id( AnotherByIdQuery { another: format!("another_{val}"), }, diff --git a/tests/worktable/index/mod.rs b/tests/worktable/index/mod.rs index dae8dc52..8fb532ed 100644 --- a/tests/worktable/index/mod.rs +++ b/tests/worktable/index/mod.rs @@ -24,7 +24,7 @@ worktable!( idx3: attr3 unique, }, queries: { - update: { + update_partial: { UniqueThreeAttrById(attr1, attr2, attr3) by id, UniqueTwoAttrByThird(attr1, attr2) by attr3, }, @@ -50,7 +50,7 @@ worktable!( idx3: attr3, }, queries: { - update: { + update_partial: { ThreeAttrById(attr1, attr2, attr3) by id, TwoAttrByThird(attr1, attr2) by attr3, }, @@ -75,7 +75,7 @@ worktable!( idx2: attr2, }, queries: { - update: { + update_partial: { AllAttrById(attr1, attr2) by id, }, delete: { @@ -103,7 +103,7 @@ async fn update_2_idx() { let pk = test_table.insert(row.clone()).await.unwrap(); test_table - .update_all_attr_by_id( + .update_partial_all_attr_by_id( AllAttrByIdQuery { attr1: attr1_new.clone(), attr2: attr2_new, @@ -181,7 +181,7 @@ worktable!( idx1: attr1, }, queries: { - update: { + update_partial: { ValByAttr(val) by attr1, Attr1ById(attr1) by id, }, @@ -209,7 +209,7 @@ async fn update_1_idx() { let pk = test_table.insert(row.clone()).await.unwrap(); test_table - .update_attr_1_by_id( + .update_partial_attr_1_by_id( Attr1ByIdQuery { attr1: attr1_new.clone(), }, diff --git a/tests/worktable/index/update_by_pk.rs b/tests/worktable/index/update_by_pk.rs index d4877ba1..4d3fa916 100644 --- a/tests/worktable/index/update_by_pk.rs +++ b/tests/worktable/index/update_by_pk.rs @@ -27,7 +27,7 @@ async fn update_by_pk_unique_indexes() { let pk = test_table.insert(row.clone()).await.unwrap(); test_table - .update_unique_three_attr_by_id( + .update_partial_unique_three_attr_by_id( UniqueThreeAttrByIdQuery { attr1: attr1_new.clone(), attr2: attr2_new, @@ -77,7 +77,7 @@ async fn update_by_pk_non_unique_indexes() { let pk = test_table.insert(row.clone()).await.unwrap(); test_table - .update_three_attr_by_id( + .update_partial_three_attr_by_id( ThreeAttrByIdQuery { attr1: attr1_new.clone(), attr2: attr2_new, @@ -132,7 +132,7 @@ async fn update_by_pk_with_reinsert_and_secondary_unique_violation() { }; assert!( test_table - .update_unique_three_attr_by_id(update, row1.id) + .update_partial_unique_three_attr_by_id(update, row1.id) .await .is_err() ); @@ -173,7 +173,7 @@ async fn update_by_pk_with_secondary_unique_violation() { }; assert!( test_table - .update_unique_three_attr_by_id(update, row1.id) + .update_partial_unique_three_attr_by_id(update, row1.id) .await .is_err() ); diff --git a/tests/worktable/index/update_query.rs b/tests/worktable/index/update_query.rs index 6964ec25..8da5411f 100644 --- a/tests/worktable/index/update_query.rs +++ b/tests/worktable/index/update_query.rs @@ -25,7 +25,7 @@ async fn update_two_via_query_unique_indexes() { let _ = test_table.insert(row.clone()).await.unwrap(); test_table - .update_unique_two_attr_by_third( + .update_partial_unique_two_attr_by_third( UniqueTwoAttrByThirdQuery { attr1: attr1_new.clone(), attr2: attr2_new, @@ -75,7 +75,7 @@ async fn update_with_reinsert_and_secondary_unique_violation() { }; assert!( test_table - .update_unique_two_attr_by_third(update, row1.attr3,) + .update_partial_unique_two_attr_by_third(update, row1.attr3,) .await .is_err() ); @@ -115,7 +115,7 @@ async fn update_with_secondary_unique_violation() { }; assert!( test_table - .update_unique_two_attr_by_third(update, row1.attr3) + .update_partial_unique_two_attr_by_third(update, row1.attr3) .await .is_err() ); @@ -150,7 +150,7 @@ async fn update_two_via_query_non_unique_indexes() { let _ = test_table.insert(row.clone()).await.unwrap(); test_table - .update_two_attr_by_third( + .update_partial_two_attr_by_third( TwoAttrByThirdQuery { attr1: attr1_new, attr2: attr2_new, diff --git a/tests/worktable/leak_probe.rs b/tests/worktable/leak_probe.rs index e0aa521a..fa95fbb8 100644 --- a/tests/worktable/leak_probe.rs +++ b/tests/worktable/leak_probe.rs @@ -16,7 +16,7 @@ worktable!( payload: String, }, queries: { - update: { + update_partial: { Payload(payload) by id, } } @@ -42,7 +42,7 @@ async fn update_churn_reclaims_under_concurrent_readers() { .unwrap(); for i in 0..100u64 { table - .update_payload( + .update_partial_payload( PayloadQuery { payload: format!("{:04}", i % 10000), }, @@ -72,7 +72,7 @@ async fn update_churn_reclaims_under_concurrent_readers() { for i in 0..5_000u64 { table - .update_payload( + .update_partial_payload( PayloadQuery { payload: format!("{:04}", i % 10000), }, @@ -114,7 +114,7 @@ async fn update_churn_does_not_grow_storage_unbounded() { let pages_after_warmup = { for i in 0..100u64 { table - .update_payload( + .update_partial_payload( PayloadQuery { payload: format!("{:04}", i % 10000), }, @@ -128,7 +128,7 @@ async fn update_churn_does_not_grow_storage_unbounded() { for i in 0..5_000u64 { table - .update_payload( + .update_partial_payload( PayloadQuery { payload: format!("{:04}", i % 10000), }, diff --git a/tests/worktable/lock_order.rs b/tests/worktable/lock_order.rs index 519e58e4..20d2c9fb 100644 --- a/tests/worktable/lock_order.rs +++ b/tests/worktable/lock_order.rs @@ -17,7 +17,7 @@ worktable!( group_b_idx: group_b, }, queries: { - update: { + update_partial: { ValueByGroupA(value) by group_a, ValueByGroupB(value) by group_b, } @@ -96,11 +96,11 @@ async fn multi_row_update_locks_in_primary_key_order_not_index_order() { let update = tokio::spawn(async move { if use_group_a { update_table - .update_value_by_group_a(ValueByGroupAQuery { value: 1 }, 1) + .update_partial_value_by_group_a(ValueByGroupAQuery { value: 1 }, 1) .await } else { update_table - .update_value_by_group_b(ValueByGroupBQuery { value: 1 }, 1) + .update_partial_value_by_group_b(ValueByGroupBQuery { value: 1 }, 1) .await } }); diff --git a/tests/worktable/multi_row_deadlock.rs b/tests/worktable/multi_row_deadlock.rs index 015c23ac..920e5841 100644 --- a/tests/worktable/multi_row_deadlock.rs +++ b/tests/worktable/multi_row_deadlock.rs @@ -17,7 +17,7 @@ worktable!( group_b_idx: group_b, }, queries: { - update: { + update_partial: { NameByGroupA(name) by group_a, NameByGroupB(name) by group_b, } @@ -58,7 +58,7 @@ async fn overlapping_multi_row_updates_do_not_deadlock() { "a-much-longer-name-value".to_string() }; table - .update_name_by_group_a(NameByGroupAQuery { name }, 1) + .update_partial_name_by_group_a(NameByGroupAQuery { name }, 1) .await .unwrap(); } @@ -74,7 +74,7 @@ async fn overlapping_multi_row_updates_do_not_deadlock() { "b-much-longer-name-value".to_string() }; table - .update_name_by_group_b(NameByGroupBQuery { name }, 1) + .update_partial_name_by_group_b(NameByGroupBQuery { name }, 1) .await .unwrap(); } diff --git a/tests/worktable/mutation_gate_deadlock.rs b/tests/worktable/mutation_gate_deadlock.rs index 0d716367..a10ee96a 100644 --- a/tests/worktable/mutation_gate_deadlock.rs +++ b/tests/worktable/mutation_gate_deadlock.rs @@ -1,7 +1,7 @@ //! Guard test for the synchronous mutation gate (`LockMap::mutation_guard`). //! //! The gate is a blocking spin/yield ticket lock, and the generated -//! `update`/`in_place`/`delete` paths hold the resulting `MutationGuard` inside +//! `update_partial`/`update_partial_in_place`/`delete` paths hold the resulting `MutationGuard` inside //! the `LockGuard` **across `.await`** (e.g. `update_with_guard(...).await`, //! `reinsert(...).await`). A review flagged this as a possible livelock/deadlock //! when two keys collide on the same 1-of-64 stripe on a constrained runtime. @@ -30,7 +30,7 @@ worktable!( val: u64, }, queries: { - update: { + update_partial: { Val(val) by id, } } @@ -78,7 +78,7 @@ fn concurrent_same_stripe_updates_do_not_deadlock() { let table = table.clone(); tokio::spawn(async move { for i in 0..500u64 { - table.update_val(ValQuery { val: i }, a).await.unwrap(); + table.update_partial_val(ValQuery { val: i }, a).await.unwrap(); } }) }; @@ -86,7 +86,7 @@ fn concurrent_same_stripe_updates_do_not_deadlock() { let table = table.clone(); tokio::spawn(async move { for i in 0..500u64 { - table.update_val(ValQuery { val: i }, b).await.unwrap(); + table.update_partial_val(ValQuery { val: i }, b).await.unwrap(); } }) }; @@ -127,7 +127,7 @@ fn many_same_stripe_updates_do_not_starve_worker_pool() { let key = if worker % 2 == 0 { a } else { b }; handles.push(tokio::spawn(async move { for i in 0..300u64 { - table.update_val(ValQuery { val: i }, key).await.unwrap(); + table.update_partial_val(ValQuery { val: i }, key).await.unwrap(); } })); } diff --git a/tests/worktable/nonunique_arctic.rs b/tests/worktable/nonunique_arctic.rs index 0267b85d..34398439 100644 --- a/tests/worktable/nonunique_arctic.rs +++ b/tests/worktable/nonunique_arctic.rs @@ -26,7 +26,7 @@ worktable! { weight_idx: weight using arctic, }, queries: { - update: { + update_partial: { SourceById(source_hash) by id, WeightBySource(weight) by source_hash, }, @@ -93,7 +93,7 @@ async fn update_moves_a_row_between_non_unique_keys() { table.insert(row(&table, SOURCE_A, 2, 20)).await.unwrap(); table - .update_source_by_id(SourceByIdQuery { source_hash: SOURCE_B }, pk.clone()) + .update_partial_source_by_id(SourceByIdQuery { source_hash: SOURCE_B }, pk.clone()) .await .unwrap(); @@ -106,7 +106,7 @@ async fn update_moves_a_row_between_non_unique_keys() { // Updating by the non-unique key touches every row under it. table - .update_weight_by_source(WeightBySourceQuery { weight: 777 }, SOURCE_A) + .update_partial_weight_by_source(WeightBySourceQuery { weight: 777 }, SOURCE_A) .await .unwrap(); let rows = table.select_by_weight(777).execute().unwrap(); diff --git a/tests/worktable/option.rs b/tests/worktable/option.rs index 7c82185d..0937e4dd 100644 --- a/tests/worktable/option.rs +++ b/tests/worktable/option.rs @@ -16,7 +16,7 @@ worktable! ( exchnage_idx: exchange, }, queries: { - update: { + update_partial: { TestById(test) by id, TestByAnother(test) by another, TestByExchange(test) by exchange, @@ -56,7 +56,7 @@ async fn update_by_another() { }; let pk = table.insert(row.clone()).await.unwrap(); table - .update_test_by_another(TestByAnotherQuery { test: Some(1) }, 1) + .update_partial_test_by_another(TestByAnotherQuery { test: Some(1) }, 1) .await .unwrap(); let selected_row = table.select(pk).unwrap(); @@ -74,7 +74,7 @@ async fn update_by_exchange() { }; let pk = table.insert(row.clone()).await.unwrap(); table - .update_test_by_exchange(TestByExchangeQuery { test: Some(1) }, 1) + .update_partial_test_by_exchange(TestByExchangeQuery { test: Some(1) }, 1) .await .unwrap(); let selected_row = table.select(pk).unwrap(); @@ -94,7 +94,7 @@ async fn update_none_to_some() { assert_eq!(table.select(pk.clone()).unwrap().test, None); table - .update_test_by_id(TestByIdQuery { test: Some(42) }, pk.clone()) + .update_partial_test_by_id(TestByIdQuery { test: Some(42) }, pk.clone()) .await .unwrap(); @@ -115,7 +115,7 @@ async fn update_some_to_none() { assert_eq!(table.select(pk.clone()).unwrap().test, Some(100)); table - .update_test_by_id(TestByIdQuery { test: None }, pk.clone()) + .update_partial_test_by_id(TestByIdQuery { test: None }, pk.clone()) .await .unwrap(); @@ -144,7 +144,7 @@ async fn update_multiple_values() { let pk2 = table.insert(row2).await.unwrap(); table - .update_test_by_id(TestByIdQuery { test: Some(30) }, pk1.clone()) + .update_partial_test_by_id(TestByIdQuery { test: Some(30) }, pk1.clone()) .await .unwrap(); @@ -165,7 +165,7 @@ worktable! ( exchnage_idx: exchange, }, queries: { - update: { + update_partial: { CustomTestById(test) by id, CustomTestByAnother(test) by another, CustomTestByExchange(test) by exchange, @@ -207,7 +207,7 @@ async fn custom_update_by_another() { let pk = table.insert(row.clone()).await.unwrap(); let test_uuid = Uuid::new_v4(); table - .update_custom_test_by_another(CustomTestByAnotherQuery { test: Some(test_uuid) }, 1) + .update_partial_custom_test_by_another(CustomTestByAnotherQuery { test: Some(test_uuid) }, 1) .await .unwrap(); let selected_row = table.select(pk).unwrap(); @@ -226,7 +226,7 @@ async fn custom_update_by_exchange() { let pk = table.insert(row.clone()).await.unwrap(); let test_uuid = Uuid::new_v4(); table - .update_custom_test_by_exchange(CustomTestByExchangeQuery { test: Some(test_uuid) }, 1) + .update_partial_custom_test_by_exchange(CustomTestByExchangeQuery { test: Some(test_uuid) }, 1) .await .unwrap(); let selected_row = table.select(pk).unwrap(); @@ -247,7 +247,7 @@ async fn custom_update_none_to_some() { let test_uuid = Uuid::new_v4(); table - .update_custom_test_by_id(CustomTestByIdQuery { test: Some(test_uuid) }, pk.clone()) + .update_partial_custom_test_by_id(CustomTestByIdQuery { test: Some(test_uuid) }, pk.clone()) .await .unwrap(); @@ -269,7 +269,7 @@ async fn custom_update_some_to_none() { assert_eq!(table.select(pk.clone()).unwrap().test, Some(test_uuid)); table - .update_custom_test_by_id(CustomTestByIdQuery { test: None }, pk.clone()) + .update_partial_custom_test_by_id(CustomTestByIdQuery { test: None }, pk.clone()) .await .unwrap(); @@ -301,7 +301,7 @@ async fn custom_update_multiple_uuids() { let uuid3 = Uuid::new_v4(); table - .update_custom_test_by_id(CustomTestByIdQuery { test: Some(uuid3) }, pk1.clone()) + .update_partial_custom_test_by_id(CustomTestByIdQuery { test: Some(uuid3) }, pk1.clone()) .await .unwrap(); @@ -323,7 +323,7 @@ worktable! ( exchnage_idx: exchange, }, queries: { - update: { + update_partial: { IndexTestById(test) by id, IndexTestByAnother(test) by another, IndexTestByExchange(test) by exchange, @@ -462,7 +462,7 @@ async fn indexed_update_indexed_field() { // Update to a new UUID let uuid2 = Uuid::new_v4(); table - .update_index_test_by_id(IndexTestByIdQuery { test: Some(uuid2) }, pk.clone()) + .update_partial_index_test_by_id(IndexTestByIdQuery { test: Some(uuid2) }, pk.clone()) .await .unwrap(); @@ -495,7 +495,7 @@ async fn indexed_update_from_some_to_none() { // Update to None table - .update_index_test_by_id(IndexTestByIdQuery { test: None }, pk.clone()) + .update_partial_index_test_by_id(IndexTestByIdQuery { test: None }, pk.clone()) .await .unwrap(); @@ -528,7 +528,7 @@ async fn indexed_update_from_none_to_some() { // Update to Some UUID let test_uuid = Uuid::new_v4(); table - .update_index_test_by_id(IndexTestByIdQuery { test: Some(test_uuid) }, pk.clone()) + .update_partial_index_test_by_id(IndexTestByIdQuery { test: Some(test_uuid) }, pk.clone()) .await .unwrap(); @@ -558,7 +558,7 @@ async fn indexed_update_via_another_index() { // Update via the unique 'another' index table - .update_index_test_by_another(IndexTestByAnotherQuery { test: Some(uuid2) }, 999) + .update_partial_index_test_by_another(IndexTestByAnotherQuery { test: Some(uuid2) }, 999) .await .unwrap(); @@ -594,7 +594,7 @@ async fn indexed_update_via_non_unique_index() { // Update both rows via the non-unique 'exchange' index table - .update_index_test_by_exchange(IndexTestByExchangeQuery { test: Some(uuid2) }, 100) + .update_partial_index_test_by_exchange(IndexTestByExchangeQuery { test: Some(uuid2) }, 100) .await .unwrap(); diff --git a/tests/worktable/partitioned.rs b/tests/worktable/partitioned.rs index 6522999d..fef07416 100644 --- a/tests/worktable/partitioned.rs +++ b/tests/worktable/partitioned.rs @@ -833,7 +833,7 @@ fn the_declared_width_is_a_bound_at_run_time_too() { #[test] fn a_column_is_updated_without_cloning_the_row() { - // The method web3.trading's `update_top_price` wants: touch one field of a + // The method web3.trading's `update_partial_top_price` wants: touch one field of a // wide row rather than reading it out, editing it and writing it back. let ticks = TickPartitions::new(); let book = ticks.partition_or_create(2).expect("a fresh partition"); @@ -984,7 +984,7 @@ fn an_empty_dense_partition_allocates_nothing() { // A dense partition carries `queries:`, keyed by position. // // This is what decides whether the shape is adoptable: web3.trading's -// `update_top_price` and `update_full` go through declared update queries, and +// `update_partial_top_price` and `update_full` go through declared update queries, and // a payload that could not carry them would be a payload they cannot use. worktable!( name: Quoted, @@ -997,7 +997,7 @@ worktable!( seq: u64 }, queries: { - update: { + update_partial: { TopPrice(bid, ask) by exchange_id, }, delete: { @@ -1021,7 +1021,7 @@ fn a_dense_partition_carries_its_update_queries() { // The same method name and the same query struct the paged table generates, // so the call reads the same. What differs is that there is no `.await`. assert_eq!( - book.update_top_price(TopPriceQuery { bid: 9.0, ask: 10.0 }, &2), + book.update_partial_top_price(TopPriceQuery { bid: 9.0, ask: 10.0 }, &2), Some(()) ); @@ -1030,7 +1030,7 @@ fn a_dense_partition_carries_its_update_queries() { assert_eq!(row.seq, 7, "a column the query does not name is untouched"); assert_eq!( - book.update_top_price(TopPriceQuery { bid: 0.0, ask: 0.0 }, &3), + book.update_partial_top_price(TopPriceQuery { bid: 0.0, ask: 0.0 }, &3), None, "a key holding no row updates nothing" ); diff --git a/tests/worktable/runtime_backends.rs b/tests/worktable/runtime_backends.rs index 61c6bc95..2e22e1bd 100644 --- a/tests/worktable/runtime_backends.rs +++ b/tests/worktable/runtime_backends.rs @@ -11,7 +11,7 @@ //! The body covers four things, and each one is here because leaving it out //! would let a broken backend pass: //! -//! 1. `insert` / `select` / `update` / `delete` / `in_place`, so a backend that +//! 1. `insert` / `select` / `update_partial` / `delete` / `update_partial_in_place`, so a backend that //! compiles but cannot drive a mutation is caught. //! 2. A persisted table opened, written, closed and reloaded, so a backend //! whose spawn or timer never reaches the persistence worker is caught. @@ -92,7 +92,7 @@ macro_rules! runtime_backend_suite { // The in-memory table. Carries an indexed column so `update` and // `delete` have index maintenance to do, and a plain one so - // `in_place` has somewhere to write that no index watches. + // `update_partial_in_place` has somewhere to write that no index watches. worktable!( name: RuntimeMatrix, persist: false, @@ -107,13 +107,13 @@ macro_rules! runtime_backend_suite { bucket_idx: bucket, }, queries: { - update: { + update_partial: { BucketById(bucket) by id, }, delete: { ByBucket() by bucket, }, - in_place: { + update_partial_in_place: { CounterById(counter) by id, } } @@ -138,10 +138,10 @@ macro_rules! runtime_backend_suite { bucket_idx: bucket, }, queries: { - update: { + update_partial: { PersistBucketById(bucket) by id, }, - in_place: { + update_partial_in_place: { PersistCounterById(counter) by id, } } @@ -241,7 +241,7 @@ macro_rules! runtime_backend_suite { // update, which also has to move the row between index buckets table - .update_bucket_by_id(BucketByIdQuery { bucket: 3 }, ids[0]) + .update_partial_bucket_by_id(BucketByIdQuery { bucket: 3 }, ids[0]) .await .unwrap(); assert_eq!(table.select(ids[0]).unwrap().bucket, 3); @@ -255,7 +255,7 @@ macro_rules! runtime_backend_suite { // republishing the row for _ in 0..64 { table - .update_counter_by_id_in_place(|counter| *counter += 1u64, ids[1]) + .update_partial_in_place_counter_by_id(|counter| *counter += 1u64, ids[1]) .await .unwrap(); } @@ -351,11 +351,11 @@ macro_rules! runtime_backend_suite { table.insert(row(id)).await.unwrap(); } table - .update_persist_bucket_by_id(PersistBucketByIdQuery { bucket: 3 }, 1) + .update_partial_persist_bucket_by_id(PersistBucketByIdQuery { bucket: 3 }, 1) .await .unwrap(); table - .update_persist_counter_by_id_in_place(|counter| *counter = 4_242u64.into(), 2) + .update_partial_in_place_persist_counter_by_id(|counter| *counter = 4_242u64.into(), 2) .await .unwrap(); diff --git a/tests/worktable/unique_fixed_unsized.rs b/tests/worktable/unique_fixed_unsized.rs index e81f0c85..89789b5a 100644 --- a/tests/worktable/unique_fixed_unsized.rs +++ b/tests/worktable/unique_fixed_unsized.rs @@ -17,7 +17,7 @@ worktable!( code_idx: code unique, }, queries: { - update: { + update_partial: { AmountByCode(amount) by code, } } @@ -37,7 +37,7 @@ async fn unique_keyed_fixed_size_update_on_unsized_row_works() { .unwrap(); table - .update_amount_by_code(AmountByCodeQuery { amount: 55 }, 10) + .update_partial_amount_by_code(AmountByCodeQuery { amount: 55 }, 10) .await .unwrap(); diff --git a/tests/worktable/unsized_.rs b/tests/worktable/unsized_.rs index 80f8faa5..b418b2ce 100644 --- a/tests/worktable/unsized_.rs +++ b/tests/worktable/unsized_.rs @@ -24,7 +24,7 @@ worktable! ( another_idx: another, } queries: { - update: { + update_partial: { ExchangeByTest(exchange) by test, ExchangeById(exchange) by id, ExchangeByAbother(exchange) by another, @@ -87,7 +87,7 @@ async fn test_update_string_by_unique() { let row = ExchangeByTestQuery { exchange: "bigger test to test string update".to_string(), }; - table.update_exchange_by_test(row, 1).await.unwrap(); + table.update_partial_exchange_by_test(row, 1).await.unwrap(); let row = table.select_by_test(1).unwrap(); @@ -122,7 +122,7 @@ async fn test_update_string_by_pk() { let row = ExchangeByIdQuery { exchange: "bigger test to test string update".to_string(), }; - table.update_exchange_by_id(row, pk).await.unwrap(); + table.update_partial_exchange_by_id(row, pk).await.unwrap(); let row = table.select_by_test(1).unwrap(); @@ -165,7 +165,7 @@ async fn test_update_string_by_non_unique() { let row = ExchangeByAbotherQuery { exchange: "bigger test to test string update".to_string(), }; - table.update_exchange_by_abother(row, 1).await.unwrap(); + table.update_partial_exchange_by_abother(row, 1).await.unwrap(); let all = table.select_all().execute().unwrap(); @@ -219,7 +219,7 @@ async fn update_many_times() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); table - .update_exchange_by_id( + .update_partial_exchange_by_id( ExchangeByIdQuery { exchange: format!("test_{val}"), }, @@ -261,7 +261,7 @@ async fn update_parallel() { let val = fastrand::u64(..); let id_to_update = fastrand::i64(1..=100); shared - .update_exchange_by_test( + .update_partial_exchange_by_test( ExchangeByTestQuery { exchange: format!("test_{val}"), }, @@ -284,7 +284,7 @@ async fn update_parallel() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); table - .update_exchange_by_id( + .update_partial_exchange_by_id( ExchangeByIdQuery { exchange: format!("test_{val}"), }, @@ -326,7 +326,7 @@ worktable! ( another_idx: another, } queries: { - update: { + update_partial: { ExchangeAndSomeByTest(exchange, some_string) by test, ExchangeAndSomeById(exchange, some_string) by id, ExchangeAgainById(exchange) by id, @@ -356,7 +356,7 @@ async fn test_update_many_strings_by_unique() { exchange: "bigger test to test string update".to_string(), some_string: "some bigger some to test".to_string(), }; - table.update_exchange_and_some_by_test(row, 1).await.unwrap(); + table.update_partial_exchange_and_some_by_test(row, 1).await.unwrap(); let row = table.select_by_test(1).unwrap(); @@ -396,7 +396,7 @@ async fn test_update_many_strings_by_pk() { exchange: "bigger test to test string update".to_string(), some_string: "some bigger some to test".to_string(), }; - table.update_exchange_and_some_by_id(row, pk).await.unwrap(); + table.update_partial_exchange_and_some_by_id(row, pk).await.unwrap(); let row = table.select_by_test(1).unwrap(); @@ -446,7 +446,7 @@ async fn test_update_many_strings_by_non_unique() { exchange: "bigger test to test string update".to_string(), some_string: "some bigger some to test".to_string(), }; - table.update_exchange_and_some_by_another(row, 1).await.unwrap(); + table.update_partial_exchange_and_some_by_another(row, 1).await.unwrap(); let all = table.select_all().execute().unwrap(); @@ -516,7 +516,7 @@ async fn test_update_many_strings_by_string() { some_string: "some bigger some to test".to_string(), }; table - .update_some_other_by_exchange(row, "test".to_string()) + .update_partial_some_other_by_exchange(row, "test".to_string()) .await .unwrap(); @@ -582,7 +582,7 @@ async fn update_parallel_more_strings() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); shared - .update_exchange_again_by_id( + .update_partial_exchange_again_by_id( ExchangeAgainByIdQuery { exchange: format!("test_{val}"), }, @@ -603,7 +603,7 @@ async fn update_parallel_more_strings() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); table - .update_some_by_id( + .update_partial_some_by_id( SomeByIdQuery { some_string: format!("some_{val}"), }, @@ -655,7 +655,7 @@ async fn update_parallel_more_strings_more_threads() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); shared - .update_exchange_again_by_id( + .update_partial_exchange_again_by_id( ExchangeAgainByIdQuery { exchange: format!("test_{val}"), }, @@ -679,7 +679,7 @@ async fn update_parallel_more_strings_more_threads() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); shared - .update_another_by_id(AnotherByIdQuery { another: val }, id_to_update) + .update_partial_another_by_id(AnotherByIdQuery { another: val }, id_to_update) .await .unwrap(); { @@ -692,7 +692,7 @@ async fn update_parallel_more_strings_more_threads() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); table - .update_some_by_id( + .update_partial_some_by_id( SomeByIdQuery { some_string: format!("some_{val}"), }, @@ -750,7 +750,7 @@ async fn update_parallel_more_strings_with_select_non_unique() { let val = fastrand::u8(0..100); let id_to_update = fastrand::u64(0..1000); shared - .update_exchange_again_by_id( + .update_partial_exchange_again_by_id( ExchangeAgainByIdQuery { exchange: format!("test_{val}"), }, @@ -774,7 +774,7 @@ async fn update_parallel_more_strings_with_select_non_unique() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..1000); shared - .update_another_by_id(AnotherByIdQuery { another: val }, id_to_update) + .update_partial_another_by_id(AnotherByIdQuery { another: val }, id_to_update) .await .unwrap(); { @@ -889,7 +889,7 @@ async fn update_parallel_more_strings_with_select_unique() { let val = fastrand::u8(0..100); let id_to_update = fastrand::u64(0..1000); shared - .update_exchange_again_by_id( + .update_partial_exchange_again_by_id( ExchangeAgainByIdQuery { exchange: format!("test_{val}"), }, @@ -913,7 +913,7 @@ async fn update_parallel_more_strings_with_select_unique() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..1000); shared - .update_another_by_id(AnotherByIdQuery { another: val }, id_to_update) + .update_partial_another_by_id(AnotherByIdQuery { another: val }, id_to_update) .await .unwrap(); { diff --git a/tests/worktable/update_delete_race.rs b/tests/worktable/update_delete_race.rs index 7c05ccab..1f9d7dd1 100644 --- a/tests/worktable/update_delete_race.rs +++ b/tests/worktable/update_delete_race.rs @@ -12,7 +12,7 @@ worktable!( value: u64, }, queries: { - update: { + update_partial: { NameById(name) by id, } } @@ -45,7 +45,7 @@ async fn concurrent_update_and_delete_never_panics() { } else { "a-longer-replacement-name".to_string() }; - match table.update_name_by_id(NameByIdQuery { name }, 1).await { + match table.update_partial_name_by_id(NameByIdQuery { name }, 1).await { Ok(()) => {} // Deleted concurrently: legal, retried next iteration. Err(WorkTableError::NotFound) => {} diff --git a/tests/worktable/update_in_place_unsized.rs b/tests/worktable/update_in_place_unsized.rs index a40488ad..f837b741 100644 --- a/tests/worktable/update_in_place_unsized.rs +++ b/tests/worktable/update_in_place_unsized.rs @@ -47,7 +47,7 @@ macro_rules! unsized_in_place_suite { balance: f64, }, queries: { - update: { + update_partial: { Payload(payload) by id, Balance(balance) by id, } @@ -81,7 +81,7 @@ macro_rules! unsized_in_place_suite { let before = link_of(&table, 1); table - .update_payload( + .update_partial_payload( PayloadQuery { payload: "12345678".to_string(), // 8 bytes — same length }, @@ -113,7 +113,7 @@ macro_rules! unsized_in_place_suite { .await.unwrap(); table - .update_payload( + .update_partial_payload( PayloadQuery { payload: "xy".to_string(), }, @@ -124,7 +124,7 @@ macro_rules! unsized_in_place_suite { assert_eq!(table.select(1).unwrap().payload, "xy"); table - .update_payload( + .update_partial_payload( PayloadQuery { payload: "much longer payload".to_string(), }, @@ -159,7 +159,7 @@ macro_rules! unsized_in_place_suite { tokio::spawn(async move { for i in 0..20_000u64 { table - .update_payload( + .update_partial_payload( PayloadQuery { payload: format!("{:04}", i % 10000), }, @@ -220,7 +220,7 @@ macro_rules! unsized_in_place_suite { let before = link_of(&table, 1); table - .update_balance(BalanceQuery { balance: 42.5 }, 1) + .update_partial_balance(BalanceQuery { balance: 42.5 }, 1) .await .unwrap(); @@ -269,7 +269,7 @@ mod opaque_wrapper { secret: WrappedString, }, queries: { - update: { + update_partial: { Secret(secret) by id, Nickname(nickname) by id, } @@ -290,7 +290,7 @@ mod opaque_wrapper { .unwrap(); table - .update_secret( + .update_partial_secret( SecretQuery { secret: WrappedString("replacement out-of-line secret!!".to_string()), }, @@ -319,7 +319,7 @@ mod opaque_wrapper { .unwrap(); table - .update_nickname( + .update_partial_nickname( NicknameQuery { nickname: Some("replacement out-of-line name".to_string()), }, diff --git a/tests/worktable/vec_table.rs b/tests/worktable/vec_table.rs index cbab9cff..1cbb7044 100644 --- a/tests/worktable/vec_table.rs +++ b/tests/worktable/vec_table.rs @@ -1655,7 +1655,7 @@ worktable!( amount_idx: amount unique using arctic, }, queries: { - update: { + update_partial: { StateById(state) by id, StateByOwner(state) by owner, AmountById(amount) by id, @@ -1664,7 +1664,7 @@ worktable!( ById() by id, ByOwner() by owner, }, - in_place: { + update_partial_in_place: { Status(state) by id, }, }, @@ -1695,13 +1695,13 @@ fn declared_queries_run_on_a_vec_table() { } // Keyed by the hash primary key: one row. - assert_eq!(table.update_state_by_id(StateByIdQuery { state: 7 }, &3), 1); + assert_eq!(table.update_partial_state_by_id(StateByIdQuery { state: 7 }, &3), 1); assert_eq!(table.select(&3).expect("present").state, 7); assert_eq!(table.select(&2).expect("present").state, 0, "only one row moved"); // Keyed by a non-unique hash secondary: every row it names. assert_eq!( - table.update_state_by_owner(StateByOwnerQuery { state: 5 }, &1), + table.update_partial_state_by_owner(StateByOwnerQuery { state: 5 }, &1), 3, "owner 1 holds ids 1, 3 and 5" ); @@ -1712,7 +1712,10 @@ fn declared_queries_run_on_a_vec_table() { } assert_eq!(table.select(&0).expect("present").amount, 100, "owner 0 untouched"); - assert_eq!(table.update_amount_by_id(AmountByIdQuery { amount: 999 }, &1), 1); + assert_eq!( + table.update_partial_amount_by_id(AmountByIdQuery { amount: 999 }, &1), + 1 + ); // The unique arctic secondary was repaired without stealing another row's key. assert!( table.select_by_amount(&101).is_none(), @@ -1725,7 +1728,7 @@ fn declared_queries_run_on_a_vec_table() { ); // in_place edits one column through a closure. - assert_eq!(table.update_status_in_place(|s| *s = 42, &0), 1); + assert_eq!(table.update_partial_in_place_status(|s| *s = 42, &0), 1); assert_eq!(table.select(&0).expect("present").state, 42); // Deletes, by the key and by a non-unique secondary. @@ -1819,7 +1822,7 @@ fn vec_declared_query_cannot_steal_another_rows_unique_key() { let before = table.unload().unwrap(); assert!( std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - table.update_amount_by_id(AmountByIdQuery { amount: 101 }, &0); + table.update_partial_amount_by_id(AmountByIdQuery { amount: 101 }, &0); })) .is_err() ); diff --git a/tests/worktable/with_enum.rs b/tests/worktable/with_enum.rs index 4b1e6175..bb40db83 100644 --- a/tests/worktable/with_enum.rs +++ b/tests/worktable/with_enum.rs @@ -17,7 +17,7 @@ worktable! ( test: SomeEnum }, queries: { - update: { + update_partial: { Test(test) by id, } } diff --git a/tests/worktable/wrong_row_update.rs b/tests/worktable/wrong_row_update.rs index a8ddf35f..1e62bac9 100644 --- a/tests/worktable/wrong_row_update.rs +++ b/tests/worktable/wrong_row_update.rs @@ -15,7 +15,7 @@ worktable!( code_idx: code unique, }, queries: { - update: { + update_partial: { ValueByCode(value) by code, } } @@ -53,7 +53,11 @@ async fn unique_update_does_not_mutate_a_row_that_stole_the_value() { let update = { let table = table.clone(); - tokio::spawn(async move { table.update_value_by_code(ValueByCodeQuery { value: 99 }, 10).await }) + tokio::spawn(async move { + table + .update_partial_value_by_code(ValueByCodeQuery { value: 99 }, 10) + .await + }) }; // Wait until the update registered its operation lock (it replaces the From 6f2f12d1e3948817d7e0e619b0c90f9c9e80a6f3 Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 16:31:21 +0700 Subject: [PATCH 12/45] Add typed mutation selectors and replace API --- CHANGELOG.md | 28 +- Cargo.toml | 6 +- README.md | 19 +- benches/cases/full_featured.rs | 16 +- benches/cases/non_unique_index.rs | 2 +- benches/cases/simple.rs | 2 +- benches/cases/unique_index.rs | 2 +- benches/cases/update_contention.rs | 8 +- benches/common/mod.rs | 4 +- codegen/Cargo.toml | 4 +- codegen/src/common/name_generator.rs | 4 +- codegen/src/generators/dense_table.rs | 117 +++- .../generators/in_memory/queries/in_place.rs | 10 +- .../src/generators/in_memory/queries/locks.rs | 8 +- .../src/generators/in_memory/queries/type.rs | 6 +- .../generators/in_memory/queries/unsized_.rs | 2 +- .../generators/in_memory/queries/update.rs | 44 +- .../src/generators/in_memory/table/impls.rs | 2 +- codegen/src/generators/mod.rs | 1 + codegen/src/generators/mutation_builder.rs | 519 ++++++++++++++++++ .../generators/persist/queries/in_place.rs | 10 +- .../src/generators/persist/queries/locks.rs | 8 +- .../src/generators/persist/queries/type.rs | 6 +- .../generators/persist/queries/unsized_.rs | 2 +- .../src/generators/persist/queries/update.rs | 42 +- codegen/src/generators/persist/table/impls.rs | 2 +- codegen/src/generators/runtime_backend.rs | 2 +- codegen/src/generators/vec_table/mod.rs | 32 +- codegen/src/worktable/mod.rs | 50 +- docs/TODO.md | 2 +- docs/beta17-validation.md | 2 +- docs/magic.md | 32 +- docs/partition-by-one-pager.md | 2 +- docs/partitioned-tables-worked-example.md | 10 +- docs/pr46-review-findings.md | 2 +- docs/queries.md | 47 +- docs/vec-persistence-design.md | 2 +- docs/why-worktables.typ | 2 +- docs/wt-user-guide.typ | 80 +-- dsl/Cargo.toml | 2 +- dsl/src/check.rs | 2 +- dsl/src/model/queries.rs | 16 +- dsl/src/parser/queries/delete.rs | 4 +- dsl/src/parser/queries/in_place.rs | 23 +- dsl/src/parser/queries/mod.rs | 58 +- dsl/src/parser/queries/select.rs | 4 +- dsl/src/parser/queries/update.rs | 14 +- dsl/src/parser/runtime.rs | 18 +- dsl/src/schema/emit_dsl.rs | 12 +- dsl/src/schema/emit_uml.rs | 4 +- dsl/src/schema/mod.rs | 30 +- dsl/src/validate.rs | 22 +- dsl/tests/diff.rs | 2 +- dsl/tests/never_panics.rs | 4 +- dsl/tests/query_storage.rs | 10 +- dsl/tests/round_trip.rs | 4 +- dsl/tests/schema.rs | 6 +- dsl/tests/trailing_commas.rs | 12 +- dsl/tests/uml.rs | 2 +- examples/guide_check.rs | 6 +- paper-bench/scripts/compile_cost.sh | 4 +- paper-bench/src/bin/ablation.rs | 6 +- paper-bench/src/bin/contention.rs | 12 +- paper-bench/src/dynamic.rs | 2 +- paper-bench/src/lib.rs | 4 +- src/persistence/operation/batch.rs | 8 +- src/table/mod.rs | 17 +- src/table/vacuum/vacuum.rs | 2 +- tests/persistence/concurrent/mod.rs | 2 +- .../persistence/duplicate_key_index_reload.rs | 6 +- tests/persistence/failure/mod.rs | 4 +- tests/persistence/failure/reinsert.rs | 12 +- tests/persistence/failure/update.rs | 4 +- .../persistence/failure/update_non_unique.rs | 8 +- tests/persistence/failure/update_unsized.rs | 16 +- tests/persistence/in_place_durability.rs | 4 +- tests/persistence/local_write_bandwidth.rs | 2 +- tests/persistence/mod.rs | 2 +- tests/persistence/multi_row_backend_order.rs | 13 +- tests/persistence/s3/mod.rs | 4 +- tests/persistence/same_size_in_place.rs | 21 +- tests/persistence/sync/failure.rs | 24 +- tests/persistence/sync/many_strings.rs | 12 +- tests/persistence/sync/mod.rs | 16 +- .../persistence/sync/opaque_unsized_update.rs | 20 +- tests/persistence/sync/option.rs | 34 +- .../persistence/sync/string_primary_index.rs | 13 +- tests/persistence/sync/string_re_read.rs | 4 +- .../sync/string_secondary_index.rs | 20 +- .../persistence/sync/string_update_timeout.rs | 4 +- tests/persistence/tuple_primary_key.rs | 2 +- tests/runtime_execution.rs | 58 +- tests/ui.rs | 2 + tests/ui/in_place_over_indexed_column.rs | 4 +- tests/ui/in_place_over_indexed_column.stderr | 2 +- tests/ui/query_over_unknown_column.rs | 2 +- tests/ui/runtime_pinned_and_call_site.rs | 2 +- tests/ui/runtime_profile_backend_mismatch.rs | 2 +- tests/ui/runtime_unknown_profile.rs | 2 +- tests/ui/update_selector_collision.rs | 17 + tests/ui/update_selector_collision.stderr | 5 + tests/ui/update_selector_wrong_value.rs | 18 + tests/ui/update_selector_wrong_value.stderr | 26 + tests/worktable/array.rs | 22 +- tests/worktable/base.rs | 24 +- tests/worktable/bench.rs | 11 +- tests/worktable/borrowed_primary_key.rs | 8 +- tests/worktable/cancel_safety.rs | 10 +- tests/worktable/columnar.rs | 14 +- tests/worktable/count.rs | 2 +- tests/worktable/delete.rs | 2 +- tests/worktable/in_place.rs | 45 +- tests/worktable/index/mod.rs | 24 +- tests/worktable/index/update_by_pk.rs | 18 +- tests/worktable/index/update_full.rs | 14 +- tests/worktable/index/update_query.rs | 18 +- tests/worktable/index_backends.rs | 8 +- tests/worktable/leak_probe.rs | 30 +- tests/worktable/lock_order.rs | 10 +- tests/worktable/multi_row_deadlock.rs | 6 +- tests/worktable/mutation_gate_deadlock.rs | 10 +- tests/worktable/nonunique_arctic.rs | 8 +- tests/worktable/option.rs | 49 +- tests/worktable/partitioned.rs | 12 +- tests/worktable/runtime_backends.rs | 20 +- tests/worktable/unique_fixed_unsized.rs | 4 +- tests/worktable/unsized_.rs | 89 +-- tests/worktable/update_delete_race.rs | 4 +- tests/worktable/update_in_place_unsized.rs | 50 +- tests/worktable/vec_table.rs | 17 +- tests/worktable/with_enum.rs | 4 +- tests/worktable/wrong_row_update.rs | 8 +- 132 files changed, 1459 insertions(+), 918 deletions(-) create mode 100644 codegen/src/generators/mutation_builder.rs create mode 100644 tests/ui/update_selector_collision.rs create mode 100644 tests/ui/update_selector_collision.stderr create mode 100644 tests/ui/update_selector_wrong_value.rs create mode 100644 tests/ui/update_selector_wrong_value.stderr diff --git a/CHANGELOG.md b/CHANGELOG.md index 85acbab7..314dcb43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,16 @@ Change Log ========== -## [1.9.0-beta1] +## [1.10.0-beta1] ### Changed -- Declared mutations now use `update_partial:` and - `update_partial_in_place:`. They generate `update_partial_` and - `update_partial_in_place_`, making their field-level semantics distinct - from the complete-row `update(row)` method. +- Declared mutations now use `update:` and + `update_in_place:`. They generate typed selector calls such as + `update_by_id(id, InvoiceColumns::AMOUNT, value)` and + `update_in_place_by_id(id, InvoiceColumns::STATE, edit)`. Multi-column + declarations expose one selector for their exact atomic field set and take + the generated query struct. Full-row replacement is now `replace(row)`. ### Added @@ -70,10 +72,8 @@ Change Log - **`queries:` on a `vec: true` table.** It was refused wholesale; it now - generates `update_partial_`, `delete_` and - `update_partial_in_place_` under - the same names the paged table uses, so a declaration reads the same either - way. + generates typed `update_by_` and `update_in_place_by_` dispatch, + plus declared delete methods, under the same names the paged table uses. These are named wrappers rather than a new execution path: a declared update is `update(&pk, |row| ..)` with the columns filled in from a generated struct, @@ -230,15 +230,15 @@ Change Log declares 65,536 rows into a partition that holds 256), and `persist: true`, which a dense partition has no engine to honour. - It carries `queries:`. An `update_partial` or `delete` query keyed by the primary key - generates the same method name against the same `Query` struct the - paged table generates, so a call reads identically; the signature does not, + It carries `queries:`. An `update` query keyed by the primary key uses the + same typed field-set selector as the paged table, and a multi-column selector + takes the same `Query` struct. The signature differs, deliberately, because there is no `.await` and no `WorkTableError`, and a call that moved between the shapes should fail to compile rather than quietly change what it guarantees. A query keyed by any other column is refused: a dense partition has no secondary index, and scanning it instead would be a keyed operation silently becoming a linear one. - `update_partial_in_place` is refused, because every update here is already in place. + `update_in_place` is refused, because every update here is already in place. Note that `memory_by_key` and `memory_total` **cannot see this saving**. They report `used_bytes`, which is rows plus indexes and excludes the fixed floor @@ -1098,7 +1098,7 @@ rather than new surface. ### Fixed - Re-reading a table from file. -- Index difference logic for `update_partial` queries. +- Index difference logic for `update` queries. ## [0.5.1] diff --git a/Cargo.toml b/Cargo.toml index 19ca3d09..4d8c2aae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["codegen", "dsl", "examples", "performance_measurement", "performance [package] name = "worktable" -version = "1.9.0-beta1" +version = "1.10.0-beta1" edition = "2024" authors = ["Handy-caT"] license = "MIT" @@ -147,12 +147,12 @@ walkdir = { version = "2", optional = true } # These pre-release workspace crates move as one train. The explicit caret # keeps the dependency policy consistent while the local path selects this # checkout during validation. -worktable_codegen = { path = "codegen", version = "^1.9.0-beta1" } +worktable_codegen = { path = "codegen", version = "^1.10.0-beta1" } # Re-exported below. Each generated table carries its declaration as a const # whose documentation says to read it with `worktable_dsl::Schema::parse`; that # instruction is only true if a plain `worktable` dependency can reach the # crate. -worktable_dsl = { path = "dsl", version = "^1.0.0-beta.19", optional = true } +worktable_dsl = { path = "dsl", version = "^1.10.0-beta1", optional = true } [target.'cfg(unix)'.dependencies] libc = { version = "^0.2", default-features = false } diff --git a/README.md b/README.md index 91dbf74f..e6fae590 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ for table in database.catalog().system_tables() { ```toml [dependencies] -worktable = { version = "^1.9.0-beta1", features = ["s3-support"] } # S3 sync, optional +worktable = { version = "^1.10.0-beta1", features = ["s3-support"] } # S3 sync, optional ``` Persisted indexes default to WorkTablesIndex. Vanilla IndexSet can be selected explicitly with `using indexset` while retaining the existing disk/S3 representation. Congee and Arctic persistence is experimental and uses their native checkpoint/WAL adapters; declarations using either backend must state `persist: true` or `persist: false` explicitly. The full syntax and capability matrix are documented in [Per-index backends with `using`](docs/index-backend-dsl-proposal.md). @@ -255,7 +255,7 @@ worktable!( exchnage_idx: exchange, } queries: { - update_partial: { + update: { AnotherByExchange(another) by exchange, AnotherByTest(another) by test, AnotherById(another) by id, @@ -426,7 +426,7 @@ There are some default query implementations that are available for all `WorkTab into grouped persistence operations, and durability follows the usual `wait_for_ops` contract. Autoincrement tables additionally get `reserve_pks(&self, count: usize) -> Range` to pre-assign contiguous keys to a batch; - `upsert(&self, row: Row) -> Result<(), WorkTableError>`; -- `update(&self, row: Row) -> Result<(), WorkTableError>`; +- `replace(&self, row: Row) -> Result<(), WorkTableError>`; - `delete(&self, pk: PrimaryKey) -> Result<(), WorkTableError>`; - `select_all<'a>(&'a self) -> SelectQueryBuilder<'a, Row, Self>`; @@ -436,7 +436,7 @@ There are some default query implementations that are available for all `WorkTab ``` queries: { - update_partial: { + update: { AnotherByExchange(another) by exchange, AnotherByTest(another) by test, AnotherById(another) by id, @@ -454,10 +454,15 @@ Default query declaration is `(*) by `. It For each query `Query` and `By` structs are generated. They will be used by user to call the query. -#### `update_partial` query declaration +#### `update` query declaration -`update_partial` queries update only the declared fields. The generated `update(row)` method replaces the full row. -When application logic updates disjoint parts of a row concurrently, `update_partial` supports +`update` queries update only the declared fields through a typed, table-scoped +column selector. For example, +`table.update_by_id(id, TestColumns::ANOTHER, value).await?` changes only +`another`. A multi-column declaration exposes one atomic selector such as +`TestColumns::NAME_AND_AMOUNT` and takes its generated query struct. The +generated `replace(row)` method replaces the full row. +When application logic updates disjoint parts of a row concurrently, `update` supports smart lock logic that allows simultaneous update of not overlapping row fields. #### `select_all` query declaration diff --git a/benches/cases/full_featured.rs b/benches/cases/full_featured.rs index dacc990a..8ca9df13 100644 --- a/benches/cases/full_featured.rs +++ b/benches/cases/full_featured.rs @@ -123,7 +123,7 @@ fn update(c: &mut Criterion) { another: format!("updated_{}", fastrand::u64(..)), something: fastrand::u64(..), }; - black_box(table.update(row).await) + black_box(table.replace(row).await) }) }); } @@ -151,7 +151,11 @@ fn update_by_pk_query(c: &mut Criterion) { let query = AnotherByIdQuery { another: format!("upd_{}", fastrand::u64(..)), }; - black_box(table.update_partial_another_by_id(query, id).await) + black_box( + table + .update_by_id(id, FullFeaturedColumns::ANOTHER, (query).another) + .await, + ) }) }); } @@ -179,7 +183,11 @@ fn update_by_unique_index_query(c: &mut Criterion) { let query = AnotherByVal1Query { another: format!("upd_{}", fastrand::u64(..)), }; - black_box(table.update_partial_another_by_val_1(query, val1).await) + black_box( + table + .update_by_val1(val1, FullFeaturedColumns::ANOTHER, (query).another) + .await, + ) }) }); } @@ -202,7 +210,7 @@ fn in_place_update(c: &mut Criterion) { c.bench_function("full_featured_in_place_update_val", |b| { b.to_async(&rt).iter(|| async { table - .update_partial_in_place_val_by_id(|val| *val += 1, black_box(pk)) + .update_in_place_by_id(black_box(pk), FullFeaturedColumns::VAL, |val| *val += 1) .await }) }); diff --git a/benches/cases/non_unique_index.rs b/benches/cases/non_unique_index.rs index c2589030..a9ecd365 100644 --- a/benches/cases/non_unique_index.rs +++ b/benches/cases/non_unique_index.rs @@ -91,7 +91,7 @@ fn update(c: &mut Criterion) { value: fastrand::u64(..), category: fastrand::u64(0..10), }; - black_box(table.update(row).await) + black_box(table.replace(row).await) }) }); } diff --git a/benches/cases/simple.rs b/benches/cases/simple.rs index afbcc353..c9a437ce 100644 --- a/benches/cases/simple.rs +++ b/benches/cases/simple.rs @@ -66,7 +66,7 @@ fn update(c: &mut Criterion) { id: pk.into(), value: fastrand::u64(..), }; - black_box(table.update(row).await) + black_box(table.replace(row).await) }) }); } diff --git a/benches/cases/unique_index.rs b/benches/cases/unique_index.rs index 73144ff2..9b03baf9 100644 --- a/benches/cases/unique_index.rs +++ b/benches/cases/unique_index.rs @@ -156,7 +156,7 @@ fn update(c: &mut Criterion) { test: fastrand::i64(..), another: fastrand::u64(..), }; - black_box(table.update(row).await) + black_box(table.replace(row).await) }) }); } diff --git a/benches/cases/update_contention.rs b/benches/cases/update_contention.rs index f6f9cb18..a7714365 100644 --- a/benches/cases/update_contention.rs +++ b/benches/cases/update_contention.rs @@ -41,7 +41,7 @@ fn single_row_update_contention(c: &mut Criterion) { another: format!("upd_{}", fastrand::u64(..)), something: fastrand::u64(..), }; - black_box(table_clone.update(row).await) + black_box(table_clone.replace(row).await) }); } while join_set.join_next().await.is_some() {} @@ -81,7 +81,11 @@ fn single_row_in_place_contention(c: &mut Criterion) { for _ in 0..level { let table_clone = table.clone(); join_set.spawn(async move { - black_box(table_clone.update_partial_in_place_val_by_id(|val| *val += 1, pk).await) + black_box( + table_clone + .update_in_place_by_id(pk, FullFeaturedColumns::VAL, |val| *val += 1) + .await, + ) }); } while join_set.join_next().await.is_some() {} diff --git a/benches/common/mod.rs b/benches/common/mod.rs index 10f42c8c..ce50e30b 100644 --- a/benches/common/mod.rs +++ b/benches/common/mod.rs @@ -53,10 +53,10 @@ worktable!( another_idx: another, }, queries: { - update_partial_in_place: { + update_in_place: { ValById(val) by id, } - update_partial: { + update: { AnotherById(another) by id, SomethingById(something) by id, AnotherByVal1(another) by val1, diff --git a/codegen/Cargo.toml b/codegen/Cargo.toml index 6dcb13e2..c70533c1 100644 --- a/codegen/Cargo.toml +++ b/codegen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "worktable_codegen" -version = "1.9.0-beta1" +version = "1.10.0-beta1" edition = "2024" license = "MIT" description = "Proc-macro companion crate for worktable: the worktable! macro and its derives." @@ -26,7 +26,7 @@ proc-macro = true # a declaration. See its crate docs for why that needed a separate crate. # Name the reviewed prerelease while accepting compatible schema-model and # validator updates. Release checks verify the resolved generated API. -worktable_dsl = { path = "../dsl", version = "^1.0.0-beta.19" } +worktable_dsl = { path = "../dsl", version = "^1.10.0-beta1" } # Test-only. As a normal dependency this proc-macro crate put `rkyv` with its # default features into the graph, which turned on `rkyv/std` for the target # build too and dragged `ptr_meta` with it. The generated code names `rkyv` diff --git a/codegen/src/common/name_generator.rs b/codegen/src/common/name_generator.rs index 5e62524f..caeca0db 100644 --- a/codegen/src/common/name_generator.rs +++ b/codegen/src/common/name_generator.rs @@ -83,11 +83,11 @@ impl WorktableNameGenerator { self.name.from_case(Case::Pascal).to_case(Case::Snake) } - pub fn get_update_partial_query_lock_ident(snake_case_name: &String) -> Ident { + pub fn get_update_query_lock_ident(snake_case_name: &String) -> Ident { Ident::new(format!("lock_update_{snake_case_name}").as_str(), Span::mixed_site()) } - pub fn get_update_partial_in_place_query_lock_ident(snake_case_name: &String) -> Ident { + pub fn get_update_in_place_query_lock_ident(snake_case_name: &String) -> Ident { Ident::new( format!("lock_update_in_place_{snake_case_name}").as_str(), Span::mixed_site(), diff --git a/codegen/src/generators/dense_table.rs b/codegen/src/generators/dense_table.rs index 58239fe3..42709489 100644 --- a/codegen/src/generators/dense_table.rs +++ b/codegen/src/generators/dense_table.rs @@ -22,12 +22,12 @@ use worktable_dsl::model::{Columns, Operation, PartitionMaxSize}; /// dense payload is emitted after them and `Queries` is not `Clone`. #[derive(Debug, Default)] pub struct DenseQueries { - /// `update_partial (columns) by `. - pub update_partials: Vec<(Ident, Operation)>, + /// `update (columns) by `. + pub updates: Vec<(Ident, Operation)>, /// `delete () by `. pub deletes: Vec<(Ident, Operation)>, - /// `update_partial_in_place (columns) by `. Refused: see [`expand`]. - pub update_partials_in_place: Vec<(Ident, Operation)>, + /// `update_in_place (columns) by `. Refused: see [`expand`]. + pub updates_in_place: Vec<(Ident, Operation)>, } impl DenseQueries { @@ -40,14 +40,14 @@ impl DenseQueries { map.iter().map(|(name, op)| (name.clone(), op.clone())).collect() }; Self { - update_partials: lift(&queries.update_partials), + updates: lift(&queries.updates), deletes: lift(&queries.deletes), - update_partials_in_place: lift(&queries.update_partials_in_place), + updates_in_place: lift(&queries.updates_in_place), } } fn is_empty(&self) -> bool { - self.update_partials.is_empty() && self.deletes.is_empty() && self.update_partials_in_place.is_empty() + self.updates.is_empty() && self.deletes.is_empty() && self.updates_in_place.is_empty() } } @@ -199,7 +199,7 @@ pub fn expand( }) .collect::>(); - let query_methods = gen_queries(name, columns, &pk, &pk_type, queries)?; + let (query_dispatch, query_methods) = gen_queries(name, columns, &pk, &pk_type, queries)?; let table_doc = format!( "One partition of [`{name}Partitions`], addressed by position.\n\n\ @@ -213,6 +213,8 @@ pub fn expand( ); Ok(quote! { + #query_dispatch + #[doc = #table_doc] #[derive(Debug)] pub struct #table { @@ -285,7 +287,7 @@ pub fn expand( /// /// `None` means the key held nothing, and nothing was written: this /// updates, it does not insert. `upsert` is the one that does both. - pub fn update( + pub fn replace( &self, row: #row_ident, ) -> Result, worktable::partition::DenseError> { @@ -352,12 +354,12 @@ pub fn expand( }) } -/// Generate one method per `queries:` entry. +/// Generate storage methods and typed-selector dispatch for `queries:` entries. /// -/// The method names and the `Query` argument structs are the paged -/// table's: a partitioned declaration still generates the full table beside the -/// dense payload, so the query structs already exist and a caller keeps the -/// same call. What differs is the signature, deliberately, the same way every +/// A partitioned declaration still generates the full table beside the dense +/// payload, so the selector markers and multi-field query structs already +/// exist and a caller keeps the same call. What differs is the result, +/// deliberately, the same way every /// other pair of shapes in this crate differs: there is no `.await` and no /// `WorkTableError`, so moving a call between them fails to compile rather than /// quietly changing what it guarantees. @@ -367,32 +369,34 @@ fn gen_queries( pk: &Ident, pk_type: &TokenStream, queries: &DenseQueries, -) -> syn::Result> { +) -> syn::Result<(TokenStream, Vec)> { if queries.is_empty() { - return Ok(Vec::new()); + return Ok((quote! {}, Vec::new())); } - // `update_partial_in_place` exists on the paged table because a write there is async and + // `update_in_place` exists on the paged table because a write there is async and // has to hold a column across a suspension point. Nothing here is async and - // `update_partial` is already in place, so generating both would be two names for + // `update` is already in place, so generating both would be two names for // one method. - if let Some((query, _)) = queries.update_partials_in_place.first() { + if let Some((query, _)) = queries.updates_in_place.first() { return Err(Error::new( query.span(), format!( - "`update_partial_in_place {query}` has no meaning on a dense partition: every update here is \ + "`update_in_place {query}` has no meaning on a dense partition: every update here is \ already in place, because there is no page to rewrite and no await to hold a \ - column across. Declare it as `update_partial {query}`, or use `partition_max_size: u64` \ + column across. Declare it as `update {query}`, or use `partition_max_size: u64` \ for the full table." ), )); } let mut out = Vec::new(); + let mut update_impls = Vec::new(); + let table = type_ident(name); - for (query, op) in &queries.update_partials { - by_must_be_the_key(pk, query, op, "update_partial")?; - let method = format_ident!("update_partial_{}", snake(query)); + for (query, op) in &queries.updates { + by_must_be_the_key(pk, query, op, "update")?; + let method = format_ident!("__wt_update_{}", snake(query)); let query_ty = format_ident!("{}Query", query); let fields = &op.columns; for column in fields { @@ -403,7 +407,7 @@ fn gen_queries( return Err(Error::new( column.span(), format!( - "`update_partial {query}` cannot update primary key `{pk}` in a dense partition: the key is \ + "`update {query}` cannot update primary key `{pk}` in a dense partition: the key is \ the row's physical position. Remove `{pk}` from the update, or delete and insert the row \ at its new key." ), @@ -411,7 +415,7 @@ fn gen_queries( } } let doc = format!( - "`update_partial {query}`, by position.\n\n\ + "`update {query}`, by position.\n\n\ Edits {} in place on the row at `{pk}`, without cloning the row. \ `None` means that key holds no row and nothing was written.\n\n\ The paged table's method of this name is `async` and returns \ @@ -421,15 +425,72 @@ fn gen_queries( ); out.push(quote! { #[doc = #doc] - pub fn #method(&self, row: #query_ty, #pk: &#pk_type) -> Option<()> { + fn #method(&self, row: #query_ty, #pk: &#pk_type) -> Option<()> { let at = Self::at(#pk)?; self.inner.update(at, |target| { #(target.#fields = row.#fields;)* }) } }); + + let selector = fields.iter().map(ToString::to_string).collect::>().join("_and_"); + let selector_pascal = { + use convert_case::{Case, Casing as _}; + selector.from_case(Case::Snake).to_case(Case::Pascal) + }; + let selector_type = format_ident!("{name}{selector_pascal}Selector"); + let trait_ident = format_ident!("{name}DenseUpdateBy{}", { + use convert_case::{Case, Casing as _}; + pk.to_string().from_case(Case::Snake).to_case(Case::Pascal) + }); + let (value_type, value) = if fields.len() == 1 { + let field = &fields[0]; + let ty = columns + .columns_map + .get(field) + .ok_or_else(|| Error::new(field.span(), format!("no column `{field}`")))?; + (quote! { #ty }, quote! { #query_ty { #field: value } }) + } else { + (quote! { #query_ty }, quote! { value }) + }; + update_impls.push(quote! { + impl #trait_ident<#value_type> for #selector_type { + fn apply(self, table: &#table, key: &#pk_type, value: #value_type) -> Option<()> { + table.#method(#value, key) + } + } + }); } + let update_dispatch = if update_impls.is_empty() { + quote! {} + } else { + let sealed = format_ident!("__{}_mutation", { + use convert_case::{Case, Casing as _}; + name.to_string().from_case(Case::Pascal).to_case(Case::Snake) + }); + let trait_ident = format_ident!("{name}DenseUpdateBy{}", { + use convert_case::{Case, Casing as _}; + pk.to_string().from_case(Case::Snake).to_case(Case::Pascal) + }); + let method = format_ident!("update_by_{pk}"); + out.push(quote! { + pub fn #method(&self, key: #pk_type, selector: S, value: V) -> Option<()> + where S: #trait_ident + { + selector.apply(self, &key, value) + } + }); + quote! { + #[doc(hidden)] + #[allow(private_bounds)] + pub trait #trait_ident: #sealed::Sealed { + fn apply(self, table: &#table, key: &#pk_type, value: V) -> Option<()>; + } + #(#update_impls)* + } + }; + for (query, op) in &queries.deletes { by_must_be_the_key(pk, query, op, "delete")?; let method = format_ident!("delete_{}", snake(query)); @@ -447,7 +508,7 @@ fn gen_queries( }); } - Ok(out) + Ok((update_dispatch, out)) } /// A dense partition has no secondary index, so a query can only be keyed by diff --git a/codegen/src/generators/in_memory/queries/in_place.rs b/codegen/src/generators/in_memory/queries/in_place.rs index e0e197a2..c0a1e82d 100644 --- a/codegen/src/generators/in_memory/queries/in_place.rs +++ b/codegen/src/generators/in_memory/queries/in_place.rs @@ -11,8 +11,8 @@ impl InMemoryGenerator { let table_ident = name_generator.get_work_table_ident(); let custom_in_place = if let Some(q) = &self.queries { - let profile = q.update_partial_in_place_runtime.clone(); - let custom_in_place = self.gen_in_place_queries(q.update_partials_in_place.clone()); + let profile = q.update_in_place_runtime.clone(); + let custom_in_place = self.gen_in_place_queries(q.updates_in_place.clone()); let custom_in_place = crate::generators::profile_dispatch::wrap( custom_in_place, profile.as_ref(), @@ -58,10 +58,10 @@ impl InMemoryGenerator { fn gen_primary_key_in_place(&self, snake_case_name: String, columns: &[Ident]) -> TokenStream { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let pk_type = name_generator.get_primary_key_type_ident(); - let lock_ident = WorktableNameGenerator::get_update_partial_in_place_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_in_place_query_lock_ident(&snake_case_name); let method_ident = Ident::new( - format!("update_partial_in_place_{snake_case_name}").as_str(), + format!("__wt_update_in_place_{snake_case_name}").as_str(), Span::mixed_site(), ); @@ -103,7 +103,7 @@ impl InMemoryGenerator { let columnar_dirty = crate::generators::columnar::table_mark_dirty(&self.columns); quote! { - pub async fn #method_ident( + async fn #method_ident( &self, mut f: F, by: Pk, diff --git a/codegen/src/generators/in_memory/queries/locks.rs b/codegen/src/generators/in_memory/queries/locks.rs index 9ae33f10..8e98e243 100644 --- a/codegen/src/generators/in_memory/queries/locks.rs +++ b/codegen/src/generators/in_memory/queries/locks.rs @@ -13,8 +13,8 @@ impl InMemoryGenerator { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let lock_type_ident = name_generator.get_lock_type_ident(); - let update_fns = Self::gen_update_query_locks(&q.update_partials); - let update_in_place_fns = Self::gen_in_place_update_query_locks(&q.update_partials_in_place); + let update_fns = Self::gen_update_query_locks(&q.updates); + let update_in_place_fns = Self::gen_in_place_update_query_locks(&q.updates_in_place); Ok(quote! { impl #lock_type_ident { @@ -33,7 +33,7 @@ impl InMemoryGenerator { .map(|name| { let snake_case_name = name.to_string().from_case(Case::Pascal).to_case(Case::Snake); - let lock_ident = WorktableNameGenerator::get_update_partial_in_place_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_in_place_query_lock_ident(&snake_case_name); let columns = &updates.get(name).as_ref().expect("exists").columns; let lock_fn = Self::gen_rows_lock_fn(columns, lock_ident); @@ -55,7 +55,7 @@ impl InMemoryGenerator { .map(|name| { let snake_case_name = name.to_string().from_case(Case::Pascal).to_case(Case::Snake); - let lock_ident = WorktableNameGenerator::get_update_partial_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_query_lock_ident(&snake_case_name); let op = updates.get(name).expect("exists"); // The lock set covers the updated columns AND the predicate diff --git a/codegen/src/generators/in_memory/queries/type.rs b/codegen/src/generators/in_memory/queries/type.rs index 351e1b1c..12cbf007 100644 --- a/codegen/src/generators/in_memory/queries/type.rs +++ b/codegen/src/generators/in_memory/queries/type.rs @@ -92,12 +92,12 @@ impl InMemoryGenerator { if let Some(queries) = &self.queries { let query_defs = queries - .update_partials + .updates .keys() .map(|v| { let ident = Ident::new(format!("{v}Query").as_str(), Span::mixed_site()); let (rows, updates): (Vec<_>, Vec<_>) = queries - .update_partials + .updates .get(v) .expect("exists") .columns @@ -159,7 +159,7 @@ impl InMemoryGenerator { .collect::, _>>()?; let by_defs = queries - .update_partials + .updates .values() .map(|op| { let ident = Ident::new(format!("{}By", op.name).as_str(), Span::mixed_site()); diff --git a/codegen/src/generators/in_memory/queries/unsized_.rs b/codegen/src/generators/in_memory/queries/unsized_.rs index 98b74e40..4e6664a0 100644 --- a/codegen/src/generators/in_memory/queries/unsized_.rs +++ b/codegen/src/generators/in_memory/queries/unsized_.rs @@ -49,7 +49,7 @@ impl InMemoryGenerator { fn gen_get_unsized_field_len_query_fn(&self) -> TokenStream { if let Some(q) = &self.queries { let query_impls: Vec<_> = q - .update_partials + .updates .iter() .filter(|(_, op)| { op.columns diff --git a/codegen/src/generators/in_memory/queries/update.rs b/codegen/src/generators/in_memory/queries/update.rs index 8a24ba6a..2716a1fa 100644 --- a/codegen/src/generators/in_memory/queries/update.rs +++ b/codegen/src/generators/in_memory/queries/update.rs @@ -14,26 +14,32 @@ struct UpdateStorage<'a> { impl InMemoryGenerator { pub fn gen_query_update_impl(&mut self) -> syn::Result { - let custom_updates = if let Some(q) = &self.queries { - let profile = q.update_partial_runtime.clone(); - let custom_updates = self.gen_custom_updates(q.update_partials.clone()); + let (custom_updates, mutation_api) = if let Some(q) = &self.queries { + let profile = q.update_runtime.clone(); + let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); + let mutation_api = crate::generators::mutation_builder::paged_mutation_api( + &self.name, + &name_generator.get_work_table_ident(), + &self.columns, + q, + )?; + let custom_updates = self.gen_custom_updates(q.updates.clone()); let custom_updates = crate::generators::profile_dispatch::wrap( custom_updates, profile.as_ref(), &WorktableNameGenerator::from_table_name(self.name.to_string()).get_row_type_ident(), )?; - quote! { - #custom_updates - } + (quote! { #custom_updates }, mutation_api) } else { - quote! {} + (quote! {}, quote! {}) }; let full_row_update = self.gen_full_row_update(); let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let table_ident = name_generator.get_work_table_ident(); Ok(quote! { + #mutation_api impl #table_ident { #full_row_update #custom_updates @@ -72,7 +78,7 @@ impl InMemoryGenerator { let full_row_lock = self.gen_full_lock_for_update(); let columnar_dirty = crate::generators::columnar::table_mark_dirty(&self.columns); let requires_rebuild = self.columns.columns_map.values().any(archived_field_requires_rebuild); - // A full-row `update(row)` replaces EVERY column, so it inherently + // A full-row `replace(row)` replaces EVERY column, so it inherently // rewrites every secondary index. The in-place fast path only applies // when no updated field is indexed (it emits no index diff), so a // full-row update on a table with any secondary index must reinsert. @@ -155,7 +161,7 @@ impl InMemoryGenerator { }; quote! { - pub async fn update(&self, row: #row_ident) -> core::result::Result<(), WorkTableError> { + pub async fn replace(&self, row: #row_ident) -> core::result::Result<(), WorkTableError> { let pk = row.get_primary_key(); let pending_lock = { #full_row_lock }; let guard = pending_lock.into_guard_with_mutation(); @@ -706,9 +712,9 @@ impl InMemoryGenerator { requires_rebuild, } = storage; let pk_ident = &self.pk.as_ref().unwrap().ident; - let method_ident = Ident::new(format!("update_partial_{snake_case_name}").as_str(), Span::mixed_site()); + let method_ident = Ident::new(format!("__wt_update_{snake_case_name}").as_str(), Span::mixed_site()); let query_ident = Ident::new(format!("{name}Query").as_str(), Span::mixed_site()); - let lock_ident = WorktableNameGenerator::get_update_partial_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_query_lock_ident(&snake_case_name); let row_updates = idents .iter() @@ -750,7 +756,7 @@ impl InMemoryGenerator { }; quote! { - pub async fn #method_ident(&self, row: #query_ident, pk: Pk) -> core::result::Result<(), WorkTableError> + async fn #method_ident(&self, row: #query_ident, pk: Pk) -> core::result::Result<(), WorkTableError> where #pk_ident: From { let pk: #pk_ident = pk.into(); @@ -788,7 +794,7 @@ impl InMemoryGenerator { } = storage; let by_field = &index.field; let index = &index.name; - let method_ident = Ident::new(format!("update_partial_{snake_case_name}").as_str(), Span::mixed_site()); + let method_ident = Ident::new(format!("__wt_update_{snake_case_name}").as_str(), Span::mixed_site()); let query_ident = Ident::new(format!("{name}Query").as_str(), Span::mixed_site()); let by_ident = Ident::new(format!("{name}By").as_str(), Span::mixed_site()); @@ -953,7 +959,7 @@ impl InMemoryGenerator { }; quote! { - pub async fn #method_ident(&self, row: #query_ident, by: #by_ident) -> core::result::Result<(), WorkTableError> { + async fn #method_ident(&self, row: #query_ident, by: #by_ident) -> core::result::Result<(), WorkTableError> { // This query may update many rows. Keep vacuum out across the // snapshot, lock acquisition, and per-row mutation gaps // without adding work to each row. @@ -1051,11 +1057,11 @@ impl InMemoryGenerator { .as_str(), ); let index = &index.name; - let method_ident = Ident::new(format!("update_partial_{snake_case_name}").as_str(), Span::mixed_site()); + let method_ident = Ident::new(format!("__wt_update_{snake_case_name}").as_str(), Span::mixed_site()); let query_ident = Ident::new(format!("{name}Query").as_str(), Span::mixed_site()); let by_ident = Ident::new(format!("{name}By").as_str(), Span::mixed_site()); - let lock_ident = WorktableNameGenerator::get_update_partial_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_query_lock_ident(&snake_case_name); let row_updates = idents .iter() @@ -1126,7 +1132,7 @@ impl InMemoryGenerator { }; quote! { - pub async fn #method_ident(&self, row: #query_ident, by: #by_ident) -> core::result::Result<(), WorkTableError> { + async fn #method_ident(&self, row: #query_ident, by: #by_ident) -> core::result::Result<(), WorkTableError> { let mut bytes = worktable::prelude::rkyv::to_bytes::(&row) .map_err(|_| WorkTableError::SerializeError)?; @@ -1256,9 +1262,9 @@ mod tests { }, ); generator.queries = Some(Queries { - update_partials: updates, + updates: updates, deletes: IndexMap::new(), - update_partials_in_place: IndexMap::new(), + updates_in_place: IndexMap::new(), ..Default::default() }); generator.gen_primary_key_def().unwrap(); diff --git a/codegen/src/generators/in_memory/table/impls.rs b/codegen/src/generators/in_memory/table/impls.rs index 54778189..e59990fb 100644 --- a/codegen/src/generators/in_memory/table/impls.rs +++ b/codegen/src/generators/in_memory/table/impls.rs @@ -226,7 +226,7 @@ impl InMemoryGenerator { let primary_key_type = name_generator.get_primary_key_type_ident(); quote! { - pub async fn reinsert(&self, row_old: #row_type, row_new: #row_type) -> core::result::Result<#primary_key_type, WorkTableError> { + async fn reinsert(&self, row_old: #row_type, row_new: #row_type) -> core::result::Result<#primary_key_type, WorkTableError> { self.0.reinsert(row_old, row_new).await } } diff --git a/codegen/src/generators/mod.rs b/codegen/src/generators/mod.rs index 1b0fe791..03bb883e 100644 --- a/codegen/src/generators/mod.rs +++ b/codegen/src/generators/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod columnar; pub(crate) mod dense_table; pub mod in_memory; pub(crate) mod index_backend; +pub(crate) mod mutation_builder; pub mod partitions; pub mod persist; pub(crate) mod primary_key; diff --git a/codegen/src/generators/mutation_builder.rs b/codegen/src/generators/mutation_builder.rs new file mode 100644 index 00000000..754d3b24 --- /dev/null +++ b/codegen/src/generators/mutation_builder.rs @@ -0,0 +1,519 @@ +use convert_case::{Case, Casing}; +use indexmap::{IndexMap, IndexSet}; +use proc_macro2::{Ident, Span, TokenStream}; +use quote::{format_ident, quote}; +use worktable_dsl::model::{Columns, Operation, Queries}; + +fn selector_name(operation: &Operation) -> String { + operation + .columns + .iter() + .map(ToString::to_string) + .collect::>() + .join("_and_") +} + +fn validate_collisions(operations: &IndexMap, family: &str) -> syn::Result<()> { + let mut generated: IndexMap<(String, String), &Ident> = IndexMap::new(); + for (name, operation) in operations { + let key = (operation.by.to_string(), selector_name(operation)); + if let Some(previous) = generated.insert(key.clone(), name) { + return Err(syn::Error::new( + name.span(), + format!( + "{family} queries `{previous}` and `{name}` both generate `{family}_by_{}(..., Columns::{}, ...)`", + key.0, + key.1.to_case(Case::UpperSnake) + ), + )); + } + } + Ok(()) +} + +fn selector_definitions(name: &Ident, queries: &Queries) -> syn::Result { + let columns = format_ident!("{name}Columns"); + let sealed = format_ident!("__{}_mutation", name.to_string().to_case(Case::Snake)); + let mut seen: IndexMap> = IndexMap::new(); + let mut definitions = Vec::new(); + let mut constants = Vec::new(); + + for operation in queries.updates.values().chain(queries.updates_in_place.values()) { + let field_set = operation.columns.iter().map(ToString::to_string).collect::>(); + let selector = selector_name(operation); + if let Some(previous) = seen.get(&selector) { + if previous != &field_set { + return Err(syn::Error::new( + operation.name.span(), + format!( + "declared field sets `{}` and `{}` generate the same selector name `{selector}`", + previous.join(", "), + field_set.join(", ") + ), + )); + } + continue; + } + seen.insert(selector.clone(), field_set); + let selector_pascal = selector.from_case(Case::Snake).to_case(Case::Pascal); + let selector_type = format_ident!("{name}{selector_pascal}Selector"); + // Preserve identifier spelling: Rust's conventional constant form is + // ASCII uppercase with the source underscores left in place. Case + // conversion would unexpectedly turn `attr1` into `ATTR_1`. + let constant = Ident::new(&selector.to_ascii_uppercase(), Span::mixed_site()); + definitions.push(quote! { + #[doc(hidden)] + #[derive(Clone, Copy, Debug, Default)] + pub struct #selector_type; + impl #sealed::Sealed for #selector_type {} + }); + constants.push(quote! { pub const #constant: #selector_type = #selector_type; }); + } + + if definitions.is_empty() { + return Ok(quote! {}); + } + Ok(quote! { + mod #sealed { pub trait Sealed {} } + pub struct #columns; + impl #columns { #(#constants)* } + #(#definitions)* + }) +} + +pub(crate) fn paged_mutation_api( + name: &Ident, + table: &Ident, + columns: &Columns, + queries: &Queries, +) -> syn::Result { + validate_collisions(&queries.updates, "update")?; + validate_collisions(&queries.updates_in_place, "update_in_place")?; + + let selectors = selector_definitions(name, queries)?; + let update = paged_updates(name, table, columns, &queries.updates, queries.update_runtime.is_some())?; + let update_in_place = paged_updates_in_place( + name, + table, + columns, + &queries.updates_in_place, + queries.update_in_place_runtime.is_some(), + )?; + Ok(quote! { #selectors #update #update_in_place }) +} + +/// The same typed selector surface for a single-writer `Vec` table. +/// +/// The dispatch is monomorphized and sealed exactly like the paged surface; +/// only the receiver and result reflect the synchronous storage shape. +pub(crate) fn vec_mutation_api( + name: &Ident, + table: &Ident, + columns: &Columns, + queries: &Queries, +) -> syn::Result { + validate_collisions(&queries.updates, "update")?; + validate_collisions(&queries.updates_in_place, "update_in_place")?; + let selectors = selector_definitions(name, queries)?; + let sealed = format_ident!("__{}_mutation", name.to_string().to_case(Case::Snake)); + let update = vec_updates(name, table, columns, &queries.updates, &sealed)?; + let update_in_place = vec_updates_in_place(name, table, columns, &queries.updates_in_place, &sealed)?; + Ok(quote! { #selectors #update #update_in_place }) +} + +fn vec_updates( + name: &Ident, + table: &Ident, + columns: &Columns, + operations: &IndexMap, + sealed: &Ident, +) -> syn::Result { + let mut by_fields = IndexSet::new(); + let mut implementations = Vec::new(); + for (query_name, operation) in operations { + by_fields.insert(operation.by.clone()); + let by = &operation.by; + let by_type = columns + .columns_map + .get(by) + .ok_or_else(|| syn::Error::new(by.span(), format!("no column `{by}`")))?; + let by_pascal = by.to_string().from_case(Case::Snake).to_case(Case::Pascal); + let trait_ident = format_ident!("{name}VecUpdateBy{by_pascal}"); + let selector_pascal = selector_name(operation).from_case(Case::Snake).to_case(Case::Pascal); + let selector = format_ident!("{name}{selector_pascal}Selector"); + let query = format_ident!("{query_name}Query"); + let hidden = format_ident!( + "__wt_update_{}", + query_name.to_string().from_case(Case::Pascal).to_case(Case::Snake) + ); + let (value_type, value) = if operation.columns.len() == 1 { + let field = &operation.columns[0]; + let ty = columns + .columns_map + .get(field) + .ok_or_else(|| syn::Error::new(field.span(), format!("no column `{field}`")))?; + (quote! { #ty }, quote! { #query { #field: value } }) + } else { + (quote! { #query }, quote! { value }) + }; + implementations.push(quote! { + impl #trait_ident<#value_type> for #selector { + type Key = #by_type; + fn apply(self, table: &mut #table, key: &Self::Key, value: #value_type) -> usize { + table.#hidden(#value, key) + } + } + }); + } + let mut traits_and_methods = Vec::new(); + for by in by_fields { + let by_type = columns + .columns_map + .get(&by) + .ok_or_else(|| syn::Error::new(by.span(), format!("no column `{by}`")))?; + let by_pascal = by.to_string().from_case(Case::Snake).to_case(Case::Pascal); + let trait_ident = format_ident!("{name}VecUpdateBy{by_pascal}"); + let method = format_ident!("update_by_{by}"); + traits_and_methods.push(quote! { + #[doc(hidden)] + #[allow(private_bounds)] + pub trait #trait_ident: #sealed::Sealed { + type Key; + fn apply(self, table: &mut #table, key: &Self::Key, value: V) -> usize; + } + impl #table { + pub fn #method(&mut self, key: #by_type, selector: S, value: V) -> usize + where S: #trait_ident + { + selector.apply(self, &key, value) + } + } + }); + } + Ok(quote! { #(#traits_and_methods)* #(#implementations)* }) +} + +fn vec_updates_in_place( + name: &Ident, + table: &Ident, + columns: &Columns, + operations: &IndexMap, + sealed: &Ident, +) -> syn::Result { + let mut by_fields = IndexSet::new(); + let mut implementations = Vec::new(); + for (query_name, operation) in operations { + by_fields.insert(operation.by.clone()); + let by = &operation.by; + let by_type = columns + .columns_map + .get(by) + .ok_or_else(|| syn::Error::new(by.span(), format!("no column `{by}`")))?; + let by_pascal = by.to_string().from_case(Case::Snake).to_case(Case::Pascal); + let trait_ident = format_ident!("{name}VecUpdateInPlaceBy{by_pascal}"); + let selector_pascal = selector_name(operation).from_case(Case::Snake).to_case(Case::Pascal); + let selector = format_ident!("{name}{selector_pascal}Selector"); + let column = &operation.columns[0]; + let column_type = columns + .columns_map + .get(column) + .ok_or_else(|| syn::Error::new(column.span(), format!("no column `{column}`")))?; + let hidden = format_ident!( + "__wt_update_in_place_{}", + query_name.to_string().from_case(Case::Pascal).to_case(Case::Snake) + ); + implementations.push(quote! { + impl #trait_ident for #selector where F: FnMut(&mut #column_type) { + type Key = #by_type; + fn apply(self, table: &mut #table, key: &Self::Key, edit: F) -> usize { + table.#hidden(edit, key) + } + } + }); + } + let mut traits_and_methods = Vec::new(); + for by in by_fields { + let by_type = columns + .columns_map + .get(&by) + .ok_or_else(|| syn::Error::new(by.span(), format!("no column `{by}`")))?; + let by_pascal = by.to_string().from_case(Case::Snake).to_case(Case::Pascal); + let trait_ident = format_ident!("{name}VecUpdateInPlaceBy{by_pascal}"); + let method = format_ident!("update_in_place_by_{by}"); + traits_and_methods.push(quote! { + #[doc(hidden)] + #[allow(private_bounds)] + pub trait #trait_ident: #sealed::Sealed { + type Key; + fn apply(self, table: &mut #table, key: &Self::Key, edit: F) -> usize; + } + impl #table { + pub fn #method(&mut self, key: #by_type, selector: S, edit: F) -> usize + where S: #trait_ident + { + selector.apply(self, &key, edit) + } + } + }); + } + Ok(quote! { #(#traits_and_methods)* #(#implementations)* }) +} + +fn paged_updates( + name: &Ident, + table: &Ident, + columns: &Columns, + operations: &IndexMap, + scheduled: bool, +) -> syn::Result { + let sealed = format_ident!("__{}_mutation", name.to_string().to_case(Case::Snake)); + let mut by_fields = IndexSet::new(); + let mut implementations = Vec::new(); + + for (query_name, operation) in operations { + by_fields.insert(operation.by.clone()); + let by = &operation.by; + let by_type = columns + .columns_map + .get(by) + .ok_or_else(|| syn::Error::new(by.span(), format!("no column `{by}`")))?; + let key_type = if columns.primary_keys.contains(by) { + let primary_key = format_ident!("{name}PrimaryKey"); + quote! { #primary_key } + } else { + quote! { #by_type } + }; + let by_pascal = by.to_string().from_case(Case::Snake).to_case(Case::Pascal); + let trait_ident = format_ident!("{name}UpdateBy{by_pascal}"); + let selector_pascal = selector_name(operation).from_case(Case::Snake).to_case(Case::Pascal); + let selector = format_ident!("{name}{selector_pascal}Selector"); + let query = format_ident!("{query_name}Query"); + let hidden = format_ident!( + "__wt_update_{}", + query_name.to_string().from_case(Case::Pascal).to_case(Case::Snake) + ); + let (value_type, value) = if operation.columns.len() == 1 { + let field = &operation.columns[0]; + let ty = columns + .columns_map + .get(field) + .ok_or_else(|| syn::Error::new(field.span(), format!("no column `{field}`")))?; + (quote! { #ty }, quote! { #query { #field: value } }) + } else { + (quote! { #query }, quote! { value }) + }; + let table_ref = if scheduled { + quote! { &worktable::prelude::Arc<#table> } + } else { + quote! { &#table } + }; + implementations.push(quote! { + impl #trait_ident<#value_type> for #selector { + type Key = #key_type; + async fn apply(self, table: #table_ref, key: Self::Key, value: #value_type) + -> core::result::Result<(), WorkTableError> + { + table.#hidden(#value, key).await + } + } + }); + } + + let mut traits_and_methods = Vec::new(); + for by in by_fields { + let by_type = columns + .columns_map + .get(&by) + .ok_or_else(|| syn::Error::new(by.span(), format!("no column `{by}`")))?; + let key_type = if columns.primary_keys.contains(&by) { + let primary_key = format_ident!("{name}PrimaryKey"); + quote! { #primary_key } + } else { + quote! { #by_type } + }; + let by_pascal = by.to_string().from_case(Case::Snake).to_case(Case::Pascal); + let trait_ident = format_ident!("{name}UpdateBy{by_pascal}"); + let method = format_ident!("update_by_{by}"); + let table_ref = if scheduled { + quote! { &worktable::prelude::Arc<#table> } + } else { + quote! { &#table } + }; + let receiver = if scheduled { + quote! { self: &worktable::prelude::Arc } + } else { + quote! { &self } + }; + let method_impl = if columns.primary_keys.contains(&by) { + quote! { + impl #table { + pub async fn #method(#receiver, key: K, selector: S, value: V) + -> core::result::Result<(), WorkTableError> + where + S: #trait_ident, + #key_type: From, + { + selector.apply(self, key.into(), value).await + } + } + } + } else { + quote! { + impl #table { + pub async fn #method(#receiver, key: #by_type, selector: S, value: V) + -> core::result::Result<(), WorkTableError> + where S: #trait_ident + { + selector.apply(self, key, value).await + } + } + } + }; + traits_and_methods.push(quote! { + #[doc(hidden)] + #[allow(private_bounds)] + #[allow(async_fn_in_trait)] + pub trait #trait_ident: #sealed::Sealed { + type Key; + async fn apply(self, table: #table_ref, key: Self::Key, value: V) + -> core::result::Result<(), WorkTableError>; + } + + #method_impl + }); + } + Ok(quote! { #(#traits_and_methods)* #(#implementations)* }) +} + +fn paged_updates_in_place( + name: &Ident, + table: &Ident, + columns: &Columns, + operations: &IndexMap, + scheduled: bool, +) -> syn::Result { + let sealed = format_ident!("__{}_mutation", name.to_string().to_case(Case::Snake)); + let mut by_fields = IndexSet::new(); + let mut implementations = Vec::new(); + + for (query_name, operation) in operations { + if operation.columns.len() != 1 { + return Err(syn::Error::new( + query_name.span(), + "an update_in_place query must declare exactly one column", + )); + } + by_fields.insert(operation.by.clone()); + let by = &operation.by; + let by_type = columns + .columns_map + .get(by) + .ok_or_else(|| syn::Error::new(by.span(), format!("no column `{by}`")))?; + let key_type = if columns.primary_keys.contains(by) { + let primary_key = format_ident!("{name}PrimaryKey"); + quote! { #primary_key } + } else { + quote! { #by_type } + }; + let by_pascal = by.to_string().from_case(Case::Snake).to_case(Case::Pascal); + let trait_ident = format_ident!("{name}UpdateInPlaceBy{by_pascal}"); + let selector_pascal = selector_name(operation).from_case(Case::Snake).to_case(Case::Pascal); + let selector = format_ident!("{name}{selector_pascal}Selector"); + let column = &operation.columns[0]; + let column_type = columns + .columns_map + .get(column) + .ok_or_else(|| syn::Error::new(column.span(), format!("no column `{column}`")))?; + let hidden = format_ident!( + "__wt_update_in_place_{}", + query_name.to_string().from_case(Case::Pascal).to_case(Case::Snake) + ); + let table_ref = if scheduled { + quote! { &worktable::prelude::Arc<#table> } + } else { + quote! { &#table } + }; + let send = if scheduled { + quote! { + Send + 'static } + } else { + quote! {} + }; + implementations.push(quote! { + impl #trait_ident for #selector + where F: FnMut(&mut <#column_type as worktable::prelude::rkyv::Archive>::Archived) #send + { + type Key = #key_type; + async fn apply(self, table: #table_ref, key: Self::Key, edit: F) + -> worktable::prelude::eyre::Result<()> + { + table.#hidden(edit, key).await + } + } + }); + } + + let mut traits_and_methods = Vec::new(); + for by in by_fields { + let by_type = columns + .columns_map + .get(&by) + .ok_or_else(|| syn::Error::new(by.span(), format!("no column `{by}`")))?; + let key_type = if columns.primary_keys.contains(&by) { + let primary_key = format_ident!("{name}PrimaryKey"); + quote! { #primary_key } + } else { + quote! { #by_type } + }; + let by_pascal = by.to_string().from_case(Case::Snake).to_case(Case::Pascal); + let trait_ident = format_ident!("{name}UpdateInPlaceBy{by_pascal}"); + let method = format_ident!("update_in_place_by_{by}"); + let table_ref = if scheduled { + quote! { &worktable::prelude::Arc<#table> } + } else { + quote! { &#table } + }; + let receiver = if scheduled { + quote! { self: &worktable::prelude::Arc } + } else { + quote! { &self } + }; + let method_impl = if columns.primary_keys.contains(&by) { + quote! { + impl #table { + pub async fn #method(#receiver, key: K, selector: S, edit: F) + -> worktable::prelude::eyre::Result<()> + where + S: #trait_ident, + #key_type: From, + { + selector.apply(self, key.into(), edit).await + } + } + } + } else { + quote! { + impl #table { + pub async fn #method(#receiver, key: #by_type, selector: S, edit: F) + -> worktable::prelude::eyre::Result<()> + where S: #trait_ident + { + selector.apply(self, key, edit).await + } + } + } + }; + traits_and_methods.push(quote! { + #[doc(hidden)] + #[allow(private_bounds)] + #[allow(async_fn_in_trait)] + pub trait #trait_ident: #sealed::Sealed { + type Key; + async fn apply(self, table: #table_ref, key: Self::Key, edit: F) + -> worktable::prelude::eyre::Result<()>; + } + + #method_impl + }); + } + Ok(quote! { #(#traits_and_methods)* #(#implementations)* }) +} diff --git a/codegen/src/generators/persist/queries/in_place.rs b/codegen/src/generators/persist/queries/in_place.rs index 4bc31c48..f87e7ee0 100644 --- a/codegen/src/generators/persist/queries/in_place.rs +++ b/codegen/src/generators/persist/queries/in_place.rs @@ -13,8 +13,8 @@ impl PersistGenerator { let table_ident = name_generator.get_work_table_ident(); let custom_in_place = if let Some(q) = &self.queries { - let profile = q.update_partial_in_place_runtime.clone(); - let custom_in_place = self.gen_in_place_queries(q.update_partials_in_place.clone()); + let profile = q.update_in_place_runtime.clone(); + let custom_in_place = self.gen_in_place_queries(q.updates_in_place.clone()); let custom_in_place = crate::generators::profile_dispatch::wrap( custom_in_place, profile.as_ref(), @@ -61,10 +61,10 @@ impl PersistGenerator { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let pk_type = name_generator.get_primary_key_type_ident(); let secondary_events_ident = name_generator.get_space_secondary_index_events_ident(); - let lock_ident = WorktableNameGenerator::get_update_partial_in_place_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_in_place_query_lock_ident(&snake_case_name); let method_ident = Ident::new( - format!("update_partial_in_place_{snake_case_name}").as_str(), + format!("__wt_update_in_place_{snake_case_name}").as_str(), Span::mixed_site(), ); @@ -106,7 +106,7 @@ impl PersistGenerator { let columnar_dirty = crate::generators::columnar::table_mark_dirty(&self.columns); quote! { - pub async fn #method_ident( + async fn #method_ident( &self, mut f: F, by: Pk, diff --git a/codegen/src/generators/persist/queries/locks.rs b/codegen/src/generators/persist/queries/locks.rs index fff1ce1b..64fcaa03 100644 --- a/codegen/src/generators/persist/queries/locks.rs +++ b/codegen/src/generators/persist/queries/locks.rs @@ -13,8 +13,8 @@ impl PersistGenerator { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let lock_type_ident = name_generator.get_lock_type_ident(); - let update_fns = Self::gen_update_query_locks(&q.update_partials); - let update_in_place_fns = Self::gen_in_place_update_query_locks(&q.update_partials_in_place); + let update_fns = Self::gen_update_query_locks(&q.updates); + let update_in_place_fns = Self::gen_in_place_update_query_locks(&q.updates_in_place); Ok(quote! { impl #lock_type_ident { @@ -33,7 +33,7 @@ impl PersistGenerator { .map(|name| { let snake_case_name = name.to_string().from_case(Case::Pascal).to_case(Case::Snake); - let lock_ident = WorktableNameGenerator::get_update_partial_in_place_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_in_place_query_lock_ident(&snake_case_name); let columns = &updates.get(name).as_ref().expect("exists").columns; let lock_fn = Self::gen_rows_lock_fn(columns, lock_ident); @@ -55,7 +55,7 @@ impl PersistGenerator { .map(|name| { let snake_case_name = name.to_string().from_case(Case::Pascal).to_case(Case::Snake); - let lock_ident = WorktableNameGenerator::get_update_partial_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_query_lock_ident(&snake_case_name); let op = updates.get(name).expect("exists"); // The lock set covers the updated columns AND the predicate diff --git a/codegen/src/generators/persist/queries/type.rs b/codegen/src/generators/persist/queries/type.rs index 3c632cbe..55987206 100644 --- a/codegen/src/generators/persist/queries/type.rs +++ b/codegen/src/generators/persist/queries/type.rs @@ -92,12 +92,12 @@ impl PersistGenerator { if let Some(queries) = &self.queries { let query_defs = queries - .update_partials + .updates .keys() .map(|v| { let ident = Ident::new(format!("{v}Query").as_str(), Span::mixed_site()); let (rows, updates): (Vec<_>, Vec<_>) = queries - .update_partials + .updates .get(v) .expect("exists") .columns @@ -159,7 +159,7 @@ impl PersistGenerator { .collect::, _>>()?; let by_defs = queries - .update_partials + .updates .values() .map(|op| { let ident = Ident::new(format!("{}By", op.name).as_str(), Span::mixed_site()); diff --git a/codegen/src/generators/persist/queries/unsized_.rs b/codegen/src/generators/persist/queries/unsized_.rs index 9e663a38..d66f9c9e 100644 --- a/codegen/src/generators/persist/queries/unsized_.rs +++ b/codegen/src/generators/persist/queries/unsized_.rs @@ -49,7 +49,7 @@ impl PersistGenerator { fn gen_get_unsized_field_len_query_fn(&self) -> TokenStream { if let Some(q) = &self.queries { let query_impls: Vec<_> = q - .update_partials + .updates .iter() .filter(|(_, op)| { op.columns diff --git a/codegen/src/generators/persist/queries/update.rs b/codegen/src/generators/persist/queries/update.rs index f98b93b4..086646ce 100644 --- a/codegen/src/generators/persist/queries/update.rs +++ b/codegen/src/generators/persist/queries/update.rs @@ -14,26 +14,32 @@ struct UpdateStorage<'a> { impl PersistGenerator { pub fn gen_query_update_impl(&mut self) -> syn::Result { - let custom_updates = if let Some(q) = &self.queries { - let profile = q.update_partial_runtime.clone(); - let custom_updates = self.gen_custom_updates(q.update_partials.clone()); + let (custom_updates, mutation_api) = if let Some(q) = &self.queries { + let profile = q.update_runtime.clone(); + let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); + let mutation_api = crate::generators::mutation_builder::paged_mutation_api( + &self.name, + &name_generator.get_work_table_ident(), + &self.columns, + q, + )?; + let custom_updates = self.gen_custom_updates(q.updates.clone()); let custom_updates = crate::generators::profile_dispatch::wrap( custom_updates, profile.as_ref(), &WorktableNameGenerator::from_table_name(self.name.to_string()).get_row_type_ident(), )?; - quote! { - #custom_updates - } + (quote! { #custom_updates }, mutation_api) } else { - quote! {} + (quote! {}, quote! {}) }; let full_row_update = self.gen_full_row_update(); let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let table_ident = name_generator.get_work_table_ident(); Ok(quote! { + #mutation_api impl #table_ident { #full_row_update #custom_updates @@ -164,7 +170,7 @@ impl PersistGenerator { }; quote! { - pub async fn update(&self, row: #row_ident) -> core::result::Result<(), WorkTableError> { + pub async fn replace(&self, row: #row_ident) -> core::result::Result<(), WorkTableError> { let pk = row.get_primary_key(); let pending_lock = { #full_row_lock }; let guard = pending_lock.into_guard_with_mutation(); @@ -766,9 +772,9 @@ impl PersistGenerator { requires_rebuild, } = storage; let pk_ident = &self.pk.as_ref().unwrap().ident; - let method_ident = Ident::new(format!("update_partial_{snake_case_name}").as_str(), Span::mixed_site()); + let method_ident = Ident::new(format!("__wt_update_{snake_case_name}").as_str(), Span::mixed_site()); let query_ident = Ident::new(format!("{name}Query").as_str(), Span::mixed_site()); - let lock_ident = WorktableNameGenerator::get_update_partial_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_query_lock_ident(&snake_case_name); let row_updates = idents .iter() @@ -812,7 +818,7 @@ impl PersistGenerator { }; quote! { - pub async fn #method_ident(&self, row: #query_ident, pk: Pk) -> core::result::Result<(), WorkTableError> + async fn #method_ident(&self, row: #query_ident, pk: Pk) -> core::result::Result<(), WorkTableError> where #pk_ident: From { let pk: #pk_ident = pk.into(); @@ -850,7 +856,7 @@ impl PersistGenerator { } = storage; let by_field = &index.field; let index = &index.name; - let method_ident = Ident::new(format!("update_partial_{snake_case_name}").as_str(), Span::mixed_site()); + let method_ident = Ident::new(format!("__wt_update_{snake_case_name}").as_str(), Span::mixed_site()); let query_ident = Ident::new(format!("{name}Query").as_str(), Span::mixed_site()); let by_ident = Ident::new(format!("{name}By").as_str(), Span::mixed_site()); @@ -1050,7 +1056,7 @@ impl PersistGenerator { }; quote! { - pub async fn #method_ident(&self, row: #query_ident, by: #by_ident) -> core::result::Result<(), WorkTableError> { + async fn #method_ident(&self, row: #query_ident, by: #by_ident) -> core::result::Result<(), WorkTableError> { // This query may update many rows. Keep vacuum out across the // snapshot, lock acquisition, and per-row mutation gaps // without adding work to each row. @@ -1149,11 +1155,11 @@ impl PersistGenerator { .as_str(), ); let index = &index.name; - let method_ident = Ident::new(format!("update_partial_{snake_case_name}").as_str(), Span::mixed_site()); + let method_ident = Ident::new(format!("__wt_update_{snake_case_name}").as_str(), Span::mixed_site()); let query_ident = Ident::new(format!("{name}Query").as_str(), Span::mixed_site()); let by_ident = Ident::new(format!("{name}By").as_str(), Span::mixed_site()); - let lock_ident = WorktableNameGenerator::get_update_partial_query_lock_ident(&snake_case_name); + let lock_ident = WorktableNameGenerator::get_update_query_lock_ident(&snake_case_name); let row_updates = idents .iter() @@ -1221,7 +1227,7 @@ impl PersistGenerator { }; quote! { - pub async fn #method_ident(&self, row: #query_ident, by: #by_ident) -> core::result::Result<(), WorkTableError> { + async fn #method_ident(&self, row: #query_ident, by: #by_ident) -> core::result::Result<(), WorkTableError> { let mut bytes = worktable::prelude::rkyv::to_bytes::(&row) .map_err(|_| WorkTableError::SerializeError)?; @@ -1345,9 +1351,9 @@ mod tests { }, ); generator.set_queries(Queries { - update_partials: updates, + updates: updates, deletes: IndexMap::new(), - update_partials_in_place: IndexMap::new(), + updates_in_place: IndexMap::new(), ..Default::default() }); generator.gen_primary_key_def().unwrap(); diff --git a/codegen/src/generators/persist/table/impls.rs b/codegen/src/generators/persist/table/impls.rs index 0d7babf2..2348663b 100644 --- a/codegen/src/generators/persist/table/impls.rs +++ b/codegen/src/generators/persist/table/impls.rs @@ -577,7 +577,7 @@ impl PersistGenerator { let secondary_events_ident = name_generator.get_space_secondary_index_events_ident(); quote! { - pub async fn reinsert(&self, row_old: #row_type, row_new: #row_type) -> core::result::Result<#primary_key_type, WorkTableError> { + async fn reinsert(&self, row_old: #row_type, row_new: #row_type) -> core::result::Result<#primary_key_type, WorkTableError> { self.1.ensure_running()?; let (op, res) = self.0.reinsert_cdc::<#secondary_events_ident>(row_old, row_new); if let Some(op) = op { diff --git a/codegen/src/generators/runtime_backend.rs b/codegen/src/generators/runtime_backend.rs index d81f10c4..d0642d03 100644 --- a/codegen/src/generators/runtime_backend.rs +++ b/codegen/src/generators/runtime_backend.rs @@ -33,7 +33,7 @@ pub(crate) fn runtime_type(backend: RuntimeBackend) -> TokenStream { /// /// The chain is: a section's own annotation, then the table's `runtime:`, then /// the built-in default. The middle step is the one worth stating: a table that -/// declares `runtime: tokio` and has an unannotated `update_partial` section must give +/// declares `runtime: tokio` and has an unannotated `update` section must give /// that section tokio, not the built-in nagoya, or the table would silently run /// two runtimes. /// diff --git a/codegen/src/generators/vec_table/mod.rs b/codegen/src/generators/vec_table/mod.rs index 798ee909..a5fb5998 100644 --- a/codegen/src/generators/vec_table/mod.rs +++ b/codegen/src/generators/vec_table/mod.rs @@ -980,6 +980,10 @@ pub fn expand( } }; + let mutation_api = match queries { + Some(queries) => crate::generators::mutation_builder::vec_mutation_api(&name, &table_ident, &columns, queries)?, + None => quote! {}, + }; let (query_structs, query_methods) = gen_queries(queries, &pk, &pk_type, &columns, &index_columns, &index_unique)?; Ok(quote! { @@ -987,6 +991,8 @@ pub fn expand( #(#query_structs)* + #mutation_api + #row_derives pub struct #row_ident { #(pub #field_names: #field_types,)* @@ -1345,8 +1351,8 @@ pub fn expand( /// Declared `queries:` against a `vec: true` table. /// -/// These are named wrappers, not a new execution path. A declared update is -/// `update(&pk, |row| ..)` with the columns filled in from a generated struct, +/// These are typed-selector wrappers, not a new execution path. A declared update is +/// the existing row edit with the columns filled in from a generated struct, /// and `update` already repairs every index the edit moved a row under — so /// delegating to it is both the shortest implementation and the only one that /// cannot get index repair wrong in a second place. @@ -1418,7 +1424,7 @@ fn gen_queries( } }; - for (name, op) in &queries.update_partials { + for (name, op) in &queries.updates { let (by_type, unique) = resolve_by(&op.by)?; let query_ty = format_ident!("{}Query", name); let fields = &op.columns; @@ -1440,12 +1446,12 @@ fn gen_queries( }); // The declared name already carries the key — `AmountById` becomes - // `update_partial_amount_by_id` — which is the paged table's convention and the + // `update_amount_by_id` — which is the paged table's convention and the // whole point of generating these. - let method = format_ident!("update_partial_{}", snake_of(name)); + let method = format_ident!("__wt_update_{}", snake_of(name)); let pick = selected(&op.by, unique); let doc = format!( - "`update_partial {name}` keyed by `{}`.\n\n\ + "`update {name}` keyed by `{}`.\n\n\ Sets {} and repairs every index the change moved a row under.\n\n\ The paged table's method of this name is `async` and returns \ `Result<(), WorkTableError>`. This one is neither, so a call cannot \ @@ -1455,7 +1461,7 @@ fn gen_queries( ); methods.push(quote! { #[doc = #doc] - pub fn #method(&mut self, query: #query_ty, key: &#by_type) -> usize { + fn #method(&mut self, query: #query_ty, key: &#by_type) -> usize { #pick let mut touched = 0usize; for found in keys { @@ -1496,13 +1502,13 @@ fn gen_queries( }); } - for (name, op) in &queries.update_partials_in_place { + for (name, op) in &queries.updates_in_place { let (by_type, unique) = resolve_by(&op.by)?; if op.columns.len() != 1 { return Err(syn::Error::new( name.span(), - "an `update_partial_in_place` query edits exactly one column through a closure. \ - For several columns at once use an `update_partial` query, which takes a \ + "an `update_in_place` query edits exactly one column through a closure. \ + For several columns at once use an `update` query, which takes a \ struct of them.", )); } @@ -1511,17 +1517,17 @@ fn gen_queries( .columns_map .get(column) .ok_or_else(|| syn::Error::new(column.span(), format!("no column `{column}`")))?; - let method = format_ident!("update_partial_in_place_{}", snake_of(name)); + let method = format_ident!("__wt_update_in_place_{}", snake_of(name)); let pick = selected(&op.by, unique); let doc = format!( - "`update_partial_in_place {name}` keyed by `{}`.\n\n\ + "`update_in_place {name}` keyed by `{}`.\n\n\ Hands a cloned candidate's `{column}` to the closure, then validates \ unique keys before replacing the row. Returns how many rows it reached.", op.by ); methods.push(quote! { #[doc = #doc] - pub fn #method( + fn #method( &mut self, mut edit: impl FnMut(&mut #column_type), key: &#by_type, diff --git a/codegen/src/worktable/mod.rs b/codegen/src/worktable/mod.rs index bb8f57f0..b20ad9d7 100644 --- a/codegen/src/worktable/mod.rs +++ b/codegen/src/worktable/mod.rs @@ -653,7 +653,7 @@ mod tests { balance: f64, }, queries: { - update_partial: { + update: { Balance(balance) by id, } } @@ -662,7 +662,7 @@ mod tests { .to_string(); let update = output - .split("pub async fn update_partial_balance") + .split("pub async fn update_balance") .nth(1) .expect("generated balance update"); assert!( @@ -690,7 +690,7 @@ mod tests { secret: EncryptedSecret, }, queries: { - update_partial: { + update: { Secret(secret) by id, } } @@ -699,7 +699,7 @@ mod tests { .to_string(); let update = output - .split("pub async fn update_partial_secret") + .split("pub async fn update_secret") .nth(1) .expect("generated opaque-field update"); assert!( @@ -736,7 +736,7 @@ mod tests { display_name: String optional, }, queries: { - update_partial: { + update: { DisplayName(display_name) by id, } } @@ -745,7 +745,7 @@ mod tests { .to_string(); let update = output - .split("pub async fn update_partial_display_name") + .split("pub async fn update_display_name") .nth(1) .expect("generated optional-string update"); assert!(update.contains("data . update_in_place")); @@ -768,7 +768,7 @@ mod tests { secret_idx: secret unique using worktables_index, }, queries: { - update_partial: { + update: { Secret(secret) by id, } } @@ -777,7 +777,7 @@ mod tests { .to_string(); let update = output - .split("pub async fn update_partial_secret") + .split("pub async fn update_secret") .nth(1) .expect("generated indexed opaque-field update"); assert!(update.contains("self . reinsert")); @@ -1343,7 +1343,7 @@ mod position_tests { partition_max_size: u8, columns: { exchange_id: u8 primary_key, bid: f64, ask: f64 }, queries: { - update_partial: { TopPrice(bid, ask) by exchange_id, }, + update: { TopPrice(bid, ask) by exchange_id, }, delete: { Stale() by exchange_id, } } }) @@ -1353,10 +1353,7 @@ mod position_tests { expanded.contains("impl PriceDenseTable"), "the dense payload must be emitted: {expanded}" ); - assert!( - expanded.contains("fn update_partial_top_price"), - "missing the update query" - ); + assert!(expanded.contains("fn update_top_price"), "missing the update query"); assert!(expanded.contains("fn delete_stale"), "missing the delete query"); } @@ -1371,7 +1368,7 @@ mod position_tests { columns: { exchange_id: u8 primary_key, venue: u32, bid: f64 }, indexes: { venue_idx: venue }, queries: { - update_partial: { ByVenue(bid) by venue, } + update: { ByVenue(bid) by venue, } } }) .expect_err("a dense partition has no secondary index") @@ -1395,7 +1392,7 @@ mod position_tests { partition_max_size: u8, columns: { exchange_id: u8 primary_key, bid: f64 }, queries: { - update_partial: { ReKey(exchange_id, bid) by exchange_id, } + update: { ReKey(exchange_id, bid) by exchange_id, } } }) .expect_err("changing a dense primary key would separate identity from position") @@ -1407,26 +1404,23 @@ mod position_tests { ); } - /// `update_partial_in_place` is redundant here, so it says so rather than generating a + /// `update_in_place` is redundant here, so it says so rather than generating a /// second name for one method. #[test] - fn update_partial_in_place_on_a_dense_partition_is_refused() { + fn update_in_place_on_a_dense_partition_is_refused() { let error = expand(quote! { name: Price, partition_by: symbol_id: u16, partition_max_size: u8, columns: { exchange_id: u8 primary_key, bid: f64 }, queries: { - update_partial_in_place: { Bump(bid) by exchange_id, } + update_in_place: { Bump(bid) by exchange_id, } } }) - .expect_err("update_partial_in_place has no meaning on a dense partition") + .expect_err("update_in_place has no meaning on a dense partition") .to_string(); assert!(error.contains("already in place"), "must say why: {error}"); - assert!( - error.contains("update_partial Bump"), - "must name the replacement: {error}" - ); + assert!(error.contains("update Bump"), "must name the replacement: {error}"); } /// A dense partition cannot persist, and says so rather than pretending. @@ -1558,14 +1552,14 @@ mod emitted_declarations { tenant_idx: tenant, }, queries: { - update_partial: { + update: { Nickname(nickname) by id, Email(email) by tenant, } delete: { ById() by id, } - update_partial_in_place: { + update_in_place: { Balance(balance) by id, } } @@ -1647,7 +1641,7 @@ mod generator_determinism { tenant_idx: tenant, }, queries: { - update_partial: { + update: { SetBalance(balance) by id, MoveTenant(tenant) by email, }, @@ -1714,7 +1708,7 @@ mod schema_const { nickname: String optional, }, indexes: { email_idx: email unique }, - queries: { update_partial: { Nickname(nickname) by id } } + queries: { update: { Nickname(nickname) by id } } }; let baked = baked_schema(expand(declaration.clone()).expect("expands"), "ACCOUNT_SCHEMA"); @@ -2016,7 +2010,7 @@ mod runtime_tests { runtime: tokio, columns: { id: u64 primary_key, value: u64 }, queries: { - update_partial: { Value(value) by id, }, + update: { Value(value) by id, }, delete: { ById() by id, }, } }); diff --git a/docs/TODO.md b/docs/TODO.md index 85cd1b96..309b39bc 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -66,7 +66,7 @@ results were rerun across all three local trees before drawing conclusions. ### WT DSL expansion and trailing commas are deterministic The trailing-comma parser fix was already present on the beta.17 branch and is -covered for `config`, `delete`, `update_partial_in_place`, block order, and the no-comma form. +covered for `config`, `delete`, `update_in_place`, block order, and the no-comma form. Expansion is now deterministic too. `columns_map`, query maps, and generated unique-type sets preserve declaration order with `IndexMap`/`IndexSet`. The diff --git a/docs/beta17-validation.md b/docs/beta17-validation.md index 6dd5aaaa..981570ac 100644 --- a/docs/beta17-validation.md +++ b/docs/beta17-validation.md @@ -45,7 +45,7 @@ checked as follows. ## WT DSL findings and fixes The trailing-comma fix already on the branch passes five focused cases: -`config`, `delete`, `update_partial_in_place`, reordered blocks, and the existing no-comma +`config`, `delete`, `update_in_place`, reordered blocks, and the existing no-comma form. Validation found a separate real beta.17 defect: macro expansion could vary diff --git a/docs/magic.md b/docs/magic.md index 5cb3dd67..7a47f70c 100644 --- a/docs/magic.md +++ b/docs/magic.md @@ -119,7 +119,7 @@ A **fixed, ordered prefix**, then a free-order section list. | 5, required with 4 | `partition_max_size:` | rows per partition, as an index width | | any order | `columns:` | the row and its primary key | | any order | `indexes:` | secondary indexes | -| any order | `queries:` | generated `update_partial` / `delete` / `update_partial_in_place` | +| any order | `queries:` | generated `update` / `delete` / `update_in_place` | | any order | `config:` | `page_size`, `row_derives` | The prefix is genuinely ordered: `parse_name` reads the first token and errors if @@ -143,7 +143,7 @@ worktable!( another_idx: another, }, queries: { - update_partial: { + update: { AnotherByExchange(another) by exchange, AnotherByTest(another) by test, AnotherById(another) by id, @@ -202,32 +202,36 @@ independent of the backend and combines with it. ## Queries -Three kinds. CamelCase in the declaration, snake_case in the generated method. +Three kinds. The declaration names each allowed lookup and field set. ```rust queries: { - update_partial: { + update: { AmountById(amount) by id, }, delete: { ByName() by name, }, - update_partial_in_place: { + update_in_place: { SomeValueById(some_value) by id, } } ``` -**`update_partial`** generates `update_partial_amount_by_id(AmountByIdQuery { amount }, id)`. The -query struct is the name plus `Query`. +**`update`** enables +`update_by_id(id, OrdersColumns::AMOUNT, amount)`. The zero-sized selector is +table-scoped and fixes both the allowed field set and value type. A multi-field +declaration uses one `Columns::FIELD_AND_FIELD` selector and its generated +`Query` value so the field set stays atomic. **`delete`** generates `delete_by_name(name)`. Empty parentheses because a delete names no columns. -**`update_partial_in_place`** generates `update_partial_in_place_some_value_by_id(|value| ..., id)`, which +**`update_in_place`** enables +`update_in_place_by_id(id, OrdersColumns::SOME_VALUE, |value| ...)`, which mutates without selecting first. Its locking is internal, so it is safe from several threads without the caller holding anything — which is also why it is a -*different concurrency point* from `update_partial`. **Only `by {pk_field}` is +*different concurrency point* from `update`. **Only `by {pk_field}` is supported.** ## Selects, which are not declared @@ -366,7 +370,7 @@ concurrency points and they want opposite things: | query fan-out | does not exist yet | independent chunks | 1.99x-2.84x on orderbook upsert/delete | The `queries:` sections already group by concurrency point: everything in -`update_partial:` goes through the same lock path, and `update_partial_in_place:` is a different path by +`update:` goes through the same lock path, and `update_in_place:` is a different path by design. So the annotation belongs on the section. ## Three positions, one keyword @@ -374,7 +378,7 @@ design. So the annotation belongs on the section. | position | scope | cost | |---|---|---| | `runtime: nagoya(spread)` at the top level | the table's sync types and default pool | changes the generated type | -| `update_partial runtime fast_local:` on a section | that concurrency point | compile time, free | +| `update runtime fast_local:` on a section | that concurrency point | compile time, free | | `.runtime(wide)` on a builder | one call | runtime, opt-in | ## Named profiles @@ -403,11 +407,11 @@ worktable!( symbol_idx: symbol using congee, }, queries: { - update_partial runtime fast_local: { // `runtime` = scheduler + update runtime fast_local: { // `runtime` = scheduler Fill(qty) by id, Cancel(qty) by symbol, }, - update_partial_in_place runtime fast_local: { + update_in_place runtime fast_local: { Bump(qty) by id, }, delete runtime wide: { @@ -524,7 +528,7 @@ runtime does this query use" and it is visible at the place you are reading. **The cost of that choice**, recorded so it is not a surprise: adding a section annotation becomes a breaking change for callers already using `.runtime()`. The window is narrow today — `.runtime()` exists only on select builders, and selects -are not declared in `queries:`, so `update_partial` / `delete` / `update_partial_in_place` annotations +are not declared in `queries:`, so `update` / `delete` / `update_in_place` annotations can never collide with it. It only bites if a `select` section annotation is added later. diff --git a/docs/partition-by-one-pager.md b/docs/partition-by-one-pager.md index e08b7621..ea5f6667 100644 --- a/docs/partition-by-one-pager.md +++ b/docs/partition-by-one-pager.md @@ -72,7 +72,7 @@ let books = OrderBookPartitions::new(); // hot path: array index, no hashing, no allocation let book = books.partition(symbol_id).ok_or(...)?; -book.update_partial_top_price(feed.into(), exchange_id).await?; +book.update_by_exchange_id(exchange_id, OrderBookColumns::BEST_BID_PRICE_AND_BEST_BID_SIZE_AND_BEST_ASK_PRICE_AND_BEST_ASK_SIZE, feed.into()).await?; // across partitions: for sweeps and maintenance, not the hot path let stale = books.select_all().filter(|r| r.ts < cutoff).execute()?; diff --git a/docs/partitioned-tables-worked-example.md b/docs/partitioned-tables-worked-example.md index c1d4e406..aab87adc 100644 --- a/docs/partitioned-tables-worked-example.md +++ b/docs/partitioned-tables-worked-example.md @@ -52,7 +52,7 @@ worktable!( rest_ask_sizes: OrderBookRestDepth, }, queries: { - update_partial: { + update: { TopPrice(best_bid_price, best_bid_size, best_ask_price, best_ask_size) by exchange_id, RestPrices(bids_size, rest_bid_prices, rest_bid_sizes, asks_size, rest_ask_prices, rest_ask_sizes) by exchange_id, @@ -138,7 +138,7 @@ worktable!( rest_ask_sizes: OrderBookRestDepth, }, queries: { - update_partial: { + update: { TopPrice(best_bid_price, best_bid_size, best_ask_price, best_ask_size) by exchange_id, RestPrices(bids_size, rest_bid_prices, rest_bid_sizes, asks_size, rest_ask_prices, rest_ask_sizes) by exchange_id, @@ -225,16 +225,16 @@ concept. See section 7. ```rust // today: hashes the symbol string on every tick let book = manager.get_order_book(&feed.symbol).ok_or_else(|| eyre!("unknown symbol"))?; -book.table.update_partial_top_price(feed.into(), row_id).await?; +book.table.update_by_exchange_id(row_id, OrderBookColumns::BEST_BID_PRICE_AND_BEST_BID_SIZE_AND_BEST_ASK_PRICE_AND_BEST_ASK_SIZE, feed.into()).await?; // with partition_by: array index let book = books.partition(feed.symbol_id).ok_or_else(|| eyre!("unknown symbol"))?; -book.update_partial_top_price(feed.into(), row_id).await?; +book.update_by_exchange_id(row_id, OrderBookColumns::BEST_BID_PRICE_AND_BEST_BID_SIZE_AND_BEST_ASK_PRICE_AND_BEST_ASK_SIZE, feed.into()).await?; ``` One indirection fewer, because there is no wrapper struct holding the key alongside the table, and 9.5 ns fewer, because the lookup stopped being a hash -of a heap string. Everything else is identical: `update_partial_top_price` is the +of a heap string. Everything else is identical: `update_top_price` is the generated query it always was, and it runs against a 23-row table. The feed handler resolves `symbol_id` once when the subscription is opened, diff --git a/docs/pr46-review-findings.md b/docs/pr46-review-findings.md index 669f5cbe..3f62f79e 100644 --- a/docs/pr46-review-findings.md +++ b/docs/pr46-review-findings.md @@ -7,7 +7,7 @@ Work lands on branch `fix/pr46-review-findings`. ## F1 — Mutation gate held across `.await` (was rated P1) — NOT REPRODUCED `LockMap::mutation_guard` (`src/lock/map.rs`) is a blocking spin/yield ticket -lock. The generated `update_partial`/`update_partial_in_place`/`delete` paths keep the `MutationGuard` +lock. The generated `update`/`update_in_place`/`delete` paths keep the `MutationGuard` inside `LockGuard` and hold it across `.await` (`update_with_guard(...).await`, `reinsert(...).await`). The concern: two keys colliding on the same 1-of-64 stripe, guard-holder parked at its await while the other task spins. diff --git a/docs/queries.md b/docs/queries.md index 2434d732..ae6b1917 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -1,6 +1,6 @@ # Queries -WorkTable supports declared `update_partial`, `delete`, and `update_partial_in_place` queries. +WorkTable supports declared `update`, `delete`, and `update_in_place` queries. ```rust worktable!( @@ -18,47 +18,60 @@ worktable!( }, // Queries declaration section. queries: { - // `update_partial` queries - update_partial: { + // `update` queries + update: { AmountById(amount) by id, }, // `delete` queries delete: { ByName() by name, }, - update_partial_in_place: { + update_in_place: { SomeValueById(some_value) by id, } } ); ``` -### `update_partial` queries +### `update` queries -`TODO` +An `update` changes only the declared field set. The lookup column is part of +the method name and the generated, table-scoped selector is an explicit +argument: + +```rust +table + .update_by_id(pk, SomethingColumns::AMOUNT, 250) + .await?; +``` + +For one field, the last argument is that field's Rust type. A declaration over +several fields exposes one selector such as `SomethingColumns::NAME_AND_AMOUNT` +and takes the generated query struct, preserving the declaration's atomic field +set. Selector dispatch is sealed, statically typed, and allocation-free. -### `update_partial_in_place` queries +### `update_in_place` queries -`update_partial_in_place` queries allow you to update a field's value +`update_in_place` queries allow you to update a field's value without need to select it before query. It is useful for counters, as example, because with internal mutation queries locking logic user's don't need to add explicit locks over `WorkTable` -object. So you can safely use `update_partial_in_place` queries in multiple threads simultaneously. +object. So you can safely use `update_in_place` queries in multiple threads simultaneously. !!! For now only `by {pk_field}` queries are supported !!! -To declare an `update_partial_in_place` query, add an `update_partial_in_place` section to `queries`. Its definition is -the same shape as `update_partial`: `{YourQueryNameCamelCase}({fields_you_want_to_update}) by {by_field_name}`. +To declare an `update_in_place` query, add an `update_in_place` section to `queries`. Its definition is +the same shape as `update`: `{YourQueryNameCamelCase}({fields_you_want_to_update}) by {by_field_name}`. For example: ``` -update_partial_in_place: { +update_in_place: { SomeValueById(some_value) by id, } ``` -It will generate `update_partial_in_place_some_value_by_id` method for `WorkTable` object (name generation logic is same -as for other queries). It will have two arguments: your `by` field value and closure, where you can use mutable -field value itself. +It enables `update_in_place_by_id` for the generated +`SomethingColumns::SOME_VALUE` selector. The method takes the lookup value, the +selector, and a closure over the mutable archived field value. ```rust #[tokio::main] @@ -75,7 +88,7 @@ async fn main() -> eyre::Result<()> { let pk = table.insert(row)?; // This will lead to `some_value` field update by adding 100 to it value. table - .update_partial_in_place_some_value_by_id(|some_value| *some_value += 100, pk.0) + .update_in_place_by_id(pk.0, SomethingColumns::SOME_VALUE, |some_value| *some_value += 100) .await?; let row = table.select(pk)?; assert_eq!(row.some_value, 100); @@ -84,7 +97,7 @@ async fn main() -> eyre::Result<()> { } ``` -You can find tests that cover `update_partial_in_place` queries [here](../tests/worktable/in_place.rs). +You can find tests that cover `update_in_place` queries [here](../tests/worktable/in_place.rs). ### `delete` queries diff --git a/docs/vec-persistence-design.md b/docs/vec-persistence-design.md index f96cbef5..cff351c7 100644 --- a/docs/vec-persistence-design.md +++ b/docs/vec-persistence-design.md @@ -129,7 +129,7 @@ The restriction today is blunter than any of that: `queries:` is refused on `vec: true` for every backend, and the dense table accepts them only `by ` because it has no secondary index. Enabling them on `vec: true` is parity work rather than performance work — a generated -`update_partial_amount_by_id` would be a named wrapper over `update(&key, |row| ..)`, +`update_amount_by_id` would be a named wrapper over `update(&key, |row| ..)`, which already exists and already takes a closure. Mixed backends already work and are tested: a `fxhash` primary key with `arctic` diff --git a/docs/why-worktables.typ b/docs/why-worktables.typ index a10ce624..26ec1572 100644 --- a/docs/why-worktables.typ +++ b/docs/why-worktables.typ @@ -105,7 +105,7 @@ leaving the consequential choices inspectable. Start with the #link("wt-user-guide.pdf")[WorkTable user guide]: declarations, every storage shape, queries, callsites, runtimes, persistence and lifecycle -examples. It describes 1.9.0-beta1; use the reviewed checkout until publication. +examples. It describes 1.10.0-beta1; use the reviewed checkout until publication. #v(0.35cm) #text(size: 8pt, fill: rgb("#526873"))[ diff --git a/docs/wt-user-guide.typ b/docs/wt-user-guide.typ index 5f470a21..d77638a8 100644 --- a/docs/wt-user-guide.typ +++ b/docs/wt-user-guide.typ @@ -22,7 +22,7 @@ #text(size: 11pt, style: "italic")[Absolutely not a database.] #v(0.6em) #text(size: 9.5pt)[A user's guide to the `worktable!` macro, its queries, its indexes and its - persistence tier. Written against 1.9.0-beta1.] + persistence tier. Written against 1.10.0-beta1.] ] #v(1.2em) @@ -39,7 +39,7 @@ See #link()[Persistence].] = Getting started ```sh -cargo add worktable@1.9.0-beta1 +cargo add worktable@1.10.0-beta1 ``` Until this beta is published, depend on the reviewed checkout with @@ -164,10 +164,9 @@ owns and whether an absent key is valid: [*operation*], [*input*], [*key absent*], [*meaning*], [`insert(row)`], [complete `Row`], [insert], [Create a new row. An existing primary key returns `PrimaryAlreadyExists`; the caller does not authorize replacement.], [`upsert(row)`], [complete `Row`], [insert], [Insert or replace. The caller declares the complete row authoritative. A row selected earlier can overwrite newer fields if it is later passed here.], - [`update(row)`], [complete `Row`], [`NotFound`], [Replace every field of an existing row. It never creates a missing row, but the supplied row is still a complete authoritative snapshot.], - [`update_partial_`\ `(Query, key)`], [declared fields], [`NotFound`], [Change only the named fields. WorkTable rereads under its mutation lock when safe reconstruction needs the complete row, preserving concurrent changes to other fields.], - [`update_partial_in_place_`\ `(closure, key)`], [mutable archived fields], [`NotFound`], [Directly mutate declared, unindexed fields of an existing row. This is the lowest-work path and is restricted to primary-key lookup.], - [`reinsert(old, new)`], [two complete rows], [`NotFound` or mismatch], [Advanced explicit replacement that moves storage and repairs indexes. Ordinary application updates should use one of the methods above.], + [`replace(row)`], [complete `Row`], [`NotFound`], [Replace every field of an existing row. It never creates a missing row, but the supplied row is still a complete authoritative snapshot.], + [`update_by_(key, Columns::FIELD_SET, value)`], [declared fields], [`NotFound`], [Change only the selector's declared fields. WorkTable rereads under its mutation lock when safe reconstruction needs the complete row, preserving concurrent changes to other fields.], + [`update_in_place_by_(key, Columns::FIELD, closure)`], [one mutable archived field], [`NotFound`], [Directly mutate a declared, unindexed field of an existing row. This is the lowest-work path and is restricted to primary-key lookup.], ) ] @@ -182,33 +181,38 @@ worktable! ( state: u8, }, queries: { - update_partial: { + update: { AmountById(amount) by id, // () by }, delete: { ById() by id, // empty parens: names no columns }, - update_partial_in_place: { + update_in_place: { StateById(state) by id, // only `by ` is supported }, }, ); ``` -CamelCase declared, snake_case generated: +The lookup column names the method and the typed selector names the changed +column. A one-column update takes that column's Rust value directly: ```rust -table.update_partial_amount_by_id(AmountByIdQuery { amount: 900 }, 1).await?; // name + "Query" +table.update_by_id(1, InvoiceColumns::AMOUNT, 900).await?; table.delete_by_id(1).await?; -table.update_partial_in_place_state_by_id(|state| *state = 2.into(), 1).await?; +table.update_in_place_by_id(1, InvoiceColumns::STATE, |state| *state = 2.into()).await?; ``` -The generated suffix describes the declared operation. `StatusById(status) by id` emits -`update_partial_status_by_id(StatusByIdQuery { status }, id)`; placing that declaration under -`update_partial_in_place` emits `update_partial_in_place_status_by_id(|status| ..., id)`. `status` is the column -name, not a storage type. +`InvoiceColumns::AMOUNT` is a generated zero-sized selector. Its sealed dispatch +implementation exists only for the declared `amount by id` combination and its +value type is `u64`, so an undeclared selector/key combination or wrong value +type fails to compile. The selector is monomorphized; it allocates nothing and +uses no dynamic dispatch. A declaration over `name, amount` exposes the atomic +selector `InvoiceColumns::NAME_AND_AMOUNT` and takes its generated query struct. +Selector constants preserve source spelling in uppercase: `attr1` becomes +`ATTR1`, while `some_field` becomes `SOME_FIELD`. -A declared `update_partial` reads, changes and writes only its named fields. Normal declared +A declared `update` reads, changes and writes only its named fields. Normal declared updates accept owned Rust values and are the safe default for strings, options and application-defined wrappers. An archived string contains a relative pointer, so WorkTable may reconstruct the complete row rather than move only that field's archived @@ -217,10 +221,10 @@ other fields. The macro cannot inspect an external type such as `EncryptedSecret prove whether its archived form contains relative pointers, so unknown custom types take that conservative path. -`update_partial_in_place` mutates without selecting first and locks internally. Use it when the +`update_in_place` mutates without selecting first and locks internally. Use it when the application can safely edit the archived representation directly, as with a scalar or a fixed `#[repr(u8)]` enum. Do not copy an archived string, vector or pointer-bearing wrapper -from another buffer into an `update_partial_in_place` closure. Persisted in-place queries enqueue the +from another buffer into an `update_in_place` closure. Persisted in-place queries enqueue the changed slot bytes; they do not skip durability. == 6. Selects you do not declare @@ -387,12 +391,13 @@ column, for a width the key cannot count to (`u16` beside a `u8` key declares 65 into a partition that holds 256), and for `persist: true`, which it has no engine to honour. -`queries:` works. An `update` or `delete` keyed by the primary key generates the same -method name and takes the same `Query` struct as the paged table, so the call reads -the same; it is not `async` and does not return `WorkTableError`, so a call cannot move -between the shapes by accident. A query keyed by any other column is refused, because a +`queries:` works. An `update` keyed by the primary key uses the same +`update_by_(key, Columns::FIELD_SET, value)` form as the paged table; a +multi-column selector takes the same generated query struct. A delete keeps its +declared method name. Dense calls are synchronous and return `Option`, so a call +cannot move between the shapes by accident. A query keyed by any other column is refused, because a dense partition has no secondary index and scanning instead would turn a keyed operation -into a linear one without saying so. `update_partial_in_place` is refused: every update +into a linear one without saying so. `update_in_place` is refused: every update here is already in place. Note that `memory_by_key` and `memory_total` cannot see any of this. They report @@ -463,10 +468,11 @@ error naming what to use instead, rather than being accepted and ignored. Declared `queries` are supported. They use equality on a primary or secondary index, including `fxhash`, and run synchronously through `&mut self`. An update declaration such as `StateById(state) by id` emits -`update_partial_state_by_id(StateByIdQuery { state: 7 }, &id) -> usize`; a delete declaration +`update_by_id(id, TicketColumns::STATE, 7) -> usize`; a delete declaration `ByOwner() by owner` emits `delete_by_owner(&owner) -> usize`. The return value counts -affected rows. `update_partial_in_place: { Status(state) by id }` emits -`update_partial_in_place_status(|state| *state = 42, &id) -> usize` and accepts one column. +affected rows. `update_in_place: { Status(state) by id }` emits +`update_in_place_by_id(id, TicketColumns::STATE, |state| *state = 42) -> usize` +and accepts one column. These methods belong to the table, not mutable wrappers on the shared partition set. Vec edits validate a cloned candidate before replacing a row. A primary or unique @@ -474,7 +480,7 @@ secondary-key collision panics with that row and its indexes unchanged; a panick edit closure also leaves the stored row unchanged. Replacing an existing row through `upsert` checks unique secondary keys first. Multi-row queries apply one row at a time and are not transactions: earlier successful edits remain if a later edit fails. -Cloning owned fields is part of this mutation cost, including Vec `update_partial_in_place` queries. +Cloning owned fields is part of this mutation cost, including Vec `update_in_place` queries. === Bytes and back: `unload` and `load` @@ -612,14 +618,14 @@ worktable! { runtime: nagoya(shared_slot), columns: { id: u64 primary_key, total: u64 }, queries: { - update_partial runtime scheduled: { TotalById(total) by id }, - update_partial_in_place runtime scheduled: { TotalById(total) by id }, + update runtime scheduled: { TotalById(total) by id }, + update_in_place runtime scheduled: { TotalById(total) by id }, } } let table = Arc::new(OrdersWorkTable::default()); table.insert(OrdersRow { id: 1, total: 10 }).await?; -table.update_partial_total_by_id(TotalByIdQuery { total: 20 }, 1u64).await?; -table.update_partial_in_place_total_by_id(|total| *total = 21.into(), 1u64).await?; +table.update_by_id(1, OrdersColumns::TOTAL, 20).await?; +table.update_in_place_by_id(1, OrdersColumns::TOTAL, |total| *total = 21.into()).await?; let rows = table.select_all() .order_on(OrdersRowFields::Total, Order::Desc) .limit(100).runtime(wide).execute_async().await?; @@ -702,9 +708,9 @@ worktable! ( by_bucket: { cluster_by: [bucket] }, }, queries: { - update_partial: { ScoreById(score) by id }, + update: { ScoreById(score) by id }, delete: { ById() by id }, - update_partial_in_place: { ScoreById(score) by id }, + update_in_place: { ScoreById(score) by id }, }, config: { page_size: 4096, @@ -948,7 +954,7 @@ more than halved randomized leaf lookup and was faster for four-client in-memory insertion. Select that tradeoff at the Cargo callsite: ```toml -worktable = { version = "^1.9.0-beta1", default-features = false, +worktable = { version = "^1.10.0-beta1", default-features = false, features = ["std", "vanilla-index", "wti-predictable-search"] } ``` @@ -980,9 +986,9 @@ Use `execute()` to materialize those builders. Unique secondary-index selects re was queued, not committed to stable storage. `delete_many(Vec).await` and `delete_range(range).await` return deleted keys and may report a `BatchDeleteError` with partial progress. Range deletion walks the keys present at that walk; it does not promise -to delete concurrent future inserts into the range. `reinsert(old, new).await` is the -explicit row-replacement operation; ordinary updates should use `upsert` or declared -queries so secondary indexes stay synchronized. +to delete concurrent future inserts into the range. Use `replace(row).await` when +the complete row is authoritative, or a declared update when only selected +columns are authoritative; both keep secondary indexes synchronized. With `autoincrement`, get a key from `get_next_pk()`, convert it into the row field, then insert. `reserve_pks(count)` reserves a disjoint range for a bulk producer. Reserved keys diff --git a/dsl/Cargo.toml b/dsl/Cargo.toml index a674c168..7aecf63b 100644 --- a/dsl/Cargo.toml +++ b/dsl/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "worktable_dsl" -version = "1.0.0-beta.19" +version = "1.10.0-beta1" edition = "2024" license = "MIT" description = "The worktable! schema language: its model and parser, readable outside the proc macro" diff --git a/dsl/src/check.rs b/dsl/src/check.rs index da91c36c..1677db0d 100644 --- a/dsl/src/check.rs +++ b/dsl/src/check.rs @@ -308,7 +308,7 @@ mod dispatch_agreement { }, indexes: { qty_idx: qty }, columnar_indexes: { host_order: { cluster_by: [host_id] } }, - queries: { update_partial: { Fill(qty) by id } }, + queries: { update: { Fill(qty) by id } }, config: { page_size: 4096 }, "; diff --git a/dsl/src/model/queries.rs b/dsl/src/model/queries.rs index d6f6e9bd..80f485f7 100644 --- a/dsl/src/model/queries.rs +++ b/dsl/src/model/queries.rs @@ -5,10 +5,10 @@ use crate::model::Operation; #[derive(Debug, Default)] pub struct Queries { - pub update_partials: IndexMap, + pub updates: IndexMap, pub deletes: IndexMap, - pub update_partials_in_place: IndexMap, - /// The profile named by `update_partial runtime :`, when the section was + pub updates_in_place: IndexMap, + /// The profile named by `update runtime :`, when the section was /// annotated. `None` is not a default: it means the section falls back to /// the table's `runtime`, and the table's own default only after that. /// @@ -16,10 +16,10 @@ pub struct Queries { /// profile is declared by `runtimes!` somewhere else in the crate, so /// whether it exists, and whether its backend matches the table's, is a /// question for code generation. - pub update_partial_runtime: Option, - /// The profile named by `delete runtime :`. See `update_partial_runtime`. + pub update_runtime: Option, + /// The profile named by `delete runtime :`. See `update_runtime`. pub delete_runtime: Option, - /// The profile named by `update_partial_in_place runtime :`. See - /// `update_partial_runtime`. - pub update_partial_in_place_runtime: Option, + /// The profile named by `update_in_place runtime :`. See + /// `update_runtime`. + pub update_in_place_runtime: Option, } diff --git a/dsl/src/parser/queries/delete.rs b/dsl/src/parser/queries/delete.rs index 99beac70..42b4ac3f 100644 --- a/dsl/src/parser/queries/delete.rs +++ b/dsl/src/parser/queries/delete.rs @@ -53,13 +53,13 @@ mod tests { #[test] fn test_update() { let tokens = quote! { - update_partial: { + update: { TestQuery(id, test) by name, Test1Query(id, name) by test, } }; let mut parser = Parser::new(tokens); - let (_, ops) = parser.parse_update_partials().unwrap(); + let (_, ops) = parser.parse_updates().unwrap(); assert_eq!(ops.len(), 2); let op = ops.get(&Ident::new("TestQuery", Span::mixed_site())).unwrap(); diff --git a/dsl/src/parser/queries/in_place.rs b/dsl/src/parser/queries/in_place.rs index 893b7f53..35ec6763 100644 --- a/dsl/src/parser/queries/in_place.rs +++ b/dsl/src/parser/queries/in_place.rs @@ -6,20 +6,17 @@ use crate::Parser; use crate::model::Operation; impl Parser { - /// The `update_partial_in_place` block, and the profile it was annotated with. See - /// [`Parser::parse_update_partials`] for why the annotation rides beside the + /// The `update_in_place` block, and the profile it was annotated with. See + /// [`Parser::parse_updates`] for why the annotation rides beside the /// operations. - pub fn parse_update_partials_in_place(&mut self) -> syn::Result<(Option, IndexMap)> { + pub fn parse_updates_in_place(&mut self) -> syn::Result<(Option, IndexMap)> { let ident = self.input_iter.next().ok_or(syn::Error::new( self.input.span(), - "Expected `update_partial_in_place` field in declaration", + "Expected `update_in_place` field in declaration", ))?; if let TokenTree::Ident(ident) = ident { - if ident.to_string().as_str() != "update_partial_in_place" { - return Err(syn::Error::new( - ident.span(), - "Expected `update_partial_in_place` field", - )); + if ident.to_string().as_str() != "update_in_place" { + return Err(syn::Error::new(ident.span(), "Expected `update_in_place` field")); } } else { return Err(syn::Error::new(ident.span(), "Expected field name identifier.")); @@ -36,8 +33,8 @@ impl Parser { if let TokenTree::Group(ops) = ops { let mut parser = Parser::new(ops.stream()); let operations = parser.parse_operations()?; - // Symmetry with `parse_update_partials`: consume a comma after the block, - // so an `update_partial_in_place` block is not required to be written last. + // Symmetry with `parse_updates`: consume a comma after the block, + // so an `update_in_place` block is not required to be written last. self.try_parse_comma()?; Ok((runtime, operations)) } else { @@ -56,12 +53,12 @@ mod tests { #[test] fn test_update() { let tokens = quote! { - update_partial_in_place: { + update_in_place: { TestQuery(id) by name, } }; let mut parser = Parser::new(tokens); - let (_, ops) = parser.parse_update_partials_in_place().unwrap(); + let (_, ops) = parser.parse_updates_in_place().unwrap(); assert_eq!(ops.len(), 1); let op = ops.get(&Ident::new("TestQuery", Span::mixed_site())).unwrap(); diff --git a/dsl/src/parser/queries/mod.rs b/dsl/src/parser/queries/mod.rs index 756a61f1..d3e88187 100644 --- a/dsl/src/parser/queries/mod.rs +++ b/dsl/src/parser/queries/mod.rs @@ -38,26 +38,26 @@ impl Parser { let mut parser = Parser::new(ops.stream()); while let Some(ident) = parser.peek_next() { match ident.to_string().as_str() { - "update_partial" => { - let (runtime, updates) = parser.parse_update_partials()?; - queries.update_partials = updates; - queries.update_partial_runtime = runtime; + "update" => { + let (runtime, updates) = parser.parse_updates()?; + queries.updates = updates; + queries.update_runtime = runtime; } "delete" => { let (runtime, deletes) = parser.parse_deletes()?; queries.deletes = deletes; queries.delete_runtime = runtime; } - "update_partial_in_place" => { - let (runtime, updates) = parser.parse_update_partials_in_place()?; - queries.update_partials_in_place = updates; - queries.update_partial_in_place_runtime = runtime; + "update_in_place" => { + let (runtime, updates) = parser.parse_updates_in_place()?; + queries.updates_in_place = updates; + queries.update_in_place_runtime = runtime; } other => { return Err(syn::Error::new( ident.span(), format!( - "Unexpected token `{other}`; expected one of `update_partial`, `delete`, `update_partial_in_place`" + "Unexpected token `{other}`; expected one of `update`, `delete`, `update_in_place`" ), )); } @@ -83,56 +83,56 @@ mod tests { fn sections_are_unannotated_by_default() { let tokens = quote! { queries: { - update_partial: { Fill(qty) by id }, + update: { Fill(qty) by id }, delete: { BySymbol() by symbol }, - update_partial_in_place: { Bump(qty) by id }, + update_in_place: { Bump(qty) by id }, } }; let queries = Parser::new(tokens).parse_queries().unwrap(); - assert!(queries.update_partial_runtime.is_none()); + assert!(queries.update_runtime.is_none()); assert!(queries.delete_runtime.is_none()); - assert!(queries.update_partial_in_place_runtime.is_none()); + assert!(queries.update_in_place_runtime.is_none()); } #[test] fn each_section_takes_a_runtime_annotation() { let tokens = quote! { queries: { - update_partial runtime fast_local: { Fill(qty) by id }, + update runtime fast_local: { Fill(qty) by id }, delete runtime wide: { BySymbol() by symbol }, - update_partial_in_place runtime bulk: { Bump(qty) by id }, + update_in_place runtime bulk: { Bump(qty) by id }, } }; let queries = Parser::new(tokens).parse_queries().unwrap(); - assert_eq!(queries.update_partial_runtime.unwrap(), "fast_local"); + assert_eq!(queries.update_runtime.unwrap(), "fast_local"); assert_eq!(queries.delete_runtime.unwrap(), "wide"); - assert_eq!(queries.update_partial_in_place_runtime.unwrap(), "bulk"); - assert_eq!(queries.update_partials.len(), 1); + assert_eq!(queries.update_in_place_runtime.unwrap(), "bulk"); + assert_eq!(queries.updates.len(), 1); assert_eq!(queries.deletes.len(), 1); - assert_eq!(queries.update_partials_in_place.len(), 1); + assert_eq!(queries.updates_in_place.len(), 1); } #[test] fn an_annotated_section_sits_beside_an_unannotated_one() { let tokens = quote! { queries: { - update_partial runtime fast_local: { Fill(qty) by id }, - update_partial_in_place: { Bump(qty) by id }, + update runtime fast_local: { Fill(qty) by id }, + update_in_place: { Bump(qty) by id }, } }; let queries = Parser::new(tokens).parse_queries().unwrap(); - assert_eq!(queries.update_partial_runtime.unwrap(), "fast_local"); - assert!(queries.update_partial_in_place_runtime.is_none()); + assert_eq!(queries.update_runtime.unwrap(), "fast_local"); + assert!(queries.update_in_place_runtime.is_none()); } #[test] fn a_section_rejects_a_backend_in_place_of_a_profile() { let tokens = quote! { queries: { - update_partial runtime nagoya: { Fill(qty) by id }, + update runtime nagoya: { Fill(qty) by id }, } }; let error = Parser::new(tokens).parse_queries().unwrap_err().to_string(); @@ -143,16 +143,6 @@ mod tests { ); } - #[test] - fn legacy_update_section_is_rejected() { - let tokens = quote! { - queries: { update: { Fill(qty) by id } } - }; - let error = Parser::new(tokens).parse_queries().unwrap_err().to_string(); - - assert!(error.contains("Unexpected token `update`"), "{error}"); - } - #[test] fn legacy_in_place_section_is_rejected() { let tokens = quote! { diff --git a/dsl/src/parser/queries/select.rs b/dsl/src/parser/queries/select.rs index 2df60ecb..0140a10b 100644 --- a/dsl/src/parser/queries/select.rs +++ b/dsl/src/parser/queries/select.rs @@ -44,13 +44,13 @@ mod tests { #[test] fn test_update() { let tokens = quote! { - update_partial: { + update: { TestQuery(id, test) by name, Test1Query(id, name) by test, } }; let mut parser = Parser::new(tokens); - let (_, ops) = parser.parse_update_partials().unwrap(); + let (_, ops) = parser.parse_updates().unwrap(); assert_eq!(ops.len(), 2); let op = ops.get(&Ident::new("TestQuery", Span::mixed_site())).unwrap(); diff --git a/dsl/src/parser/queries/update.rs b/dsl/src/parser/queries/update.rs index 12adca2c..c7563a53 100644 --- a/dsl/src/parser/queries/update.rs +++ b/dsl/src/parser/queries/update.rs @@ -6,20 +6,20 @@ use crate::Parser; use crate::model::Operation; impl Parser { - /// The `update_partial` block, and the profile it was annotated with. + /// The `update` block, and the profile it was annotated with. /// /// The annotation is returned beside the operations rather than folded /// into them because it applies to the block: every query in it runs on /// the same runtime, and saying so once is the point of writing it at the /// section rather than on each query. - pub fn parse_update_partials(&mut self) -> syn::Result<(Option, IndexMap)> { + pub fn parse_updates(&mut self) -> syn::Result<(Option, IndexMap)> { let ident = self.input_iter.next().ok_or(syn::Error::new( self.input.span(), - "Expected `update_partial` field in declaration", + "Expected `update` field in declaration", ))?; if let TokenTree::Ident(ident) = ident { - if ident.to_string().as_str() != "update_partial" { - return Err(syn::Error::new(ident.span(), "Expected `update_partial` field")); + if ident.to_string().as_str() != "update" { + return Err(syn::Error::new(ident.span(), "Expected `update` field")); } } else { return Err(syn::Error::new(ident.span(), "Expected field name identifier.")); @@ -54,13 +54,13 @@ mod tests { #[test] fn test_update() { let tokens = quote! { - update_partial: { + update: { TestQuery(id, test) by name, Test1Query(id, name) by test, } }; let mut parser = Parser::new(tokens); - let (_, ops) = parser.parse_update_partials().unwrap(); + let (_, ops) = parser.parse_updates().unwrap(); assert_eq!(ops.len(), 2); let op = ops.get(&Ident::new("TestQuery", Span::mixed_site())).unwrap(); diff --git a/dsl/src/parser/runtime.rs b/dsl/src/parser/runtime.rs index 2a5e7074..06f4e6eb 100644 --- a/dsl/src/parser/runtime.rs +++ b/dsl/src/parser/runtime.rs @@ -43,7 +43,7 @@ fn expected_flavor() -> String { const TOKIO_HAS_NO_FLAVORS: &str = "`tokio` has no flavors; write `runtime: tokio`, or select a flavored runtime with `runtime: nagoya(spread)`"; -const EXPECTED_PROFILE: &str = "expected a profile name after `runtime`, as in `update_partial runtime fast_local:`; \ +const EXPECTED_PROFILE: &str = "expected a profile name after `runtime`, as in `update runtime fast_local:`; \ profiles are declared with `runtimes!`"; impl Parser { @@ -152,7 +152,7 @@ impl Parser { } /// The optional `runtime ` between a query section's keyword and - /// its colon, as in `update_partial runtime fast_local: { .. }`. + /// its colon, as in `update runtime fast_local: { .. }`. /// /// The token after `runtime` is a profile name, never a backend literal. /// A section names a profile because a profile carries tuning as well as a @@ -393,7 +393,7 @@ mod tests { " name: Last, columns: { id: u64 primary_key, qty: u64 }, - queries: { update_partial: { Fill(qty) by id } }, + queries: { update: { Fill(qty) by id } }, runtime: tokio, ", ); @@ -439,15 +439,15 @@ mod tests { name: Annotated, columns: { id: u64 primary_key, qty: u64, symbol: u64 }, queries: { - update_partial runtime fast_local: { Fill(qty) by id }, + update runtime fast_local: { Fill(qty) by id }, delete runtime wide: { BySymbol() by symbol }, - update_partial_in_place: { Bump(qty) by id }, + update_in_place: { Bump(qty) by id }, }, ", ); - assert_eq!(schema.queries.update_partial_runtime.as_deref(), Some("fast_local")); + assert_eq!(schema.queries.update_runtime.as_deref(), Some("fast_local")); assert_eq!(schema.queries.delete_runtime.as_deref(), Some("wide")); - assert_eq!(schema.queries.update_partial_in_place_runtime, None); + assert_eq!(schema.queries.update_in_place_runtime, None); } #[test] @@ -456,13 +456,13 @@ mod tests { name: RoundTrip, columns: { id: u64 primary_key, qty: u64 }, runtime: nagoya(spread), - queries: { update_partial runtime wide: { Fill(qty) by id } }, + queries: { update runtime wide: { Fill(qty) by id } }, "; let once = schema(source); let twice = schema(&once.to_dsl()); assert_eq!(once, twice); assert_eq!(twice.runtime, RuntimeBackend::Nagoya(Flavor::Spread)); - assert_eq!(twice.queries.update_partial_runtime.as_deref(), Some("wide")); + assert_eq!(twice.queries.update_runtime.as_deref(), Some("wide")); } #[test] diff --git a/dsl/src/schema/emit_dsl.rs b/dsl/src/schema/emit_dsl.rs index a08c38f0..6c75459a 100644 --- a/dsl/src/schema/emit_dsl.rs +++ b/dsl/src/schema/emit_dsl.rs @@ -93,9 +93,9 @@ impl Schema { let _ = writeln!(out, "queries: {{"); write_query_block( &mut out, - "update_partial", - self.queries.update_partial_runtime.as_deref(), - &self.queries.update_partials, + "update", + self.queries.update_runtime.as_deref(), + &self.queries.updates, ); write_query_block( &mut out, @@ -105,9 +105,9 @@ impl Schema { ); write_query_block( &mut out, - "update_partial_in_place", - self.queries.update_partial_in_place_runtime.as_deref(), - &self.queries.update_partials_in_place, + "update_in_place", + self.queries.update_in_place_runtime.as_deref(), + &self.queries.updates_in_place, ); let _ = writeln!(out, "}},"); } diff --git a/dsl/src/schema/emit_uml.rs b/dsl/src/schema/emit_uml.rs index 51a25ddd..15269fe0 100644 --- a/dsl/src/schema/emit_uml.rs +++ b/dsl/src/schema/emit_uml.rs @@ -54,9 +54,9 @@ impl Schema { } for (kind, operations) in [ - ("update_partial", &self.queries.update_partials), + ("update", &self.queries.updates), ("delete", &self.queries.deletes), - ("update_partial_in_place", &self.queries.update_partials_in_place), + ("update_in_place", &self.queries.updates_in_place), ] { for operation in operations { let _ = writeln!( diff --git a/dsl/src/schema/mod.rs b/dsl/src/schema/mod.rs index 91f802af..2a1b27c4 100644 --- a/dsl/src/schema/mod.rs +++ b/dsl/src/schema/mod.rs @@ -196,26 +196,26 @@ pub struct PartitionKeySpec { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(default))] pub struct QueriesSpec { - /// `update_partial:` operations. - pub update_partials: Vec, + /// `update:` operations. + pub updates: Vec, /// `delete:` operations. pub deletes: Vec, - /// `update_partial_in_place:` operations. - pub update_partials_in_place: Vec, - /// The profile named by `update_partial runtime :`, if written. Unresolved: - /// see [`crate::model::Queries::update_partial_runtime`]. - pub update_partial_runtime: Option, + /// `update_in_place:` operations. + pub updates_in_place: Vec, + /// The profile named by `update runtime :`, if written. Unresolved: + /// see [`crate::model::Queries::update_runtime`]. + pub update_runtime: Option, /// The profile named by `delete runtime :`, if written. pub delete_runtime: Option, - /// The profile named by `update_partial_in_place runtime :`, if written. - pub update_partial_in_place_runtime: Option, + /// The profile named by `update_in_place runtime :`, if written. + pub update_in_place_runtime: Option, } impl QueriesSpec { /// Whether any query was declared. An empty block and an absent one are /// the same thing to the macro, so the emitter writes neither. pub fn is_empty(&self) -> bool { - self.update_partials.is_empty() && self.deletes.is_empty() && self.update_partials_in_place.is_empty() + self.updates.is_empty() && self.deletes.is_empty() && self.updates_in_place.is_empty() } } @@ -496,14 +496,12 @@ fn queries_from_model(queries: Queries) -> QueriesSpec { } QueriesSpec { - update_partial_runtime: queries.update_partial_runtime.map(|profile| profile.to_string()), + update_runtime: queries.update_runtime.map(|profile| profile.to_string()), delete_runtime: queries.delete_runtime.map(|profile| profile.to_string()), - update_partial_in_place_runtime: queries - .update_partial_in_place_runtime - .map(|profile| profile.to_string()), - update_partials: convert(queries.update_partials), + update_in_place_runtime: queries.update_in_place_runtime.map(|profile| profile.to_string()), + updates: convert(queries.updates), deletes: convert(queries.deletes), - update_partials_in_place: convert(queries.update_partials_in_place), + updates_in_place: convert(queries.updates_in_place), } } diff --git a/dsl/src/validate.rs b/dsl/src/validate.rs index 675ffcfe..3b8d931e 100644 --- a/dsl/src/validate.rs +++ b/dsl/src/validate.rs @@ -87,20 +87,20 @@ pub fn validate_arctic_page_size(columns: &Columns, config: Option<&crate::model Ok(()) } -/// `update_partial_in_place` queries hand the caller a mutable reference to the archived +/// `update_in_place` queries hand the caller a mutable reference to the archived /// column bytes and bypass all index maintenance, so a column that any index /// is built over cannot be mutated in place: the index would keep resolving /// the old value. pub fn validate_in_place_queries(columns: &Columns, queries: &crate::model::Queries) -> syn::Result<()> { - for (name, op) in &queries.update_partials_in_place { + for (name, op) in &queries.updates_in_place { for column in &op.columns { if columns.indexes.values().any(|index| &index.field == column) { return Err(syn::Error::new( column.span(), format!( - "update_partial_in_place query `{name}` mutates column `{column}`, which is covered by an index; \ + "update_in_place query `{name}` mutates column `{column}`, which is covered by an index; \ indexed columns cannot be updated in place because secondary indexes are not \ - maintained on this path. Use an `update_partial` query instead" + maintained on this path. Use an `update` query instead" ), )); } @@ -379,10 +379,10 @@ pub fn validate_query_storage( ) -> syn::Result<()> { if storage.is_vec() { if let Some(profile) = queries - .update_partial_runtime + .update_runtime .as_ref() .or(queries.delete_runtime.as_ref()) - .or(queries.update_partial_in_place_runtime.as_ref()) + .or(queries.update_in_place_runtime.as_ref()) { return Err(syn::Error::new( profile.span(), @@ -391,32 +391,32 @@ pub fn validate_query_storage( } return Ok(()); } - for (name, op) in &queries.update_partials { + for (name, op) in &queries.updates { let by_primary = columns.primary_keys.len() == 1 && columns.primary_keys.first() == Some(&op.by); let by_index = columns.indexes.values().any(|index| index.field == op.by); if !by_primary && !by_index { return Err(syn::Error::new( op.by.span(), format!( - "update_partial query `{name}` requires a single-column primary key or a secondary index on `{}`", + "update query `{name}` requires a single-column primary key or a secondary index on `{}`", op.by ), )); } } - for (name, op) in &queries.update_partials_in_place { + for (name, op) in &queries.updates_in_place { if columns.primary_keys.len() != 1 || columns.primary_keys.first() != Some(&op.by) { return Err(syn::Error::new( op.by.span(), format!( - "update_partial_in_place query `{name}` requires selection by the single-column primary key; use an update_partial query for an indexed predicate" + "update_in_place query `{name}` requires selection by the single-column primary key; use an update query for an indexed predicate" ), )); } if op.columns.iter().any(|column| columns.primary_keys.contains(column)) { return Err(syn::Error::new( name.span(), - "update_partial_in_place queries cannot mutate primary key columns; use an update_partial query to maintain indexes", + "update_in_place queries cannot mutate primary key columns; use an update query to maintain indexes", )); } } diff --git a/dsl/tests/diff.rs b/dsl/tests/diff.rs index 15a55bb6..b072349b 100644 --- a/dsl/tests/diff.rs +++ b/dsl/tests/diff.rs @@ -329,7 +329,7 @@ fn queries_and_config_never_reach_the_data() { name: User, version: 2, persist: true, columns: { id: u64 primary_key autoincrement, email: String, age: u8 }, indexes: { email_idx: email unique }, - queries: { update_partial: { Age(age) by id } }, + queries: { update: { Age(age) by id } }, config: { row_derives: Clone } ", ); diff --git a/dsl/tests/never_panics.rs b/dsl/tests/never_panics.rs index ca295ec3..caa2b166 100644 --- a/dsl/tests/never_panics.rs +++ b/dsl/tests/never_panics.rs @@ -15,7 +15,7 @@ const SEEDS: &[&str] = &[ "worktable!(name: T, columns: { id: u64 primary_key });", "worktable!(name: T, persist: true, columns: { id: u64 primary_key autoincrement, v: String }, indexes: { v_idx: v unique });", "worktable!(name: T, columns: { id: u32 primary_key using arctic, v: i64 }, indexes: { v_idx: v });", - "worktable!(name: T, columns: { id: u64 primary_key }, queries: { update_partial: { ById(v) by id } });", + "worktable!(name: T, columns: { id: u64 primary_key }, queries: { update: { ById(v) by id } });", "worktable!(name: T, persist: false, columns: { id: u8 primary_key using congee }, config: { page_size: 4096 });", ]; @@ -159,7 +159,7 @@ fn check_reports_semantic_errors_rather_than_panicking() { ), ( "a query over a column that does not exist", - "worktable!(name: T, columns: { id: u64 primary_key }, queries: { update_partial: { ById(nope) by id } });", + "worktable!(name: T, columns: { id: u64 primary_key }, queries: { update: { ById(nope) by id } });", ), ( "a page size that is not a number", diff --git a/dsl/tests/query_storage.rs b/dsl/tests/query_storage.rs index d3622c72..5b86aa5e 100644 --- a/dsl/tests/query_storage.rs +++ b/dsl/tests/query_storage.rs @@ -3,9 +3,9 @@ use worktable_dsl::check::check; #[test] fn paged_mutation_shapes_fail_before_emission() { for query in [ - "update_partial: { Change(value) by value }", - "update_partial_in_place: { Change(value) by value }", - "update_partial_in_place: { Change(id) by id }", + "update: { Change(value) by value }", + "update_in_place: { Change(value) by value }", + "update_in_place: { Change(id) by id }", ] { let checked = check(&format!( "name: T, columns: {{ id: u64 primary_key, value: u64 }}, queries: {{ {query} }}" @@ -16,14 +16,14 @@ fn paged_mutation_shapes_fail_before_emission() { #[test] fn a_vec_query_cannot_silently_ignore_a_runtime_profile() { let checked = check( - "name: T, vec: true, columns: { id: u64 primary_key, value: u64 }, queries: { update_partial runtime scheduled: { Change(value) by id } }", + "name: T, vec: true, columns: { id: u64 primary_key, value: u64 }, queries: { update runtime scheduled: { Change(value) by id } }", ); assert!(checked.diagnostics.iter().any(|d| d.message.contains("synchronous"))); } #[test] fn supported_paged_index_updates_remain_valid() { let checked = check( - "name: T, columns: { id: u64 primary_key, value: u64, amount: u64 }, indexes: { value_idx: value }, queries: { update_partial: { Change(amount) by value } }", + "name: T, columns: { id: u64 primary_key, value: u64, amount: u64 }, indexes: { value_idx: value }, queries: { update: { Change(amount) by value } }", ); assert!(checked.diagnostics.is_empty(), "{:?}", checked.diagnostics); } diff --git a/dsl/tests/round_trip.rs b/dsl/tests/round_trip.rs index 139104c2..6fc0d934 100644 --- a/dsl/tests/round_trip.rs +++ b/dsl/tests/round_trip.rs @@ -115,7 +115,7 @@ fn reading_the_same_declaration_twice_gives_the_same_schema() { let source = " name: Repeatable, columns: { id: u64 primary_key, a: u64, b: u64, c: String }, - queries: { update_partial: { A(a) by id, B(b) by id, C(c) by id } } + queries: { update: { A(a) by id, B(b) by id, C(c) by id } } "; let first = Schema::parse(source).expect("parses"); let second = Schema::parse(source).expect("parses"); @@ -152,7 +152,7 @@ fn every_top_level_block_survives_the_emitter() { ), ( "queries", - "name: A, columns: { id: u64 primary_key, x: u64 }, queries: { update_partial: { X(x) by id } }", + "name: A, columns: { id: u64 primary_key, x: u64 }, queries: { update: { X(x) by id } }", ), ( "config", diff --git a/dsl/tests/schema.rs b/dsl/tests/schema.rs index 7b64937a..aeeaa9fc 100644 --- a/dsl/tests/schema.rs +++ b/dsl/tests/schema.rs @@ -32,10 +32,10 @@ fn queries_are_in_declaration_order() { " name: Sorted, columns: { id: u64 primary_key, a: u64, b: u64, c: u64 }, - queries: { update_partial: { Charlie(c) by id, Alpha(a) by id, Bravo(b) by id } } + queries: { update: { Charlie(c) by id, Alpha(a) by id, Bravo(b) by id } } ", ); - let names: Vec<&str> = schema.queries.update_partials.iter().map(|q| q.name.as_str()).collect(); + let names: Vec<&str> = schema.queries.updates.iter().map(|q| q.name.as_str()).collect(); assert_eq!(names, ["Charlie", "Alpha", "Bravo"]); } @@ -113,7 +113,7 @@ fn a_schema_survives_a_trip_through_serde() { payload: String optional, }, indexes: { payload_idx: payload unique }, - queries: { update_partial: { Payload(payload) by id } }, + queries: { update: { Payload(payload) by id } }, config: { page_size: 16384, row_derives: Clone, Debug } ", ); diff --git a/dsl/tests/trailing_commas.rs b/dsl/tests/trailing_commas.rs index e3b0bfef..a9e4e763 100644 --- a/dsl/tests/trailing_commas.rs +++ b/dsl/tests/trailing_commas.rs @@ -37,12 +37,12 @@ fn config_does_not_have_to_be_written_last() { "name: Ordered, columns: { id: u64 primary_key, name: String }, config: { page_size: 8192 }, - queries: { update_partial: { Renamed(name) by id, } }", + queries: { update: { Renamed(name) by id, } }", ) .expect("block order should not depend on which parser eats a comma"); assert_eq!(schema.config.page_size, Some(8192)); - assert_eq!(schema.queries.update_partials.len(), 1); + assert_eq!(schema.queries.updates.len(), 1); } /// The same asymmetry inside `queries`, where `delete` and `in_place` sat. @@ -53,15 +53,15 @@ fn a_comma_after_delete_or_in_place_is_accepted() { columns: { id: u64 primary_key, name: String }, queries: { delete: { ByName() by name, }, - update_partial_in_place: { SetName(name) by id, }, - update_partial: { Renamed(name) by id, } + update_in_place: { SetName(name) by id, }, + update: { Renamed(name) by id, } }", ) .expect("`delete` and `in_place` should not have to be written last either"); assert_eq!(schema.queries.deletes.len(), 1); - assert_eq!(schema.queries.update_partials_in_place.len(), 1); - assert_eq!(schema.queries.update_partials.len(), 1); + assert_eq!(schema.queries.updates_in_place.len(), 1); + assert_eq!(schema.queries.updates.len(), 1); } /// Omitting the comma stays valid. The fix is permissive, not a new rule. diff --git a/dsl/tests/uml.rs b/dsl/tests/uml.rs index 2071fb7a..2989df3e 100644 --- a/dsl/tests/uml.rs +++ b/dsl/tests/uml.rs @@ -52,7 +52,7 @@ fn mermaid_draws_queries_as_operations() { name: Ledger, columns: { id: u64 primary_key, balance: f64, note: String }, queries: { - update_partial: { Balance(balance) by id } + update: { Balance(balance) by id } delete: { ById() by id } } ", diff --git a/examples/guide_check.rs b/examples/guide_check.rs index 7ac2784a..0d192949 100644 --- a/examples/guide_check.rs +++ b/examples/guide_check.rs @@ -14,7 +14,7 @@ worktable! ( symbol_idx: symbol, }, queries: { - update_partial: { QuantityById(quantity) by id, } + update: { QuantityById(quantity) by id, } } ); @@ -53,9 +53,7 @@ async fn main() -> eyre::Result<()> { quantity: 5, }]) .await?; - table - .update_partial_quantity_by_id(QuantityByIdQuery { quantity: 7 }, 100) - .await?; + table.update_by_id(100, OrderColumns::QUANTITY, 7).await?; assert_eq!(table.select(100).unwrap().quantity, 7); assert_eq!(table.select_all().limit(1).execute()?.len(), 1); assert_eq!(table.row_count(), 2); diff --git a/paper-bench/scripts/compile_cost.sh b/paper-bench/scripts/compile_cost.sh index 729d3206..a224fdbe 100644 --- a/paper-bench/scripts/compile_cost.sh +++ b/paper-bench/scripts/compile_cost.sh @@ -39,8 +39,8 @@ worktable!( }, indexes: { a_idx_$i: a, }, queries: { - update_partial: { UpdA$i(a) by id, }, - update_partial_in_place: { IncB$i(b) by id, } + update: { UpdA$i(a) by id, }, + update_in_place: { IncB$i(b) by id, } } ); EOF diff --git a/paper-bench/src/bin/ablation.rs b/paper-bench/src/bin/ablation.rs index 4d758152..c6d1b8b0 100644 --- a/paper-bench/src/bin/ablation.rs +++ b/paper-bench/src/bin/ablation.rs @@ -57,7 +57,7 @@ fn main() { rt.block_on(async { for _ in 0..n { let pk = rng.below(rows); - table.update_partial_upd_b(UpdBQuery { b: pk }, pk).await.unwrap(); + table.update_by_id(pk, BenchColumns::B, pk).await.unwrap(); } }); n @@ -74,7 +74,7 @@ fn main() { rt.block_on(async { for _ in 0..n { let pk = rng.below(rows); - table.update_partial_upd_a(UpdAQuery { a: pk }, pk).await.unwrap(); + table.update_by_id(pk, BenchColumns::A, pk).await.unwrap(); } }); n @@ -91,7 +91,7 @@ fn main() { rt.block_on(async { for _ in 0..n { let pk = rng.below(rows); - table.update_partial_in_place_inc_b(|b| *b += 1, pk).await.unwrap(); + table.update_in_place_by_id(pk, BenchColumns::B, |b| *b += 1).await.unwrap(); } }); n diff --git a/paper-bench/src/bin/contention.rs b/paper-bench/src/bin/contention.rs index b986323e..46164297 100644 --- a/paper-bench/src/bin/contention.rs +++ b/paper-bench/src/bin/contention.rs @@ -38,24 +38,24 @@ async fn run(mode: &'static str, tasks: usize) -> f64 { match mode { "disjoint" => { if i % 2 == 0 { - table.update_partial_upd_b(UpdBQuery { b: n }, pk_val).await.unwrap(); + table.update_by_id(pk_val, BenchColumns::B, n).await.unwrap(); } else { - table.update_partial_upd_e(UpdEQuery { e: n }, pk_val).await.unwrap(); + table.update_by_id(pk_val, BenchColumns::E, n).await.unwrap(); } } "overlap" => { - table.update_partial_upd_be(UpdBEQuery { b: n, e: n }, pk_val).await.unwrap(); + table.update_by_id(pk_val, BenchColumns::B_AND_E, UpdBEQuery { b: n, e: n }).await.unwrap(); } "mutex" => { let _g = big_lock.lock().await; if i % 2 == 0 { - table.update_partial_upd_b(UpdBQuery { b: n }, pk_val).await.unwrap(); + table.update_by_id(pk_val, BenchColumns::B, n).await.unwrap(); } else { - table.update_partial_upd_e(UpdEQuery { e: n }, pk_val).await.unwrap(); + table.update_by_id(pk_val, BenchColumns::E, n).await.unwrap(); } } "inplace" => { - table.update_partial_in_place_inc_b(|b| *b += 1, pk_val).await.unwrap(); + table.update_in_place_by_id(pk_val, BenchColumns::B, |b| *b += 1).await.unwrap(); } _ => unreachable!(), } diff --git a/paper-bench/src/dynamic.rs b/paper-bench/src/dynamic.rs index 912005d8..f6e61a18 100644 --- a/paper-bench/src/dynamic.rs +++ b/paper-bench/src/dynamic.rs @@ -127,7 +127,7 @@ impl DynTable { } /// Field update through the catalog — the dynamic path a specialized - /// `update_partial_upd_b` avoids: lock table, hash lookup, decode, dispatch, + /// `update_upd_b` avoids: lock table, hash lookup, decode, dispatch, /// re-encode, write back. pub fn update_field(&self, pk: u64, col: &str, v: Value) -> Option<()> { let lock = { diff --git a/paper-bench/src/lib.rs b/paper-bench/src/lib.rs index 95ec7587..2063e0e7 100644 --- a/paper-bench/src/lib.rs +++ b/paper-bench/src/lib.rs @@ -22,13 +22,13 @@ worktable!( a_idx: a, }, queries: { - update_partial: { + update: { UpdA(a) by id, UpdB(b) by id, UpdE(e) by id, UpdBE(b, e) by id, }, - update_partial_in_place: { + update_in_place: { IncB(b) by id, } } diff --git a/src/persistence/operation/batch.rs b/src/persistence/operation/batch.rs index d494f76b..822d18db 100644 --- a/src/persistence/operation/batch.rs +++ b/src/persistence/operation/batch.rs @@ -55,7 +55,7 @@ worktable! ( pos_idx: pos unique, }, queries: { - update_partial: { + update: { PosById(pos) by id, } } @@ -261,7 +261,7 @@ where .select_by_pos(old_pos) .ok_or_else(|| eyre::eyre!("batch metadata position {old_pos} is missing during reindex"))?; self.info_wt - .update_partial_pos_by_id(PosByIdQuery { pos: old_pos - 1 }, row.id) + .update_by_id(row.id, BatchInnerColumns::POS, old_pos - 1) .await?; } Ok(()) @@ -867,10 +867,10 @@ mod tests { // into fixed UUID positions used to produce A, update-B, B and discard // the update as superseded by B's older row image. let insert_b = event_insert(1, link, vec![2; 4], vec![1]); - let update_partial_b = eventless_update(2, link, vec![3; 4]); + let update_b = eventless_update(2, link, vec![3; 4]); let insert_a = event_insert(3, link, vec![1; 4], vec![0]); - let batch = latest_data_writes(&[insert_b, update_partial_b, insert_a]); + let batch = latest_data_writes(&[insert_b, update_b, insert_a]); assert_eq!(batch.get(&PageId::from(1u32)).unwrap(), &vec![(link, vec![3; 4])]); } diff --git a/src/table/mod.rs b/src/table/mod.rs index 0d430a08..fffcc077 100644 --- a/src/table/mod.rs +++ b/src/table/mod.rs @@ -1171,16 +1171,14 @@ where (ops, Ok(pks)) } - /// Reinserts provided row with updating indexes and saving it's data in new - /// place. Is used to not delete and insert because this situation causes - /// a possible gap when row doesn't exist. + /// Internal relocation primitive used by generated replacement code. /// - /// For reinsert it's ok that part of indexes will lead to old row and other - /// part is for new row. Goal is to make `PrimaryKey` of the row always - /// acceptable. As for reinsert `PrimaryKey` will be same for both old and - /// new [`Link`]'s, goal will be achieved. - /// - /// [`Link`]: data_bucket::Link + /// `row_old` must be the exact row currently stored at `row_new`'s primary + /// key, and the caller must participate in the table's mutation protocol. + /// This is not compare-and-replace: the method checks only primary-key + /// equality and uses the caller-supplied old row to repair secondary + /// indexes. Supplying a stale row can therefore leave those indexes wrong. + #[doc(hidden)] pub async fn reinsert(&self, row_old: Row, row_new: Row) -> Result where Row: Archive @@ -1262,6 +1260,7 @@ where } #[allow(clippy::type_complexity)] + #[doc(hidden)] pub fn reinsert_cdc( &self, row_old: Row, diff --git a/src/table/vacuum/vacuum.rs b/src/table/vacuum/vacuum.rs index 7e270b18..e1786662 100644 --- a/src/table/vacuum/vacuum.rs +++ b/src/table/vacuum/vacuum.rs @@ -1783,7 +1783,7 @@ mod tests { another: 11, exchange: "updated0".to_string(), }; - table.update(updated_target.clone()).await.unwrap(); + table.replace(updated_target.clone()).await.unwrap(); let current_target_link = table .0 .primary_index diff --git a/tests/persistence/concurrent/mod.rs b/tests/persistence/concurrent/mod.rs index 381c3c80..4ea6a997 100644 --- a/tests/persistence/concurrent/mod.rs +++ b/tests/persistence/concurrent/mod.rs @@ -40,7 +40,7 @@ worktable! ( value_idx: value unique, }, queries: { - update_partial: { + update: { AnotherById(another) by id, }, delete: { diff --git a/tests/persistence/duplicate_key_index_reload.rs b/tests/persistence/duplicate_key_index_reload.rs index dced4639..b275ff6c 100644 --- a/tests/persistence/duplicate_key_index_reload.rs +++ b/tests/persistence/duplicate_key_index_reload.rs @@ -23,7 +23,7 @@ worktable!( bucket_idx: bucket, }, queries: { - update_partial: { + update: { ScoreById(score) by id, } } @@ -273,7 +273,7 @@ fn test_duplicate_key_secondary_index_survives_reload() { if model.by_score.values().any(|ids| ids.contains(&id)) { let new_score = (id % KEYS) + 1_000; table - .update_partial_score_by_id(ScoreByIdQuery { score: new_score }, id) + .update_by_id(id, DuplicateKeyReloadColumns::SCORE, new_score) .await .unwrap(); model.move_score(id, new_score); @@ -461,7 +461,7 @@ fn test_duplicate_key_mutations_without_reload() { if model.by_score.values().any(|ids| ids.contains(&id)) { let new_score = (id % KEYS) + 1_000; table - .update_partial_score_by_id(ScoreByIdQuery { score: new_score }, id) + .update_by_id(id, DuplicateKeyReloadColumns::SCORE, new_score) .await .unwrap(); model.move_score(id, new_score); diff --git a/tests/persistence/failure/mod.rs b/tests/persistence/failure/mod.rs index ecd26e01..58e0ad58 100644 --- a/tests/persistence/failure/mod.rs +++ b/tests/persistence/failure/mod.rs @@ -53,7 +53,7 @@ worktable!( unique_value_idx: unique_value unique, }, queries: { - update_partial: { UniqueValueByCategory(unique_value) by category }, + update: { UniqueValueByCategory(unique_value) by category }, }, ); @@ -80,7 +80,7 @@ worktable!( unique_value_idx: unique_value unique, }, queries: { - update_partial: { NameAndValueByCategory(name, unique_value) by category }, + update: { NameAndValueByCategory(name, unique_value) by category }, }, ); diff --git a/tests/persistence/failure/reinsert.rs b/tests/persistence/failure/reinsert.rs index 86f92631..277dcd47 100644 --- a/tests/persistence/failure/reinsert.rs +++ b/tests/persistence/failure/reinsert.rs @@ -56,7 +56,7 @@ fn test_reinsert_pk_mismatch() { unique_b: 200, }; - let result = table.update(new_row).await; + let result = table.replace(new_row).await; assert!(result.is_err()); let valid_row3 = TwoUniqueIdxRow { @@ -148,7 +148,7 @@ fn test_reinsert_two_indexes_first_fail() { unique_b: 500, }; - let result = table.update(updated_row).await; + let result = table.replace(updated_row).await; assert!(result.is_err()); let valid_row3 = TwoUniqueIdxRow { @@ -248,7 +248,7 @@ fn test_reinsert_two_indexes_second_fail() { unique_b: conflict_b, }; - let result = table.update(updated_row).await; + let result = table.replace(updated_row).await; assert!(result.is_err()); let valid_row3 = TwoUniqueIdxRow { @@ -353,7 +353,7 @@ fn test_reinsert_three_indexes_first_fail() { unique_c: 800, }; - let result = table.update(updated_row).await; + let result = table.replace(updated_row).await; assert!(result.is_err()); let valid_row3 = ThreeUniqueIdxRow { @@ -454,7 +454,7 @@ fn test_reinsert_three_indexes_middle_fail() { unique_c: 800, }; - let result = table.update(updated_row).await; + let result = table.replace(updated_row).await; assert!(result.is_err()); let valid_row3 = ThreeUniqueIdxRow { @@ -558,7 +558,7 @@ fn test_reinsert_three_indexes_last_fail() { unique_c: conflict_c, }; - let result = table.update(updated_row).await; + let result = table.replace(updated_row).await; assert!(result.is_err()); let valid_row3 = ThreeUniqueIdxRow { diff --git a/tests/persistence/failure/update.rs b/tests/persistence/failure/update.rs index fce70ad6..91f3ec09 100644 --- a/tests/persistence/failure/update.rs +++ b/tests/persistence/failure/update.rs @@ -64,7 +64,7 @@ fn test_update_unique_secondary_conflict() { unique_b: 500, }; - let result = table.update(updated_row).await; + let result = table.replace(updated_row).await; assert!(result.is_err()); let valid_row3 = TwoUniqueIdxRow { @@ -148,7 +148,7 @@ fn test_update_pk_based_success() { unique_b: 250, }; - let result = table.update(updated_row).await; + let result = table.replace(updated_row).await; assert!(result.is_ok()); let valid_row3 = TwoUniqueIdxRow { diff --git a/tests/persistence/failure/update_non_unique.rs b/tests/persistence/failure/update_non_unique.rs index a7f1a079..185350cd 100644 --- a/tests/persistence/failure/update_non_unique.rs +++ b/tests/persistence/failure/update_non_unique.rs @@ -72,7 +72,9 @@ fn test_update_non_unique_middle_fail() { tokio::time::sleep(Duration::from_millis(100)).await; let query = UniqueValueByCategoryQuery { unique_value: 99 }; - let result = table.update_partial_unique_value_by_category(query, 1).await; + let result = table + .update_by_category(1, MixedIdxColumns::UNIQUE_VALUE, (query).unique_value) + .await; assert!(result.is_err()); let valid_row3 = MixedIdxRow { @@ -180,7 +182,9 @@ fn test_update_non_unique_last_fail() { tokio::time::sleep(Duration::from_millis(100)).await; let query = UniqueValueByCategoryQuery { unique_value: 99 }; - let result = table.update_partial_unique_value_by_category(query, 1).await; + let result = table + .update_by_category(1, MixedIdxColumns::UNIQUE_VALUE, (query).unique_value) + .await; assert!(result.is_err()); let valid_row3 = MixedIdxRow { diff --git a/tests/persistence/failure/update_unsized.rs b/tests/persistence/failure/update_unsized.rs index e0991f67..9ec7fa2f 100644 --- a/tests/persistence/failure/update_unsized.rs +++ b/tests/persistence/failure/update_unsized.rs @@ -75,7 +75,9 @@ fn test_update_unsized_same_size() { name: "xxx".to_string(), unique_value: 99, }; - let result = table.update_partial_name_and_value_by_category(query, 1).await; + let result = table + .update_by_category(1, NonUniqueUnsizedColumns::NAME_AND_UNIQUE_VALUE, query) + .await; assert!(result.is_err()); let valid_row3 = NonUniqueUnsizedRow { @@ -162,7 +164,9 @@ fn test_update_unsized_larger_all_success() { unique_value: 20, }; - let result = table.update_partial_name_and_value_by_category(query, 1).await; + let result = table + .update_by_category(1, NonUniqueUnsizedColumns::NAME_AND_UNIQUE_VALUE, query) + .await; assert!(result.is_ok()); let valid_row3 = NonUniqueUnsizedRow { @@ -273,7 +277,9 @@ fn test_update_unsized_larger_middle_fail() { unique_value: 99, }; - let result = table.update_partial_name_and_value_by_category(query, 1).await; + let result = table + .update_by_category(1, NonUniqueUnsizedColumns::NAME_AND_UNIQUE_VALUE, query) + .await; assert!(result.is_err()); let valid_row3 = NonUniqueUnsizedRow { @@ -376,7 +382,9 @@ fn test_update_unsized_larger_last_fail() { unique_value: 99, }; - let result = table.update_partial_name_and_value_by_category(query, 1).await; + let result = table + .update_by_category(1, NonUniqueUnsizedColumns::NAME_AND_UNIQUE_VALUE, query) + .await; assert!(result.is_err()); let valid_row3 = NonUniqueUnsizedRow { diff --git a/tests/persistence/in_place_durability.rs b/tests/persistence/in_place_durability.rs index 0dd6c8c5..4b3599c2 100644 --- a/tests/persistence/in_place_durability.rs +++ b/tests/persistence/in_place_durability.rs @@ -13,7 +13,7 @@ worktable!( note: String, }, queries: { - update_partial_in_place: { + update_in_place: { CounterById(counter) by id, } } @@ -57,7 +57,7 @@ fn in_place_update_survives_reload() { .await .unwrap(); table - .update_partial_in_place_counter_by_id(|counter| *counter = 42u64.into(), 1) + .update_in_place_by_id(1, InPlaceDurabilityColumns::COUNTER, |counter| *counter = 42u64.into()) .await .unwrap(); table.wait_for_ops().await.unwrap(); diff --git a/tests/persistence/local_write_bandwidth.rs b/tests/persistence/local_write_bandwidth.rs index 5d74f201..80058b23 100644 --- a/tests/persistence/local_write_bandwidth.rs +++ b/tests/persistence/local_write_bandwidth.rs @@ -145,7 +145,7 @@ fn local_write_bandwidth() { // Spread across the whole table rather than a contiguous run. let id = (n * (ROWS / UPDATES)) % ROWS; table - .update(WriteBandwidthRow { + .replace(WriteBandwidthRow { id, payload: replacement.clone(), }) diff --git a/tests/persistence/mod.rs b/tests/persistence/mod.rs index 0039a7ad..157b644f 100644 --- a/tests/persistence/mod.rs +++ b/tests/persistence/mod.rs @@ -46,7 +46,7 @@ worktable! ( another_idx: another, }, queries: { - update_partial: { + update: { AnotherById(another) by id, }, delete: { diff --git a/tests/persistence/multi_row_backend_order.rs b/tests/persistence/multi_row_backend_order.rs index 420a8a36..1a90867e 100644 --- a/tests/persistence/multi_row_backend_order.rs +++ b/tests/persistence/multi_row_backend_order.rs @@ -8,6 +8,7 @@ macro_rules! persisted_multi_row_backend_case { $module:ident, $name:ident, $table:ident, + $columns:ident, $row:ident, $engine:ident, $backend:ident, @@ -28,7 +29,7 @@ macro_rules! persisted_multi_row_backend_case { group_idx: group_id, }, queries: { - update_partial: { + update: { PayloadByGroup(payload) by group_id, } } @@ -56,12 +57,7 @@ macro_rules! persisted_multi_row_backend_case { let replacement = "new-payload".repeat(64); table - .update_partial_payload_by_group( - PayloadByGroupQuery { - payload: replacement.clone(), - }, - 7, - ) + .update_by_group_id(7, $columns::PAYLOAD, replacement.clone()) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -84,6 +80,7 @@ persisted_multi_row_backend_case!( wti, MultiRowWti, MultiRowWtiWorkTable, + MultiRowWtiColumns, MultiRowWtiRow, MultiRowWtiPersistenceEngine, worktables_index, @@ -93,6 +90,7 @@ persisted_multi_row_backend_case!( congee, MultiRowCongee, MultiRowCongeeWorkTable, + MultiRowCongeeColumns, MultiRowCongeeRow, MultiRowCongeePersistenceEngine, congee, @@ -102,6 +100,7 @@ persisted_multi_row_backend_case!( arctic, MultiRowArctic, MultiRowArcticWorkTable, + MultiRowArcticColumns, MultiRowArcticRow, MultiRowArcticPersistenceEngine, arctic, diff --git a/tests/persistence/s3/mod.rs b/tests/persistence/s3/mod.rs index d8c9844a..ae025497 100644 --- a/tests/persistence/s3/mod.rs +++ b/tests/persistence/s3/mod.rs @@ -425,7 +425,7 @@ fn s3_engine_reuses_logical_persistence_for_a_loaded_default_arctic_table() { let table = TestS3WorkTable::load(engine).await.unwrap(); let mut row = table.select(257).expect("persisted row"); row.value = 10_000; - table.update(row).await.unwrap(); + table.replace(row).await.unwrap(); table.wait_for_ops().await.unwrap(); let uploaded_before = s3.puts.lock().unwrap().iter().map(|(_, length)| length).sum::(); @@ -435,7 +435,7 @@ fn s3_engine_reuses_logical_persistence_for_a_loaded_default_arctic_table() { .sum::(); let mut row = table.select(1300).expect("persisted row"); row.value = 20_000; - table.update(row).await.unwrap(); + table.replace(row).await.unwrap(); table.wait_for_ops().await.unwrap(); let uploaded_after = s3.puts.lock().unwrap().iter().map(|(_, length)| length).sum::(); let incremental_bytes = uploaded_after - uploaded_before; diff --git a/tests/persistence/same_size_in_place.rs b/tests/persistence/same_size_in_place.rs index b6479f79..491d7c5c 100644 --- a/tests/persistence/same_size_in_place.rs +++ b/tests/persistence/same_size_in_place.rs @@ -13,7 +13,7 @@ worktable!( note: String, }, queries: { - update_partial: { + update: { AmountById(amount) by id, NoteById(note) by id, } @@ -58,10 +58,7 @@ fn same_size_updates_keep_the_row_link() { let link_before = table.0.primary_index.pk_map.get_value(&pk).unwrap().0; // Fixed-size column update: archived in-place swap, same slot. - table - .update_partial_amount_by_id(AmountByIdQuery { amount: 2 }, 1) - .await - .unwrap(); + table.update_by_id(1, SameSizeInPlaceColumns::AMOUNT, 2).await.unwrap(); let link_after_amount = table.0.primary_index.pk_map.get_value(&pk).unwrap().0; assert_eq!( link_before, link_after_amount, @@ -70,12 +67,7 @@ fn same_size_updates_keep_the_row_link() { // Same-length String update: same-size in-place path, same slot. table - .update_partial_note_by_id( - NoteByIdQuery { - note: "bbbb".to_string(), - }, - 1, - ) + .update_by_id(1, SameSizeInPlaceColumns::NOTE, "bbbb".to_string()) .await .unwrap(); let link_after_note = table.0.primary_index.pk_map.get_value(&pk).unwrap().0; @@ -86,11 +78,10 @@ fn same_size_updates_keep_the_row_link() { // A size-changing String update must still reinsert correctly. table - .update_partial_note_by_id( - NoteByIdQuery { - note: "a-considerably-longer-note".to_string(), - }, + .update_by_id( 1, + SameSizeInPlaceColumns::NOTE, + "a-considerably-longer-note".to_string(), ) .await .unwrap(); diff --git a/tests/persistence/sync/failure.rs b/tests/persistence/sync/failure.rs index 39de85f2..cd8f69b6 100644 --- a/tests/persistence/sync/failure.rs +++ b/tests/persistence/sync/failure.rs @@ -1,7 +1,7 @@ use crate::remove_dir_if_exists; use worktable::prelude::*; -use super::{AnotherByIdQuery, FieldByAnotherQuery, TestSyncPersistenceEngine, TestSyncRow, TestSyncWorkTable}; +use super::{TestSyncColumns, TestSyncPersistenceEngine, TestSyncRow, TestSyncWorkTable}; #[test] fn test_failed_update_by_pk_doesnt_corrupt_persistence() { @@ -43,20 +43,13 @@ fn test_failed_update_by_pk_doesnt_corrupt_persistence() { let engine = TestSyncPersistenceEngine::new(config.clone()).await.unwrap(); let table = TestSyncWorkTable::load(engine).await.unwrap(); - let result = table - .update_partial_another_by_id(AnotherByIdQuery { another: 9999 }, 9999) - .await; + let result = table.update_by_id(9999, TestSyncColumns::ANOTHER, 9999).await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), WorkTableError::NotFound)); for (i, pk) in pks.iter().enumerate() { table - .update_partial_another_by_id( - AnotherByIdQuery { - another: i as u64 + 1000, - }, - *pk, - ) + .update_by_id(*pk, TestSyncColumns::ANOTHER, i as u64 + 1000) .await .unwrap(); } @@ -116,20 +109,13 @@ fn test_failed_update_by_unique_index_doesnt_corrupt_persistence() { let engine = TestSyncPersistenceEngine::new(config.clone()).await.unwrap(); let table = TestSyncWorkTable::load(engine).await.unwrap(); - let result = table - .update_partial_field_by_another(FieldByAnotherQuery { field: 9999.0 }, 9999) - .await; + let result = table.update_by_another(9999, TestSyncColumns::FIELD, 9999.0).await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), WorkTableError::NotFound)); for (i, _pk) in pks.iter().enumerate() { table - .update_partial_field_by_another( - FieldByAnotherQuery { - field: i as f64 + 1000.0, - }, - i as u64, - ) + .update_by_another(i as u64, TestSyncColumns::FIELD, i as f64 + 1000.0) .await .unwrap(); } diff --git a/tests/persistence/sync/many_strings.rs b/tests/persistence/sync/many_strings.rs index f26d9b63..654d813e 100644 --- a/tests/persistence/sync/many_strings.rs +++ b/tests/persistence/sync/many_strings.rs @@ -12,7 +12,7 @@ worktable! ( another: u64, }, queries: { - update_partial: { + update: { FieldAnotherById(field, another) by id, }, } @@ -63,7 +63,10 @@ fn test_space_update_query_pk_sync() { field: "Some field value".to_string(), another: 0, }; - table.update_partial_field_another_by_id(q, pk.clone()).await.unwrap(); + table + .update_by_id(pk.clone(), TestSyncColumns::FIELD_AND_ANOTHER, q) + .await + .unwrap(); table.wait_for_ops().await.unwrap(); } { @@ -122,7 +125,10 @@ fn test_space_update_query_pk_many_times_sync() { field: "Some field value".to_string(), another: i, }; - table.update_partial_field_another_by_id(q, pk.clone()).await.unwrap(); + table + .update_by_id(pk.clone(), TestSyncColumns::FIELD_AND_ANOTHER, q) + .await + .unwrap(); } table.wait_for_ops().await.unwrap(); diff --git a/tests/persistence/sync/mod.rs b/tests/persistence/sync/mod.rs index f38f7eaf..d32f1f5d 100644 --- a/tests/persistence/sync/mod.rs +++ b/tests/persistence/sync/mod.rs @@ -31,7 +31,7 @@ worktable! ( non_unique_idx: non_unique }, queries: { - update_partial: { + update: { AnotherById(another) by id, FieldByAnother(field) by another, AnotherByNonUnique(another) by non_unique @@ -186,7 +186,7 @@ fn test_space_update_full_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update(TestSyncRow { + .replace(TestSyncRow { another: 13, non_unique: 0, field: 0.0, @@ -235,10 +235,7 @@ fn test_space_update_query_pk_sync() { id: table.get_next_pk().0, }; table.insert(row.clone()).await.unwrap(); - table - .update_partial_another_by_id(AnotherByIdQuery { another: 13 }, row.id) - .await - .unwrap(); + table.update_by_id(row.id, TestSyncColumns::ANOTHER, 13).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id }; @@ -280,10 +277,7 @@ fn test_space_update_query_unique_sync() { id: table.get_next_pk().0, }; table.insert(row.clone()).await.unwrap(); - table - .update_partial_field_by_another(FieldByAnotherQuery { field: 1.0 }, 42) - .await - .unwrap(); + table.update_by_another(42, TestSyncColumns::FIELD, 1.0).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id }; @@ -326,7 +320,7 @@ fn test_space_update_query_non_unique_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update_partial_another_by_non_unique(AnotherByNonUniqueQuery { another: 13 }, 10) + .update_by_non_unique(10, TestSyncColumns::ANOTHER, 13) .await .unwrap(); table.wait_for_ops().await.unwrap(); diff --git a/tests/persistence/sync/opaque_unsized_update.rs b/tests/persistence/sync/opaque_unsized_update.rs index 217b4007..4398b4db 100644 --- a/tests/persistence/sync/opaque_unsized_update.rs +++ b/tests/persistence/sync/opaque_unsized_update.rs @@ -27,7 +27,7 @@ worktable!( untouched: u64, }, queries: { - update_partial: { + update: { SecretById(secret) by id, } } @@ -75,11 +75,10 @@ fn targeted_update_of_string_wrapper_survives_read_and_reload() { let link_before = link_of(&table, 7); table - .update_partial_secret_by_id( - SecretByIdQuery { - secret: WrappedSecret("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string()), - }, + .update_by_id( 7, + OpaqueUnsizedUpdateColumns::SECRET, + WrappedSecret("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string()), ) .await .unwrap(); @@ -106,13 +105,10 @@ fn targeted_update_of_string_wrapper_survives_read_and_reload() { assert_eq!(row.untouched, 42); table - .update_partial_secret_by_id( - SecretByIdQuery { - secret: WrappedSecret( - "a replacement with a deliberately different serialized length".to_string(), - ), - }, + .update_by_id( 7, + OpaqueUnsizedUpdateColumns::SECRET, + WrappedSecret("a replacement with a deliberately different serialized length".to_string()), ) .await .unwrap(); @@ -130,7 +126,7 @@ fn targeted_update_of_string_wrapper_survives_read_and_reload() { assert_eq!(row.untouched, 42); table - .update(OpaqueUnsizedUpdateRow { + .replace(OpaqueUnsizedUpdateRow { id: 7, secret: WrappedSecret("full-row replacement after targeted updates".to_string()), untouched: 84, diff --git a/tests/persistence/sync/option.rs b/tests/persistence/sync/option.rs index 6beca4c8..f22f36a1 100644 --- a/tests/persistence/sync/option.rs +++ b/tests/persistence/sync/option.rs @@ -18,7 +18,7 @@ worktable! ( exchnage_idx: exchange, }, queries: { - update_partial: { + update: { TestById(test) by id, TestByAnother(test) by another, TestByExchange(test) by exchange, @@ -140,7 +140,7 @@ fn test_option_update_full_sync() { table.insert(row.clone()).await.unwrap(); table - .update(TestOptionSyncRow { + .replace(TestOptionSyncRow { id: row.id, test: Some(100), another: 1, @@ -165,7 +165,7 @@ fn test_option_update_full_sync() { #[test] fn test_option_update_by_id_sync() { let config = DiskConfig::new_with_table_name( - "tests/data/option_sync/update_partial_by_id", + "tests/data/option_sync/update_by_id", TestOptionSyncWorkTable::name_snake_case(), TestOptionSyncWorkTable::version(), ); @@ -178,7 +178,7 @@ fn test_option_update_by_id_sync() { .unwrap(); runtime.block_on(async { - remove_dir_if_exists("tests/data/option_sync/update_partial_by_id".to_string()).await; + remove_dir_if_exists("tests/data/option_sync/update_by_id".to_string()).await; let pk = { let engine = TestOptionSyncPersistenceEngine::new(config.clone()).await.unwrap(); @@ -192,7 +192,7 @@ fn test_option_update_by_id_sync() { table.insert(row.clone()).await.unwrap(); table - .update_partial_test_by_id(TestByIdQuery { test: Some(42) }, row.id) + .update_by_id(row.id, TestOptionSyncColumns::TEST, Some(42)) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -239,7 +239,7 @@ fn test_option_update_none_to_some_sync() { table.insert(row.clone()).await.unwrap(); table - .update_partial_test_by_id(TestByIdQuery { test: Some(55) }, row.id) + .update_by_id(row.id, TestOptionSyncColumns::TEST, Some(55)) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -286,7 +286,7 @@ fn test_option_update_some_to_none_sync() { table.insert(row.clone()).await.unwrap(); table - .update_partial_test_by_id(TestByIdQuery { test: None }, row.id) + .update_by_id(row.id, TestOptionSyncColumns::TEST, None) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -333,7 +333,7 @@ fn test_option_update_by_another_sync() { table.insert(row.clone()).await.unwrap(); table - .update_partial_test_by_another(TestByAnotherQuery { test: Some(77) }, 123) + .update_by_another(123, TestOptionSyncColumns::TEST, Some(77)) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -380,7 +380,7 @@ fn test_option_update_by_exchange_sync() { table.insert(row.clone()).await.unwrap(); table - .update_partial_test_by_exchange(TestByExchangeQuery { test: Some(88) }, 456) + .update_by_exchange(456, TestOptionSyncColumns::TEST, Some(88)) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -436,7 +436,7 @@ fn test_option_multiple_rows_sync() { let pk2 = table.insert(row2).await.unwrap(); table - .update_partial_test_by_id(TestByIdQuery { test: Some(30) }, pk1.clone()) + .update_by_id(pk1.clone(), TestOptionSyncColumns::TEST, Some(30)) .await .unwrap(); @@ -468,7 +468,7 @@ worktable! ( exchnage_idx: exchange, }, queries: { - update_partial: { + update: { IndexTestById(test) by id, IndexTestByAnother(test) by another, IndexTestByExchange(test) by exchange, @@ -590,7 +590,7 @@ fn test_option_indexed_update_none_to_some_by_id_sync() { table.insert(row.clone()).await.unwrap(); table - .update_partial_index_test_by_id(IndexTestByIdQuery { test: Some(55) }, row.id) + .update_by_id(row.id, TestOptionSyncIndexColumns::TEST, Some(55)) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -637,7 +637,7 @@ fn test_option_indexed_update_some_to_none_by_id_sync() { table.insert(row.clone()).await.unwrap(); table - .update_partial_index_test_by_id(IndexTestByIdQuery { test: None }, row.id) + .update_by_id(row.id, TestOptionSyncIndexColumns::TEST, None) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -684,7 +684,7 @@ fn test_option_indexed_update_by_another_sync() { table.insert(row.clone()).await.unwrap(); table - .update_partial_index_test_by_another(IndexTestByAnotherQuery { test: Some(77) }, 123) + .update_by_another(123, TestOptionSyncIndexColumns::TEST, Some(77)) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -748,12 +748,12 @@ fn test_option_indexed_multiple_rows_sync() { let pk3 = table.insert(row3).await.unwrap(); table - .update_partial_index_test_by_id(IndexTestByIdQuery { test: Some(40) }, pk1.clone()) + .update_by_id(pk1.clone(), TestOptionSyncIndexColumns::TEST, Some(40)) .await .unwrap(); table - .update_partial_index_test_by_id(IndexTestByIdQuery { test: Some(50) }, pk2.clone()) + .update_by_id(pk2.clone(), TestOptionSyncIndexColumns::TEST, Some(50)) .await .unwrap(); @@ -801,7 +801,7 @@ fn test_option_indexed_full_row_update_sync() { table.insert(row.clone()).await.unwrap(); table - .update(TestOptionSyncIndexRow { + .replace(TestOptionSyncIndexRow { id: row.id, test: Some(99), another: 100, diff --git a/tests/persistence/sync/string_primary_index.rs b/tests/persistence/sync/string_primary_index.rs index 64294026..b887d0c8 100644 --- a/tests/persistence/sync/string_primary_index.rs +++ b/tests/persistence/sync/string_primary_index.rs @@ -18,7 +18,7 @@ worktable! ( non_unique_idx: non_unique }, queries: { - update_partial: { + update: { AnotherById(another) by id, FieldByAnother(field) by another, AnotherByNonUnique(another) by non_unique @@ -145,7 +145,7 @@ fn test_space_update_full_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update(TestSyncRow { + .replace(TestSyncRow { another: 13, non_unique: 0, field: 0.0, @@ -194,7 +194,7 @@ fn test_space_update_query_pk_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update_partial_another_by_id(AnotherByIdQuery { another: 13 }, row.id.clone()) + .update_by_id(row.id.clone(), TestSyncColumns::ANOTHER, 13) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -237,10 +237,7 @@ fn test_space_update_query_unique_sync() { id: "Some string before".to_string(), }; table.insert(row.clone()).await.unwrap(); - table - .update_partial_field_by_another(FieldByAnotherQuery { field: 1.0 }, 42) - .await - .unwrap(); + table.update_by_another(42, TestSyncColumns::FIELD, 1.0).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id }; @@ -282,7 +279,7 @@ fn test_space_update_query_non_unique_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update_partial_another_by_non_unique(AnotherByNonUniqueQuery { another: 13 }, 10) + .update_by_non_unique(10, TestSyncColumns::ANOTHER, 13) .await .unwrap(); table.wait_for_ops().await.unwrap(); diff --git a/tests/persistence/sync/string_re_read.rs b/tests/persistence/sync/string_re_read.rs index fd54f2b2..744961ed 100644 --- a/tests/persistence/sync/string_re_read.rs +++ b/tests/persistence/sync/string_re_read.rs @@ -593,7 +593,7 @@ fn test_toc_not_updated_when_index_value_same_but_link_changes() { let table = StringReReadWorkTable::load(engine).await.unwrap(); table - .update(StringReReadRow { + .replace(StringReReadRow { first: "same_first".to_string(), id: pk1.into(), third: "third_updated".to_string(), @@ -749,7 +749,7 @@ fn test_unique_index_same_value_link_changes() { // Update: same second value, other fields change table - .update(StringReReadRow { + .replace(StringReReadRow { first: "first_updated".to_string(), id: pk1.into(), third: "third_updated".to_string(), diff --git a/tests/persistence/sync/string_secondary_index.rs b/tests/persistence/sync/string_secondary_index.rs index bcd16777..bf03744d 100644 --- a/tests/persistence/sync/string_secondary_index.rs +++ b/tests/persistence/sync/string_secondary_index.rs @@ -19,7 +19,7 @@ worktable! ( non_unique_idx: non_unique }, queries: { - update_partial: { + update: { AnotherById(another) by id, FieldByAnother(field) by another, AnotherByNonUnique(another) by non_unique @@ -245,7 +245,7 @@ fn test_space_update_full_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update(TestSyncRow { + .replace(TestSyncRow { another: "Some string to test updated".to_string(), non_unique: 0, field: 0.0, @@ -302,11 +302,10 @@ fn test_space_update_query_pk_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update_partial_another_by_id( - AnotherByIdQuery { - another: "Some string to test updated".to_string(), - }, + .update_by_id( row.id, + TestSyncColumns::ANOTHER, + "Some string to test updated".to_string(), ) .await .unwrap(); @@ -355,7 +354,7 @@ fn test_space_update_query_unique_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update_partial_field_by_another(FieldByAnotherQuery { field: 1.0 }, "Some string before".to_string()) + .update_by_another("Some string before".to_string(), TestSyncColumns::FIELD, 1.0) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -400,12 +399,7 @@ fn test_space_update_query_non_unique_sync() { }; table.insert(row.clone()).await.unwrap(); table - .update_partial_another_by_non_unique( - AnotherByNonUniqueQuery { - another: "Some string to test updated".to_string(), - }, - 10, - ) + .update_by_non_unique(10, TestSyncColumns::ANOTHER, "Some string to test updated".to_string()) .await .unwrap(); table.wait_for_ops().await.unwrap(); diff --git a/tests/persistence/sync/string_update_timeout.rs b/tests/persistence/sync/string_update_timeout.rs index 907aadc2..88090b33 100644 --- a/tests/persistence/sync/string_update_timeout.rs +++ b/tests/persistence/sync/string_update_timeout.rs @@ -26,7 +26,7 @@ worktable!( fk_app_public_id_idx: fk_app_pub_id, }, queries: { - update_partial: { + update: { DisplayNameByPublicId(display_name) by public_id, UsernameByPublicId(username) by public_id, StatusByPublicId(status) by public_id, @@ -83,7 +83,7 @@ fn test_string_update_doesnt_block_persistence() { let engine = UserPersistenceEngine::new(config.clone()).await.unwrap(); let table = UserWorkTable::load(engine).await.unwrap(); - table.update(row.clone()).await.unwrap(); + table.replace(row.clone()).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await diff --git a/tests/persistence/tuple_primary_key.rs b/tests/persistence/tuple_primary_key.rs index 2f31bc1e..62549c86 100644 --- a/tests/persistence/tuple_primary_key.rs +++ b/tests/persistence/tuple_primary_key.rs @@ -72,7 +72,7 @@ async fn composite_primary_key_survives_mutations_and_reload() { value: 99, ..rows[1].clone() }; - table.update(updated.clone()).await.unwrap(); + table.replace(updated.clone()).await.unwrap(); table.delete((7, 41)).await.unwrap(); assert_eq!(table.select((7, 42)), Some(updated)); assert!(table.select((7, 41)).is_none()); diff --git a/tests/runtime_execution.rs b/tests/runtime_execution.rs index 769a724b..4dc53808 100644 --- a/tests/runtime_execution.rs +++ b/tests/runtime_execution.rs @@ -10,9 +10,9 @@ worktable! { columns: { id: u64 primary_key, value: u64, group: u64 }, indexes: { group_idx: group }, queries: { - update_partial runtime scheduled: { ValueById(value) by id }, + update runtime scheduled: { ValueById(value) by id }, delete runtime scheduled: { ByGroup() by group }, - update_partial_in_place runtime scheduled: { ValueById(value) by id }, + update_in_place runtime scheduled: { ValueById(value) by id }, } } @@ -47,19 +47,13 @@ fn generated_profiles_dispatch_mutations_and_owned_selects() { .await .unwrap(); } - table - .update_partial_value_by_id(ValueByIdQuery { value: 100 }, 9u64) - .await - .unwrap(); + table.update_by_id(9, ScheduledColumns::VALUE, 100).await.unwrap(); let caller = std::thread::current().id(); table - .update_partial_in_place_value_by_id( - move |value| { - assert_ne!(std::thread::current().id(), caller); - *value = 101.into(); - }, - 9u64, - ) + .update_in_place_by_id(9, ScheduledColumns::VALUE, move |value| { + assert_ne!(std::thread::current().id(), caller); + *value = 101.into(); + }) .await .unwrap(); assert_eq!(table.select(9u64).unwrap().value, 101); @@ -102,10 +96,7 @@ fn nested_dispatch_progresses_on_one_worker() { }) .await .unwrap(); - table - .update_partial_value_by_id(ValueByIdQuery { value: 4 }, 1u64) - .await - .unwrap(); + table.update_by_id(1, ScheduledColumns::VALUE, 4).await.unwrap(); table.select_all().runtime(scheduled).execute_async().await.unwrap()[0].value }) .await @@ -162,7 +153,7 @@ worktable! { persist: true, runtime: nagoya(shared_slot), columns: { id: u64 primary_key, value: u64 }, - queries: { update_partial runtime scheduled: { DiskValueById(value) by id } } + queries: { update runtime scheduled: { DiskValueById(value) by id } } } #[test] @@ -179,10 +170,7 @@ fn scheduled_mutation_is_persisted_and_reopened() { let engine = ScheduledDiskPersistenceEngine::new(config.clone()).await.unwrap(); let table = Arc::new(ScheduledDiskWorkTable::load(engine).await.unwrap()); table.insert(ScheduledDiskRow { id: 1, value: 2 }).await.unwrap(); - table - .update_partial_disk_value_by_id(DiskValueByIdQuery { value: 99 }, 1u64) - .await - .unwrap(); + table.update_by_id(1, ScheduledDiskColumns::VALUE, 99).await.unwrap(); assert_eq!( table.select_all().runtime(scheduled).execute_async().await.unwrap()[0].value, 99 @@ -205,7 +193,7 @@ mod tokio_execution { name: TokioScheduled, runtime: tokio, columns: { id: u64 primary_key, value: u64 }, - queries: { update_partial_in_place runtime on_tokio: { TokioValueById(value) by id } } + queries: { update_in_place runtime on_tokio: { TokioValueById(value) by id } } } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn tokio_profiles_dispatch_on_the_entered_runtime() { @@ -213,13 +201,10 @@ mod tokio_execution { let table = Arc::new(TokioScheduledWorkTable::default()); table.insert(TokioScheduledRow { id: 1, value: 0 }).await.unwrap(); table - .update_partial_in_place_tokio_value_by_id( - move |value| { - assert_ne!(std::thread::current().id(), caller); - *value = 8.into(); - }, - 1u64, - ) + .update_in_place_by_id(1, TokioScheduledColumns::VALUE, move |value| { + assert_ne!(std::thread::current().id(), caller); + *value = 8.into(); + }) .await .unwrap(); assert_eq!( @@ -270,7 +255,7 @@ runtimes! { on_spread: nagoya(spread), } worktable! { name: Tunable, columns: { id: u64 primary_key, value: u64 }, - queries: { update_partial_in_place runtime on_spread: { TunedValue(value) by id } } + queries: { update_in_place runtime on_spread: { TunedValue(value) by id } } } #[test] fn callsites_can_tune_nagoya_without_changing_the_table_default() { @@ -279,13 +264,10 @@ fn callsites_can_tune_nagoya_without_changing_the_table_default() { table.insert(TunableRow { id: 1, value: 2 }).await.unwrap(); let caller = std::thread::current().id(); table - .update_partial_in_place_tuned_value( - move |value| { - assert_ne!(std::thread::current().id(), caller); - *value = 3.into(); - }, - 1u64, - ) + .update_in_place_by_id(1, TunableColumns::VALUE, move |value| { + assert_ne!(std::thread::current().id(), caller); + *value = 3.into(); + }) .await .unwrap(); assert_eq!( diff --git a/tests/ui.rs b/tests/ui.rs index 240194fa..f0f509e5 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -35,4 +35,6 @@ fn compile_fail() { // Query rules. t.compile_fail("tests/ui/autoincrement_unsupported_key.rs"); t.compile_fail("tests/ui/in_place_over_indexed_column.rs"); + t.compile_fail("tests/ui/update_selector_wrong_value.rs"); + t.compile_fail("tests/ui/update_selector_collision.rs"); } diff --git a/tests/ui/in_place_over_indexed_column.rs b/tests/ui/in_place_over_indexed_column.rs index 36a2295b..29b20cc1 100644 --- a/tests/ui/in_place_over_indexed_column.rs +++ b/tests/ui/in_place_over_indexed_column.rs @@ -1,4 +1,4 @@ -// Rule: an `update_partial_in_place` query writes the archived column bytes and maintains no +// Rule: an `update_in_place` query writes the archived column bytes and maintains no // index, so a column any index is built over cannot be mutated on that path. // The index would keep resolving the old value. use worktable::worktable; @@ -14,7 +14,7 @@ worktable! { value_idx: value unique, }, queries: { - update_partial_in_place: { + update_in_place: { ValueById(value) by id, } }, diff --git a/tests/ui/in_place_over_indexed_column.stderr b/tests/ui/in_place_over_indexed_column.stderr index f1f7a835..5ea4e79c 100644 --- a/tests/ui/in_place_over_indexed_column.stderr +++ b/tests/ui/in_place_over_indexed_column.stderr @@ -1,4 +1,4 @@ -error: update_partial_in_place query `ValueById` mutates column `value`, which is covered by an index; indexed columns cannot be updated in place because secondary indexes are not maintained on this path. Use an `update_partial` query instead +error: update_in_place query `ValueById` mutates column `value`, which is covered by an index; indexed columns cannot be updated in place because secondary indexes are not maintained on this path. Use an `update` query instead --> tests/ui/in_place_over_indexed_column.rs:18:23 | 18 | ValueById(value) by id, diff --git a/tests/ui/query_over_unknown_column.rs b/tests/ui/query_over_unknown_column.rs index c0b7db62..4e754589 100644 --- a/tests/ui/query_over_unknown_column.rs +++ b/tests/ui/query_over_unknown_column.rs @@ -11,7 +11,7 @@ worktable! { value: u64, }, queries: { - update_partial: { + update: { MissingById(missing) by id, } }, diff --git a/tests/ui/runtime_pinned_and_call_site.rs b/tests/ui/runtime_pinned_and_call_site.rs index e5308c67..ddefac6a 100644 --- a/tests/ui/runtime_pinned_and_call_site.rs +++ b/tests/ui/runtime_pinned_and_call_site.rs @@ -28,7 +28,7 @@ worktable! { qty: u64, }, queries: { - update_partial runtime wide: { + update runtime wide: { Fill(qty) by id, } }, diff --git a/tests/ui/runtime_profile_backend_mismatch.rs b/tests/ui/runtime_profile_backend_mismatch.rs index 58d35340..3baf363b 100644 --- a/tests/ui/runtime_profile_backend_mismatch.rs +++ b/tests/ui/runtime_profile_backend_mismatch.rs @@ -23,7 +23,7 @@ worktable! { qty: u64, }, queries: { - update_partial runtime tokio_max: { + update runtime tokio_max: { Fill(qty) by id, } }, diff --git a/tests/ui/runtime_unknown_profile.rs b/tests/ui/runtime_unknown_profile.rs index 8fc8e1e9..9b06c180 100644 --- a/tests/ui/runtime_unknown_profile.rs +++ b/tests/ui/runtime_unknown_profile.rs @@ -22,7 +22,7 @@ worktable! { qty: u64, }, queries: { - update_partial runtime nope: { + update runtime nope: { Fill(qty) by id, } }, diff --git a/tests/ui/update_selector_collision.rs b/tests/ui/update_selector_collision.rs new file mode 100644 index 00000000..b6c609eb --- /dev/null +++ b/tests/ui/update_selector_collision.rs @@ -0,0 +1,17 @@ +use worktable::worktable; + +worktable!( + name: SelectorCollision, + columns: { + id: u64 primary_key, + amount: u64, + }, + queries: { + update: { + First(amount) by id, + Second(amount) by id, + } + } +); + +fn main() {} diff --git a/tests/ui/update_selector_collision.stderr b/tests/ui/update_selector_collision.stderr new file mode 100644 index 00000000..3a2a8834 --- /dev/null +++ b/tests/ui/update_selector_collision.stderr @@ -0,0 +1,5 @@ +error: update queries `First` and `Second` both generate `update_by_id(..., Columns::AMOUNT, ...)` + --> tests/ui/update_selector_collision.rs:12:13 + | +12 | Second(amount) by id, + | ^^^^^^ diff --git a/tests/ui/update_selector_wrong_value.rs b/tests/ui/update_selector_wrong_value.rs new file mode 100644 index 00000000..e115c21a --- /dev/null +++ b/tests/ui/update_selector_wrong_value.rs @@ -0,0 +1,18 @@ +use worktable::prelude::*; +use worktable::worktable; + +worktable!( + name: SelectorTypes, + columns: { + id: u64 primary_key, + amount: u64, + }, + queries: { + update: { AmountById(amount) by id } + } +); + +fn main() { + let table = SelectorTypesWorkTable::default(); + let _ = table.update_by_id(1, SelectorTypesColumns::AMOUNT, "not a u64"); +} diff --git a/tests/ui/update_selector_wrong_value.stderr b/tests/ui/update_selector_wrong_value.stderr new file mode 100644 index 00000000..7e07e5d0 --- /dev/null +++ b/tests/ui/update_selector_wrong_value.stderr @@ -0,0 +1,26 @@ +error[E0308]: mismatched types + --> tests/ui/update_selector_wrong_value.rs:17:65 + | +17 | let _ = table.update_by_id(1, SelectorTypesColumns::AMOUNT, "not a u64"); + | ------------ ^^^^^^^^^^^ expected `u64`, found `&str` + | | + | arguments to this method are incorrect + | +help: the return type of this call is `&'static str` due to the type of the argument passed + --> tests/ui/update_selector_wrong_value.rs:17:13 + | +17 | let _ = table.update_by_id(1, SelectorTypesColumns::AMOUNT, "not a u64"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^-----------^ + | | + | this argument influences the return type of `update_by_id` +note: method defined here + --> tests/ui/update_selector_wrong_value.rs:4:1 + | + 4 | / worktable!( + 5 | | name: SelectorTypes, + 6 | | columns: { + 7 | | id: u64 primary_key, +... | +13 | | ); + | |_^ + = note: this error originates in the macro `worktable` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/worktable/array.rs b/tests/worktable/array.rs index 68b167c0..2c2cccb5 100644 --- a/tests/worktable/array.rs +++ b/tests/worktable/array.rs @@ -10,7 +10,7 @@ worktable! ( test: Arr }, queries: { - update_partial: { + update: { TestById(test) by id, } } @@ -33,7 +33,7 @@ async fn update() { let row = TestRow { id: 1, test: [1; 20] }; let pk = table.insert(row.clone()).await.unwrap(); let new_row = TestRow { id: 1, test: [2; 20] }; - table.update(new_row.clone()).await.unwrap(); + table.replace(new_row.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row, new_row); @@ -48,7 +48,7 @@ async fn update_in_a_middle() { let _ = table.insert(row.clone()).await.unwrap(); } let new_row = TestRow { id: 3, test: [1; 20] }; - table.update(new_row.clone()).await.unwrap(); + table.replace(new_row.clone()).await.unwrap(); let selected_row = table.select(3).unwrap(); assert_eq!(selected_row, new_row); @@ -60,7 +60,10 @@ async fn update_query() { let row = TestRow { id: 1, test: [1; 20] }; let pk = table.insert(row.clone()).await.unwrap(); let q = TestByIdQuery { test: [2; 20] }; - table.update_partial_test_by_id(q.clone(), pk.clone()).await.unwrap(); + table + .update_by_id(pk.clone(), TestColumns::TEST, (q.clone()).test) + .await + .unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row.test, q.test); @@ -76,7 +79,7 @@ worktable! ( test: ArrI }, queries: { - update_partial: { + update: { TestIById(test) by id, } } @@ -99,7 +102,7 @@ async fn update_i() { let row = TestIRow { id: 1, test: [1; 20] }; let pk = table.insert(row.clone()).await.unwrap(); let new_row = TestIRow { id: 1, test: [2; 20] }; - table.update(new_row.clone()).await.unwrap(); + table.replace(new_row.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row, new_row); @@ -114,7 +117,7 @@ async fn update_in_a_middle_i() { let _ = table.insert(row.clone()).await.unwrap(); } let new_row = TestIRow { id: 3, test: [1; 20] }; - table.update(new_row.clone()).await.unwrap(); + table.replace(new_row.clone()).await.unwrap(); let selected_row = table.select(3).unwrap(); assert_eq!(selected_row, new_row); @@ -126,7 +129,10 @@ async fn update_query_i() { let row = TestIRow { id: 1, test: [1; 20] }; let pk = table.insert(row.clone()).await.unwrap(); let q = TestIByIdQuery { test: [2; 20] }; - table.update_partial_test_i_by_id(q.clone(), pk.clone()).await.unwrap(); + table + .update_by_id(pk.clone(), TestIColumns::TEST, (q.clone()).test) + .await + .unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row.test, q.test); diff --git a/tests/worktable/base.rs b/tests/worktable/base.rs index 7a9886f2..403c0499 100644 --- a/tests/worktable/base.rs +++ b/tests/worktable/base.rs @@ -24,7 +24,7 @@ worktable! ( another_idx: another, } queries: { - update_partial: { + update: { AnotherByExchange(another) by exchange, AnotherByTest(another) by test, AnotherById(another) by id, @@ -128,7 +128,7 @@ async fn update_spawn() { }; let shared = table.clone(); let shared_updated = updated.clone(); - tokio::spawn(async move { shared.update(shared_updated).await }) + tokio::spawn(async move { shared.replace(shared_updated).await }) .await .unwrap() .unwrap(); @@ -182,7 +182,7 @@ async fn update() { another: 3, exchange: "test".to_string(), }; - table.update(updated.clone()).await.unwrap(); + table.replace(updated.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row, updated); @@ -206,7 +206,7 @@ async fn update_string() { another: 3, exchange: "much bigger test to make size of new row bigger than previous one".to_string(), }; - table.update(updated.clone()).await.unwrap(); + table.replace(updated.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row, updated); @@ -238,7 +238,7 @@ async fn update_parallel() { let val = fastrand::u64(..); let id_to_update = fastrand::i64(1..=100); shared - .update_partial_another_by_test(AnotherByTestQuery { another: val }, id_to_update) + .update_by_test(id_to_update, TestColumns::ANOTHER, val) .await .unwrap(); { @@ -253,7 +253,7 @@ async fn update_parallel() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); table - .update_partial_another_by_id(AnotherByIdQuery { another: val }, id_to_update) + .update_by_id(id_to_update, TestColumns::ANOTHER, val) .await .unwrap(); { @@ -288,15 +288,13 @@ async fn secondary_update_follows_concurrent_row_relocation() { let writer = tokio::spawn(async move { writer_barrier.wait().await; for revision in 1..=2000 { - writer_table.update_partial_exchange_by_id(ExchangeByIdQuery { - exchange: format!("relocated-{revision}-{}", "x".repeat(revision % 64)), - }, 0).await.unwrap(); + writer_table.update_by_id(0, TestColumns::EXCHANGE, format!("relocated-{revision}-{}", "x".repeat(revision % 64))).await.unwrap(); tokio::task::yield_now().await; } }); barrier.wait().await; for revision in 1..=2000 { - table.update_partial_another_by_test(AnotherByTestQuery { another: revision }, 1) + table.update_by_test(1, TestColumns::ANOTHER, revision) .await.unwrap(); tokio::task::yield_now().await; } @@ -1152,7 +1150,7 @@ async fn test_update_by_non_unique() { let _ = table.insert(row2.clone()).await.unwrap(); let row = AnotherByExchangeQuery { another: 3 }; - table.update_partial_another_by_exchange(row, "test".to_string()).await.unwrap(); + table.update_by_exchange("test".to_string(), TestColumns::ANOTHER, (row).another).await.unwrap(); let all = table.select_all().execute().unwrap(); @@ -1189,7 +1187,7 @@ async fn test_update_by_unique() { let _ = table.insert(row.clone()).await.unwrap(); let row = AnotherByTestQuery { another: 3 }; - table.update_partial_another_by_test(row, 1).await.unwrap(); + table.update_by_test(1, TestColumns::ANOTHER, (row).another).await.unwrap(); let row = table.select_by_test(1).unwrap(); @@ -1216,7 +1214,7 @@ async fn test_update_by_pk() { let pk = table.insert(row.clone()).await.unwrap(); let row = AnotherByIdQuery { another: 3 }; - table.update_partial_another_by_id(row, pk).await.unwrap(); + table.update_by_id(pk, TestColumns::ANOTHER, (row).another).await.unwrap(); let row = table.select_by_test(1).unwrap(); diff --git a/tests/worktable/bench.rs b/tests/worktable/bench.rs index ff68f62d..ca574766 100644 --- a/tests/worktable/bench.rs +++ b/tests/worktable/bench.rs @@ -14,7 +14,7 @@ worktable!( value: String }, queries: { - update_partial: { + update: { ValueById(value) by id, } } @@ -66,13 +66,18 @@ async fn _rw_lock_hash_map_vs_wt() { for i in 0..100000u64 { let s: String = Alphanumeric.sample_string(&mut rand::rng(), 8); let q = ValueByIdQuery { value: s }; - task_wt.update_partial_value_by_id(q, (i % 50) * 2).await.unwrap(); + task_wt + .update_by_id((i % 50) * 2, MapColumns::VALUE, (q).value) + .await + .unwrap(); } }); for i in 0..100000u64 { let s: String = Alphanumeric.sample_string(&mut rand::rng(), 8); let q = ValueByIdQuery { value: s }; - wt.update_partial_value_by_id(q, (i % 50) * 2 + 1).await.unwrap(); + wt.update_by_id((i % 50) * 2 + 1, MapColumns::VALUE, (q).value) + .await + .unwrap(); } h.await.unwrap(); println!("wt update in {} μs", wt_start.elapsed().as_micros()); diff --git a/tests/worktable/borrowed_primary_key.rs b/tests/worktable/borrowed_primary_key.rs index bcc53da7..acd8fe60 100644 --- a/tests/worktable/borrowed_primary_key.rs +++ b/tests/worktable/borrowed_primary_key.rs @@ -8,10 +8,10 @@ worktable!( value: u64, }, queries: { - update_partial: { + update: { BorrowedValueById(value) by id, } - update_partial_in_place: { + update_in_place: { BorrowedValueById(value) by id, } } @@ -43,11 +43,11 @@ async fn string_primary_key_accepts_borrowed_forms() { assert_eq!(table.select(&generated), Some(row)); table - .update_partial_borrowed_value_by_id(BorrowedValueByIdQuery { value: 8 }, &id) + .update_by_id(&id, BorrowedStringKeyColumns::VALUE, 8) .await .unwrap(); table - .update_partial_in_place_borrowed_value_by_id(|value| *value += 1, &id) + .update_in_place_by_id(&id, BorrowedStringKeyColumns::VALUE, |value| *value += 1) .await .unwrap(); assert_eq!(table.select(&id).unwrap().value, 9); diff --git a/tests/worktable/cancel_safety.rs b/tests/worktable/cancel_safety.rs index bcc9caf3..86074571 100644 --- a/tests/worktable/cancel_safety.rs +++ b/tests/worktable/cancel_safety.rs @@ -12,7 +12,7 @@ worktable!( other: u64, }, queries: { - update_partial: { + update: { ValueById(value) by id, } } @@ -55,7 +55,7 @@ async fn cancelled_full_row_update_releases_registered_lock() { // blocker; the timeout cancels it at exactly that await. let cancelled = tokio::time::timeout( Duration::from_millis(200), - table.update(CancelSafetyRow { + table.replace(CancelSafetyRow { id: 1, value: 7, other: 7, @@ -73,7 +73,7 @@ async fn cancelled_full_row_update_releases_registered_lock() { // failure instead of a hang. tokio::time::timeout( Duration::from_secs(5), - table.update(CancelSafetyRow { + table.replace(CancelSafetyRow { id: 1, value: 9, other: 9, @@ -109,7 +109,7 @@ async fn cancelled_custom_update_releases_registered_lock() { let cancelled = tokio::time::timeout( Duration::from_millis(200), - table.update_partial_value_by_id(ValueByIdQuery { value: 5 }, 3), + table.update_by_id(3, CancelSafetyColumns::VALUE, 5), ) .await; assert!( @@ -121,7 +121,7 @@ async fn cancelled_custom_update_releases_registered_lock() { tokio::time::timeout( Duration::from_secs(5), - table.update_partial_value_by_id(ValueByIdQuery { value: 11 }, 3), + table.update_by_id(3, CancelSafetyColumns::VALUE, 11), ) .await .expect("update after a cancelled predecessor must not hang") diff --git a/tests/worktable/columnar.rs b/tests/worktable/columnar.rs index ac1c6556..33840d64 100644 --- a/tests/worktable/columnar.rs +++ b/tests/worktable/columnar.rs @@ -40,10 +40,10 @@ worktable!( }, }, queries: { - update_partial: { + update: { TemperatureById(temperature) by id, }, - update_partial_in_place: { + update_in_place: { TimestampById(timestamp) by id, } }, @@ -142,7 +142,7 @@ async fn columnar_fields_and_clustered_index_follow_mutations() { assert_eq!(projected.iter().map(|(_, value)| *value).collect::>(), [1, 2]); table - .update(ColumnarMetricsRow { + .replace(ColumnarMetricsRow { id: 1, host_id: 3, timestamp: 30, @@ -158,13 +158,13 @@ async fn columnar_fields_and_clustered_index_follow_mutations() { assert_eq!(table.columnar_project_temperature(&updated).unwrap()[0].1, 75); table - .update_partial_temperature_by_id(TemperatureByIdQuery { temperature: 76 }, 1) + .update_by_id(1, ColumnarMetricsColumns::TEMPERATURE, 76) .await .unwrap(); assert_eq!(table.columnar_project_temperature(&updated).unwrap()[0].1, 76); table - .update_partial_in_place_timestamp_by_id(|value| *value = 40.into(), 1) + .update_in_place_by_id(1, ColumnarMetricsColumns::TIMESTAMP, |value| *value = 40.into()) .await .unwrap(); assert!(table.columnar_is_dirty()); @@ -198,7 +198,7 @@ async fn concurrent_reinsert_and_columnar_refresh_preserve_row_identity() { tokio::spawn(async move { for value in 1..=200 { table - .update(ColumnarMetricsRow { + .replace(ColumnarMetricsRow { id: 7, host_id: 1, timestamp: value, @@ -308,7 +308,7 @@ async fn columnar_side_indexes_compose_with_congee_and_arctic_using_backends() { [10, 20] ); - table.update($row { id: 1, value: 5 }).await.unwrap(); + table.replace($row { id: 1, value: 5 }).await.unwrap(); assert_eq!(table.columnar_select_value_order(20).unwrap(), []); assert_eq!(table.columnar_select_value_order(5).unwrap().len(), 1); diff --git a/tests/worktable/count.rs b/tests/worktable/count.rs index 418e691a..7398304b 100644 --- a/tests/worktable/count.rs +++ b/tests/worktable/count.rs @@ -19,7 +19,7 @@ worktable!( idx2: attr2 unique, }, queries: { - update_partial: { + update: { ThreeAttrById(attr1, attr2) by id, }, delete: { diff --git a/tests/worktable/delete.rs b/tests/worktable/delete.rs index d58834c3..fb3aaf4b 100644 --- a/tests/worktable/delete.rs +++ b/tests/worktable/delete.rs @@ -13,7 +13,7 @@ worktable!( val2_idx: val2, }, queries: { - update_partial: { + update: { Val1ByToken(val1) by token, }, delete: { diff --git a/tests/worktable/in_place.rs b/tests/worktable/in_place.rs index 69b8d167..f187bdc9 100644 --- a/tests/worktable/in_place.rs +++ b/tests/worktable/in_place.rs @@ -21,11 +21,11 @@ worktable!( something: u64, }, queries: { - update_partial_in_place: { + update_in_place: { ValById(val) by id, Val2ById(val2) by id, } - update_partial: { + update: { AnotherById(another) by id, SomethingById(something) by id, } @@ -45,7 +45,7 @@ async fn test_update_val_by_id() -> eyre::Result<()> { }; let pk = table.insert(row).await?; for _ in 0..10000 { - table.update_partial_in_place_val_by_id(|val| *val += 1, pk.0).await? + table.update_in_place_by_id(pk.0, TestColumns::VAL, |val| *val += 1).await? } let row = table.select(pk).unwrap(); assert_eq!(row.val, 10000); @@ -65,7 +65,7 @@ async fn test_update_val2_by_id() -> eyre::Result<()> { }; let pk = table.insert(row).await?; for _ in 0..100 { - table.update_partial_in_place_val_2_by_id(|val| *val += 1, pk.0).await? + table.update_in_place_by_id(pk.0, TestColumns::VAL2, |val| *val += 1).await? } let row = table.select(pk).unwrap(); assert_eq!(row.val2, 100); @@ -88,13 +88,13 @@ async fn test_update_val_by_id_two_thread() -> eyre::Result<()> { let h = tokio::spawn(async move { for _ in 0..10_000 { shared_table - .update_partial_in_place_val_by_id(|val| *val += 1, pk.0) + .update_in_place_by_id(pk.0, TestColumns::VAL, |val| *val += 1) .await .unwrap() } }); for _ in 0..10_000 { - table.update_partial_in_place_val_by_id(|val| *val += 1, pk.0).await? + table.update_in_place_by_id(pk.0, TestColumns::VAL, |val| *val += 1).await? } h.await?; let row = table.select(pk).unwrap(); @@ -118,7 +118,7 @@ async fn test_update_val_and_val2_by_id_four_thread() -> eyre::Result<()> { let h1 = tokio::spawn(async move { for _ in 0..10_000 { shared_table - .update_partial_in_place_val_by_id(|val| *val += 1, pk.0) + .update_in_place_by_id(pk.0, TestColumns::VAL, |val| *val += 1) .await .unwrap() } @@ -127,7 +127,7 @@ async fn test_update_val_and_val2_by_id_four_thread() -> eyre::Result<()> { let h2 = tokio::spawn(async move { for _ in 0..10_000 { shared_table - .update_partial_in_place_val_2_by_id(|val| *val += 1, pk.0) + .update_in_place_by_id(pk.0, TestColumns::VAL2, |val| *val += 1) .await .unwrap() } @@ -136,13 +136,13 @@ async fn test_update_val_and_val2_by_id_four_thread() -> eyre::Result<()> { let h3 = tokio::spawn(async move { for _ in 0..10_000 { shared_table - .update_partial_in_place_val_by_id(|val| *val += 1, pk.0) + .update_in_place_by_id(pk.0, TestColumns::VAL, |val| *val += 1) .await .unwrap() } }); for _ in 0..10_000 { - table.update_partial_in_place_val_2_by_id(|val| *val += 1, pk.0).await? + table.update_in_place_by_id(pk.0, TestColumns::VAL2, |val| *val += 1).await? } h1.await?; h2.await?; @@ -169,7 +169,7 @@ async fn test_update_val_by_id_four_thread() -> eyre::Result<()> { let h1 = tokio::spawn(async move { for _ in 0..10_000 { shared_table - .update_partial_in_place_val_by_id(|val| *val += 1, pk.0) + .update_in_place_by_id(pk.0, TestColumns::VAL, |val| *val += 1) .await .unwrap() } @@ -178,7 +178,7 @@ async fn test_update_val_by_id_four_thread() -> eyre::Result<()> { let h2 = tokio::spawn(async move { for _ in 0..10_000 { shared_table - .update_partial_in_place_val_by_id(|val| *val += 1, pk.0) + .update_in_place_by_id(pk.0, TestColumns::VAL, |val| *val += 1) .await .unwrap() } @@ -187,13 +187,13 @@ async fn test_update_val_by_id_four_thread() -> eyre::Result<()> { let h3 = tokio::spawn(async move { for _ in 0..10_000 { shared_table - .update_partial_in_place_val_by_id(|val| *val += 1, pk.0) + .update_in_place_by_id(pk.0, TestColumns::VAL, |val| *val += 1) .await .unwrap() } }); for _ in 0..10_000 { - table.update_partial_in_place_val_by_id(|val| *val += 1, pk.0).await? + table.update_in_place_by_id(pk.0, TestColumns::VAL, |val| *val += 1).await? } h1.await?; h2.await?; @@ -227,7 +227,7 @@ async fn test_update_in_place_and_update_sized_multithread() -> eyre::Result<()> let val = fastrand::i64(..); let id_to_update = fastrand::u64(0..=99); shared - .update_partial_in_place_val_by_id(|v| *v = val.into(), id_to_update) + .update_in_place_by_id(id_to_update, TestColumns::VAL, |v| *v = val.into()) .await .unwrap(); { @@ -243,7 +243,7 @@ async fn test_update_in_place_and_update_sized_multithread() -> eyre::Result<()> let val = fastrand::i16(..); let id_to_update = fastrand::u64(0..=99); shared - .update_partial_in_place_val_2_by_id(|v| *v = val.into(), id_to_update) + .update_in_place_by_id(id_to_update, TestColumns::VAL2, |v| *v = val.into()) .await .unwrap(); { @@ -257,7 +257,7 @@ async fn test_update_in_place_and_update_sized_multithread() -> eyre::Result<()> let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); table - .update_partial_something_by_id(SomethingByIdQuery { something: val }, id_to_update) + .update_by_id(id_to_update, TestColumns::SOMETHING, val) .await?; { let mut guard = i_state.lock(); @@ -306,7 +306,7 @@ async fn test_update_in_place_and_update_unsized_multithread() -> eyre::Result<( let val = fastrand::i64(..); let id_to_update = fastrand::u64(0..=99); shared - .update_partial_in_place_val_by_id(|v| *v = val.into(), id_to_update) + .update_in_place_by_id(id_to_update, TestColumns::VAL, |v| *v = val.into()) .await .unwrap(); { @@ -322,7 +322,7 @@ async fn test_update_in_place_and_update_unsized_multithread() -> eyre::Result<( let val = fastrand::i16(..); let id_to_update = fastrand::u64(0..=99); shared - .update_partial_in_place_val_2_by_id(|v| *v = val.into(), id_to_update) + .update_in_place_by_id(id_to_update, TestColumns::VAL2, |v| *v = val.into()) .await .unwrap(); { @@ -336,12 +336,7 @@ async fn test_update_in_place_and_update_unsized_multithread() -> eyre::Result<( let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); table - .update_partial_another_by_id( - AnotherByIdQuery { - another: format!("another_{val}"), - }, - id_to_update, - ) + .update_by_id(id_to_update, TestColumns::ANOTHER, format!("another_{val}")) .await?; { let mut guard = i_state.lock(); diff --git a/tests/worktable/index/mod.rs b/tests/worktable/index/mod.rs index 8fb532ed..9cb51faa 100644 --- a/tests/worktable/index/mod.rs +++ b/tests/worktable/index/mod.rs @@ -24,7 +24,7 @@ worktable!( idx3: attr3 unique, }, queries: { - update_partial: { + update: { UniqueThreeAttrById(attr1, attr2, attr3) by id, UniqueTwoAttrByThird(attr1, attr2) by attr3, }, @@ -50,7 +50,7 @@ worktable!( idx3: attr3, }, queries: { - update_partial: { + update: { ThreeAttrById(attr1, attr2, attr3) by id, TwoAttrByThird(attr1, attr2) by attr3, }, @@ -75,7 +75,7 @@ worktable!( idx2: attr2, }, queries: { - update_partial: { + update: { AllAttrById(attr1, attr2) by id, }, delete: { @@ -103,12 +103,13 @@ async fn update_2_idx() { let pk = test_table.insert(row.clone()).await.unwrap(); test_table - .update_partial_all_attr_by_id( + .update_by_id( + pk.clone(), + Test2Columns::ATTR1_AND_ATTR2, AllAttrByIdQuery { attr1: attr1_new.clone(), attr2: attr2_new, }, - pk.clone(), ) .await .unwrap(); @@ -145,7 +146,7 @@ async fn update_2_idx_full_row() { let pk = test_table.insert(row.clone()).await.unwrap(); test_table - .update(Test2Row { + .replace(Test2Row { id: pk.clone().into(), attr1: attr1_new.clone(), attr2: attr2_new, @@ -181,7 +182,7 @@ worktable!( idx1: attr1, }, queries: { - update_partial: { + update: { ValByAttr(val) by attr1, Attr1ById(attr1) by id, }, @@ -209,12 +210,7 @@ async fn update_1_idx() { let pk = test_table.insert(row.clone()).await.unwrap(); test_table - .update_partial_attr_1_by_id( - Attr1ByIdQuery { - attr1: attr1_new.clone(), - }, - pk.clone(), - ) + .update_by_id(pk.clone(), TestColumns::ATTR1, attr1_new.clone()) .await .unwrap(); @@ -245,7 +241,7 @@ async fn update_1_idx_full_row() { let pk = test_table.insert(row.clone()).await.unwrap(); test_table - .update(TestRow { + .replace(TestRow { attr2: row.attr2, id: pk.clone().into(), attr1: attr1_new.clone(), diff --git a/tests/worktable/index/update_by_pk.rs b/tests/worktable/index/update_by_pk.rs index 4d3fa916..1b08bc08 100644 --- a/tests/worktable/index/update_by_pk.rs +++ b/tests/worktable/index/update_by_pk.rs @@ -1,8 +1,8 @@ use worktable::prelude::SelectQueryExecutor; use crate::worktable::index::{ - Test3NonUniqueRow, Test3NonUniqueWorkTable, Test3UniqueRow, Test3UniqueWorkTable, ThreeAttrByIdQuery, - UniqueThreeAttrByIdQuery, + Test3NonUniqueColumns, Test3NonUniqueRow, Test3NonUniqueWorkTable, Test3UniqueColumns, Test3UniqueRow, + Test3UniqueWorkTable, ThreeAttrByIdQuery, UniqueThreeAttrByIdQuery, }; #[tokio::test] @@ -27,13 +27,14 @@ async fn update_by_pk_unique_indexes() { let pk = test_table.insert(row.clone()).await.unwrap(); test_table - .update_partial_unique_three_attr_by_id( + .update_by_id( + pk.clone(), + Test3UniqueColumns::ATTR1_AND_ATTR2_AND_ATTR3, UniqueThreeAttrByIdQuery { attr1: attr1_new.clone(), attr2: attr2_new, attr3: attr3_new, }, - pk.clone(), ) .await .unwrap(); @@ -77,13 +78,14 @@ async fn update_by_pk_non_unique_indexes() { let pk = test_table.insert(row.clone()).await.unwrap(); test_table - .update_partial_three_attr_by_id( + .update_by_id( + pk.clone(), + Test3NonUniqueColumns::ATTR1_AND_ATTR2_AND_ATTR3, ThreeAttrByIdQuery { attr1: attr1_new.clone(), attr2: attr2_new, attr3: attr3_new, }, - pk.clone(), ) .await .unwrap(); @@ -132,7 +134,7 @@ async fn update_by_pk_with_reinsert_and_secondary_unique_violation() { }; assert!( test_table - .update_partial_unique_three_attr_by_id(update, row1.id) + .update_by_id(row1.id, Test3UniqueColumns::ATTR1_AND_ATTR2_AND_ATTR3, update) .await .is_err() ); @@ -173,7 +175,7 @@ async fn update_by_pk_with_secondary_unique_violation() { }; assert!( test_table - .update_partial_unique_three_attr_by_id(update, row1.id) + .update_by_id(row1.id, Test3UniqueColumns::ATTR1_AND_ATTR2_AND_ATTR3, update) .await .is_err() ); diff --git a/tests/worktable/index/update_full.rs b/tests/worktable/index/update_full.rs index 065ceb27..86a644c5 100644 --- a/tests/worktable/index/update_full.rs +++ b/tests/worktable/index/update_full.rs @@ -23,7 +23,7 @@ async fn update_by_full_row_unique_indexes() { let pk = test_table.insert(row.clone()).await.unwrap(); test_table - .update(Test3UniqueRow { + .replace(Test3UniqueRow { attr1: attr1_new.clone(), id: pk.clone().into(), val: row.val, @@ -72,7 +72,7 @@ async fn update_by_full_row_non_unique_indexes() { let pk = test_table.insert(row.clone()).await.unwrap(); test_table - .update(Test3NonUniqueRow { + .replace(Test3NonUniqueRow { attr1: attr1_new.clone(), id: pk.clone().into(), val: row.val, @@ -122,7 +122,7 @@ async fn update_by_full_row_unique_with_string_update() { let pk = test_table.insert(row.clone()).await.unwrap(); test_table - .update(Test3UniqueRow { + .replace(Test3UniqueRow { attr1: attr1_new.clone(), id: pk.clone().into(), val: row.val, @@ -171,7 +171,7 @@ async fn update_by_full_row_non_unique_with_string_update() { let pk = test_table.insert(row.clone()).await.unwrap(); test_table - .update(Test3NonUniqueRow { + .replace(Test3NonUniqueRow { attr1: attr1_new.clone(), id: pk.clone().into(), val: row.val, @@ -222,7 +222,7 @@ async fn update_by_full_row_with_reinsert_and_primary_key_violation() { let mut update = row1.clone(); update.id = row2.id; update.attr1 = "TEST_______________________1".to_string(); - assert!(test_table.update(update).await.is_err()); + assert!(test_table.replace(update).await.is_err()); assert_eq!(test_table.select(row1.id).unwrap(), row1); assert_eq!(test_table.select_by_attr1(row1.attr1.clone()).unwrap(), row1); @@ -257,7 +257,7 @@ async fn update_by_full_row_with_reinsert_and_secondary_unique_violation() { test_table.insert(row2.clone()).await.unwrap(); let mut update = row1.clone(); update.attr1 = row2.attr1.clone(); - assert!(test_table.update(update).await.is_err()); + assert!(test_table.replace(update).await.is_err()); assert_eq!(test_table.select(row1.id).unwrap(), row1); assert_eq!(test_table.select_by_attr1(row1.attr1.clone()).unwrap(), row1); @@ -292,7 +292,7 @@ async fn update_by_full_row_with_secondary_unique_violation() { test_table.insert(row2.clone()).await.unwrap(); let mut update = row1.clone(); update.attr2 = row2.attr2; - assert!(test_table.update(update).await.is_err()); + assert!(test_table.replace(update).await.is_err()); assert_eq!(test_table.select(row1.id).unwrap(), row1); assert_eq!(test_table.select_by_attr1(row1.attr1.clone()).unwrap(), row1); diff --git a/tests/worktable/index/update_query.rs b/tests/worktable/index/update_query.rs index 8da5411f..ac433340 100644 --- a/tests/worktable/index/update_query.rs +++ b/tests/worktable/index/update_query.rs @@ -1,6 +1,6 @@ use crate::worktable::index::{ - Test3NonUniqueRow, Test3NonUniqueWorkTable, Test3UniqueRow, Test3UniqueWorkTable, TwoAttrByThirdQuery, - UniqueTwoAttrByThirdQuery, + Test3NonUniqueColumns, Test3NonUniqueRow, Test3NonUniqueWorkTable, Test3UniqueColumns, Test3UniqueRow, + Test3UniqueWorkTable, TwoAttrByThirdQuery, UniqueTwoAttrByThirdQuery, }; use worktable::prelude::SelectQueryExecutor; @@ -25,12 +25,13 @@ async fn update_two_via_query_unique_indexes() { let _ = test_table.insert(row.clone()).await.unwrap(); test_table - .update_partial_unique_two_attr_by_third( + .update_by_attr3( + attr3_old, + Test3UniqueColumns::ATTR1_AND_ATTR2, UniqueTwoAttrByThirdQuery { attr1: attr1_new.clone(), attr2: attr2_new, }, - attr3_old, ) .await .unwrap(); @@ -75,7 +76,7 @@ async fn update_with_reinsert_and_secondary_unique_violation() { }; assert!( test_table - .update_partial_unique_two_attr_by_third(update, row1.attr3,) + .update_by_attr3(row1.attr3, Test3UniqueColumns::ATTR1_AND_ATTR2, update) .await .is_err() ); @@ -115,7 +116,7 @@ async fn update_with_secondary_unique_violation() { }; assert!( test_table - .update_partial_unique_two_attr_by_third(update, row1.attr3) + .update_by_attr3(row1.attr3, Test3UniqueColumns::ATTR1_AND_ATTR2, update) .await .is_err() ); @@ -150,12 +151,13 @@ async fn update_two_via_query_non_unique_indexes() { let _ = test_table.insert(row.clone()).await.unwrap(); test_table - .update_partial_two_attr_by_third( + .update_by_attr3( + attr3_old, + Test3NonUniqueColumns::ATTR1_AND_ATTR2, TwoAttrByThirdQuery { attr1: attr1_new, attr2: attr2_new, }, - attr3_old, ) .await .unwrap(); diff --git a/tests/worktable/index_backends.rs b/tests/worktable/index_backends.rs index 09064dfc..7947018d 100644 --- a/tests/worktable/index_backends.rs +++ b/tests/worktable/index_backends.rs @@ -178,7 +178,7 @@ async fn all_unique_backends_support_crud_ranges_and_conflict_rollback() { congee_key: 23, arctic_key: 24, }; - table.update(updated.clone()).await.unwrap(); + table.replace(updated.clone()).await.unwrap(); assert_eq!(table.select(pk), Some(updated.clone())); assert!(table.select_by_wti_key(11).is_none()); assert!(table.select_by_upstream_key(12).is_none()); @@ -223,7 +223,7 @@ async fn alternative_primary_backends_support_point_crud() { id: original.id, value: 2, }; - table.update(updated.clone()).await.unwrap(); + table.replace(updated.clone()).await.unwrap(); assert_eq!(table.select(pk.clone()), Some(updated)); table.delete(original.id).await.unwrap(); @@ -428,7 +428,7 @@ async fn native_art_backends_recover_concurrent_same_row_updates() { barrier.wait().await; for update in 0..UPDATES_PER_WORKER { table - .update(PersistedArcticRow { + .replace(PersistedArcticRow { id, congee_key: 10_000 + worker * UPDATES_PER_WORKER + update, }) @@ -496,7 +496,7 @@ async fn logical_wti_recovers_concurrent_same_row_updates() { barrier.wait().await; for update in 0..UPDATES_PER_WORKER { table - .update(wti::ProviderSwitchRow { + .replace(wti::ProviderSwitchRow { id, unique_key: 10_000 + worker * UPDATES_PER_WORKER + update, }) diff --git a/tests/worktable/leak_probe.rs b/tests/worktable/leak_probe.rs index fa95fbb8..e9a1a740 100644 --- a/tests/worktable/leak_probe.rs +++ b/tests/worktable/leak_probe.rs @@ -16,7 +16,7 @@ worktable!( payload: String, }, queries: { - update_partial: { + update: { Payload(payload) by id, } } @@ -42,12 +42,7 @@ async fn update_churn_reclaims_under_concurrent_readers() { .unwrap(); for i in 0..100u64 { table - .update_partial_payload( - PayloadQuery { - payload: format!("{:04}", i % 10000), - }, - 1, - ) + .update_by_id(1, LeakProbeColumns::PAYLOAD, format!("{:04}", i % 10000)) .await .unwrap(); } @@ -72,12 +67,7 @@ async fn update_churn_reclaims_under_concurrent_readers() { for i in 0..5_000u64 { table - .update_partial_payload( - PayloadQuery { - payload: format!("{:04}", i % 10000), - }, - 1, - ) + .update_by_id(1, LeakProbeColumns::PAYLOAD, format!("{:04}", i % 10000)) .await .unwrap(); } @@ -114,12 +104,7 @@ async fn update_churn_does_not_grow_storage_unbounded() { let pages_after_warmup = { for i in 0..100u64 { table - .update_partial_payload( - PayloadQuery { - payload: format!("{:04}", i % 10000), - }, - 1, - ) + .update_by_id(1, LeakProbeColumns::PAYLOAD, format!("{:04}", i % 10000)) .await .unwrap(); } @@ -128,12 +113,7 @@ async fn update_churn_does_not_grow_storage_unbounded() { for i in 0..5_000u64 { table - .update_partial_payload( - PayloadQuery { - payload: format!("{:04}", i % 10000), - }, - 1, - ) + .update_by_id(1, LeakProbeColumns::PAYLOAD, format!("{:04}", i % 10000)) .await .unwrap(); } diff --git a/tests/worktable/lock_order.rs b/tests/worktable/lock_order.rs index 20d2c9fb..abe17c57 100644 --- a/tests/worktable/lock_order.rs +++ b/tests/worktable/lock_order.rs @@ -17,7 +17,7 @@ worktable!( group_b_idx: group_b, }, queries: { - update_partial: { + update: { ValueByGroupA(value) by group_a, ValueByGroupB(value) by group_b, } @@ -95,13 +95,9 @@ async fn multi_row_update_locks_in_primary_key_order_not_index_order() { let update_table = table.clone(); let update = tokio::spawn(async move { if use_group_a { - update_table - .update_partial_value_by_group_a(ValueByGroupAQuery { value: 1 }, 1) - .await + update_table.update_by_group_a(1, LockOrderColumns::VALUE, 1).await } else { - update_table - .update_partial_value_by_group_b(ValueByGroupBQuery { value: 1 }, 1) - .await + update_table.update_by_group_b(1, LockOrderColumns::VALUE, 1).await } }); diff --git a/tests/worktable/multi_row_deadlock.rs b/tests/worktable/multi_row_deadlock.rs index 920e5841..9941da8f 100644 --- a/tests/worktable/multi_row_deadlock.rs +++ b/tests/worktable/multi_row_deadlock.rs @@ -17,7 +17,7 @@ worktable!( group_b_idx: group_b, }, queries: { - update_partial: { + update: { NameByGroupA(name) by group_a, NameByGroupB(name) by group_b, } @@ -58,7 +58,7 @@ async fn overlapping_multi_row_updates_do_not_deadlock() { "a-much-longer-name-value".to_string() }; table - .update_partial_name_by_group_a(NameByGroupAQuery { name }, 1) + .update_by_group_a(1, MultiRowDeadlockColumns::NAME, name) .await .unwrap(); } @@ -74,7 +74,7 @@ async fn overlapping_multi_row_updates_do_not_deadlock() { "b-much-longer-name-value".to_string() }; table - .update_partial_name_by_group_b(NameByGroupBQuery { name }, 1) + .update_by_group_b(1, MultiRowDeadlockColumns::NAME, name) .await .unwrap(); } diff --git a/tests/worktable/mutation_gate_deadlock.rs b/tests/worktable/mutation_gate_deadlock.rs index a10ee96a..d565bed3 100644 --- a/tests/worktable/mutation_gate_deadlock.rs +++ b/tests/worktable/mutation_gate_deadlock.rs @@ -1,7 +1,7 @@ //! Guard test for the synchronous mutation gate (`LockMap::mutation_guard`). //! //! The gate is a blocking spin/yield ticket lock, and the generated -//! `update_partial`/`update_partial_in_place`/`delete` paths hold the resulting `MutationGuard` inside +//! `update`/`update_in_place`/`delete` paths hold the resulting `MutationGuard` inside //! the `LockGuard` **across `.await`** (e.g. `update_with_guard(...).await`, //! `reinsert(...).await`). A review flagged this as a possible livelock/deadlock //! when two keys collide on the same 1-of-64 stripe on a constrained runtime. @@ -30,7 +30,7 @@ worktable!( val: u64, }, queries: { - update_partial: { + update: { Val(val) by id, } } @@ -78,7 +78,7 @@ fn concurrent_same_stripe_updates_do_not_deadlock() { let table = table.clone(); tokio::spawn(async move { for i in 0..500u64 { - table.update_partial_val(ValQuery { val: i }, a).await.unwrap(); + table.update_by_id(a, GateBenchColumns::VAL, i).await.unwrap(); } }) }; @@ -86,7 +86,7 @@ fn concurrent_same_stripe_updates_do_not_deadlock() { let table = table.clone(); tokio::spawn(async move { for i in 0..500u64 { - table.update_partial_val(ValQuery { val: i }, b).await.unwrap(); + table.update_by_id(b, GateBenchColumns::VAL, i).await.unwrap(); } }) }; @@ -127,7 +127,7 @@ fn many_same_stripe_updates_do_not_starve_worker_pool() { let key = if worker % 2 == 0 { a } else { b }; handles.push(tokio::spawn(async move { for i in 0..300u64 { - table.update_partial_val(ValQuery { val: i }, key).await.unwrap(); + table.update_by_id(key, GateBenchColumns::VAL, i).await.unwrap(); } })); } diff --git a/tests/worktable/nonunique_arctic.rs b/tests/worktable/nonunique_arctic.rs index 34398439..ed394fc0 100644 --- a/tests/worktable/nonunique_arctic.rs +++ b/tests/worktable/nonunique_arctic.rs @@ -26,7 +26,7 @@ worktable! { weight_idx: weight using arctic, }, queries: { - update_partial: { + update: { SourceById(source_hash) by id, WeightBySource(weight) by source_hash, }, @@ -93,7 +93,7 @@ async fn update_moves_a_row_between_non_unique_keys() { table.insert(row(&table, SOURCE_A, 2, 20)).await.unwrap(); table - .update_partial_source_by_id(SourceByIdQuery { source_hash: SOURCE_B }, pk.clone()) + .update_by_id(pk.clone(), ArcticAdjacencyColumns::SOURCE_HASH, SOURCE_B) .await .unwrap(); @@ -106,7 +106,7 @@ async fn update_moves_a_row_between_non_unique_keys() { // Updating by the non-unique key touches every row under it. table - .update_partial_weight_by_source(WeightBySourceQuery { weight: 777 }, SOURCE_A) + .update_by_source_hash(SOURCE_A, ArcticAdjacencyColumns::WEIGHT, 777) .await .unwrap(); let rows = table.select_by_weight(777).execute().unwrap(); @@ -298,7 +298,7 @@ mod persisted { source_hash: 6, weight: 101, }; - table.update(moved.clone()).await.unwrap(); + table.replace(moved.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); drop(table); diff --git a/tests/worktable/option.rs b/tests/worktable/option.rs index 0937e4dd..5c59ed60 100644 --- a/tests/worktable/option.rs +++ b/tests/worktable/option.rs @@ -16,7 +16,7 @@ worktable! ( exchnage_idx: exchange, }, queries: { - update_partial: { + update: { TestById(test) by id, TestByAnother(test) by another, TestByExchange(test) by exchange, @@ -40,7 +40,7 @@ async fn update() { another: 1, exchange: 1, }; - table.update(new_row.clone()).await.unwrap(); + table.replace(new_row.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row, new_row); } @@ -55,10 +55,7 @@ async fn update_by_another() { exchange: 1, }; let pk = table.insert(row.clone()).await.unwrap(); - table - .update_partial_test_by_another(TestByAnotherQuery { test: Some(1) }, 1) - .await - .unwrap(); + table.update_by_another(1, TestColumns::TEST, Some(1)).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row.test, Some(1)); } @@ -73,10 +70,7 @@ async fn update_by_exchange() { exchange: 1, }; let pk = table.insert(row.clone()).await.unwrap(); - table - .update_partial_test_by_exchange(TestByExchangeQuery { test: Some(1) }, 1) - .await - .unwrap(); + table.update_by_exchange(1, TestColumns::TEST, Some(1)).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row.test, Some(1)); } @@ -94,7 +88,7 @@ async fn update_none_to_some() { assert_eq!(table.select(pk.clone()).unwrap().test, None); table - .update_partial_test_by_id(TestByIdQuery { test: Some(42) }, pk.clone()) + .update_by_id(pk.clone(), TestColumns::TEST, Some(42)) .await .unwrap(); @@ -114,10 +108,7 @@ async fn update_some_to_none() { let pk = table.insert(row.clone()).await.unwrap(); assert_eq!(table.select(pk.clone()).unwrap().test, Some(100)); - table - .update_partial_test_by_id(TestByIdQuery { test: None }, pk.clone()) - .await - .unwrap(); + table.update_by_id(pk.clone(), TestColumns::TEST, None).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row.test, None); @@ -144,7 +135,7 @@ async fn update_multiple_values() { let pk2 = table.insert(row2).await.unwrap(); table - .update_partial_test_by_id(TestByIdQuery { test: Some(30) }, pk1.clone()) + .update_by_id(pk1.clone(), TestColumns::TEST, Some(30)) .await .unwrap(); @@ -165,7 +156,7 @@ worktable! ( exchnage_idx: exchange, }, queries: { - update_partial: { + update: { CustomTestById(test) by id, CustomTestByAnother(test) by another, CustomTestByExchange(test) by exchange, @@ -190,7 +181,7 @@ async fn custom_update() { another: 1, exchange: 1, }; - table.update(new_row.clone()).await.unwrap(); + table.replace(new_row.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row, new_row); } @@ -207,7 +198,7 @@ async fn custom_update_by_another() { let pk = table.insert(row.clone()).await.unwrap(); let test_uuid = Uuid::new_v4(); table - .update_partial_custom_test_by_another(CustomTestByAnotherQuery { test: Some(test_uuid) }, 1) + .update_by_another(1, TestCustomColumns::TEST, Some(test_uuid)) .await .unwrap(); let selected_row = table.select(pk).unwrap(); @@ -226,7 +217,7 @@ async fn custom_update_by_exchange() { let pk = table.insert(row.clone()).await.unwrap(); let test_uuid = Uuid::new_v4(); table - .update_partial_custom_test_by_exchange(CustomTestByExchangeQuery { test: Some(test_uuid) }, 1) + .update_by_exchange(1, TestCustomColumns::TEST, Some(test_uuid)) .await .unwrap(); let selected_row = table.select(pk).unwrap(); @@ -247,7 +238,7 @@ async fn custom_update_none_to_some() { let test_uuid = Uuid::new_v4(); table - .update_partial_custom_test_by_id(CustomTestByIdQuery { test: Some(test_uuid) }, pk.clone()) + .update_by_id(pk.clone(), TestCustomColumns::TEST, Some(test_uuid)) .await .unwrap(); @@ -269,7 +260,7 @@ async fn custom_update_some_to_none() { assert_eq!(table.select(pk.clone()).unwrap().test, Some(test_uuid)); table - .update_partial_custom_test_by_id(CustomTestByIdQuery { test: None }, pk.clone()) + .update_by_id(pk.clone(), TestCustomColumns::TEST, None) .await .unwrap(); @@ -301,7 +292,7 @@ async fn custom_update_multiple_uuids() { let uuid3 = Uuid::new_v4(); table - .update_partial_custom_test_by_id(CustomTestByIdQuery { test: Some(uuid3) }, pk1.clone()) + .update_by_id(pk1.clone(), TestCustomColumns::TEST, Some(uuid3)) .await .unwrap(); @@ -323,7 +314,7 @@ worktable! ( exchnage_idx: exchange, }, queries: { - update_partial: { + update: { IndexTestById(test) by id, IndexTestByAnother(test) by another, IndexTestByExchange(test) by exchange, @@ -462,7 +453,7 @@ async fn indexed_update_indexed_field() { // Update to a new UUID let uuid2 = Uuid::new_v4(); table - .update_partial_index_test_by_id(IndexTestByIdQuery { test: Some(uuid2) }, pk.clone()) + .update_by_id(pk.clone(), TestIndexColumns::TEST, Some(uuid2)) .await .unwrap(); @@ -495,7 +486,7 @@ async fn indexed_update_from_some_to_none() { // Update to None table - .update_partial_index_test_by_id(IndexTestByIdQuery { test: None }, pk.clone()) + .update_by_id(pk.clone(), TestIndexColumns::TEST, None) .await .unwrap(); @@ -528,7 +519,7 @@ async fn indexed_update_from_none_to_some() { // Update to Some UUID let test_uuid = Uuid::new_v4(); table - .update_partial_index_test_by_id(IndexTestByIdQuery { test: Some(test_uuid) }, pk.clone()) + .update_by_id(pk.clone(), TestIndexColumns::TEST, Some(test_uuid)) .await .unwrap(); @@ -558,7 +549,7 @@ async fn indexed_update_via_another_index() { // Update via the unique 'another' index table - .update_partial_index_test_by_another(IndexTestByAnotherQuery { test: Some(uuid2) }, 999) + .update_by_another(999, TestIndexColumns::TEST, Some(uuid2)) .await .unwrap(); @@ -594,7 +585,7 @@ async fn indexed_update_via_non_unique_index() { // Update both rows via the non-unique 'exchange' index table - .update_partial_index_test_by_exchange(IndexTestByExchangeQuery { test: Some(uuid2) }, 100) + .update_by_exchange(100, TestIndexColumns::TEST, Some(uuid2)) .await .unwrap(); diff --git a/tests/worktable/partitioned.rs b/tests/worktable/partitioned.rs index fef07416..67b09f54 100644 --- a/tests/worktable/partitioned.rs +++ b/tests/worktable/partitioned.rs @@ -205,7 +205,7 @@ async fn updates_are_scoped_to_one_partition() { a.insert(row(7, 100.0)).await.unwrap(); b.insert(row(7, 200.0)).await.unwrap(); - a.update(PriceRow { + a.replace(PriceRow { exchange_id: 7, bid: 999.0, ask: 1000.0, @@ -833,7 +833,7 @@ fn the_declared_width_is_a_bound_at_run_time_too() { #[test] fn a_column_is_updated_without_cloning_the_row() { - // The method web3.trading's `update_partial_top_price` wants: touch one field of a + // The method web3.trading's `update_top_price` wants: touch one field of a // wide row rather than reading it out, editing it and writing it back. let ticks = TickPartitions::new(); let book = ticks.partition_or_create(2).expect("a fresh partition"); @@ -984,7 +984,7 @@ fn an_empty_dense_partition_allocates_nothing() { // A dense partition carries `queries:`, keyed by position. // // This is what decides whether the shape is adoptable: web3.trading's -// `update_partial_top_price` and `update_full` go through declared update queries, and +// `update_top_price` and `update_full` go through declared update queries, and // a payload that could not carry them would be a payload they cannot use. worktable!( name: Quoted, @@ -997,7 +997,7 @@ worktable!( seq: u64 }, queries: { - update_partial: { + update: { TopPrice(bid, ask) by exchange_id, }, delete: { @@ -1021,7 +1021,7 @@ fn a_dense_partition_carries_its_update_queries() { // The same method name and the same query struct the paged table generates, // so the call reads the same. What differs is that there is no `.await`. assert_eq!( - book.update_partial_top_price(TopPriceQuery { bid: 9.0, ask: 10.0 }, &2), + book.update_by_exchange_id(2, QuotedColumns::BID_AND_ASK, TopPriceQuery { bid: 9.0, ask: 10.0 }), Some(()) ); @@ -1030,7 +1030,7 @@ fn a_dense_partition_carries_its_update_queries() { assert_eq!(row.seq, 7, "a column the query does not name is untouched"); assert_eq!( - book.update_partial_top_price(TopPriceQuery { bid: 0.0, ask: 0.0 }, &3), + book.update_by_exchange_id(3, QuotedColumns::BID_AND_ASK, TopPriceQuery { bid: 0.0, ask: 0.0 }), None, "a key holding no row updates nothing" ); diff --git a/tests/worktable/runtime_backends.rs b/tests/worktable/runtime_backends.rs index 2e22e1bd..d717b6a9 100644 --- a/tests/worktable/runtime_backends.rs +++ b/tests/worktable/runtime_backends.rs @@ -11,7 +11,7 @@ //! The body covers four things, and each one is here because leaving it out //! would let a broken backend pass: //! -//! 1. `insert` / `select` / `update_partial` / `delete` / `update_partial_in_place`, so a backend that +//! 1. `insert` / `select` / `update` / `delete` / `update_in_place`, so a backend that //! compiles but cannot drive a mutation is caught. //! 2. A persisted table opened, written, closed and reloaded, so a backend //! whose spawn or timer never reaches the persistence worker is caught. @@ -92,7 +92,7 @@ macro_rules! runtime_backend_suite { // The in-memory table. Carries an indexed column so `update` and // `delete` have index maintenance to do, and a plain one so - // `update_partial_in_place` has somewhere to write that no index watches. + // `update_in_place` has somewhere to write that no index watches. worktable!( name: RuntimeMatrix, persist: false, @@ -107,13 +107,13 @@ macro_rules! runtime_backend_suite { bucket_idx: bucket, }, queries: { - update_partial: { + update: { BucketById(bucket) by id, }, delete: { ByBucket() by bucket, }, - update_partial_in_place: { + update_in_place: { CounterById(counter) by id, } } @@ -138,10 +138,10 @@ macro_rules! runtime_backend_suite { bucket_idx: bucket, }, queries: { - update_partial: { + update: { PersistBucketById(bucket) by id, }, - update_partial_in_place: { + update_in_place: { PersistCounterById(counter) by id, } } @@ -241,7 +241,7 @@ macro_rules! runtime_backend_suite { // update, which also has to move the row between index buckets table - .update_partial_bucket_by_id(BucketByIdQuery { bucket: 3 }, ids[0]) + .update_by_id(ids[0], RuntimeMatrixColumns::BUCKET, 3) .await .unwrap(); assert_eq!(table.select(ids[0]).unwrap().bucket, 3); @@ -255,7 +255,7 @@ macro_rules! runtime_backend_suite { // republishing the row for _ in 0..64 { table - .update_partial_in_place_counter_by_id(|counter| *counter += 1u64, ids[1]) + .update_in_place_by_id(ids[1], RuntimeMatrixColumns::COUNTER, |counter| *counter += 1u64) .await .unwrap(); } @@ -351,11 +351,11 @@ macro_rules! runtime_backend_suite { table.insert(row(id)).await.unwrap(); } table - .update_partial_persist_bucket_by_id(PersistBucketByIdQuery { bucket: 3 }, 1) + .update_by_id(1, RuntimeMatrixPersistColumns::BUCKET, 3) .await .unwrap(); table - .update_partial_in_place_persist_counter_by_id(|counter| *counter = 4_242u64.into(), 2) + .update_in_place_by_id(2, RuntimeMatrixPersistColumns::COUNTER, |counter| *counter = 4_242u64.into()) .await .unwrap(); diff --git a/tests/worktable/unique_fixed_unsized.rs b/tests/worktable/unique_fixed_unsized.rs index 89789b5a..87388a1c 100644 --- a/tests/worktable/unique_fixed_unsized.rs +++ b/tests/worktable/unique_fixed_unsized.rs @@ -17,7 +17,7 @@ worktable!( code_idx: code unique, }, queries: { - update_partial: { + update: { AmountByCode(amount) by code, } } @@ -37,7 +37,7 @@ async fn unique_keyed_fixed_size_update_on_unsized_row_works() { .unwrap(); table - .update_partial_amount_by_code(AmountByCodeQuery { amount: 55 }, 10) + .update_by_code(10, UniqueFixedUnsizedColumns::AMOUNT, 55) .await .unwrap(); diff --git a/tests/worktable/unsized_.rs b/tests/worktable/unsized_.rs index b418b2ce..0068dfdc 100644 --- a/tests/worktable/unsized_.rs +++ b/tests/worktable/unsized_.rs @@ -24,7 +24,7 @@ worktable! ( another_idx: another, } queries: { - update_partial: { + update: { ExchangeByTest(exchange) by test, ExchangeById(exchange) by id, ExchangeByAbother(exchange) by another, @@ -45,7 +45,7 @@ async fn test_update_string_full_row() { let first_link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); table - .update(TestRow { + .replace(TestRow { id: row.id, test: 1, another: 1, @@ -87,7 +87,7 @@ async fn test_update_string_by_unique() { let row = ExchangeByTestQuery { exchange: "bigger test to test string update".to_string(), }; - table.update_partial_exchange_by_test(row, 1).await.unwrap(); + table.update_by_test(1, TestColumns::EXCHANGE, (row).exchange).await.unwrap(); let row = table.select_by_test(1).unwrap(); @@ -122,7 +122,7 @@ async fn test_update_string_by_pk() { let row = ExchangeByIdQuery { exchange: "bigger test to test string update".to_string(), }; - table.update_partial_exchange_by_id(row, pk).await.unwrap(); + table.update_by_id(pk, TestColumns::EXCHANGE, (row).exchange).await.unwrap(); let row = table.select_by_test(1).unwrap(); @@ -165,7 +165,7 @@ async fn test_update_string_by_non_unique() { let row = ExchangeByAbotherQuery { exchange: "bigger test to test string update".to_string(), }; - table.update_partial_exchange_by_abother(row, 1).await.unwrap(); + table.update_by_another(1, TestColumns::EXCHANGE, (row).exchange).await.unwrap(); let all = table.select_all().execute().unwrap(); @@ -219,12 +219,7 @@ async fn update_many_times() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); table - .update_partial_exchange_by_id( - ExchangeByIdQuery { - exchange: format!("test_{val}"), - }, - id_to_update, - ) + .update_by_id(id_to_update, TestColumns::EXCHANGE, format!("test_{val}")) .await .unwrap(); { @@ -261,12 +256,7 @@ async fn update_parallel() { let val = fastrand::u64(..); let id_to_update = fastrand::i64(1..=100); shared - .update_partial_exchange_by_test( - ExchangeByTestQuery { - exchange: format!("test_{val}"), - }, - id_to_update, - ) + .update_by_test(id_to_update, TestColumns::EXCHANGE, format!("test_{val}")) .await .unwrap(); { @@ -284,12 +274,7 @@ async fn update_parallel() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); table - .update_partial_exchange_by_id( - ExchangeByIdQuery { - exchange: format!("test_{val}"), - }, - id_to_update, - ) + .update_by_id(id_to_update, TestColumns::EXCHANGE, format!("test_{val}")) .await .unwrap(); { @@ -326,7 +311,7 @@ worktable! ( another_idx: another, } queries: { - update_partial: { + update: { ExchangeAndSomeByTest(exchange, some_string) by test, ExchangeAndSomeById(exchange, some_string) by id, ExchangeAgainById(exchange) by id, @@ -356,7 +341,7 @@ async fn test_update_many_strings_by_unique() { exchange: "bigger test to test string update".to_string(), some_string: "some bigger some to test".to_string(), }; - table.update_partial_exchange_and_some_by_test(row, 1).await.unwrap(); + table.update_by_test(1, TestMoreStringsColumns::EXCHANGE_AND_SOME_STRING, row).await.unwrap(); let row = table.select_by_test(1).unwrap(); @@ -396,7 +381,7 @@ async fn test_update_many_strings_by_pk() { exchange: "bigger test to test string update".to_string(), some_string: "some bigger some to test".to_string(), }; - table.update_partial_exchange_and_some_by_id(row, pk).await.unwrap(); + table.update_by_id(pk, TestMoreStringsColumns::EXCHANGE_AND_SOME_STRING, row).await.unwrap(); let row = table.select_by_test(1).unwrap(); @@ -446,7 +431,7 @@ async fn test_update_many_strings_by_non_unique() { exchange: "bigger test to test string update".to_string(), some_string: "some bigger some to test".to_string(), }; - table.update_partial_exchange_and_some_by_another(row, 1).await.unwrap(); + table.update_by_another(1, TestMoreStringsColumns::EXCHANGE_AND_SOME_STRING, row).await.unwrap(); let all = table.select_all().execute().unwrap(); @@ -516,7 +501,7 @@ async fn test_update_many_strings_by_string() { some_string: "some bigger some to test".to_string(), }; table - .update_partial_some_other_by_exchange(row, "test".to_string()) + .update_by_exchange("test".to_string(), TestMoreStringsColumns::SOME_STRING_AND_OTHER_SRTING, row) .await .unwrap(); @@ -582,12 +567,7 @@ async fn update_parallel_more_strings() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); shared - .update_partial_exchange_again_by_id( - ExchangeAgainByIdQuery { - exchange: format!("test_{val}"), - }, - id_to_update, - ) + .update_by_id(id_to_update, TestMoreStringsColumns::EXCHANGE, format!("test_{val}")) .await .unwrap(); { @@ -603,12 +583,7 @@ async fn update_parallel_more_strings() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); table - .update_partial_some_by_id( - SomeByIdQuery { - some_string: format!("some_{val}"), - }, - id_to_update, - ) + .update_by_id(id_to_update, TestMoreStringsColumns::SOME_STRING, format!("some_{val}")) .await .unwrap(); { @@ -655,12 +630,7 @@ async fn update_parallel_more_strings_more_threads() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); shared - .update_partial_exchange_again_by_id( - ExchangeAgainByIdQuery { - exchange: format!("test_{val}"), - }, - id_to_update, - ) + .update_by_id(id_to_update, TestMoreStringsColumns::EXCHANGE, format!("test_{val}")) .await .unwrap(); { @@ -679,7 +649,7 @@ async fn update_parallel_more_strings_more_threads() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); shared - .update_partial_another_by_id(AnotherByIdQuery { another: val }, id_to_update) + .update_by_id(id_to_update, TestMoreStringsColumns::ANOTHER, val) .await .unwrap(); { @@ -692,12 +662,7 @@ async fn update_parallel_more_strings_more_threads() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..=99); table - .update_partial_some_by_id( - SomeByIdQuery { - some_string: format!("some_{val}"), - }, - id_to_update, - ) + .update_by_id(id_to_update, TestMoreStringsColumns::SOME_STRING, format!("some_{val}")) .await .unwrap(); { @@ -750,12 +715,7 @@ async fn update_parallel_more_strings_with_select_non_unique() { let val = fastrand::u8(0..100); let id_to_update = fastrand::u64(0..1000); shared - .update_partial_exchange_again_by_id( - ExchangeAgainByIdQuery { - exchange: format!("test_{val}"), - }, - id_to_update, - ) + .update_by_id(id_to_update, TestMoreStringsColumns::EXCHANGE, format!("test_{val}")) .await .unwrap(); { @@ -774,7 +734,7 @@ async fn update_parallel_more_strings_with_select_non_unique() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..1000); shared - .update_partial_another_by_id(AnotherByIdQuery { another: val }, id_to_update) + .update_by_id(id_to_update, TestMoreStringsColumns::ANOTHER, val) .await .unwrap(); { @@ -889,12 +849,7 @@ async fn update_parallel_more_strings_with_select_unique() { let val = fastrand::u8(0..100); let id_to_update = fastrand::u64(0..1000); shared - .update_partial_exchange_again_by_id( - ExchangeAgainByIdQuery { - exchange: format!("test_{val}"), - }, - id_to_update, - ) + .update_by_id(id_to_update, TestMoreStringsColumns::EXCHANGE, format!("test_{val}")) .await .unwrap(); { @@ -913,7 +868,7 @@ async fn update_parallel_more_strings_with_select_unique() { let val = fastrand::u64(..); let id_to_update = fastrand::u64(0..1000); shared - .update_partial_another_by_id(AnotherByIdQuery { another: val }, id_to_update) + .update_by_id(id_to_update, TestMoreStringsColumns::ANOTHER, val) .await .unwrap(); { diff --git a/tests/worktable/update_delete_race.rs b/tests/worktable/update_delete_race.rs index 1f9d7dd1..911fb18c 100644 --- a/tests/worktable/update_delete_race.rs +++ b/tests/worktable/update_delete_race.rs @@ -12,7 +12,7 @@ worktable!( value: u64, }, queries: { - update_partial: { + update: { NameById(name) by id, } } @@ -45,7 +45,7 @@ async fn concurrent_update_and_delete_never_panics() { } else { "a-longer-replacement-name".to_string() }; - match table.update_partial_name_by_id(NameByIdQuery { name }, 1).await { + match table.update_by_id(1, UpdateDeleteRaceColumns::NAME, name).await { Ok(()) => {} // Deleted concurrently: legal, retried next iteration. Err(WorkTableError::NotFound) => {} diff --git a/tests/worktable/update_in_place_unsized.rs b/tests/worktable/update_in_place_unsized.rs index f837b741..44ba891d 100644 --- a/tests/worktable/update_in_place_unsized.rs +++ b/tests/worktable/update_in_place_unsized.rs @@ -47,7 +47,7 @@ macro_rules! unsized_in_place_suite { balance: f64, }, queries: { - update_partial: { + update: { Payload(payload) by id, Balance(balance) by id, } @@ -81,11 +81,10 @@ macro_rules! unsized_in_place_suite { let before = link_of(&table, 1); table - .update_partial_payload( - PayloadQuery { - payload: "12345678".to_string(), // 8 bytes — same length - }, + .update_by_id( 1, + UnsizedUpdateColumns::PAYLOAD, + "12345678".to_string(), // 8 bytes — same length ) .await .unwrap(); @@ -113,23 +112,13 @@ macro_rules! unsized_in_place_suite { .await.unwrap(); table - .update_partial_payload( - PayloadQuery { - payload: "xy".to_string(), - }, - 1, - ) + .update_by_id(1, UnsizedUpdateColumns::PAYLOAD, "xy".to_string()) .await .unwrap(); assert_eq!(table.select(1).unwrap().payload, "xy"); table - .update_partial_payload( - PayloadQuery { - payload: "much longer payload".to_string(), - }, - 1, - ) + .update_by_id(1, UnsizedUpdateColumns::PAYLOAD, "much longer payload".to_string()) .await .unwrap(); assert_eq!(table.select(1).unwrap().payload, "much longer payload"); @@ -159,12 +148,7 @@ macro_rules! unsized_in_place_suite { tokio::spawn(async move { for i in 0..20_000u64 { table - .update_partial_payload( - PayloadQuery { - payload: format!("{:04}", i % 10000), - }, - 1, - ) + .update_by_id(1, UnsizedUpdateColumns::PAYLOAD, format!("{:04}", i % 10000)) .await .unwrap(); } @@ -220,7 +204,7 @@ macro_rules! unsized_in_place_suite { let before = link_of(&table, 1); table - .update_partial_balance(BalanceQuery { balance: 42.5 }, 1) + .update_by_id(1, UnsizedUpdateColumns::BALANCE, 42.5) .await .unwrap(); @@ -269,7 +253,7 @@ mod opaque_wrapper { secret: WrappedString, }, queries: { - update_partial: { + update: { Secret(secret) by id, Nickname(nickname) by id, } @@ -290,11 +274,10 @@ mod opaque_wrapper { .unwrap(); table - .update_partial_secret( - SecretQuery { - secret: WrappedString("replacement out-of-line secret!!".to_string()), - }, + .update_by_id( 1, + OpaqueWrapperUpdateColumns::SECRET, + WrappedString("replacement out-of-line secret!!".to_string()), ) .await .unwrap(); @@ -319,11 +302,10 @@ mod opaque_wrapper { .unwrap(); table - .update_partial_nickname( - NicknameQuery { - nickname: Some("replacement out-of-line name".to_string()), - }, + .update_by_id( 1, + OpaqueWrapperUpdateColumns::NICKNAME, + Some("replacement out-of-line name".to_string()), ) .await .unwrap(); @@ -346,7 +328,7 @@ mod opaque_wrapper { .unwrap(); table - .update(OpaqueOnlyRow { + .replace(OpaqueOnlyRow { id: 1, secret: WrappedString("replacement out-of-line secret value".to_string()), }) diff --git a/tests/worktable/vec_table.rs b/tests/worktable/vec_table.rs index 1cbb7044..2077bf61 100644 --- a/tests/worktable/vec_table.rs +++ b/tests/worktable/vec_table.rs @@ -1655,7 +1655,7 @@ worktable!( amount_idx: amount unique using arctic, }, queries: { - update_partial: { + update: { StateById(state) by id, StateByOwner(state) by owner, AmountById(amount) by id, @@ -1664,7 +1664,7 @@ worktable!( ById() by id, ByOwner() by owner, }, - update_partial_in_place: { + update_in_place: { Status(state) by id, }, }, @@ -1695,13 +1695,13 @@ fn declared_queries_run_on_a_vec_table() { } // Keyed by the hash primary key: one row. - assert_eq!(table.update_partial_state_by_id(StateByIdQuery { state: 7 }, &3), 1); + assert_eq!(table.update_by_id(3, TicketColumns::STATE, 7), 1); assert_eq!(table.select(&3).expect("present").state, 7); assert_eq!(table.select(&2).expect("present").state, 0, "only one row moved"); // Keyed by a non-unique hash secondary: every row it names. assert_eq!( - table.update_partial_state_by_owner(StateByOwnerQuery { state: 5 }, &1), + table.update_by_owner(1, TicketColumns::STATE, 5), 3, "owner 1 holds ids 1, 3 and 5" ); @@ -1712,10 +1712,7 @@ fn declared_queries_run_on_a_vec_table() { } assert_eq!(table.select(&0).expect("present").amount, 100, "owner 0 untouched"); - assert_eq!( - table.update_partial_amount_by_id(AmountByIdQuery { amount: 999 }, &1), - 1 - ); + assert_eq!(table.update_by_id(1, TicketColumns::AMOUNT, 999), 1); // The unique arctic secondary was repaired without stealing another row's key. assert!( table.select_by_amount(&101).is_none(), @@ -1728,7 +1725,7 @@ fn declared_queries_run_on_a_vec_table() { ); // in_place edits one column through a closure. - assert_eq!(table.update_partial_in_place_status(|s| *s = 42, &0), 1); + assert_eq!(table.update_in_place_by_id(0, TicketColumns::STATE, |s| *s = 42), 1); assert_eq!(table.select(&0).expect("present").state, 42); // Deletes, by the key and by a non-unique secondary. @@ -1822,7 +1819,7 @@ fn vec_declared_query_cannot_steal_another_rows_unique_key() { let before = table.unload().unwrap(); assert!( std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - table.update_partial_amount_by_id(AmountByIdQuery { amount: 101 }, &0); + table.update_by_id(0, TicketColumns::AMOUNT, 101); })) .is_err() ); diff --git a/tests/worktable/with_enum.rs b/tests/worktable/with_enum.rs index bb40db83..2423f564 100644 --- a/tests/worktable/with_enum.rs +++ b/tests/worktable/with_enum.rs @@ -17,7 +17,7 @@ worktable! ( test: SomeEnum }, queries: { - update_partial: { + update: { Test(test) by id, } } @@ -49,7 +49,7 @@ async fn update() { id: 1, test: SomeEnum::Second, }; - table.update(updated.clone()).await.unwrap(); + table.replace(updated.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row, updated); diff --git a/tests/worktable/wrong_row_update.rs b/tests/worktable/wrong_row_update.rs index 1e62bac9..806c3b25 100644 --- a/tests/worktable/wrong_row_update.rs +++ b/tests/worktable/wrong_row_update.rs @@ -15,7 +15,7 @@ worktable!( code_idx: code unique, }, queries: { - update_partial: { + update: { ValueByCode(value) by code, } } @@ -53,11 +53,7 @@ async fn unique_update_does_not_mutate_a_row_that_stole_the_value() { let update = { let table = table.clone(); - tokio::spawn(async move { - table - .update_partial_value_by_code(ValueByCodeQuery { value: 99 }, 10) - .await - }) + tokio::spawn(async move { table.update_by_code(10, WrongRowColumns::VALUE, 99).await }) }; // Wait until the update registered its operation lock (it replaces the From c7594aacb0ebbe358358f5d12608a239650bc8e3 Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 16:38:26 +0700 Subject: [PATCH 13/45] Keep mutation generator tests lint-clean --- codegen/src/generators/in_memory/queries/update.rs | 2 +- codegen/src/generators/persist/queries/update.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/codegen/src/generators/in_memory/queries/update.rs b/codegen/src/generators/in_memory/queries/update.rs index 2716a1fa..4e51eee8 100644 --- a/codegen/src/generators/in_memory/queries/update.rs +++ b/codegen/src/generators/in_memory/queries/update.rs @@ -1262,7 +1262,7 @@ mod tests { }, ); generator.queries = Some(Queries { - updates: updates, + updates, deletes: IndexMap::new(), updates_in_place: IndexMap::new(), ..Default::default() diff --git a/codegen/src/generators/persist/queries/update.rs b/codegen/src/generators/persist/queries/update.rs index 086646ce..72996806 100644 --- a/codegen/src/generators/persist/queries/update.rs +++ b/codegen/src/generators/persist/queries/update.rs @@ -1351,7 +1351,7 @@ mod tests { }, ); generator.set_queries(Queries { - updates: updates, + updates, deletes: IndexMap::new(), updates_in_place: IndexMap::new(), ..Default::default() From 7299aa998f7d1497b259d4fe19ea8d65bf941f93 Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 16:39:36 +0700 Subject: [PATCH 14/45] Update codegen assertions for hidden mutation helpers --- codegen/src/worktable/mod.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/codegen/src/worktable/mod.rs b/codegen/src/worktable/mod.rs index b20ad9d7..f7a0e978 100644 --- a/codegen/src/worktable/mod.rs +++ b/codegen/src/worktable/mod.rs @@ -662,7 +662,7 @@ mod tests { .to_string(); let update = output - .split("pub async fn update_balance") + .split("async fn __wt_update_balance") .nth(1) .expect("generated balance update"); assert!( @@ -699,7 +699,7 @@ mod tests { .to_string(); let update = output - .split("pub async fn update_secret") + .split("async fn __wt_update_secret") .nth(1) .expect("generated opaque-field update"); assert!( @@ -745,7 +745,7 @@ mod tests { .to_string(); let update = output - .split("pub async fn update_display_name") + .split("async fn __wt_update_display_name") .nth(1) .expect("generated optional-string update"); assert!(update.contains("data . update_in_place")); @@ -777,7 +777,7 @@ mod tests { .to_string(); let update = output - .split("pub async fn update_secret") + .split("async fn __wt_update_secret") .nth(1) .expect("generated indexed opaque-field update"); assert!(update.contains("self . reinsert")); @@ -1353,7 +1353,10 @@ mod position_tests { expanded.contains("impl PriceDenseTable"), "the dense payload must be emitted: {expanded}" ); - assert!(expanded.contains("fn update_top_price"), "missing the update query"); + assert!( + expanded.contains("fn __wt_update_top_price"), + "missing the update query" + ); assert!(expanded.contains("fn delete_stale"), "missing the delete query"); } From 72b018f11663cd88ed2b66dbf891cc78ff35001f Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 16:56:12 +0700 Subject: [PATCH 15/45] Support atomic typed mutation field sets --- CHANGELOG.md | 9 +- .../src/generators/in_memory/primary_key.rs | 29 +++- codegen/src/generators/mutation_builder.rs | 153 ++++++++++-------- codegen/src/generators/persist/primary_key.rs | 30 +++- .../src/generators/read_only/primary_key.rs | 29 +++- codegen/src/generators/vec_table/mod.rs | 42 +++-- docs/magic.md | 6 +- docs/queries.md | 8 +- docs/wt-user-guide.typ | 23 ++- src/persistence/operation/batch.rs | 2 +- tests/persistence/in_place_durability.rs | 13 +- tests/persistence/mod.rs | 1 + .../u128_primary_index_capacity.rs | 113 +++++++++++++ tests/worktable/duplicate_pk_index_update.rs | 37 +++++ tests/worktable/in_place.rs | 27 ++++ tests/worktable/mod.rs | 1 + tests/worktable/vec_table.rs | 16 ++ 17 files changed, 445 insertions(+), 94 deletions(-) create mode 100644 tests/persistence/u128_primary_index_capacity.rs create mode 100644 tests/worktable/duplicate_pk_index_update.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 314dcb43..7178bfe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ Change Log `update_by_id(id, InvoiceColumns::AMOUNT, value)` and `update_in_place_by_id(id, InvoiceColumns::STATE, edit)`. Multi-column declarations expose one selector for their exact atomic field set and take - the generated query struct. Full-row replacement is now `replace(row)`. + the generated query struct. Multi-column `update_in_place` declarations pass + a tuple of mutable archived fields to one closure, preserving the declared + atomic field set. Full-row replacement is now `replace(row)`. ### Added @@ -21,6 +23,11 @@ Change Log ### Fixed +- Generated fixed-size primary-key wrappers now report their actual archived + alignment. A `u128` primary key previously under-budgeted each persisted WTI + entry, producing an 18,518-byte index archive for a 16,356-byte default inner + page; the corrected 16-byte alignment selects a capacity whose archive fits. + - Dropping a persisted table with queued writes now joins its private writer before the final handle disappears. An immediate same-path reopen can no longer race detached writes and observe a partial store or torn index header. diff --git a/codegen/src/generators/in_memory/primary_key.rs b/codegen/src/generators/in_memory/primary_key.rs index fcdd0d17..33ba0002 100644 --- a/codegen/src/generators/in_memory/primary_key.rs +++ b/codegen/src/generators/in_memory/primary_key.rs @@ -121,6 +121,33 @@ impl InMemoryGenerator { } }; + // `data_bucket_derive::SizeMeasure` currently reports only 8-byte + // field alignment. A generated newtype around `u128` therefore says + // its archived alignment is unknown even though rkyv aligns it to 16. + // Index page capacity then budgets 28 bytes per entry while the + // archive writes 32, overflowing a default page. Measure the generated + // wrapper directly so its storage model follows rkyv's actual layout. + let field_indexes = (0..types.len()).map(syn::Index::from).collect::>(); + let size_measure_impl = quote! { + impl worktable::prelude::SizeMeasurable for #ident { + fn aligned_size(&self) -> usize { + let len = #( + worktable::prelude::SizeMeasurable::aligned_size(&self.#field_indexes) + +)* 0; + let alignment = core::mem::align_of::< + ::Archived + >().max(8); + let remainder = len % alignment; + if remainder == 0 { len } else { len + alignment - remainder } + } + + fn align() -> Option { + Some(core::mem::align_of::< + ::Archived + >()) + } + } + }; Ok(quote! { #[derive( Clone, @@ -135,7 +162,6 @@ impl InMemoryGenerator { PartialEq, PartialOrd, Ord, - SizeMeasure, MemStat, #unsized_derive )] @@ -145,6 +171,7 @@ impl InMemoryGenerator { #from_impl #into_impl + #size_measure_impl #borrowed_impl #backend_impl diff --git a/codegen/src/generators/mutation_builder.rs b/codegen/src/generators/mutation_builder.rs index 754d3b24..580a0ab5 100644 --- a/codegen/src/generators/mutation_builder.rs +++ b/codegen/src/generators/mutation_builder.rs @@ -213,17 +213,28 @@ fn vec_updates_in_place( let trait_ident = format_ident!("{name}VecUpdateInPlaceBy{by_pascal}"); let selector_pascal = selector_name(operation).from_case(Case::Snake).to_case(Case::Pascal); let selector = format_ident!("{name}{selector_pascal}Selector"); - let column = &operation.columns[0]; - let column_type = columns - .columns_map - .get(column) - .ok_or_else(|| syn::Error::new(column.span(), format!("no column `{column}`")))?; + let fields = &operation.columns; + let field_types = fields + .iter() + .map(|field| { + columns + .columns_map + .get(field) + .ok_or_else(|| syn::Error::new(field.span(), format!("no column `{field}`"))) + }) + .collect::>>()?; + let closure_arg = if field_types.len() == 1 { + let ty = field_types[0]; + quote! { &mut #ty } + } else { + quote! { ( #(&mut #field_types),* ) } + }; let hidden = format_ident!( "__wt_update_in_place_{}", query_name.to_string().from_case(Case::Pascal).to_case(Case::Snake) ); implementations.push(quote! { - impl #trait_ident for #selector where F: FnMut(&mut #column_type) { + impl #trait_ident for #selector where F: FnMut(#closure_arg) { type Key = #by_type; fn apply(self, table: &mut #table, key: &Self::Key, edit: F) -> usize { table.#hidden(edit, key) @@ -277,7 +288,8 @@ fn paged_updates( .columns_map .get(by) .ok_or_else(|| syn::Error::new(by.span(), format!("no column `{by}`")))?; - let key_type = if columns.primary_keys.contains(by) { + let key_type = if columns.primary_keys.contains(by) && !columns.indexes.values().any(|index| index.field == *by) + { let primary_key = format_ident!("{name}PrimaryKey"); quote! { #primary_key } } else { @@ -325,7 +337,8 @@ fn paged_updates( .columns_map .get(&by) .ok_or_else(|| syn::Error::new(by.span(), format!("no column `{by}`")))?; - let key_type = if columns.primary_keys.contains(&by) { + let key_type = if columns.primary_keys.contains(&by) && !columns.indexes.values().any(|index| index.field == by) + { let primary_key = format_ident!("{name}PrimaryKey"); quote! { #primary_key } } else { @@ -344,31 +357,32 @@ fn paged_updates( } else { quote! { &self } }; - let method_impl = if columns.primary_keys.contains(&by) { - quote! { - impl #table { - pub async fn #method(#receiver, key: K, selector: S, value: V) - -> core::result::Result<(), WorkTableError> - where - S: #trait_ident, - #key_type: From, - { - selector.apply(self, key.into(), value).await + let method_impl = + if columns.primary_keys.contains(&by) && !columns.indexes.values().any(|index| index.field == by) { + quote! { + impl #table { + pub async fn #method(#receiver, key: K, selector: S, value: V) + -> core::result::Result<(), WorkTableError> + where + S: #trait_ident, + #key_type: From, + { + selector.apply(self, key.into(), value).await + } } } - } - } else { - quote! { - impl #table { - pub async fn #method(#receiver, key: #by_type, selector: S, value: V) - -> core::result::Result<(), WorkTableError> - where S: #trait_ident - { - selector.apply(self, key, value).await + } else { + quote! { + impl #table { + pub async fn #method(#receiver, key: #by_type, selector: S, value: V) + -> core::result::Result<(), WorkTableError> + where S: #trait_ident + { + selector.apply(self, key, value).await + } } } - } - }; + }; traits_and_methods.push(quote! { #[doc(hidden)] #[allow(private_bounds)] @@ -397,19 +411,14 @@ fn paged_updates_in_place( let mut implementations = Vec::new(); for (query_name, operation) in operations { - if operation.columns.len() != 1 { - return Err(syn::Error::new( - query_name.span(), - "an update_in_place query must declare exactly one column", - )); - } by_fields.insert(operation.by.clone()); let by = &operation.by; let by_type = columns .columns_map .get(by) .ok_or_else(|| syn::Error::new(by.span(), format!("no column `{by}`")))?; - let key_type = if columns.primary_keys.contains(by) { + let key_type = if columns.primary_keys.contains(by) && !columns.indexes.values().any(|index| index.field == *by) + { let primary_key = format_ident!("{name}PrimaryKey"); quote! { #primary_key } } else { @@ -419,11 +428,25 @@ fn paged_updates_in_place( let trait_ident = format_ident!("{name}UpdateInPlaceBy{by_pascal}"); let selector_pascal = selector_name(operation).from_case(Case::Snake).to_case(Case::Pascal); let selector = format_ident!("{name}{selector_pascal}Selector"); - let column = &operation.columns[0]; - let column_type = columns - .columns_map - .get(column) - .ok_or_else(|| syn::Error::new(column.span(), format!("no column `{column}`")))?; + let field_types = operation + .columns + .iter() + .map(|field| { + columns + .columns_map + .get(field) + .ok_or_else(|| syn::Error::new(field.span(), format!("no column `{field}`"))) + }) + .collect::>>()?; + let closure_arg = if field_types.len() == 1 { + let ty = field_types[0]; + quote! { &mut <#ty as worktable::prelude::rkyv::Archive>::Archived } + } else { + let archived = field_types.iter().map(|ty| { + quote! { &mut <#ty as worktable::prelude::rkyv::Archive>::Archived } + }); + quote! { ( #(#archived),* ) } + }; let hidden = format_ident!( "__wt_update_in_place_{}", query_name.to_string().from_case(Case::Pascal).to_case(Case::Snake) @@ -440,7 +463,7 @@ fn paged_updates_in_place( }; implementations.push(quote! { impl #trait_ident for #selector - where F: FnMut(&mut <#column_type as worktable::prelude::rkyv::Archive>::Archived) #send + where F: FnMut(#closure_arg) #send { type Key = #key_type; async fn apply(self, table: #table_ref, key: Self::Key, edit: F) @@ -458,7 +481,8 @@ fn paged_updates_in_place( .columns_map .get(&by) .ok_or_else(|| syn::Error::new(by.span(), format!("no column `{by}`")))?; - let key_type = if columns.primary_keys.contains(&by) { + let key_type = if columns.primary_keys.contains(&by) && !columns.indexes.values().any(|index| index.field == by) + { let primary_key = format_ident!("{name}PrimaryKey"); quote! { #primary_key } } else { @@ -477,31 +501,32 @@ fn paged_updates_in_place( } else { quote! { &self } }; - let method_impl = if columns.primary_keys.contains(&by) { - quote! { - impl #table { - pub async fn #method(#receiver, key: K, selector: S, edit: F) - -> worktable::prelude::eyre::Result<()> - where - S: #trait_ident, - #key_type: From, - { - selector.apply(self, key.into(), edit).await + let method_impl = + if columns.primary_keys.contains(&by) && !columns.indexes.values().any(|index| index.field == by) { + quote! { + impl #table { + pub async fn #method(#receiver, key: K, selector: S, edit: F) + -> worktable::prelude::eyre::Result<()> + where + S: #trait_ident, + #key_type: From, + { + selector.apply(self, key.into(), edit).await + } } } - } - } else { - quote! { - impl #table { - pub async fn #method(#receiver, key: #by_type, selector: S, edit: F) - -> worktable::prelude::eyre::Result<()> - where S: #trait_ident - { - selector.apply(self, key, edit).await + } else { + quote! { + impl #table { + pub async fn #method(#receiver, key: #by_type, selector: S, edit: F) + -> worktable::prelude::eyre::Result<()> + where S: #trait_ident + { + selector.apply(self, key, edit).await + } } } - } - }; + }; traits_and_methods.push(quote! { #[doc(hidden)] #[allow(private_bounds)] diff --git a/codegen/src/generators/persist/primary_key.rs b/codegen/src/generators/persist/primary_key.rs index 5d1b4098..f647b1b8 100644 --- a/codegen/src/generators/persist/primary_key.rs +++ b/codegen/src/generators/persist/primary_key.rs @@ -117,6 +117,34 @@ impl PersistGenerator { } }; + // `data_bucket_derive::SizeMeasure` currently reports only 8-byte + // field alignment. A generated newtype around `u128` therefore says + // its archived alignment is unknown even though rkyv aligns it to 16. + // Index page capacity then budgets 28 bytes per entry while the + // archive writes 32, overflowing a default page. Measure the generated + // wrapper directly so its storage model follows rkyv's actual layout. + let field_indexes = (0..types.len()).map(syn::Index::from).collect::>(); + let size_measure_impl = quote! { + impl worktable::prelude::SizeMeasurable for #ident { + fn aligned_size(&self) -> usize { + let len = #( + worktable::prelude::SizeMeasurable::aligned_size(&self.#field_indexes) + +)* 0; + let alignment = core::mem::align_of::< + ::Archived + >().max(8); + let remainder = len % alignment; + if remainder == 0 { len } else { len + alignment - remainder } + } + + fn align() -> Option { + Some(core::mem::align_of::< + ::Archived + >()) + } + } + }; + Ok(quote! { #[derive( Clone, @@ -131,7 +159,6 @@ impl PersistGenerator { PartialEq, PartialOrd, Ord, - SizeMeasure, MemStat, #unsized_derive )] @@ -141,6 +168,7 @@ impl PersistGenerator { #from_impl #into_impl + #size_measure_impl #borrowed_impl #backend_impl diff --git a/codegen/src/generators/read_only/primary_key.rs b/codegen/src/generators/read_only/primary_key.rs index e7ea6bff..fd1f8ec6 100644 --- a/codegen/src/generators/read_only/primary_key.rs +++ b/codegen/src/generators/read_only/primary_key.rs @@ -117,6 +117,33 @@ impl ReadOnlyGenerator { } }; + // `data_bucket_derive::SizeMeasure` currently reports only 8-byte + // field alignment. A generated newtype around `u128` therefore says + // its archived alignment is unknown even though rkyv aligns it to 16. + // Index page capacity then budgets 28 bytes per entry while the + // archive writes 32, overflowing a default page. Measure the generated + // wrapper directly so its storage model follows rkyv's actual layout. + let field_indexes = (0..types.len()).map(syn::Index::from).collect::>(); + let size_measure_impl = quote! { + impl worktable::prelude::SizeMeasurable for #ident { + fn aligned_size(&self) -> usize { + let len = #( + worktable::prelude::SizeMeasurable::aligned_size(&self.#field_indexes) + +)* 0; + let alignment = core::mem::align_of::< + ::Archived + >().max(8); + let remainder = len % alignment; + if remainder == 0 { len } else { len + alignment - remainder } + } + + fn align() -> Option { + Some(core::mem::align_of::< + ::Archived + >()) + } + } + }; Ok(quote! { #[derive( Clone, @@ -131,7 +158,6 @@ impl ReadOnlyGenerator { PartialEq, PartialOrd, Ord, - SizeMeasure, MemStat, #unsized_derive )] @@ -141,6 +167,7 @@ impl ReadOnlyGenerator { #from_impl #into_impl + #size_measure_impl #borrowed_impl #backend_impl diff --git a/codegen/src/generators/vec_table/mod.rs b/codegen/src/generators/vec_table/mod.rs index a5fb5998..86db3358 100644 --- a/codegen/src/generators/vec_table/mod.rs +++ b/codegen/src/generators/vec_table/mod.rs @@ -1504,24 +1504,34 @@ fn gen_queries( for (name, op) in &queries.updates_in_place { let (by_type, unique) = resolve_by(&op.by)?; - if op.columns.len() != 1 { - return Err(syn::Error::new( - name.span(), - "an `update_in_place` query edits exactly one column through a closure. \ - For several columns at once use an `update` query, which takes a \ - struct of them.", - )); - } - let column = &op.columns[0]; - let column_type = columns - .columns_map - .get(column) - .ok_or_else(|| syn::Error::new(column.span(), format!("no column `{column}`")))?; + let fields = &op.columns; + let field_types = fields + .iter() + .map(|field| { + columns + .columns_map + .get(field) + .ok_or_else(|| syn::Error::new(field.span(), format!("no column `{field}`"))) + }) + .collect::>>()?; + let closure_arg = if field_types.len() == 1 { + let ty = field_types[0]; + quote! { &mut #ty } + } else { + quote! { ( #(&mut #field_types),* ) } + }; + let closure_fields = if fields.len() == 1 { + let field = &fields[0]; + quote! { &mut row.#field } + } else { + quote! { ( #(&mut row.#fields),* ) } + }; + let field_set = fields.iter().map(ToString::to_string).collect::>().join(", "); let method = format_ident!("__wt_update_in_place_{}", snake_of(name)); let pick = selected(&op.by, unique); let doc = format!( "`update_in_place {name}` keyed by `{}`.\n\n\ - Hands a cloned candidate's `{column}` to the closure, then validates \ + Hands a cloned candidate's declared field set ({field_set}) to the closure, then validates \ unique keys before replacing the row. Returns how many rows it reached.", op.by ); @@ -1529,13 +1539,13 @@ fn gen_queries( #[doc = #doc] fn #method( &mut self, - mut edit: impl FnMut(&mut #column_type), + mut edit: impl FnMut(#closure_arg), key: &#by_type, ) -> usize { #pick let mut touched = 0usize; for found in keys { - if self.update(&found, |row| edit(&mut row.#column)) { + if self.update(&found, |row| edit(#closure_fields)) { touched += 1; } } diff --git a/docs/magic.md b/docs/magic.md index 7a47f70c..92acfc0a 100644 --- a/docs/magic.md +++ b/docs/magic.md @@ -231,8 +231,10 @@ names no columns. `update_in_place_by_id(id, OrdersColumns::SOME_VALUE, |value| ...)`, which mutates without selecting first. Its locking is internal, so it is safe from several threads without the caller holding anything — which is also why it is a -*different concurrency point* from `update`. **Only `by {pk_field}` is -supported.** +*different concurrency point* from `update`. A multi-column declaration uses +one field-set selector such as `OrdersColumns::STATUS_AND_LAST_USED_AT` and a +tuple closure `|(status, last_used_at)| ...`; the declared set changes under one +row lock. **Only `by {pk_field}` is supported.** ## Selects, which are not declared diff --git a/docs/queries.md b/docs/queries.md index ae6b1917..0d050dd1 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -52,7 +52,7 @@ set. Selector dispatch is sealed, statically typed, and allocation-free. ### `update_in_place` queries -`update_in_place` queries allow you to update a field's value +`update_in_place` queries allow you to update a declared field set without need to select it before query. It is useful for counters, as example, because with internal mutation queries locking logic user's don't need to add explicit locks over `WorkTable` object. So you can safely use `update_in_place` queries in multiple threads simultaneously. @@ -66,12 +66,18 @@ For example: ``` update_in_place: { SomeValueById(some_value) by id, + AmountAndSomeValueById(amount, some_value) by id, } ``` It enables `update_in_place_by_id` for the generated `SomethingColumns::SOME_VALUE` selector. The method takes the lookup value, the selector, and a closure over the mutable archived field value. +For a multi-column declaration the selector preserves the exact atomic field +set and the closure receives a tuple, for example +`update_in_place_by_id(id, SomethingColumns::AMOUNT_AND_SOME_VALUE, +|(amount, some_value)| ...)`. Both fields are edited under one row lock and +persisted as one mutation. ```rust #[tokio::main] diff --git a/docs/wt-user-guide.typ b/docs/wt-user-guide.typ index d77638a8..829123b1 100644 --- a/docs/wt-user-guide.typ +++ b/docs/wt-user-guide.typ @@ -166,7 +166,7 @@ owns and whether an absent key is valid: [`upsert(row)`], [complete `Row`], [insert], [Insert or replace. The caller declares the complete row authoritative. A row selected earlier can overwrite newer fields if it is later passed here.], [`replace(row)`], [complete `Row`], [`NotFound`], [Replace every field of an existing row. It never creates a missing row, but the supplied row is still a complete authoritative snapshot.], [`update_by_(key, Columns::FIELD_SET, value)`], [declared fields], [`NotFound`], [Change only the selector's declared fields. WorkTable rereads under its mutation lock when safe reconstruction needs the complete row, preserving concurrent changes to other fields.], - [`update_in_place_by_(key, Columns::FIELD, closure)`], [one mutable archived field], [`NotFound`], [Directly mutate a declared, unindexed field of an existing row. This is the lowest-work path and is restricted to primary-key lookup.], + [`update_in_place_by_(key, Columns::FIELD_SET, closure)`], [declared mutable archived fields], [`NotFound`], [Directly mutate a declared, unindexed field set of an existing row. This is the lowest-work path and is restricted to primary-key lookup.], ) ] @@ -189,6 +189,7 @@ worktable! ( }, update_in_place: { StateById(state) by id, // only `by ` is supported + AmountAndStateById(amount, state) by id, }, }, ); @@ -201,6 +202,14 @@ column. A one-column update takes that column's Rust value directly: table.update_by_id(1, InvoiceColumns::AMOUNT, 900).await?; table.delete_by_id(1).await?; table.update_in_place_by_id(1, InvoiceColumns::STATE, |state| *state = 2.into()).await?; +table.update_in_place_by_id( + 1, + InvoiceColumns::AMOUNT_AND_STATE, + |(amount, state)| { + *amount = 925.into(); + *state = 3.into(); + }, +).await?; ``` `InvoiceColumns::AMOUNT` is a generated zero-sized selector. Its sealed dispatch @@ -221,9 +230,11 @@ other fields. The macro cannot inspect an external type such as `EncryptedSecret prove whether its archived form contains relative pointers, so unknown custom types take that conservative path. -`update_in_place` mutates without selecting first and locks internally. Use it when the -application can safely edit the archived representation directly, as with a scalar or a -fixed `#[repr(u8)]` enum. Do not copy an archived string, vector or pointer-bearing wrapper +`update_in_place` mutates one declared field set without selecting first and locks internally. +A multi-column declaration passes a tuple of mutable archived fields to one closure, so the +set changes under the same row lock and persistence operation. Use it when the application +can safely edit the archived representation directly, as with scalars or fixed `#[repr(u8)]` +enums. Do not copy an archived string, vector or pointer-bearing wrapper from another buffer into an `update_in_place` closure. Persisted in-place queries enqueue the changed slot bytes; they do not skip durability. @@ -472,7 +483,9 @@ such as `StateById(state) by id` emits `ByOwner() by owner` emits `delete_by_owner(&owner) -> usize`. The return value counts affected rows. `update_in_place: { Status(state) by id }` emits `update_in_place_by_id(id, TicketColumns::STATE, |state| *state = 42) -> usize` -and accepts one column. +for one column. A declaration such as `StateAndRevisionById(state, revision) by id` +uses `TicketColumns::STATE_AND_REVISION` and passes `|(state, revision)| ...` to +one closure. These methods belong to the table, not mutable wrappers on the shared partition set. Vec edits validate a cloned candidate before replacing a row. A primary or unique diff --git a/src/persistence/operation/batch.rs b/src/persistence/operation/batch.rs index 822d18db..22903ec1 100644 --- a/src/persistence/operation/batch.rs +++ b/src/persistence/operation/batch.rs @@ -6,8 +6,8 @@ use core::hash::Hash; use core::marker::PhantomData; use hashbrown::HashMap; +use data_bucket::Link; use data_bucket::page::PageId; -use data_bucket::{Link, SizeMeasurable}; use indexset::cdc::change::ChangeEvent; use indexset::core::pair::Pair; use worktable_codegen::{MemStat, worktable}; diff --git a/tests/persistence/in_place_durability.rs b/tests/persistence/in_place_durability.rs index 4b3599c2..e1ab5004 100644 --- a/tests/persistence/in_place_durability.rs +++ b/tests/persistence/in_place_durability.rs @@ -10,11 +10,13 @@ worktable!( columns: { id: u64 primary_key, counter: u64, + revision: u64, note: String, }, queries: { update_in_place: { CounterById(counter) by id, + CounterAndRevisionById(counter, revision) by id, } } ); @@ -52,12 +54,20 @@ fn in_place_update_survives_reload() { .insert(InPlaceDurabilityRow { id: 1, counter: 10, + revision: 1, note: "row".to_string(), }) .await .unwrap(); table - .update_in_place_by_id(1, InPlaceDurabilityColumns::COUNTER, |counter| *counter = 42u64.into()) + .update_in_place_by_id( + 1, + InPlaceDurabilityColumns::COUNTER_AND_REVISION, + |(counter, revision)| { + *counter = 42u64.into(); + *revision = 2u64.into(); + }, + ) .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -70,6 +80,7 @@ fn in_place_update_survives_reload() { 42, "the in-place update was not persisted" ); + assert_eq!(table.select(1).unwrap().revision, 2); } remove_dir_if_exists(dir.to_string()).await; diff --git a/tests/persistence/mod.rs b/tests/persistence/mod.rs index 157b644f..d8f8533d 100644 --- a/tests/persistence/mod.rs +++ b/tests/persistence/mod.rs @@ -30,6 +30,7 @@ mod sync; mod toc; mod torn_shutdown; mod tuple_primary_key; +mod u128_primary_index_capacity; mod vacuum; #[cfg(feature = "s3-support")] diff --git a/tests/persistence/u128_primary_index_capacity.rs b/tests/persistence/u128_primary_index_capacity.rs new file mode 100644 index 00000000..43086ce7 --- /dev/null +++ b/tests/persistence/u128_primary_index_capacity.rs @@ -0,0 +1,113 @@ +use worktable::prelude::*; +use worktable::worktable; + +use crate::remove_dir_if_exists; + +worktable!( + name: U128PrimaryIndexCapacity, + persist: true, + columns: { + id: u128 primary_key using worktables_index, + value: u64, + }, +); + +worktable!( + name: U8ThenU128KeyLayout, + columns: { + shard: u8 primary_key using worktables_index, + id: u128 primary_key using worktables_index, + value: u64, + }, +); + +worktable!( + name: U128ThenU8KeyLayout, + columns: { + id: u128 primary_key using worktables_index, + shard: u8 primary_key using worktables_index, + value: u64, + }, +); + +worktable!( + name: StringKeyLayout, + columns: { + id: String primary_key using worktables_index, + value: u64, + }, +); + +#[test] +fn generated_key_size_measure_tracks_archived_alignment_and_dynamic_length() { + use data_bucket::{IndexPage, IndexValue, Persistable}; + + macro_rules! assert_default_index_page_fits { + ($key:ty) => {{ + let capacity = get_index_page_size_from_data_length::<$key>(INNER_PAGE_SIZE); + let page = IndexPage::<$key>::new(IndexValue::default(), capacity); + assert!(page.as_bytes().as_ref().len() <= INNER_PAGE_SIZE); + }}; + } + + assert_default_index_page_fits!(U128PrimaryIndexCapacityPrimaryKey); + assert_default_index_page_fits!(U8ThenU128KeyLayoutPrimaryKey); + assert_default_index_page_fits!(U128ThenU8KeyLayoutPrimaryKey); + + assert_eq!(U128PrimaryIndexCapacityPrimaryKey::align(), Some(16)); + assert_eq!(U8ThenU128KeyLayoutPrimaryKey::align(), Some(16)); + assert_eq!(U128ThenU8KeyLayoutPrimaryKey::align(), Some(16)); + + let empty = StringKeyLayoutPrimaryKey(String::new()).aligned_size(); + let populated = StringKeyLayoutPrimaryKey("a dynamic primary key".to_string()).aligned_size(); + assert!( + populated > empty, + "dynamic key length was lost by the generated wrapper" + ); +} + +/// The generated primary-key newtype must report its 16-byte archived +/// alignment. Under-reporting it makes the WTI node larger than its serialized +/// default page before the first batch is written. +#[test] +fn u128_primary_index_fits_and_survives_default_page_persistence() { + let dir = "tests/data/u128_primary_index_capacity"; + let config = DiskConfig::new_with_table_name( + dir, + U128PrimaryIndexCapacityWorkTable::name_snake_case(), + U128PrimaryIndexCapacityWorkTable::version(), + ); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_io() + .enable_time() + .build() + .unwrap(); + + runtime.block_on(async { + remove_dir_if_exists(dir.to_string()).await; + { + let engine = U128PrimaryIndexCapacityPersistenceEngine::new(config.clone()) + .await + .unwrap(); + let table = U128PrimaryIndexCapacityWorkTable::load(engine).await.unwrap(); + for id in 0..64u128 { + table + .insert(U128PrimaryIndexCapacityRow { id, value: id as u64 }) + .await + .unwrap(); + } + table.wait_for_ops().await.unwrap(); + } + { + let engine = U128PrimaryIndexCapacityPersistenceEngine::new(config.clone()) + .await + .unwrap(); + let table = U128PrimaryIndexCapacityWorkTable::load(engine).await.unwrap(); + for id in 0..64u128 { + assert_eq!(table.select(id).unwrap().value, id as u64); + } + } + remove_dir_if_exists(dir.to_string()).await; + }); +} diff --git a/tests/worktable/duplicate_pk_index_update.rs b/tests/worktable/duplicate_pk_index_update.rs new file mode 100644 index 00000000..3702f675 --- /dev/null +++ b/tests/worktable/duplicate_pk_index_update.rs @@ -0,0 +1,37 @@ +use worktable::prelude::*; +use worktable::worktable; + +worktable!( + name: DuplicatePrimaryIndex, + columns: { + id: u64 primary_key, + value: u64, + }, + indexes: { + id_idx: id unique, + }, + queries: { + update: { + ValueById(value) by id, + } + } +); + +/// An explicit index on the primary-key field has routing precedence in the +/// generated update query. Its hidden method accepts the raw indexed type, so +/// typed selector dispatch must not wrap that key as the table primary key. +#[tokio::test] +async fn update_by_primary_key_duplicated_as_unique_index_uses_raw_key() { + let table = DuplicatePrimaryIndexWorkTable::default(); + table + .insert(DuplicatePrimaryIndexRow { id: 7, value: 1 }) + .await + .unwrap(); + + table + .update_by_id(7, DuplicatePrimaryIndexColumns::VALUE, 9) + .await + .unwrap(); + + assert_eq!(table.select(7).unwrap().value, 9); +} diff --git a/tests/worktable/in_place.rs b/tests/worktable/in_place.rs index f187bdc9..d2ae96e5 100644 --- a/tests/worktable/in_place.rs +++ b/tests/worktable/in_place.rs @@ -24,6 +24,7 @@ worktable!( update_in_place: { ValById(val) by id, Val2ById(val2) by id, + ValAndVal2ById(val, val2) by id, } update: { AnotherById(another) by id, @@ -32,6 +33,32 @@ worktable!( } ); +#[tokio::test] +async fn test_update_two_fields_atomically_by_id() -> eyre::Result<()> { + let table = TestWorkTable::default(); + let pk = table + .insert(TestRow { + id: table.get_next_pk().0, + val: 3, + val1: 0, + val2: 5, + another: "another".to_string(), + something: 0, + }) + .await?; + + table + .update_in_place_by_id(pk.0, TestColumns::VAL_AND_VAL2, |(val, val2)| { + *val += 7; + *val2 += 11; + }) + .await?; + + let row = table.select(pk).unwrap(); + assert_eq!((row.val, row.val2), (10, 16)); + Ok(()) +} + #[tokio::test] async fn test_update_val_by_id() -> eyre::Result<()> { let table = TestWorkTable::default(); diff --git a/tests/worktable/mod.rs b/tests/worktable/mod.rs index b2cf318b..9569c329 100644 --- a/tests/worktable/mod.rs +++ b/tests/worktable/mod.rs @@ -10,6 +10,7 @@ mod count; mod custom_pk; mod delete; mod delete_many; +mod duplicate_pk_index_update; mod float; mod in_place; mod index; diff --git a/tests/worktable/vec_table.rs b/tests/worktable/vec_table.rs index 2077bf61..4b45b3fe 100644 --- a/tests/worktable/vec_table.rs +++ b/tests/worktable/vec_table.rs @@ -1649,6 +1649,7 @@ worktable!( owner: u64, state: u8, amount: u64, + revision: u64, }, indexes: { owner_idx: owner using fxhash, @@ -1666,6 +1667,7 @@ worktable!( }, update_in_place: { Status(state) by id, + StateAndRevisionById(state, revision) by id, }, }, ); @@ -1690,6 +1692,7 @@ fn declared_queries_run_on_a_vec_table() { owner: id % 2, state: 0, amount: 100 + id, + revision: 0, }) .expect("fresh"); } @@ -1727,6 +1730,18 @@ fn declared_queries_run_on_a_vec_table() { // in_place edits one column through a closure. assert_eq!(table.update_in_place_by_id(0, TicketColumns::STATE, |s| *s = 42), 1); assert_eq!(table.select(&0).expect("present").state, 42); + assert_eq!( + table.update_in_place_by_id(2, TicketColumns::STATE_AND_REVISION, |(state, revision)| { + *state = 8; + *revision = 1; + }), + 1 + ); + assert_eq!( + (table.select(&2).unwrap().state, table.select(&2).unwrap().revision), + (8, 1) + ); + assert_eq!(table.select_by_amount(&102).unwrap().id, 2); // Deletes, by the key and by a non-unique secondary. assert_eq!(table.delete_by_id(&0), 1); @@ -1813,6 +1828,7 @@ fn vec_declared_query_cannot_steal_another_rows_unique_key() { owner: id, state: 0, amount: 100 + id, + revision: 0, }) .unwrap(); } From 45c015dac7530980e41abf4bad55399c10af2145 Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 17:40:48 +0700 Subject: [PATCH 16/45] Fix persisted index page sizing and batch lookup --- codegen/src/common/name_generator.rs | 11 ++ .../src/generators/in_memory/primary_key.rs | 86 +++++++--- codegen/src/generators/persist/primary_key.rs | 85 +++++++--- codegen/src/generators/persist/table/impls.rs | 4 +- .../src/generators/read_only/primary_key.rs | 86 +++++++--- codegen/src/persist_table/generator/space.rs | 7 +- .../persist_table/generator/space_file/mod.rs | 21 ++- .../generator/space_file/worktable_impls.rs | 9 +- src/persistence/space/index/mod.rs | 21 ++- .../space/index/table_of_contents.rs | 35 ++++ src/persistence/space/index/unsized_.rs | 20 ++- tests/persistence/mod.rs | 1 + .../persistence/space_index/unsized_write.rs | 63 +++++++ tests/persistence/space_index/write.rs | 73 +++++++++ .../u128_primary_index_capacity.rs | 101 ++++++++++++ tests/persistence/uuid_primary_upsert.rs | 155 ++++++++++++++++++ 16 files changed, 696 insertions(+), 82 deletions(-) create mode 100644 tests/persistence/uuid_primary_upsert.rs diff --git a/codegen/src/common/name_generator.rs b/codegen/src/common/name_generator.rs index caeca0db..83b0ed6e 100644 --- a/codegen/src/common/name_generator.rs +++ b/codegen/src/common/name_generator.rs @@ -166,6 +166,17 @@ impl WorktableNameGenerator { quote::quote! { (#page_size - worktable::prelude::GENERAL_HEADER_SIZE) } } + /// Payload budget whose end is aligned for a tail-stored archived key. + /// Variable-width index entries are written backwards from this boundary. + pub fn get_aligned_disk_page_capacity(&self, key_type: &proc_macro2::TokenStream) -> proc_macro2::TokenStream { + let capacity = self.get_disk_page_capacity(); + quote::quote! { + (#capacity - (#capacity % core::mem::align_of::< + <#key_type as worktable::prelude::rkyv::Archive>::Archived + >())) + } + } + pub fn get_page_inner_size_const_ident(&self) -> Ident { let upper_snake_case_name = self.name.from_case(Case::Pascal).to_case(Case::UpperSnake); Ident::new( diff --git a/codegen/src/generators/in_memory/primary_key.rs b/codegen/src/generators/in_memory/primary_key.rs index 33ba0002..389eb637 100644 --- a/codegen/src/generators/in_memory/primary_key.rs +++ b/codegen/src/generators/in_memory/primary_key.rs @@ -59,13 +59,7 @@ impl InMemoryGenerator { }) .collect::>(); - let unsized_derive = if is_unsized_vec(&types.iter().map(|v| v.to_string()).collect::>()) { - quote! { - VariableSizeMeasure, - } - } else { - quote! {} - }; + let is_unsized_key = is_unsized_vec(&types.iter().map(|v| v.to_string()).collect::>()); let (backend_derive, backend_impl) = primary_key_backend_impl(self.columns.primary_index_backend, &ident, types)?; let borrowed_impl = gen_borrowed_primary_key_impl(&ident, types); @@ -121,24 +115,47 @@ impl InMemoryGenerator { } }; - // `data_bucket_derive::SizeMeasure` currently reports only 8-byte - // field alignment. A generated newtype around `u128` therefore says - // its archived alignment is unknown even though rkyv aligns it to 16. - // Index page capacity then budgets 28 bytes per entry while the - // archive writes 32, overflowing a default page. Measure the generated - // wrapper directly so its storage model follows rkyv's actual layout. - let field_indexes = (0..types.len()).map(syn::Index::from).collect::>(); + // `data_bucket_derive::SizeMeasure` reports only 8-byte field + // alignment, so a generated `u128` newtype under-budgets fixed index + // pages. It also cannot derive `VariableSizeMeasure` for a composite + // `(String, u128)` key because the fixed member does not implement the + // variable-size trait. Model the archived wrapper itself: fixed keys + // are exactly its archived root, while String keys add only their + // out-of-line payload to that root. + let string_fields = types + .iter() + .enumerate() + .filter(|(_, ty)| ty.to_string() == "String") + .map(|(index, ty)| (syn::Index::from(index), *ty)) + .collect::>(); + let string_field_indexes = string_fields.iter().map(|(index, _)| index); + let string_field_types = string_fields.iter().map(|(_, ty)| ty); + let aligned_size_body = if is_unsized_key { + quote! { + let len = core::mem::size_of::< + ::Archived + >() #(+ worktable::prelude::SizeMeasurable::aligned_size( + &self.#string_field_indexes + ).saturating_sub(core::mem::size_of::< + <#string_field_types as worktable::prelude::rkyv::Archive>::Archived + >()))*; + let alignment = core::mem::align_of::< + ::Archived + >(); + let remainder = len % alignment; + if remainder == 0 { len } else { len + alignment - remainder } + } + } else { + quote! { + core::mem::size_of::< + ::Archived + >() + } + }; let size_measure_impl = quote! { impl worktable::prelude::SizeMeasurable for #ident { fn aligned_size(&self) -> usize { - let len = #( - worktable::prelude::SizeMeasurable::aligned_size(&self.#field_indexes) - +)* 0; - let alignment = core::mem::align_of::< - ::Archived - >().max(8); - let remainder = len % alignment; - if remainder == 0 { len } else { len + alignment - remainder } + #aligned_size_body } fn align() -> Option { @@ -148,6 +165,29 @@ impl InMemoryGenerator { } } }; + + let variable_size_measure_impl = if is_unsized_key { + let string_field_types = string_fields.iter().map(|(_, ty)| ty); + quote! { + impl worktable::prelude::VariableSizeMeasurable for #ident { + fn aligned_size(length: usize) -> usize { + let len = core::mem::size_of::< + ::Archived + >() #(+ <#string_field_types as worktable::prelude::VariableSizeMeasurable> + ::aligned_size(length).saturating_sub(core::mem::size_of::< + <#string_field_types as worktable::prelude::rkyv::Archive>::Archived + >()))*; + let alignment = core::mem::align_of::< + ::Archived + >(); + let remainder = len % alignment; + if remainder == 0 { len } else { len + alignment - remainder } + } + } + } + } else { + quote! {} + }; Ok(quote! { #[derive( Clone, @@ -163,7 +203,6 @@ impl InMemoryGenerator { PartialOrd, Ord, MemStat, - #unsized_derive )] #[rkyv(crate = worktable::prelude::rkyv)] #[rkyv(derive(PartialEq, Eq, PartialOrd, Ord, Debug))] @@ -172,6 +211,7 @@ impl InMemoryGenerator { #from_impl #into_impl #size_measure_impl + #variable_size_measure_impl #borrowed_impl #backend_impl diff --git a/codegen/src/generators/persist/primary_key.rs b/codegen/src/generators/persist/primary_key.rs index f647b1b8..f3a2c4a1 100644 --- a/codegen/src/generators/persist/primary_key.rs +++ b/codegen/src/generators/persist/primary_key.rs @@ -55,13 +55,7 @@ impl PersistGenerator { .expect("should exist as got from definition") }) .collect::>(); - let unsized_derive = if is_unsized_vec(&types.iter().map(|v| v.to_string()).collect::>()) { - quote! { - VariableSizeMeasure, - } - } else { - quote! {} - }; + let is_unsized_key = is_unsized_vec(&types.iter().map(|v| v.to_string()).collect::>()); let (backend_derive, backend_impl) = primary_key_backend_impl(self.columns.primary_index_backend, &ident, types)?; let borrowed_impl = gen_borrowed_primary_key_impl(&ident, types); @@ -117,24 +111,47 @@ impl PersistGenerator { } }; - // `data_bucket_derive::SizeMeasure` currently reports only 8-byte - // field alignment. A generated newtype around `u128` therefore says - // its archived alignment is unknown even though rkyv aligns it to 16. - // Index page capacity then budgets 28 bytes per entry while the - // archive writes 32, overflowing a default page. Measure the generated - // wrapper directly so its storage model follows rkyv's actual layout. - let field_indexes = (0..types.len()).map(syn::Index::from).collect::>(); + // `data_bucket_derive::SizeMeasure` reports only 8-byte field + // alignment, so a generated `u128` newtype under-budgets fixed index + // pages. It also cannot derive `VariableSizeMeasure` for a composite + // `(String, u128)` key because the fixed member does not implement the + // variable-size trait. Model the archived wrapper itself: fixed keys + // are exactly its archived root, while String keys add only their + // out-of-line payload to that root. + let string_fields = types + .iter() + .enumerate() + .filter(|(_, ty)| ty.to_string() == "String") + .map(|(index, ty)| (syn::Index::from(index), *ty)) + .collect::>(); + let string_field_indexes = string_fields.iter().map(|(index, _)| index); + let string_field_types = string_fields.iter().map(|(_, ty)| ty); + let aligned_size_body = if is_unsized_key { + quote! { + let len = core::mem::size_of::< + ::Archived + >() #(+ worktable::prelude::SizeMeasurable::aligned_size( + &self.#string_field_indexes + ).saturating_sub(core::mem::size_of::< + <#string_field_types as worktable::prelude::rkyv::Archive>::Archived + >()))*; + let alignment = core::mem::align_of::< + ::Archived + >(); + let remainder = len % alignment; + if remainder == 0 { len } else { len + alignment - remainder } + } + } else { + quote! { + core::mem::size_of::< + ::Archived + >() + } + }; let size_measure_impl = quote! { impl worktable::prelude::SizeMeasurable for #ident { fn aligned_size(&self) -> usize { - let len = #( - worktable::prelude::SizeMeasurable::aligned_size(&self.#field_indexes) - +)* 0; - let alignment = core::mem::align_of::< - ::Archived - >().max(8); - let remainder = len % alignment; - if remainder == 0 { len } else { len + alignment - remainder } + #aligned_size_body } fn align() -> Option { @@ -145,6 +162,28 @@ impl PersistGenerator { } }; + let variable_size_measure_impl = if is_unsized_key { + let string_field_types = string_fields.iter().map(|(_, ty)| ty); + quote! { + impl worktable::prelude::VariableSizeMeasurable for #ident { + fn aligned_size(length: usize) -> usize { + let len = core::mem::size_of::< + ::Archived + >() #(+ <#string_field_types as worktable::prelude::VariableSizeMeasurable> + ::aligned_size(length).saturating_sub(core::mem::size_of::< + <#string_field_types as worktable::prelude::rkyv::Archive>::Archived + >()))*; + let alignment = core::mem::align_of::< + ::Archived + >(); + let remainder = len % alignment; + if remainder == 0 { len } else { len + alignment - remainder } + } + } + } + } else { + quote! {} + }; Ok(quote! { #[derive( Clone, @@ -160,7 +199,6 @@ impl PersistGenerator { PartialOrd, Ord, MemStat, - #unsized_derive )] #[rkyv(crate = worktable::prelude::rkyv)] #[rkyv(derive(PartialEq, Eq, PartialOrd, Ord, Debug))] @@ -169,6 +207,7 @@ impl PersistGenerator { #from_impl #into_impl #size_measure_impl + #variable_size_measure_impl #borrowed_impl #backend_impl diff --git a/codegen/src/generators/persist/table/impls.rs b/codegen/src/generators/persist/table/impls.rs index 2348663b..3d16b1d5 100644 --- a/codegen/src/generators/persist/table/impls.rs +++ b/codegen/src/generators/persist/table/impls.rs @@ -248,6 +248,8 @@ impl PersistGenerator { }) .collect::>(); let pk_types_unsized = is_unsized_vec(pk_types); + let pk_tokens = quote! { #pk_type }; + let unsized_node_capacity = name_generator.get_aligned_disk_page_capacity(&pk_tokens); let wti_map = if cfg!(feature = "logical-index-persistence") { quote! { PersistentWtiIndex } } else { @@ -262,7 +264,7 @@ impl PersistGenerator { } else if pk_types_unsized { quote! { inner.primary_index = worktable::prelude::Arc::new(PrimaryIndex::from_map( - #wti_map::<#pk_type, OffsetEqLink<#const_name>, UnsizedNode<_>>::with_maximum_node_size(#node_capacity) + #wti_map::<#pk_type, OffsetEqLink<#const_name>, UnsizedNode<_>>::with_maximum_node_size(#unsized_node_capacity) )); } } else { diff --git a/codegen/src/generators/read_only/primary_key.rs b/codegen/src/generators/read_only/primary_key.rs index fd1f8ec6..78e6b136 100644 --- a/codegen/src/generators/read_only/primary_key.rs +++ b/codegen/src/generators/read_only/primary_key.rs @@ -55,13 +55,7 @@ impl ReadOnlyGenerator { .expect("should exist as got from definition") }) .collect::>(); - let unsized_derive = if is_unsized_vec(&types.iter().map(|v| v.to_string()).collect::>()) { - quote! { - VariableSizeMeasure, - } - } else { - quote! {} - }; + let is_unsized_key = is_unsized_vec(&types.iter().map(|v| v.to_string()).collect::>()); let (backend_derive, backend_impl) = primary_key_backend_impl(self.columns.primary_index_backend, &ident, types)?; let borrowed_impl = gen_borrowed_primary_key_impl(&ident, types); @@ -117,24 +111,47 @@ impl ReadOnlyGenerator { } }; - // `data_bucket_derive::SizeMeasure` currently reports only 8-byte - // field alignment. A generated newtype around `u128` therefore says - // its archived alignment is unknown even though rkyv aligns it to 16. - // Index page capacity then budgets 28 bytes per entry while the - // archive writes 32, overflowing a default page. Measure the generated - // wrapper directly so its storage model follows rkyv's actual layout. - let field_indexes = (0..types.len()).map(syn::Index::from).collect::>(); + // `data_bucket_derive::SizeMeasure` reports only 8-byte field + // alignment, so a generated `u128` newtype under-budgets fixed index + // pages. It also cannot derive `VariableSizeMeasure` for a composite + // `(String, u128)` key because the fixed member does not implement the + // variable-size trait. Model the archived wrapper itself: fixed keys + // are exactly its archived root, while String keys add only their + // out-of-line payload to that root. + let string_fields = types + .iter() + .enumerate() + .filter(|(_, ty)| ty.to_string() == "String") + .map(|(index, ty)| (syn::Index::from(index), *ty)) + .collect::>(); + let string_field_indexes = string_fields.iter().map(|(index, _)| index); + let string_field_types = string_fields.iter().map(|(_, ty)| ty); + let aligned_size_body = if is_unsized_key { + quote! { + let len = core::mem::size_of::< + ::Archived + >() #(+ worktable::prelude::SizeMeasurable::aligned_size( + &self.#string_field_indexes + ).saturating_sub(core::mem::size_of::< + <#string_field_types as worktable::prelude::rkyv::Archive>::Archived + >()))*; + let alignment = core::mem::align_of::< + ::Archived + >(); + let remainder = len % alignment; + if remainder == 0 { len } else { len + alignment - remainder } + } + } else { + quote! { + core::mem::size_of::< + ::Archived + >() + } + }; let size_measure_impl = quote! { impl worktable::prelude::SizeMeasurable for #ident { fn aligned_size(&self) -> usize { - let len = #( - worktable::prelude::SizeMeasurable::aligned_size(&self.#field_indexes) - +)* 0; - let alignment = core::mem::align_of::< - ::Archived - >().max(8); - let remainder = len % alignment; - if remainder == 0 { len } else { len + alignment - remainder } + #aligned_size_body } fn align() -> Option { @@ -144,6 +161,29 @@ impl ReadOnlyGenerator { } } }; + + let variable_size_measure_impl = if is_unsized_key { + let string_field_types = string_fields.iter().map(|(_, ty)| ty); + quote! { + impl worktable::prelude::VariableSizeMeasurable for #ident { + fn aligned_size(length: usize) -> usize { + let len = core::mem::size_of::< + ::Archived + >() #(+ <#string_field_types as worktable::prelude::VariableSizeMeasurable> + ::aligned_size(length).saturating_sub(core::mem::size_of::< + <#string_field_types as worktable::prelude::rkyv::Archive>::Archived + >()))*; + let alignment = core::mem::align_of::< + ::Archived + >(); + let remainder = len % alignment; + if remainder == 0 { len } else { len + alignment - remainder } + } + } + } + } else { + quote! {} + }; Ok(quote! { #[derive( Clone, @@ -159,7 +199,6 @@ impl ReadOnlyGenerator { PartialOrd, Ord, MemStat, - #unsized_derive )] #[rkyv(crate = worktable::prelude::rkyv)] #[rkyv(derive(PartialEq, Eq, PartialOrd, Ord, Debug))] @@ -168,6 +207,7 @@ impl ReadOnlyGenerator { #from_impl #into_impl #size_measure_impl + #variable_size_measure_impl #borrowed_impl #backend_impl diff --git a/codegen/src/persist_table/generator/space.rs b/codegen/src/persist_table/generator/space.rs index fb9ba1df..5cb081c4 100644 --- a/codegen/src/persist_table/generator/space.rs +++ b/codegen/src/persist_table/generator/space.rs @@ -32,7 +32,12 @@ impl Generator { let ident = name_generator.get_persistence_engine_ident(); let primary_key_type = name_generator.get_primary_key_type_ident(); let inner_const_name = name_generator.get_page_inner_size_const_ident(); - let disk_capacity = name_generator.get_disk_page_capacity(); + let key_type = quote! { #primary_key_type }; + let disk_capacity = if self.attributes.pk_unsized { + name_generator.get_aligned_disk_page_capacity(&key_type) + } else { + name_generator.get_disk_page_capacity() + }; let page_const_name = name_generator.get_page_size_const_ident(); let const_name = name_generator.get_page_size_const_ident(); let space_primary_index = name_generator.get_space_primary_index_ident(); diff --git a/codegen/src/persist_table/generator/space_file/mod.rs b/codegen/src/persist_table/generator/space_file/mod.rs index 0da5952f..b832d99b 100644 --- a/codegen/src/persist_table/generator/space_file/mod.rs +++ b/codegen/src/persist_table/generator/space_file/mod.rs @@ -26,8 +26,13 @@ impl Generator { let name_generator = WorktableNameGenerator::from_struct_ident(&self.struct_def.ident); let index_persisted_ident = name_generator.get_persisted_index_ident(); let inner_const_name = name_generator.get_page_inner_size_const_ident(); - let disk_capacity = name_generator.get_disk_page_capacity(); let pk_type = name_generator.get_primary_key_type_ident(); + let pk_tokens = quote! { #pk_type }; + let disk_capacity = if self.attributes.pk_unsized { + name_generator.get_aligned_disk_page_capacity(&pk_tokens) + } else { + name_generator.get_disk_page_capacity() + }; let space_file_ident = name_generator.get_space_file_ident(); let primary_index = if self.attributes.pk_unsized { quote! { @@ -132,8 +137,13 @@ impl Generator { let index_ident = name_generator.get_index_type_ident(); let task_ident = name_generator.get_persistence_task_ident(); let const_name = name_generator.get_page_inner_size_const_ident(); - let node_capacity = name_generator.get_disk_page_capacity(); let pk_type = name_generator.get_primary_key_type_ident(); + let pk_tokens = quote! { #pk_type }; + let node_capacity = if self.attributes.pk_unsized { + name_generator.get_aligned_disk_page_capacity(&pk_tokens) + } else { + name_generator.get_disk_page_capacity() + }; let lock_type = name_generator.get_lock_type_ident(); let table_name = name_generator.get_work_table_literal_name(); let secondary_index_events = name_generator.get_space_secondary_index_events_ident(); @@ -354,7 +364,12 @@ impl Generator { let pk_type = name_generator.get_primary_key_type_ident(); let page_const_name = name_generator.get_page_size_const_ident(); let inner_const_name = name_generator.get_page_inner_size_const_ident(); - let disk_capacity = name_generator.get_disk_page_capacity(); + let pk_tokens = quote! { #pk_type }; + let disk_capacity = if self.attributes.pk_unsized { + name_generator.get_aligned_disk_page_capacity(&pk_tokens) + } else { + name_generator.get_disk_page_capacity() + }; let persisted_index_name = name_generator.get_persisted_index_ident(); let version_const_name = name_generator.get_version_const_ident(); let index_extension = Literal::string(WT_INDEX_EXTENSION); diff --git a/codegen/src/persist_table/generator/space_file/worktable_impls.rs b/codegen/src/persist_table/generator/space_file/worktable_impls.rs index 12c92ccb..9822fa29 100644 --- a/codegen/src/persist_table/generator/space_file/worktable_impls.rs +++ b/codegen/src/persist_table/generator/space_file/worktable_impls.rs @@ -184,8 +184,13 @@ impl Generator { let name_generator = WorktableNameGenerator::from_struct_ident(&self.struct_def.ident); let pk_type = name_generator.get_primary_key_type_ident(); let const_name = name_generator.get_page_inner_size_const_ident(); - let node_capacity = name_generator.get_disk_page_capacity(); - let disk_capacity = name_generator.get_disk_page_capacity(); + let pk_tokens = quote! { #pk_type }; + let node_capacity = if self.attributes.pk_unsized { + name_generator.get_aligned_disk_page_capacity(&pk_tokens) + } else { + name_generator.get_disk_page_capacity() + }; + let disk_capacity = node_capacity.clone(); let page_const_name = name_generator.get_page_size_const_ident(); if self.attributes.pk_congee { // Congee durability is maintained by its native checkpoint/WAL. diff --git a/src/persistence/space/index/mod.rs b/src/persistence/space/index/mod.rs index bc256825..592f1860 100644 --- a/src/persistence/space/index/mod.rs +++ b/src/persistence/space/index/mod.rs @@ -87,6 +87,7 @@ where &self, aliases: &page_aliases::PageAliases, event_page_key: &(T, Link), + event_value: Option<&(T, Link)>, ) -> Option<(PageId, Option<(T, Link)>)> { self.table_of_contents .get(event_page_key) @@ -96,6 +97,17 @@ where .resolve(event_page_key) .map(|(page_id, current_key)| (page_id, Some(current_key.clone()))) }) + .or_else(|| { + let event_value = event_value?; + // Page maxima are identities, so removing a maximum at the + // end of one persistence batch can make the next batch's + // recorded identity stale. Insert/remove events carry the + // affected value, which still identifies the ordered page + // range. Structural events do not, and remain hard errors. + self.table_of_contents + .page_containing(event_value) + .map(|(current_key, page_id)| (page_id, Some(current_key))) + }) } pub async fn new>(index_file_path: S, space_id: SpaceId, version: u32) -> eyre::Result { @@ -478,14 +490,16 @@ where let mut page_aliases = page_aliases::PageAliases::default(); for ev in events { match &ev { - ChangeEvent::InsertAt { max_value, .. } | ChangeEvent::RemoveAt { max_value, .. } => { + ChangeEvent::InsertAt { max_value, value, .. } | ChangeEvent::RemoveAt { max_value, value, .. } => { let event_page_key = (max_value.key.clone(), max_value.value); + let event_value = (value.key.clone(), value.value); // A direct TOC hit means the event key is the page's // canonical pre-event identity. An alias hit carries the // current canonical identity captured when the alias was // installed. This lets us compare the actual post-apply // identity without predicting DataBucket's mutation rules. - let Some((page_index, aliased_page_key)) = self.resolve_batch_page(&page_aliases, &event_page_key) + let Some((page_index, aliased_page_key)) = + self.resolve_batch_page(&page_aliases, &event_page_key, Some(&event_value)) else { // Naming the event and the identity it wanted, not // just the sizes. The counts alone say a lookup failed @@ -579,7 +593,8 @@ where split_index, } => { let event_page_key = (max_value.key.clone(), max_value.value); - let Some((page_index, aliased_page_key)) = self.resolve_batch_page(&page_aliases, &event_page_key) + let Some((page_index, aliased_page_key)) = + self.resolve_batch_page(&page_aliases, &event_page_key, None) else { return Err(eyre!( "index split references a missing page (toc_segments={}, buffered_pages={}, aliases={})", diff --git a/src/persistence/space/index/table_of_contents.rs b/src/persistence/space/index/table_of_contents.rs index 064b6081..106c86a7 100644 --- a/src/persistence/space/index/table_of_contents.rs +++ b/src/persistence/space/index/table_of_contents.rs @@ -74,6 +74,27 @@ where None } + /// Finds the page whose ordered range contains `value` and returns its + /// current maximum identity. CDC events name the maximum observed when the + /// event was created; that identity can become stale at a persistence + /// batch boundary after a preceding max removal re-keyed the page. + pub(crate) fn page_containing(&self, value: &T) -> Option<(T, PageId)> + where + T: Clone, + { + let mut ceiling: Option<(&T, &PageId)> = None; + let mut last: Option<(&T, &PageId)> = None; + for (maximum, page_id) in self.iter() { + if last.is_none_or(|(current, _)| maximum > current) { + last = Some((maximum, page_id)); + } + if maximum >= value && ceiling.is_none_or(|(current, _)| maximum < current) { + ceiling = Some((maximum, page_id)); + } + } + ceiling.or(last).map(|(maximum, page_id)| (maximum.clone(), *page_id)) + } + fn get_current_page_mut(&mut self) -> &mut GeneralPage> { &mut self.pages[self.current_page] } @@ -365,6 +386,20 @@ mod tests { assert_eq!(toc.get(&9), None); } + #[test] + fn page_containing_uses_the_smallest_ceiling_and_the_tail_for_larger_values() { + let mut toc = IndexTableOfContents::::new(0.into(), Arc::new(AtomicU32::new(1))); + toc.insert(40, 4.into()); + toc.insert(10, 1.into()); + toc.insert(25, 2.into()); + + assert_eq!(toc.page_containing(&0), Some((10, 1.into()))); + assert_eq!(toc.page_containing(&10), Some((10, 1.into()))); + assert_eq!(toc.page_containing(&11), Some((25, 2.into()))); + assert_eq!(toc.page_containing(&40), Some((40, 4.into()))); + assert_eq!(toc.page_containing(&41), Some((40, 4.into()))); + } + #[test] fn growing_key_update_moves_the_entry_instead_of_overflowing_the_segment() { const DATA_LENGTH: u32 = 128; diff --git a/src/persistence/space/index/unsized_.rs b/src/persistence/space/index/unsized_.rs index babafe6b..c2fe6fd0 100644 --- a/src/persistence/space/index/unsized_.rs +++ b/src/persistence/space/index/unsized_.rs @@ -66,6 +66,7 @@ where &self, aliases: &PageAliases, event_page_key: &(T, Link), + event_value: Option<&(T, Link)>, ) -> Option<(PageId, Option<(T, Link)>)> { self.table_of_contents .get(event_page_key) @@ -75,6 +76,16 @@ where .resolve(event_page_key) .map(|(page_id, current_key)| (page_id, Some(current_key.clone()))) }) + .or_else(|| { + let event_value = event_value?; + // A maximum removed in the preceding persistence batch can + // leave this event's page identity stale. The affected value + // still selects the ordered page range. Structural events do + // not carry such a value and remain hard errors. + self.table_of_contents + .page_containing(event_value) + .map(|(current_key, page_id)| (page_id, Some(current_key))) + }) } fn compact_page_if_needed(page: &mut UnsizedIndexPage) -> eyre::Result<()> { @@ -489,14 +500,16 @@ where let mut page_aliases = PageAliases::default(); for ev in events { match &ev { - ChangeEvent::InsertAt { max_value, .. } | ChangeEvent::RemoveAt { max_value, .. } => { + ChangeEvent::InsertAt { max_value, value, .. } | ChangeEvent::RemoveAt { max_value, value, .. } => { let event_page_key = (max_value.key.clone(), max_value.value); + let event_value = (value.key.clone(), value.value); // A direct TOC hit means the event key is the page's // canonical pre-event identity. An alias hit carries the // current canonical identity captured when the alias was // installed. This lets us compare the actual post-apply // identity without predicting DataBucket's mutation rules. - let Some((page_index, aliased_page_key)) = self.resolve_batch_page(&page_aliases, &event_page_key) + let Some((page_index, aliased_page_key)) = + self.resolve_batch_page(&page_aliases, &event_page_key, Some(&event_value)) else { return Err(eyre!( "unsized index event references a missing page (toc_segments={}, buffered_pages={}, aliases={})", @@ -574,7 +587,8 @@ where } => { let event_page_key = (max_value.key.clone(), max_value.value); - let Some((page_index, aliased_page_key)) = self.resolve_batch_page(&page_aliases, &event_page_key) + let Some((page_index, aliased_page_key)) = + self.resolve_batch_page(&page_aliases, &event_page_key, None) else { return Err(eyre!( "unsized index split references a missing page (toc_segments={}, buffered_pages={}, aliases={})", diff --git a/tests/persistence/mod.rs b/tests/persistence/mod.rs index d8f8533d..6825ac52 100644 --- a/tests/persistence/mod.rs +++ b/tests/persistence/mod.rs @@ -31,6 +31,7 @@ mod toc; mod torn_shutdown; mod tuple_primary_key; mod u128_primary_index_capacity; +mod uuid_primary_upsert; mod vacuum; #[cfg(feature = "s3-support")] diff --git a/tests/persistence/space_index/unsized_write.rs b/tests/persistence/space_index/unsized_write.rs index da84b980..6be6b31e 100644 --- a/tests/persistence/space_index/unsized_write.rs +++ b/tests/persistence/space_index/unsized_write.rs @@ -552,3 +552,66 @@ async fn test_space_index_process_split_node() { "tests/data/expected/space_index_unsized/process_split_node.wt.idx".to_string() )) } + +#[tokio::test] +async fn max_removed_at_batch_boundary_does_not_orphan_the_next_unsized_event() { + let path = "tests/data/space_index_unsized/cross_batch_max.wt.idx"; + remove_file_if_exists(path.to_string()).await; + + let mut space_index = + SpaceIndexUnsized::::new(path, 0.into(), 1) + .await + .unwrap(); + let pair = |key: &str, offset: u32| Pair { + key: key.to_owned(), + value: Link { + page_id: 0.into(), + offset, + length: 24, + }, + }; + + space_index + .process_change_event_batch(vec![ + ChangeEvent::CreateNode { + event_id: 0.into(), + max_value: pair("30", 30), + }, + ChangeEvent::InsertAt { + event_id: 0.into(), + max_value: pair("30", 30), + value: pair("10", 10), + index: 0, + }, + ChangeEvent::InsertAt { + event_id: 0.into(), + max_value: pair("30", 30), + value: pair("20", 20), + index: 1, + }, + ChangeEvent::RemoveAt { + event_id: 0.into(), + max_value: pair("30", 30), + value: pair("30", 30), + index: 2, + }, + ]) + .await + .unwrap(); + + space_index + .process_change_event_batch(vec![ChangeEvent::InsertAt { + event_id: 0.into(), + max_value: pair("30", 30), + value: pair("25", 25), + index: 2, + }]) + .await + .unwrap(); + + let restored = space_index.parse_indexset().await.unwrap(); + for key in ["10", "20", "25"] { + assert!(restored.contains_key(key), "key {key} must survive cross-batch replay"); + } + assert!(!restored.contains_key("30")); +} diff --git a/tests/persistence/space_index/write.rs b/tests/persistence/space_index/write.rs index 81a0befa..8fe4d116 100644 --- a/tests/persistence/space_index/write.rs +++ b/tests/persistence/space_index/write.rs @@ -632,6 +632,79 @@ async fn batch_split_then_max_remove_then_historical_identity_insert_applies() { ); } +/// A page alias cannot be carried forever: persistence deliberately scopes +/// aliases to one batch. The following batch must recover a stale maximum +/// from the affected value's current ordered page range. +#[tokio::test] +async fn max_removed_at_batch_boundary_does_not_orphan_the_next_event() { + remove_file_if_exists("tests/data/space_index/cross_batch_max.wt.idx".to_string()).await; + + let mut space_index = SpaceIndex::::new( + "tests/data/space_index/cross_batch_max.wt.idx", + 0.into(), + 1, + ) + .await + .unwrap(); + + fn pair(key: u32) -> Pair { + Pair { + key, + value: Link { + page_id: 0.into(), + offset: key, + length: 24, + }, + } + } + + space_index + .process_change_event_batch(vec![ + ChangeEvent::CreateNode { + event_id: 0.into(), + max_value: pair(30), + }, + ChangeEvent::InsertAt { + event_id: 0.into(), + max_value: pair(30), + value: pair(10), + index: 0, + }, + ChangeEvent::InsertAt { + event_id: 0.into(), + max_value: pair(30), + value: pair(20), + index: 1, + }, + ChangeEvent::RemoveAt { + event_id: 0.into(), + max_value: pair(30), + value: pair(30), + index: 2, + }, + ]) + .await + .unwrap(); + + // The source CDC stream was created while this page was still identified + // by 30, but the preceding persisted batch re-keyed it to 20. + space_index + .process_change_event_batch(vec![ChangeEvent::InsertAt { + event_id: 0.into(), + max_value: pair(30), + value: pair(25), + index: 2, + }]) + .await + .unwrap(); + + let restored = space_index.parse_indexset().await.unwrap(); + for key in [10u32, 20, 25] { + assert!(restored.contains_key(&key), "key {key} must survive cross-batch replay"); + } + assert!(!restored.contains_key(&30)); +} + /// End-to-end equivalence: one batch carrying a real CDC stream with node /// splits, maximum removals, and re-inserts must replay into exactly the /// source index. This is the sized twin of the unsized alias machinery's diff --git a/tests/persistence/u128_primary_index_capacity.rs b/tests/persistence/u128_primary_index_capacity.rs index 43086ce7..c0b18e82 100644 --- a/tests/persistence/u128_primary_index_capacity.rs +++ b/tests/persistence/u128_primary_index_capacity.rs @@ -38,6 +38,26 @@ worktable!( }, ); +worktable!( + name: StringThenU128KeyLayout, + persist: true, + columns: { + tenant: String primary_key using worktables_index, + id: u128 primary_key using worktables_index, + value: u64, + }, +); + +worktable!( + name: U128ThenStringKeyLayout, + persist: true, + columns: { + id: u128 primary_key using worktables_index, + tenant: String primary_key using worktables_index, + value: u64, + }, +); + #[test] fn generated_key_size_measure_tracks_archived_alignment_and_dynamic_length() { use data_bucket::{IndexPage, IndexValue, Persistable}; @@ -66,6 +86,87 @@ fn generated_key_size_measure_tracks_archived_alignment_and_dynamic_length() { ); } +#[tokio::test] +async fn mixed_unsized_and_u128_key_orders_survive_persistence() { + let string_first_dir = "tests/data/string_then_u128_primary_index"; + let u128_first_dir = "tests/data/u128_then_string_primary_index"; + remove_dir_if_exists(string_first_dir.to_string()).await; + remove_dir_if_exists(u128_first_dir.to_string()).await; + + let string_first_config = DiskConfig::new_with_table_name( + string_first_dir, + StringThenU128KeyLayoutWorkTable::name_snake_case(), + StringThenU128KeyLayoutWorkTable::version(), + ); + { + let engine = StringThenU128KeyLayoutPersistenceEngine::new(string_first_config.clone()) + .await + .unwrap(); + let table = StringThenU128KeyLayoutWorkTable::load(engine).await.unwrap(); + table + .insert(StringThenU128KeyLayoutRow { + tenant: "tenant-with-a-long-name".to_string(), + id: u128::MAX - 1, + value: 17, + }) + .await + .unwrap(); + table.close().await.unwrap(); + } + { + let engine = StringThenU128KeyLayoutPersistenceEngine::new(string_first_config) + .await + .unwrap(); + let table = StringThenU128KeyLayoutWorkTable::load(engine).await.unwrap(); + assert_eq!( + table + .select(("tenant-with-a-long-name".to_string(), u128::MAX - 1)) + .unwrap() + .value, + 17 + ); + table.close().await.unwrap(); + } + + let u128_first_config = DiskConfig::new_with_table_name( + u128_first_dir, + U128ThenStringKeyLayoutWorkTable::name_snake_case(), + U128ThenStringKeyLayoutWorkTable::version(), + ); + { + let engine = U128ThenStringKeyLayoutPersistenceEngine::new(u128_first_config.clone()) + .await + .unwrap(); + let table = U128ThenStringKeyLayoutWorkTable::load(engine).await.unwrap(); + table + .insert(U128ThenStringKeyLayoutRow { + id: u128::MAX - 2, + tenant: "another-long-tenant-name".to_string(), + value: 23, + }) + .await + .unwrap(); + table.close().await.unwrap(); + } + { + let engine = U128ThenStringKeyLayoutPersistenceEngine::new(u128_first_config) + .await + .unwrap(); + let table = U128ThenStringKeyLayoutWorkTable::load(engine).await.unwrap(); + assert_eq!( + table + .select((u128::MAX - 2, "another-long-tenant-name".to_string())) + .unwrap() + .value, + 23 + ); + table.close().await.unwrap(); + } + + remove_dir_if_exists(string_first_dir.to_string()).await; + remove_dir_if_exists(u128_first_dir.to_string()).await; +} + /// The generated primary-key newtype must report its 16-byte archived /// alignment. Under-reporting it makes the WTI node larger than its serialized /// default page before the first batch is written. diff --git a/tests/persistence/uuid_primary_upsert.rs b/tests/persistence/uuid_primary_upsert.rs new file mode 100644 index 00000000..89e79f50 --- /dev/null +++ b/tests/persistence/uuid_primary_upsert.rs @@ -0,0 +1,155 @@ +use rkyv::{Archive, Deserialize, Serialize}; +use uuid::Uuid; +use worktable::prelude::*; +use worktable::worktable; + +use crate::remove_dir_if_exists; + +#[derive( + Archive, + Clone, + Copy, + Debug, + Default, + Deserialize, + Eq, + Hash, + MemStat, + Ord, + PartialEq, + PartialOrd, + Serialize, + SizeMeasure, +)] +#[rkyv(compare(PartialEq), derive(Clone, Debug, PartialOrd, PartialEq, Eq, Ord))] +struct PersistedPaymentId(Uuid); + +#[derive( + Archive, + Clone, + Copy, + Debug, + Default, + Deserialize, + Eq, + Hash, + MemStat, + Ord, + PartialEq, + PartialOrd, + Serialize, + SizeMeasure, +)] +#[rkyv(compare(PartialEq), derive(Debug, PartialOrd, PartialEq, Eq, Ord))] +struct PersistedAppId(Uuid); + +#[derive(Archive, Clone, Copy, Debug, Deserialize, Eq, Hash, MemStat, Ord, PartialEq, PartialOrd, Serialize)] +#[rkyv(compare(PartialEq), derive(Debug))] +#[repr(u8)] +enum PersistedPaymentStatus { + SetUp, + Confirmed, +} + +worktable! { + name: PersistedUuidPrimaryMutation, + persist: true, + columns: { + id: PersistedPaymentId primary_key using worktables_index, + app_id: PersistedAppId, + endpoint_address: String, + symbol: String, + network: u64 optional, + deposited_amount: String, + goal_amount: String, + goal_amount_usd: String, + status: PersistedPaymentStatus, + created_at: i64, + deposit_until: i64 optional, + completed_at: i64 optional, + }, + indexes: { + symbol_idx: symbol, + endpoint_address_idx: endpoint_address, + app_id_idx: app_id using worktables_index, + created_at_idx: created_at, + }, +} + +fn payment_id(index: usize) -> PersistedPaymentId { + PersistedPaymentId(Uuid::from_u128( + 0x0199_45d2_0000_7000_8000_0000_0000_0000 | index as u128, + )) +} + +fn row(index: usize, status: PersistedPaymentStatus) -> PersistedUuidPrimaryMutationRow { + PersistedUuidPrimaryMutationRow { + id: payment_id(index), + app_id: PersistedAppId(Uuid::from_u128( + 0x66c0_af2b_ef09_4255_86db_9c60_0000_0000 | (index % 32) as u128, + )), + endpoint_address: format!("0x{:040x}", index % 32), + symbol: if index.is_multiple_of(2) { + "usdc".to_owned() + } else { + "pol".to_owned() + }, + network: Some(137), + deposited_amount: format!("{index}.000001"), + goal_amount: format!("{}.025", 100 + index), + goal_amount_usd: format!("{}.50", 25 + index), + status, + created_at: 1_789_000_000 + index as i64, + deposit_until: Some(1_789_000_900 + index as i64), + completed_at: None, + } +} + +#[tokio::test] +async fn pays_shaped_uuid_primary_upserts_survive_batch_boundaries_and_reload() { + const ROWS: usize = 256; + + let directory = std::env::temp_dir().join(format!("worktable-uuid-primary-upsert-{}", Uuid::new_v4())); + let root = directory.to_string_lossy().into_owned(); + remove_dir_if_exists(root.clone()).await; + let config = DiskConfig::new_with_table_name( + &root, + PersistedUuidPrimaryMutationWorkTable::name_snake_case(), + PersistedUuidPrimaryMutationWorkTable::version(), + ); + + { + let engine = PersistedUuidPrimaryMutationPersistenceEngine::new(config.clone()) + .await + .unwrap(); + let table = PersistedUuidPrimaryMutationWorkTable::load(engine).await.unwrap(); + for index in 0..ROWS { + table.insert(row(index, PersistedPaymentStatus::SetUp)).await.unwrap(); + } + table.wait_for_ops().await.unwrap(); + + for index in 0..ROWS { + let permuted = (index * 129) & (ROWS - 1); + table + .upsert(row(permuted, PersistedPaymentStatus::Confirmed)) + .await + .unwrap(); + } + table.wait_for_ops().await.unwrap(); + table.close().await.unwrap(); + } + + let engine = PersistedUuidPrimaryMutationPersistenceEngine::new(config) + .await + .unwrap(); + let table = PersistedUuidPrimaryMutationWorkTable::load(engine).await.unwrap(); + assert_eq!(table.select_all().execute().unwrap().len(), ROWS); + for index in [0, ROWS / 2, ROWS - 1] { + assert_eq!( + table.select(payment_id(index)).unwrap().status, + PersistedPaymentStatus::Confirmed + ); + } + table.close().await.unwrap(); + remove_dir_if_exists(root).await; +} From d4b8aacf96d13625d98fd5e88b484fda835ff332 Mon Sep 17 00:00:00 2001 From: meh Date: Mon, 14 Sep 2026 05:20:33 +0700 Subject: [PATCH 17/45] Document shipping-schema mutation cost in the user guide Record the Pays CustomerPayment 9-sample medians next to the mutation API table so replace, typed update, and in-place stay distinct from upsert, with the string-money rerun caveat. --- docs/wt-user-guide.typ | 57 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/docs/wt-user-guide.typ b/docs/wt-user-guide.typ index 829123b1..fcbede2f 100644 --- a/docs/wt-user-guide.typ +++ b/docs/wt-user-guide.typ @@ -158,18 +158,69 @@ owns and whether an absent key is valid: #text(size: 7.5pt)[ #table( - columns: (1.4fr, 1.1fr, 0.9fr, 3fr), + columns: (1.7fr, 1.05fr, 0.85fr, 2.7fr), stroke: 0.4pt + rgb("#cccccc"), inset: 5pt, [*operation*], [*input*], [*key absent*], [*meaning*], [`insert(row)`], [complete `Row`], [insert], [Create a new row. An existing primary key returns `PrimaryAlreadyExists`; the caller does not authorize replacement.], [`upsert(row)`], [complete `Row`], [insert], [Insert or replace. The caller declares the complete row authoritative. A row selected earlier can overwrite newer fields if it is later passed here.], [`replace(row)`], [complete `Row`], [`NotFound`], [Replace every field of an existing row. It never creates a missing row, but the supplied row is still a complete authoritative snapshot.], - [`update_by_(key, Columns::FIELD_SET, value)`], [declared fields], [`NotFound`], [Change only the selector's declared fields. WorkTable rereads under its mutation lock when safe reconstruction needs the complete row, preserving concurrent changes to other fields.], - [`update_in_place_by_(key, Columns::FIELD_SET, closure)`], [declared mutable archived fields], [`NotFound`], [Directly mutate a declared, unindexed field set of an existing row. This is the lowest-work path and is restricted to primary-key lookup.], + [#stack(spacing: 0.22em, [`update_by_(`], [`key, Columns::`], [`FIELD_SET, value)`])], [declared fields], [`NotFound`], [Change only the selector's declared fields. WorkTable rereads under its mutation lock when safe reconstruction needs the complete row, preserving concurrent changes to other fields.], + [#stack(spacing: 0.22em, [`update_in_place_`], [`by_(key,`], [`Columns::FIELD_SET,`], [`closure)`])], [#stack(spacing: 0.22em, [declared mutable], [archived fields])], [`NotFound`], [Directly mutate a declared, unindexed field set of an existing row. This is the lowest-work path and is restricted to primary-key lookup.], ) ] +#text(size: 7.5pt)[ +#block( + fill: rgb("#f7f7f4"), + inset: 8pt, + radius: 2pt, + width: 100%, + breakable: false, +)[ +*Pays shipping-schema cost, Apple M4 Max, `taskpolicy -b`.* Persisted +`CustomerPayment`: autoincrement `u64` private key, packed 16-character Base62 +`payment_id` public key, four secondary indexes, 32,768 rows, 9 balanced +fresh-process samples. Monetary columns are strings; rerun after typed money. +Base `b1b9546` uses historical `update(row)` / generated query structs. Final +`45c015d` uses `replace` and typed selectors. Strings and custom archived +wrappers take the conservative complete-row `update` path, not in-place. + +#table( + columns: (1.6fr, 1.1fr, 1.1fr, 0.9fr), + stroke: 0.4pt + rgb("#cccccc"), + inset: 4pt, + [*operation*], [*base ns/op*], [*final ns/op*], [*final / in-place*], + [`upsert`], [76175], [74430], [9.83x], + [`replace`], [71118], [69518], [9.18x], + [`update`], [11907], [11561], [1.53x], + [`update_in_place`], [7340], [7571], [1.00x], +) + +#let bar(label, ns, max-ns) = { + let frac = ns / max-ns + grid( + columns: (3.4cm, 1fr, 1.8cm), + column-gutter: 6pt, + text(size: 7pt, raw(label)), + box(width: 100%, height: 7pt, fill: rgb("#e6e6e1"), + box(width: frac * 100%, height: 7pt, fill: rgb("#3a6ea5"))), + align(right, text(size: 7pt, [#ns ns])), + ) +} +#v(0.3em) +*Final `45c015d` median ns/op* +#v(0.15em) +#bar("upsert", 74430, 74430) +#v(0.1em) +#bar("replace", 69518, 74430) +#v(0.1em) +#bar("update", 11561, 74430) +#v(0.1em) +#bar("update_in_place", 7571, 74430) +] +] + Declare targeted updates, deletes and direct archived-field mutations with the table: ```rust From d9b6847195b85db05afcc2a050bceafbac966cc2 Mon Sep 17 00:00:00 2001 From: meh Date: Mon, 14 Sep 2026 12:35:12 +0700 Subject: [PATCH 18/45] Document mutation concurrency, index backends, and range cost Keep ns/op and add application-layer updates/s. Record disjoint-worker scaling, the absence of an update_range primitive, and unique-u64 WTI / Arctic / Congee / in-memory FxHash replace cost on the same campaign. --- docs/wt-user-guide.typ | 67 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/docs/wt-user-guide.typ b/docs/wt-user-guide.typ index fcbede2f..e6693299 100644 --- a/docs/wt-user-guide.typ +++ b/docs/wt-user-guide.typ @@ -187,14 +187,14 @@ Base `b1b9546` uses historical `update(row)` / generated query structs. Final wrappers take the conservative complete-row `update` path, not in-place. #table( - columns: (1.6fr, 1.1fr, 1.1fr, 0.9fr), + columns: (1.45fr, 0.95fr, 0.95fr, 0.95fr, 0.85fr), stroke: 0.4pt + rgb("#cccccc"), inset: 4pt, - [*operation*], [*base ns/op*], [*final ns/op*], [*final / in-place*], - [`upsert`], [76175], [74430], [9.83x], - [`replace`], [71118], [69518], [9.18x], - [`update`], [11907], [11561], [1.53x], - [`update_in_place`], [7340], [7571], [1.00x], + [*operation*], [*base ns/op*], [*final ns/op*], [*final updates/s*], [*vs in-place*], + [`upsert`], [76175], [74430], [13435], [9.83x], + [`replace`], [71118], [69518], [14385], [9.18x], + [`update`], [11907], [11561], [86500], [1.53x], + [`update_in_place`], [7340], [7571], [132082], [1.00x], ) #let bar(label, ns, max-ns) = { @@ -221,6 +221,61 @@ wrappers take the conservative complete-row `update` path, not in-place. ] ] +#text(size: 7.5pt)[ +#block( + fill: rgb("#f7f7f4"), + inset: 8pt, + radius: 2pt, + width: 100%, + breakable: false, +)[ +*Same shipping row, extended campaign, 5 samples, `d4b8aac`.* Indexes on +that table: unique `payment_id` and `app_id` are WTI; non-unique `symbol` and +`endpoint_address` are Arctic. There is no `update_range` primitive. +`range_ids` is consecutive private keys the caller already holds; +`range_scan` is `range_on` then update each. Workers are disjoint keys. +`using fxhash` is refused on a persisted table. + +#table( + columns: (0.7fr, 1.5fr, 1.1fr, 1.2fr), + stroke: 0.4pt + rgb("#cccccc"), + inset: 4pt, + [*workers*], [*mutation*], [*median ns/op*], [*updates/s*], + [1], [`replace`], [49675], [20131], + [8], [`replace`], [40652], [24599], + [12], [`replace`], [42820], [23353], + [1], [`update_in_place`], [6652], [150335], + [8], [`update_in_place`], [5747], [174017], + [12], [`update_in_place`], [6094], [164086], +) + +Eight workers peak. Twelve performance cores do not beat eight. + +#table( + columns: (1.6fr, 1.2fr, 1.2fr), + stroke: 0.4pt + rgb("#cccccc"), + inset: 4pt, + [*range (256 keys)*], [*ns/row*], [*updates/s*], + [`range_ids`], [6264], [159634], + [`range_scan`], [58262], [17164], +) + +#table( + columns: (1.1fr, 1.3fr, 1.1fr, 1.2fr), + stroke: 0.4pt + rgb("#cccccc"), + inset: 4pt, + [*backend*], [*mutation*], [*ns/op*], [*updates/s*], + [WTI persist], [`replace`], [5636], [177444], + [Arctic persist], [`replace`], [3923], [254934], + [Congee persist], [`replace`], [3938], [253928], + [FxHash `vec`], [`upsert`], [56], [17733214], +) + +FxHash is in-memory only. Arctic/Congee persisted `replace` on a unique +`u64` secondary sit together; WTI `replace` is slower on that fixture. +] +] + Declare targeted updates, deletes and direct archived-field mutations with the table: ```rust From becd794b19ebd0165ed90cea7ad98ace5953e8c2 Mon Sep 17 00:00:00 2001 From: meh Date: Mon, 14 Sep 2026 14:07:54 +0700 Subject: [PATCH 19/45] Correct mutation concurrency: background QoS capped cores at 1.3 Eight tokio tasks overlapped. taskpolicy -b still held the process to about one core, which is why 8-worker replace only moved 1.22x. Inherit policy burns ~6 cores for 1.27x replace; in-place loses throughput. --- docs/wt-user-guide.typ | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/wt-user-guide.typ b/docs/wt-user-guide.typ index e6693299..46e45709 100644 --- a/docs/wt-user-guide.typ +++ b/docs/wt-user-guide.typ @@ -236,20 +236,27 @@ that table: unique `payment_id` and `app_id` are WTI; non-unique `symbol` and `range_scan` is `range_on` then update each. Workers are disjoint keys. `using fxhash` is refused on a persisted table. +The first 8-worker numbers were taken under `taskpolicy -b`. Eight tokio +tasks did overlap (`overlap_max=8`) but Darwin background QoS held the +process at ~1.3 cores, so the table looked like "concurrency does almost +nothing". Inherit policy, same JoinSet: + #table( - columns: (0.7fr, 1.5fr, 1.1fr, 1.2fr), + columns: (0.7fr, 1.5fr, 1.0fr, 1.1fr, 0.8fr), stroke: 0.4pt + rgb("#cccccc"), inset: 4pt, - [*workers*], [*mutation*], [*median ns/op*], [*updates/s*], - [1], [`replace`], [49675], [20131], - [8], [`replace`], [40652], [24599], - [12], [`replace`], [42820], [23353], - [1], [`update_in_place`], [6652], [150335], - [8], [`update_in_place`], [5747], [174017], - [12], [`update_in_place`], [6094], [164086], + [*workers*], [*mutation*], [*updates/s*], [*cores*], [*vs 1*], + [1], [`replace`], [139920], [2.00], [1.00x], + [8], [`replace`], [177017], [6.14], [1.27x], + [12], [`replace`], [159189], [7.50], [1.14x], + [1], [`update_in_place`], [714567], [1.95], [1.00x], + [8], [`update_in_place`], [552544], [6.74], [0.77x], + [12], [`update_in_place`], [490803], [9.03], [0.69x], ) -Eight workers peak. Twelve performance cores do not beat eight. +Eight workers burn six cores for 27% more replace/s. In-place gets *worse*. +On a unique `u64` secondary, 1-thread Congee/WTI/Arctic replace are within a +few percent; at 8 workers all three lose ~22% (Congee least-bad, not Arctic). #table( columns: (1.6fr, 1.2fr, 1.2fr), From 512f97c00389e93de3b69a8907316686f22f2fab Mon Sep 17 00:00:00 2001 From: meh Date: Mon, 14 Sep 2026 16:20:30 +0700 Subject: [PATCH 20/45] Shard the row-lock map so disjoint writers do not serialize Every acquire miss and every LockAcquirer drop took a table-wide RwLock write. Eight disjoint in-place workers then used ~6.7 cores and lost throughput. 64 shards, same hash as mutation stripes. --- src/lock/map.rs | 85 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 66 insertions(+), 19 deletions(-) diff --git a/src/lock/map.rs b/src/lock/map.rs index 065a7591..5bd9d469 100644 --- a/src/lock/map.rs +++ b/src/lock/map.rs @@ -12,6 +12,11 @@ use parking_lot::RwLock; use crate::lock::RowLock; const MUTATION_STRIPE_COUNT: usize = 64; +/// Same count as mutation stripes so a key's row-lock shard and mutation +/// stripe are one hash. One global `RwLock` serialized every +/// acquire and drop; 8 disjoint writers then burned ~6 cores for 1.27× +/// replace. Per-shard maps let those writers proceed independently. +const MAP_SHARD_COUNT: usize = MUTATION_STRIPE_COUNT; #[derive(Debug, Default)] struct MutationStripe { @@ -120,15 +125,22 @@ impl Drop for BulkMutationGuard { /// /// # Sync/async lock boundary /// -/// The `parking_lot` map guard is never returned and never crosses an +/// The `parking_lot` shard guard is never returned and never crosses an /// `.await`. Acquisition clones a tracked `Arc>` before -/// releasing the map guard. Cleanup may synchronously take the short-lived map -/// write guard, but only probes the per-row lock with `try_read`; it never waits -/// on a Tokio lock while holding the map. This one-way boundary prevents a -/// map-lock/per-row-lock cycle during cancellation and `Drop`. +/// releasing the shard guard. Cleanup may synchronously take the short-lived +/// shard write guard, but only probes the per-row lock with `try_read`; it never +/// waits on a Tokio lock while holding the shard. This one-way boundary prevents +/// a map-lock/per-row-lock cycle during cancellation and `Drop`. +/// +/// # Sharding +/// +/// The map is `MAP_SHARD_COUNT` independent `RwLock`s, keyed by the +/// same hash as mutation stripes. A single table-wide map lock made every +/// `get_or_insert_with` miss and every `LockAcquirer` drop exclusive against +/// every other row. #[derive(Debug)] pub struct LockMap { - map: RwLock>>, + map: Box<[RwLock>>; MAP_SHARD_COUNT]>, next_id: AtomicU16, mutation_stripes: Arc<[MutationStripe; MUTATION_STRIPE_COUNT]>, bulk_mutations: Arc, @@ -137,7 +149,7 @@ pub struct LockMap { impl Default for LockMap { fn default() -> Self { Self { - map: RwLock::new(HashMap::new()), + map: Box::new(core::array::from_fn(|_| RwLock::new(HashMap::new()))), next_id: AtomicU16::default(), mutation_stripes: Arc::new(core::array::from_fn(|_| MutationStripe::default())), bulk_mutations: Arc::default(), @@ -149,6 +161,18 @@ impl LockMap where PrimaryKey: Hash + Eq + Debug + Clone, { + fn shard( + &self, + key: &PrimaryKey, + ) -> &RwLock>> { + &self.map[Self::stripe_of(key)] + } + + #[cfg(test)] + fn contains_key(&self, key: &PrimaryKey) -> bool { + self.shard(key).read().contains_key(key) + } + /// Inserts a raw lock entry. /// /// A returned or externally retained `Arc` pins cleanup through @@ -160,7 +184,7 @@ where key: PrimaryKey, lock: Arc>, ) -> Option>> { - self.map + self.shard(&key) .write() .insert( key, @@ -175,7 +199,7 @@ where /// Returns an untracked raw lock clone, which keeps the map entry alive /// until that clone is dropped. pub fn get(&self, key: &PrimaryKey) -> Option>> { - self.map.read().get(key).map(|entry| entry.lock.clone()) + self.shard(key).read().get(key).map(|entry| entry.lock.clone()) } /// Returns the lock for `key`, inserting one built by `f` if absent. @@ -197,7 +221,7 @@ where // under the guard, so `remove_with_lock_check` (which needs the write // lock) either runs before we looked or sees our extra strong reference // and keeps the entry. - if let Some(entry) = self.map.read().get(&key) { + if let Some(entry) = self.shard(&key).read().get(&key) { entry.acquirers.fetch_add(1, Ordering::AcqRel); return LockAcquirer { lock: Some(entry.lock.clone()), @@ -206,7 +230,7 @@ where primary_key: key, }; } - let mut map = self.map.write(); + let mut map = self.shard(&key).write(); // Re-check: another task can insert between the read and write guards. let entry = map.entry(key.clone()).or_insert_with(|| LockEntry { lock: Arc::new(nagoya::sync::RwLock::new(f())), @@ -222,14 +246,14 @@ where } pub fn remove(&mut self, key: &PrimaryKey) { - self.map.write().remove(key); + self.shard(key).write().remove(key); } pub fn remove_with_lock_check(&self, key: &PrimaryKey) where LockType: RowLock, { - let mut set = self.map.write(); + let mut set = self.shard(key).write(); let should_remove = set.get(key).is_some_and(|entry| { let Some(guard) = entry.lock.try_read() else { return false; @@ -442,10 +466,10 @@ mod tests { let acquirer = lock_map.get_or_insert_with(31, FullRowLock::new); lock_map.remove_with_lock_check(&31); - assert!(lock_map.map.read().contains_key(&31)); + assert!(lock_map.contains_key(&31)); drop(acquirer); - assert!(!lock_map.map.read().contains_key(&31)); + assert!(!lock_map.contains_key(&31)); } /// Cloning the acquisition handle represents two tasks between lookup and @@ -458,10 +482,10 @@ mod tests { let second = first.clone(); drop(first); - assert!(lock_map.map.read().contains_key(&33)); + assert!(lock_map.contains_key(&33)); drop(second); - assert!(!lock_map.map.read().contains_key(&33)); + assert!(!lock_map.contains_key(&33)); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -477,10 +501,33 @@ mod tests { waiting_task.abort(); assert!(waiting_task.await.unwrap_err().is_cancelled()); - assert!(lock_map.map.read().contains_key(&41)); + assert!(lock_map.contains_key(&41)); drop(owner_guard); drop(owner); - assert!(!lock_map.map.read().contains_key(&41)); + assert!(!lock_map.contains_key(&41)); + } + + /// Disjoint keys must not share a map write lock. Eight threads each + /// acquiring and dropping a private key 10_000 times used to serialize on + /// one `RwLock`; they must complete without deadlock. + #[test] + fn disjoint_keys_do_not_share_a_map_write_lock() { + let lock_map: Arc> = Arc::new(LockMap::default()); + let mut handles = Vec::new(); + for worker in 0..8u64 { + let map = lock_map.clone(); + handles.push(std::thread::spawn(move || { + for step in 0..10_000u64 { + let key = worker << 32 | step; + let acquirer = map.get_or_insert_with(key, FullRowLock::new); + drop(acquirer); + assert!(!map.contains_key(&key)); + } + })); + } + for handle in handles { + handle.join().unwrap(); + } } } From d4e6cdc24fb622d4960bbc9ef3c3aa46ab43d459 Mon Sep 17 00:00:00 2001 From: meh Date: Mon, 14 Sep 2026 20:29:39 +0700 Subject: [PATCH 21/45] Seqlock point select so shared readers do not CAS cell stripes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packed stripe atomics serialized shared select at ~2.4× while vec: true and a private paged table both scaled ~7×. Copy archived bytes, validate the stripe, then deserialize. Pad stripe states to a cache line. --- src/in_memory/data.rs | 101 ++++++++++++++++++++++++++++++++++++++--- src/in_memory/pages.rs | 5 +- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/src/in_memory/data.rs b/src/in_memory/data.rs index 91949143..be7904f0 100644 --- a/src/in_memory/data.rs +++ b/src/in_memory/data.rs @@ -35,9 +35,21 @@ const CELL_LOCK_SLOTS: usize = 256; const CELL_READER_MASK: u32 = (1_u32 << 31) - 1; const CELL_WRITER: u32 = 1 << 31; +/// One stripe's reader/writer count, alone on a cache line. +/// +/// Packed `[AtomicU32; 256]` put 16 stripes on one 64-byte line. Shared +/// `select` then bounced that line across cores and stalled at ~2.4× while a +/// private table (its own `CellLocks`) and `vec: true` both scaled ~7×. +/// Owners stay packed: the uncontended read path never loads them. +#[repr(align(64))] +#[derive(Debug)] +struct PaddedCellState { + value: CellState, +} + #[derive(Debug)] struct CellLocks { - states: [CellState; CELL_LOCK_SLOTS], + states: [PaddedCellState; CELL_LOCK_SLOTS], owners: [CellOwner; CELL_LOCK_SLOTS], nested_reads: CellState, } @@ -45,7 +57,9 @@ struct CellLocks { impl Default for CellLocks { fn default() -> Self { Self { - states: core::array::from_fn(|_| CellState::new(0)), + states: core::array::from_fn(|_| PaddedCellState { + value: CellState::new(0), + }), owners: core::array::from_fn(|_| CellOwner::new(0)), nested_reads: CellState::new(0), } @@ -60,9 +74,11 @@ impl CellLocks { /// when it is created inside an `Arc`, where that copy is unnecessary. unsafe fn initialize_at(target: *mut Self) { unsafe { - let states = core::ptr::addr_of_mut!((*target).states).cast::(); + let states = core::ptr::addr_of_mut!((*target).states).cast::(); for index in 0..CELL_LOCK_SLOTS { - states.add(index).write(CellState::new(0)); + states.add(index).write(PaddedCellState { + value: CellState::new(0), + }); } let owners = core::ptr::addr_of_mut!((*target).owners).cast::(); @@ -133,9 +149,46 @@ impl CellLocks { } } + /// Snapshot the stripe if no writer is in the critical section. + /// + /// Readers do not CAS. Shared `select` was bouncing packed stripe atomics + /// and stalled at ~2.4×; `vec: true` and a private paged table both scale + /// ~7×. The caller copies bytes, then [`Self::still_stable`]. + fn load_stable(&self, link: Link) -> Result { + let index = Self::start(link); + let state = &self.states[index].value; + let mut spins = 0; + loop { + let current = state.load(Ordering::Acquire); + if current & CELL_WRITER == 0 { + return Ok(current); + } + if self.owners[index].load(Ordering::Acquire) == current_owner() { + let writer_key = current & CELL_READER_MASK; + let requested_key = link.offset.checked_add(1).ok_or(ExecutionError::InvalidLink)?; + if writer_key == requested_key { + return Err(ExecutionError::CellLockReentry); + } + if writer_key != 0 { + return Ok(current); + } + } + Self::wait(&mut spins); + } + } + + fn still_stable(&self, link: Link, stamp: u32) -> bool { + if stamp & CELL_WRITER != 0 { + return true; + } + let index = Self::start(link); + let current = self.states[index].value.load(Ordering::Acquire); + current == stamp && current & CELL_WRITER == 0 + } + fn read(&self, link: Link) -> Result, ExecutionError> { let index = Self::start(link); - let state = &self.states[index]; + let state = &self.states[index].value; let mut spins = 0; loop { let current = state.load(Ordering::Acquire); @@ -171,7 +224,7 @@ impl CellLocks { fn write(&self, link: Link) -> Result, ExecutionError> { let index = Self::start(link); - let state = &self.states[index]; + let state = &self.states[index].value; let owner = &self.owners[index]; let mut spins = 0; loop { @@ -207,7 +260,7 @@ impl CellLocks { owner.store(0, Ordering::Relaxed); } for state in &self.states { - state.store(0, Ordering::Release); + state.value.store(0, Ordering::Release); } self.nested_reads.store(0, Ordering::Relaxed); } @@ -578,6 +631,40 @@ impl Data { rkyv::deserialize::<_, rkyv::rancor::Error>(row).map_err(|_| ExecutionError::DeserializeError) } + /// Point-read without a reader CAS on the cell stripe. + /// + /// Copies archived bytes, then checks the stripe still has no writer. + /// Deserialize runs only on that private copy. Rows larger than the stack + /// buffer keep the locking path. + pub fn get_row_seqlock(&self, link: Link) -> Result + where + Row: Archive, + ::Archived: Deserialize>, + { + self.validate_link(link)?; + const STACK: usize = 256; + loop { + let stamp = self.cell_locks.load_stable(link)?; + let inner = unsafe { &*self.inner_data.get() }; + let start = link.offset as usize; + let end = start + link.length as usize; + let len = end - start; + if len > STACK { + let _guard = self.cell_locks.read(link)?; + return self.get_row(link); + } + let mut buf = [0u8; STACK]; + buf[..len].copy_from_slice(&inner[start..end]); + if !self.cell_locks.still_stable(link, stamp) { + continue; + } + let archived = + unsafe { rkyv::access_unchecked::<::Archived>(&buf[..len]) }; + return rkyv::deserialize::<_, rkyv::rancor::Error>(archived) + .map_err(|_| ExecutionError::DeserializeError); + } + } + /// Validates persisted bytes before deserializing them. /// /// The regular in-memory path only reads bytes written by WorkTable in the diff --git a/src/in_memory/pages.rs b/src/in_memory/pages.rs index 0fcf56fa..2833cfbb 100644 --- a/src/in_memory/pages.rs +++ b/src/in_memory/pages.rs @@ -540,8 +540,9 @@ where Portable + Deserialize<::WrappedRow, HighDeserializer>, { let page = self.page_ref(link.page_id)?; - let _cell_guard = page.read_cell(link).map_err(ExecutionError::DataPageError)?; - let wrapped = page.get_row(link).map_err(ExecutionError::DataPageError)?; + let wrapped = page + .get_row_seqlock(link) + .map_err(ExecutionError::DataPageError)?; let flags = Self::publication_flags(&wrapped); Ok((wrapped.get_inner(), flags)) } From 59e99614134f7ea42d9290398772fd092b63fc02 Mon Sep 17 00:00:00 2001 From: meh Date: Mon, 14 Sep 2026 21:12:05 +0700 Subject: [PATCH 22/45] Add select_with so point reads skip cell memcpy and deserialize Owned select still copies an archived cell and builds a Row. Shared readers that only need fields can seqlock-load the archived inner row in place. select_ref keeps a pin-guard snapshot for callers that want a named view. --- .../src/generators/in_memory/table/impls.rs | 15 +++ codegen/src/generators/in_memory/wrapper.rs | 15 ++- codegen/src/generators/persist/table/impls.rs | 15 +++ codegen/src/generators/persist/wrapper.rs | 15 ++- .../src/generators/read_only/table/impls.rs | 15 +++ codegen/src/generators/read_only/wrapper.rs | 15 ++- src/in_memory/data.rs | 83 ++++++++++-- src/in_memory/mod.rs | 5 +- src/in_memory/pages.rs | 122 +++++++++++++++++- src/in_memory/row.rs | 4 + src/lib.rs | 2 +- src/table/mod.rs | 56 +++++++- tests/worktable/base.rs | 21 +++ 13 files changed, 360 insertions(+), 23 deletions(-) diff --git a/codegen/src/generators/in_memory/table/impls.rs b/codegen/src/generators/in_memory/table/impls.rs index e59990fb..e5d0b391 100644 --- a/codegen/src/generators/in_memory/table/impls.rs +++ b/codegen/src/generators/in_memory/table/impls.rs @@ -80,6 +80,21 @@ impl InMemoryGenerator { where #primary_key_type: From { self.0.select(pk.into()) } + + /// Pin-guard plus archived inner row. Does not deserialize. + pub fn select_ref(&self, pk: Pk) -> Option> + where #primary_key_type: From { + self.0.select_ref(pk.into()) + } + + /// Apply `f` to the archived inner row. No cell memcpy; `f` must copy out. + pub fn select_with(&self, pk: Pk, f: F) -> Option + where + #primary_key_type: From, + F: FnMut(&<#row_type as worktable::prelude::rkyv::Archive>::Archived) -> T, + { + self.0.select_with(pk.into(), f) + } } } diff --git a/codegen/src/generators/in_memory/wrapper.rs b/codegen/src/generators/in_memory/wrapper.rs index 84563e42..c6dd7f58 100644 --- a/codegen/src/generators/in_memory/wrapper.rs +++ b/codegen/src/generators/in_memory/wrapper.rs @@ -86,10 +86,21 @@ impl InMemoryGenerator { fn get_archived_wrapper_impl(&self) -> TokenStream { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); - let row_ident = name_generator.get_archived_wrapper_type_ident(); + let archived_ident = name_generator.get_archived_wrapper_type_ident(); + let row_ident = name_generator.get_row_type_ident(); quote! { - impl ArchivedRowWrapper for #row_ident { + impl ArchivedRowWrapper for #archived_ident { + type Inner = <#row_ident as worktable::prelude::rkyv::Archive>::Archived; + + fn inner(&self) -> &Self::Inner { + &self.inner + } + + fn is_ghosted(&self) -> bool { + self.is_ghosted + } + fn unghost(&mut self) { self.is_ghosted = false; } diff --git a/codegen/src/generators/persist/table/impls.rs b/codegen/src/generators/persist/table/impls.rs index 3d16b1d5..bb62a640 100644 --- a/codegen/src/generators/persist/table/impls.rs +++ b/codegen/src/generators/persist/table/impls.rs @@ -386,6 +386,21 @@ impl PersistGenerator { where #primary_key_type: From { self.0.select(pk.into()) } + + /// Pin-guard plus archived inner row. Does not deserialize. + pub fn select_ref(&self, pk: Pk) -> Option> + where #primary_key_type: From { + self.0.select_ref(pk.into()) + } + + /// Apply `f` to the archived inner row. No cell memcpy; `f` must copy out. + pub fn select_with(&self, pk: Pk, f: F) -> Option + where + #primary_key_type: From, + F: FnMut(&<#row_type as worktable::prelude::rkyv::Archive>::Archived) -> T, + { + self.0.select_with(pk.into(), f) + } } } diff --git a/codegen/src/generators/persist/wrapper.rs b/codegen/src/generators/persist/wrapper.rs index a28bd36f..3174aad4 100644 --- a/codegen/src/generators/persist/wrapper.rs +++ b/codegen/src/generators/persist/wrapper.rs @@ -86,10 +86,21 @@ impl PersistGenerator { fn get_archived_wrapper_impl(&self) -> TokenStream { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); - let row_ident = name_generator.get_archived_wrapper_type_ident(); + let archived_ident = name_generator.get_archived_wrapper_type_ident(); + let row_ident = name_generator.get_row_type_ident(); quote! { - impl ArchivedRowWrapper for #row_ident { + impl ArchivedRowWrapper for #archived_ident { + type Inner = <#row_ident as worktable::prelude::rkyv::Archive>::Archived; + + fn inner(&self) -> &Self::Inner { + &self.inner + } + + fn is_ghosted(&self) -> bool { + self.is_ghosted + } + fn unghost(&mut self) { self.is_ghosted = false; } diff --git a/codegen/src/generators/read_only/table/impls.rs b/codegen/src/generators/read_only/table/impls.rs index fdd6a8a1..743c7b84 100644 --- a/codegen/src/generators/read_only/table/impls.rs +++ b/codegen/src/generators/read_only/table/impls.rs @@ -340,6 +340,21 @@ impl ReadOnlyGenerator { where #primary_key_type: From { self.0.select(pk.into()) } + + /// Pin-guard plus archived inner row. Does not deserialize. + pub fn select_ref(&self, pk: Pk) -> Option> + where #primary_key_type: From { + self.0.select_ref(pk.into()) + } + + /// Apply `f` to the archived inner row. No cell memcpy; `f` must copy out. + pub fn select_with(&self, pk: Pk, f: F) -> Option + where + #primary_key_type: From, + F: FnMut(&<#row_type as worktable::prelude::rkyv::Archive>::Archived) -> T, + { + self.0.select_with(pk.into(), f) + } } } diff --git a/codegen/src/generators/read_only/wrapper.rs b/codegen/src/generators/read_only/wrapper.rs index 3a8ead5f..9d953c54 100644 --- a/codegen/src/generators/read_only/wrapper.rs +++ b/codegen/src/generators/read_only/wrapper.rs @@ -86,10 +86,21 @@ impl ReadOnlyGenerator { fn get_archived_wrapper_impl(&self) -> TokenStream { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); - let row_ident = name_generator.get_archived_wrapper_type_ident(); + let archived_ident = name_generator.get_archived_wrapper_type_ident(); + let row_ident = name_generator.get_row_type_ident(); quote! { - impl ArchivedRowWrapper for #row_ident { + impl ArchivedRowWrapper for #archived_ident { + type Inner = <#row_ident as worktable::prelude::rkyv::Archive>::Archived; + + fn inner(&self) -> &Self::Inner { + &self.inner + } + + fn is_ghosted(&self) -> bool { + self.is_ghosted + } + fn unghost(&mut self) { self.is_ghosted = false; } diff --git a/src/in_memory/data.rs b/src/in_memory/data.rs index be7904f0..562ad20d 100644 --- a/src/in_memory/data.rs +++ b/src/in_memory/data.rs @@ -2,6 +2,7 @@ use alloc::{sync::Arc, vec::Vec}; use core::cell::UnsafeCell; use core::fmt::Debug; use core::marker::PhantomData; +use core::mem::MaybeUninit; use core::ops::{Deref, DerefMut}; #[cfg(not(wt_loom))] use core::sync::atomic::{AtomicU32 as CellState, AtomicUsize as CellOwner}; @@ -300,6 +301,30 @@ pub const DATA_HEADER_LENGTH: usize = 4; /// Length of the inner [`Data`] page part. pub const DATA_INNER_LENGTH: usize = INNER_PAGE_SIZE - DATA_HEADER_LENGTH; +const SEQLOCK_STACK: usize = 256; + +/// Seqlock snapshot of one archived cell. +/// +/// Stack for rows that fit in 256 bytes (the shipping fixture). Larger rows +/// take the cell read lock once and copy onto the heap. +pub(crate) enum ArchivedCopy { + Stack { + buf: AlignedBytes, + len: u16, + }, + Heap(AlignedVec), +} + +impl ArchivedCopy { + #[inline] + pub(crate) fn as_bytes(&self) -> &[u8] { + match self { + Self::Stack { buf, len } => &buf.0[..*len as usize], + Self::Heap(bytes) => bytes.as_slice(), + } + } +} + #[derive(Archive, Clone, Copy, Debug, Deserialize, Serialize)] #[repr(C, align(16))] pub struct AlignedBytes(pub [u8; N]); @@ -641,27 +666,59 @@ impl Data { Row: Archive, ::Archived: Deserialize>, { + let copy = self.copy_row_seqlock(link)?; + let archived = + unsafe { rkyv::access_unchecked::<::Archived>(copy.as_bytes()) }; + rkyv::deserialize::<_, rkyv::rancor::Error>(archived).map_err(|_| ExecutionError::DeserializeError) + } + + /// Seqlock-copy archived bytes without deserializing them. + pub(crate) fn copy_row_seqlock(&self, link: Link) -> Result { self.validate_link(link)?; - const STACK: usize = 256; + let len = link.length as usize; + if len > SEQLOCK_STACK { + let _guard = self.cell_locks.read(link)?; + let inner = unsafe { &*self.inner_data.get() }; + let start = link.offset as usize; + let mut heap = AlignedVec::with_capacity(len); + heap.extend_from_slice(&inner[start..start + len]); + return Ok(ArchivedCopy::Heap(heap)); + } loop { let stamp = self.cell_locks.load_stable(link)?; let inner = unsafe { &*self.inner_data.get() }; let start = link.offset as usize; - let end = start + link.length as usize; - let len = end - start; - if len > STACK { - let _guard = self.cell_locks.read(link)?; - return self.get_row(link); - } - let mut buf = [0u8; STACK]; - buf[..len].copy_from_slice(&inner[start..end]); + let mut storage = MaybeUninit::>::uninit(); + let buf = unsafe { &mut *storage.as_mut_ptr() }; + buf.0[..len].copy_from_slice(&inner[start..start + len]); if !self.cell_locks.still_stable(link, stamp) { continue; } - let archived = - unsafe { rkyv::access_unchecked::<::Archived>(&buf[..len]) }; - return rkyv::deserialize::<_, rkyv::rancor::Error>(archived) - .map_err(|_| ExecutionError::DeserializeError); + return Ok(ArchivedCopy::Stack { + buf: unsafe { storage.assume_init() }, + len: len as u16, + }); + } + } + + /// Run `f` on the archived cell under seqlock, without memcpy. + /// + /// `f` must copy out what it needs and must not stash a reference into the + /// page. A concurrent writer can tear the bytes `f` reads; `f` must not + /// follow pointers or otherwise trap. Retry discards a torn result. + pub(crate) fn with_archived_seqlock(&self, link: Link, mut f: F) -> Result + where + Row: Archive, + F: FnMut(&::Archived) -> T, + { + self.validate_link(link)?; + loop { + let stamp = self.cell_locks.load_stable(link)?; + let archived = self.get_row_ref(link)?; + let result = f(archived); + if self.cell_locks.still_stable(link, stamp) { + return Ok(result); + } } } diff --git a/src/in_memory/mod.rs b/src/in_memory/mod.rs index 227cd8c7..e8057d38 100644 --- a/src/in_memory/mod.rs +++ b/src/in_memory/mod.rs @@ -4,6 +4,9 @@ mod pages; mod row; pub use data::{DATA_INNER_LENGTH, Data, ExecutionError as DataExecutionError}; +pub(crate) use data::ArchivedCopy; pub use empty_link_registry::EmptyLinkRegistry; -pub use pages::{DataPages, ExecutionError as PagesExecutionError, ReadGuard as DataPagesReadGuard}; +pub use pages::{ + DataPages, ExecutionError as PagesExecutionError, ReadGuard as DataPagesReadGuard, SelectRef, +}; pub use row::{ArchivedRowWrapper, PublicationSafe, Query, RowWrapper, StorableRow}; diff --git a/src/in_memory/pages.rs b/src/in_memory/pages.rs index 2833cfbb..ba6b4c97 100644 --- a/src/in_memory/pages.rs +++ b/src/in_memory/pages.rs @@ -5,6 +5,7 @@ use alloc::{boxed::Box, vec::Vec}; use arc_swap::ArcSwap; use core::fmt::Debug; use core::marker::PhantomData; +use core::ops::Deref; use core::sync::atomic::{AtomicPtr, AtomicU32, AtomicUsize}; use core::sync::atomic::{AtomicU64, Ordering}; use data_bucket::page::PageId; @@ -27,7 +28,7 @@ use crate::prelude::ArchivedRowWrapper; use crate::util::epoch::EpochDomain; use crate::{ in_memory::{ - DATA_INNER_LENGTH, Data, DataExecutionError, + ArchivedCopy, DATA_INNER_LENGTH, Data, DataExecutionError, row::{RowWrapper, StorableRow}, }, prelude::Link, @@ -316,6 +317,47 @@ pub struct ReadGuard<'a> { marker: PhantomData<&'a ()>, } +/// Point-read that does not deserialize. +/// +/// Holds the epoch pin and a seqlock copy of the archived wrapper. Deref is +/// the archived inner row, so a caller can read fields without building an +/// owned row. Owned [`DataPages::select`] stays for callers that need a `Row`. +/// +/// Not `Send`: the pin belongs to the acquiring thread. +pub struct SelectRef<'a, Row: StorableRow> { + _pin: ReadGuard<'a>, + copy: ArchivedCopy, + _ty: PhantomData, +} + +impl<'a, Row: StorableRow> SelectRef<'a, Row> { + pub(crate) fn new(pin: ReadGuard<'a>, copy: ArchivedCopy) -> Self { + Self { + _pin: pin, + copy, + _ty: PhantomData, + } + } + + #[inline] + fn archived(&self) -> &<::WrappedRow as rkyv::Archive>::Archived { + unsafe { + rkyv::access_unchecked::<<::WrappedRow as rkyv::Archive>::Archived>( + self.copy.as_bytes(), + ) + } + } +} + +impl Deref for SelectRef<'_, Row> { + type Target = <<::WrappedRow as rkyv::Archive>::Archived as ArchivedRowWrapper>::Inner; + + #[inline] + fn deref(&self) -> &Self::Target { + self.archived().inner() + } +} + /// One unit of retired state waiting out its grace period. Reclamation is /// *recycling*, not just freeing: links return to `empty_links` and pages /// return to `empty_pages`. @@ -547,6 +589,60 @@ where Ok((wrapped.get_inner(), flags)) } + /// Seqlock-copy a live (non-ghosted, non-deleted) archived row. + pub(crate) fn copy_non_ghosted(&self, link: Link) -> Result + where + <::WrappedRow as Archive>::Archived: ArchivedRowWrapper, + { + let page = self.page_ref(link.page_id)?; + let copy = page + .copy_row_seqlock(link) + .map_err(ExecutionError::DataPageError)?; + let archived = unsafe { + rkyv::access_unchecked::<<::WrappedRow as Archive>::Archived>( + copy.as_bytes(), + ) + }; + if archived.is_ghosted() { + return Err(ExecutionError::Ghosted); + } + if archived.is_deleted() { + return Err(ExecutionError::Deleted); + } + Ok(copy) + } + + /// Apply `f` to the archived inner row under seqlock, without memcpy. + pub(crate) fn with_non_ghosted(&self, link: Link, mut f: F) -> Result + where + <::WrappedRow as Archive>::Archived: ArchivedRowWrapper, + F: FnMut( + &<<::WrappedRow as Archive>::Archived as ArchivedRowWrapper>::Inner, + ) -> T, + { + let page = self.page_ref(link.page_id)?; + page.with_archived_seqlock(link, |wrapped| { + if wrapped.is_ghosted() { + Err(ExecutionError::Ghosted) + } else if wrapped.is_deleted() { + Err(ExecutionError::Deleted) + } else { + Ok(f(wrapped.inner())) + } + }) + .map_err(ExecutionError::DataPageError)? + } + + /// Point-read that yields a pin-guard plus archived inner row. + pub fn select_ref(&self, link: Link) -> Result, ExecutionError> + where + <::WrappedRow as Archive>::Archived: ArchivedRowWrapper, + { + let pin = self.read_guard(); + let copy = self.copy_non_ghosted(link)?; + Ok(SelectRef::new(pin, copy)) + } + pub fn read_guard(&self) -> ReadGuard<'_> { ReadGuard { _guard: self.epoch.pin(), @@ -1624,6 +1720,16 @@ mod tests { where T: Archive, { + type Inner = T::Archived; + + fn inner(&self) -> &Self::Inner { + &self.inner + } + + fn is_ghosted(&self) -> bool { + self.is_ghosted + } + fn unghost(&mut self) { self.is_ghosted = false } @@ -1676,6 +1782,20 @@ mod tests { assert_eq!(res, row) } + #[test] + fn select_ref_after_unghost() { + let pages = DataPages::::new(); + let row = TestRow { a: 10, b: 20 }; + let link = pages.insert(row).unwrap(); + assert!(pages.select_ref(link).is_err()); + unsafe { + pages.with_mut_ref(link, |archived| archived.unghost()).unwrap(); + } + let view = pages.select_ref(link).unwrap(); + assert_eq!(view.a, 10); + assert_eq!(view.b, 20); + } + #[test] fn select_non_ghosted() { let pages = DataPages::::new(); diff --git a/src/in_memory/row.rs b/src/in_memory/row.rs index 38066fff..a8c0ff7b 100644 --- a/src/in_memory/row.rs +++ b/src/in_memory/row.rs @@ -21,6 +21,10 @@ pub trait RowWrapper { } pub trait ArchivedRowWrapper { + type Inner: ?Sized; + + fn inner(&self) -> &Self::Inner; + fn is_ghosted(&self) -> bool; fn unghost(&mut self); fn set_in_vacuum_process(&mut self); fn delete(&mut self); diff --git a/src/lib.rs b/src/lib.rs index ec48e855..58c757c7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -177,7 +177,7 @@ pub mod prelude { pub use hashbrown::{HashMap, HashSet}; pub use crate::atomic_key_table::AtomicKeyTable; - pub use crate::in_memory::{ArchivedRowWrapper, Data, DataPages, Query, RowWrapper, StorableRow}; + pub use crate::in_memory::{ArchivedRowWrapper, Data, DataPages, Query, RowWrapper, SelectRef, StorableRow}; pub use crate::lock::FullRowLock; pub use crate::lock::{Lock, RowLock}; pub use crate::lock::{LockAcquirer, LockGuard, LockMap, PendingLock}; diff --git a/src/table/mod.rs b/src/table/mod.rs index fffcc077..8c7a6372 100644 --- a/src/table/mod.rs +++ b/src/table/mod.rs @@ -4,7 +4,7 @@ pub mod system_info; #[cfg(feature = "std")] pub mod vacuum; -use crate::in_memory::{ArchivedRowWrapper, DataPages, RowWrapper, StorableRow}; +use crate::in_memory::{ArchivedRowWrapper, DataPages, RowWrapper, SelectRef, StorableRow}; #[cfg(feature = "std")] use crate::persistence::PersistenceLoadError; use crate::persistence::operation::new_operation_uuid; @@ -250,6 +250,60 @@ where None } + /// Point-read that yields a pin-guard plus archived inner row. + /// + /// Skips rkyv deserialize. The guard must outlive any use of the archived + /// fields. Owned [`Self::select`] stays for callers that need a `Row`. + /// Returning the guard copies the archived cell; [`Self::select_with`] + /// keeps that copy on the stack when the caller only needs a field. + pub fn select_ref(&self, pk: PrimaryKey) -> Option> + where + LockType: 'static, + <::WrappedRow as Archive>::Archived: ArchivedRowWrapper, + { + let pin = self.data.read_guard(); + for _ in 0..64 { + let link = self.primary_index.pk_map.lookup_for_select(&pk).map(Into::into)?; + if let Ok(copy) = self.data.copy_non_ghosted(link) { + return Some(SelectRef::new(pin, copy)); + } + + let current_link: Option = self.primary_index.pk_map.lookup_for_select(&pk).map(Into::into); + if current_link == Some(link) { + return None; + } + core::hint::spin_loop(); + } + None + } + + /// Point-read that applies `f` to the archived inner row and returns `f`'s + /// result. No memcpy of the cell; `f` must copy out and not stash a + /// reference into the page. + pub fn select_with(&self, pk: PrimaryKey, mut f: F) -> Option + where + LockType: 'static, + <::WrappedRow as Archive>::Archived: ArchivedRowWrapper, + F: FnMut(&<<::WrappedRow as Archive>::Archived as ArchivedRowWrapper>::Inner) -> T, + { + let _pin = self.data.read_guard(); + for _ in 0..64 { + let link = self.primary_index.pk_map.lookup_for_select(&pk).map(Into::into)?; + match self.data.with_non_ghosted(link, &mut f) { + Ok(value) => return Some(value), + Err(_) => { + let current_link: Option = + self.primary_index.pk_map.lookup_for_select(&pk).map(Into::into); + if current_link == Some(link) { + return None; + } + core::hint::spin_loop(); + } + } + } + None + } + #[cfg_attr(feature = "perf_measurements", performance_measurement(prefix_name = "WorkTable"))] pub fn insert(&self, row: Row) -> Result where diff --git a/tests/worktable/base.rs b/tests/worktable/base.rs index 403c0499..9201d1a8 100644 --- a/tests/worktable/base.rs +++ b/tests/worktable/base.rs @@ -580,6 +580,27 @@ async fn select_multiple_by_exchange() { ) } +#[tokio::test] +async fn select_ref_matches_select() { + let table = TestWorkTable::default(); + let row = TestRow { + id: table.get_next_pk().into(), + test: 1, + another: 1, + exchange: "test".to_string(), + }; + let _ = table.insert(row.clone()).await.unwrap(); + let owned = table.select(row.id).unwrap(); + let view = table.select_ref(row.id).unwrap(); + assert_eq!(owned.another, view.another); + assert_eq!(owned.test, view.test); + let via_with = table + .select_with(row.id, |archived| archived.another) + .unwrap(); + assert_eq!(owned.another, via_with); + assert!(table.select_ref(u64::MAX).is_none()); +} + #[tokio::test] async fn select_by_test() { let table = TestWorkTable::default(); From 8c73ea86a95d1d12d7299bf78e944e1a150a90c6 Mon Sep 17 00:00:00 2001 From: meh Date: Mon, 14 Sep 2026 22:32:34 +0700 Subject: [PATCH 23/45] Give the cell seqlock a version counter A writer releases by storing 0 and load_stable returns 0 for an idle cell, so a write that began and finished inside one reader's window left no trace in the state word. still_stable compared 0 to 0, called the snapshot good, and get_row_seqlock handed rkyv::access_unchecked a buffer copied out of the middle of that write. The counter lives in the padding PaddedCellState already reserved, so it costs no extra line. Bump on write release before clearing the writer bit, and in reset, which also replaces page contents. still_stable keeps load_stable's reentry exception: this task's own write on a different row of the same stripe is not a retry reason, and rejecting it spins forever because only that task can clear the bit. paged_select w8/w1 is 7.78x before and after. --- src/in_memory/data.rs | 98 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 91 insertions(+), 7 deletions(-) diff --git a/src/in_memory/data.rs b/src/in_memory/data.rs index 562ad20d..0964b364 100644 --- a/src/in_memory/data.rs +++ b/src/in_memory/data.rs @@ -46,6 +46,15 @@ const CELL_WRITER: u32 = 1 << 31; #[derive(Debug)] struct PaddedCellState { value: CellState, + /// Monotonic write counter, advanced once per completed write. + /// + /// `value` alone cannot carry the seqlock stamp: a writer releases by + /// storing `0`, which is exactly what an idle cell reads, so a write that + /// begins and ends inside one reader's window leaves no trace in it. The + /// reader would then keep bytes copied out of the middle of that write and + /// hand them to `rkyv::access_unchecked`. The counter shares this cache + /// line, which the padding above already reserved. + version: CellState, } #[derive(Debug)] @@ -60,6 +69,7 @@ impl Default for CellLocks { Self { states: core::array::from_fn(|_| PaddedCellState { value: CellState::new(0), + version: CellState::new(0), }), owners: core::array::from_fn(|_| CellOwner::new(0)), nested_reads: CellState::new(0), @@ -79,6 +89,7 @@ impl CellLocks { for index in 0..CELL_LOCK_SLOTS { states.add(index).write(PaddedCellState { value: CellState::new(0), + version: CellState::new(0), }); } @@ -158,11 +169,15 @@ impl CellLocks { fn load_stable(&self, link: Link) -> Result { let index = Self::start(link); let state = &self.states[index].value; + let version = &self.states[index].version; let mut spins = 0; loop { let current = state.load(Ordering::Acquire); if current & CELL_WRITER == 0 { - return Ok(current); + // Read the counter AFTER observing no writer. A writer that + // starts later bumps it on release, so `still_stable` sees the + // move even though the state word returns to `0`. + return Ok(version.load(Ordering::Acquire)); } if self.owners[index].load(Ordering::Acquire) == current_owner() { let writer_key = current & CELL_READER_MASK; @@ -171,7 +186,10 @@ impl CellLocks { return Err(ExecutionError::CellLockReentry); } if writer_key != 0 { - return Ok(current); + // This task is the writer. It sees its own uncommitted + // bytes by definition; the counter is the stamp it will + // compare against and its own release has not run yet. + return Ok(version.load(Ordering::Acquire)); } } Self::wait(&mut spins); @@ -179,12 +197,36 @@ impl CellLocks { } fn still_stable(&self, link: Link, stamp: u32) -> bool { - if stamp & CELL_WRITER != 0 { - return true; - } let index = Self::start(link); - let current = self.states[index].value.load(Ordering::Acquire); - current == stamp && current & CELL_WRITER == 0 + let state = &self.states[index]; + // Two ways the snapshot dies: a writer is in the critical section now, + // or one completed since [`Self::load_stable`]. The version alone + // catches the second; checking it alone would let a torn copy through + // while a writer is still mid-update. + // + // The exception is this task's own write on a DIFFERENT row of the + // same stripe, which `load_stable` already admits: it is reading rows + // the writer is not touching, and it can never make progress by + // retrying because only it can clear that bit. Rejecting it here + // spins forever (`in_place_callback_can_select_a_colliding_row_ + // without_deadlock`). + let current = state.value.load(Ordering::Acquire); + if current & CELL_WRITER != 0 && !self.writer_is_self_on_other_row(index, link, current) { + return false; + } + state.version.load(Ordering::Acquire) == stamp + } + + /// Is the in-flight writer this same task, working on a different row? + /// + /// Mirrors the reentry arm of [`Self::load_stable`]. A same-row match is + /// `CellLockReentry` there and never reaches a stability check. + fn writer_is_self_on_other_row(&self, index: usize, link: Link, current: u32) -> bool { + if self.owners[index].load(Ordering::Acquire) != current_owner() { + return false; + } + let writer_key = current & CELL_READER_MASK; + writer_key != 0 && Some(writer_key) != link.offset.checked_add(1) } fn read(&self, link: Link) -> Result, ExecutionError> { @@ -252,6 +294,7 @@ impl CellLocks { Ok(CellWriteGuard { state, owner, + version: &self.states[index].version, _not_send: PhantomData, }) } @@ -261,6 +304,10 @@ impl CellLocks { owner.store(0, Ordering::Relaxed); } for state in &self.states { + // Reset replaces page contents, so it counts as a write for + // snapshot readers: bump before clearing, or an in-flight + // `copy_row_seqlock` would accept bytes from across the reset. + state.version.fetch_add(1, Ordering::Release); state.value.store(0, Ordering::Release); } self.nested_reads.store(0, Ordering::Relaxed); @@ -284,6 +331,7 @@ impl Drop for CellReadGuard<'_> { pub(crate) struct CellWriteGuard<'a> { state: &'a CellState, owner: &'a CellOwner, + version: &'a CellState, _not_send: PhantomData<*mut ()>, } @@ -291,6 +339,11 @@ impl Drop for CellWriteGuard<'_> { #[inline] fn drop(&mut self) { self.owner.store(0, Ordering::Relaxed); + // Advance the seqlock counter before clearing the writer bit. A + // snapshot reader that already copied these bytes compares the counter + // and retries; ordering the bump first means it can never observe the + // cell idle at the pre-write version. + self.version.fetch_add(1, Ordering::Release); self.state.store(0, Ordering::Release); } } @@ -908,6 +961,37 @@ mod tests { b: u64, } + /// A writer that begins AND completes between `load_stable` and + /// `still_stable` must not be invisible. + /// + /// `CellWriteGuard::drop` stores `0` and `load_stable` returns `0` for an + /// idle cell, so the stamp an uncontended reader captures is the same value + /// the cell returns to after any number of completed writes. Without a + /// counter that moves on release, `still_stable` cannot distinguish "no + /// write happened" from "a whole write happened", and the reader keeps a + /// buffer it memcpy'd out of the middle of that write. + #[test] + fn a_completed_write_between_snapshot_and_check_is_detected() { + let locks = super::CellLocks::default(); + let link = Link { + page_id: 1.into(), + offset: 7, + length: 16, + }; + + let stamp = locks.load_stable(link).expect("idle cell snapshots"); + + // The whole write happens inside the reader's window. + { + let _write = locks.write(link).expect("uncontended writer"); + } + + assert!( + !locks.still_stable(link, stamp), + "a completed write inside the read window must invalidate the snapshot" + ); + } + #[test] fn colliding_rows_keep_using_one_stable_stripe() { let locks = super::CellLocks::default(); From 2ff7bfcbdbe6884d6c8ea549bd73f42657f96afe Mon Sep 17 00:00:00 2001 From: meh Date: Mon, 14 Sep 2026 22:36:10 +0700 Subject: [PATCH 24/45] Copy the cell before running a select_with closure with_archived_seqlock handed the caller's closure a reference into the live page and only checked stability afterwards. A torn u64 would be harmless there, since the retry discards the result, but the closure receives &Archived and rkyv archived types carry relative pointers: a torn pointer dereferenced inside the closure is undefined behaviour before still_stable runs. The shipping test row has a String column, so this is reachable. copy_row_seqlock already validates its copy, so reuse it and let the closure see one write generation. This costs select_with the memcpy it was added to avoid: 53.8M to 31.1M at w=1, level with plain select. Scaling is unaffected at 8.0x. Keeping the fast path for rows whose archived form holds no pointers is the open question, not something to assume here. --- src/in_memory/data.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/in_memory/data.rs b/src/in_memory/data.rs index 0964b364..48acca39 100644 --- a/src/in_memory/data.rs +++ b/src/in_memory/data.rs @@ -764,15 +764,21 @@ impl Data { Row: Archive, F: FnMut(&::Archived) -> T, { - self.validate_link(link)?; - loop { - let stamp = self.cell_locks.load_stable(link)?; - let archived = self.get_row_ref(link)?; - let result = f(archived); - if self.cell_locks.still_stable(link, stamp) { - return Ok(result); - } - } + // Run `f` on a validated private copy, not on the live page. + // + // Reading in place and checking stability afterwards lets `f` observe + // a cell mid-write. A torn `u64` would be harmless because the retry + // discards the result, but `f` receives `&Archived`, and rkyv archived + // types carry relative pointers: a torn pointer dereferenced inside + // `f` (an archived `String` or `Vec` field) is undefined behaviour + // before `still_stable` ever runs. The shipping test row has a + // `String` column, so this is reachable, not theoretical. + // + // `copy_row_seqlock` already validates the copy before returning it, + // so the closure only ever sees bytes from one write generation. + let copy = self.copy_row_seqlock(link)?; + let archived = unsafe { rkyv::access_unchecked::<::Archived>(copy.as_bytes()) }; + Ok(f(archived)) } /// Validates persisted bytes before deserializing them. From e52893a0bb5a11596dba5c65d8d68c5e35028fef Mon Sep 17 00:00:00 2001 From: meh Date: Mon, 14 Sep 2026 22:51:40 +0700 Subject: [PATCH 25/45] Gate zero-copy select_with on a pointer-free archived row The previous commit bought soundness with a copy, which cost select_with the whole reason it exists: 53.8M to 31.1M at one worker, level with plain select. Neither the tear nor the copy is acceptable, so make the compiler separate the two cases. select_with reads the cell in place and validates afterwards, so the closure can see a value a writer is changing. For an inline scalar that is recoverable, since the retry discards whatever the closure computed from a torn number. For a relative pointer, an archived String or Vec field, the closure dereferences it before the check runs. InlineArchived marks rows that hold no relative pointers. The macro emits it only when every column is a known scalar shape, and generates select_with only for those tables. A table with a String column now has no select_with, so the call is a compile error naming the missing method rather than a silent copy or a torn pointer. Opaque user types are refused, because the macro cannot inspect their archived layout: that costs a table the fast path, it never grants one unsoundly. select_with is back to 52.2M at one worker and 365M at eight. --- codegen/src/common/name_generator.rs | 50 +++++++++++++++++++ .../src/generators/in_memory/table/impls.rs | 36 +++++++++---- codegen/src/generators/in_memory/wrapper.rs | 31 +++++++++++- codegen/src/generators/persist/table/impls.rs | 36 +++++++++---- codegen/src/generators/persist/wrapper.rs | 29 ++++++++++- .../src/generators/read_only/table/impls.rs | 36 +++++++++---- codegen/src/generators/read_only/wrapper.rs | 29 ++++++++++- docs/cell-lock-registry.md | 38 ++++++++++++++ src/in_memory/data.rs | 36 +++++++------ src/in_memory/mod.rs | 2 +- src/in_memory/pages.rs | 2 + src/in_memory/row.rs | 24 +++++++++ src/lib.rs | 4 +- src/table/mod.rs | 3 +- tests/ui.rs | 3 ++ tests/ui/select_with_needs_inline_archived.rs | 22 ++++++++ .../select_with_needs_inline_archived.stderr | 31 ++++++++++++ tests/worktable/base.rs | 33 ++++++++++-- 18 files changed, 392 insertions(+), 53 deletions(-) create mode 100644 tests/ui/select_with_needs_inline_archived.rs create mode 100644 tests/ui/select_with_needs_inline_archived.stderr diff --git a/codegen/src/common/name_generator.rs b/codegen/src/common/name_generator.rs index 83b0ed6e..df79ba9a 100644 --- a/codegen/src/common/name_generator.rs +++ b/codegen/src/common/name_generator.rs @@ -60,6 +60,56 @@ pub fn archived_field_requires_rebuild(ty: &TokenStream) -> bool { .unwrap_or(true) } +/// Whether this column's archived form is a fixed-size scalar sitting inline +/// in the cell, with no relative pointer. +/// +/// This is the safety condition for the zero-copy `select_with` path: a +/// concurrent writer can tear an inline scalar the reader's closure observes, +/// and the seqlock retry throws that away, but a torn *pointer* dereferenced +/// inside the closure is undefined behaviour. +/// +/// Distinct from [`archived_field_requires_rebuild`], which answers a +/// different question and treats `String` as fine because the variable-size +/// path handles it. Here `String` is precisely what must be excluded. +/// +/// Opaque user types are refused, because the macro cannot inspect a user +/// type's `Archive::Archived` layout. That is conservative in the safe +/// direction: an unrecognised column costs the table its zero-copy path, it +/// does not grant one unsoundly. +pub fn archived_field_is_inline_scalar(ty: &TokenStream) -> bool { + fn type_is_inline(ty: &Type) -> bool { + let Type::Path(type_path) = ty else { + return false; + }; + let Some(segment) = type_path.path.segments.last() else { + return false; + }; + + match segment.ident.to_string().as_str() { + "bool" | "char" | "u8" | "u16" | "u32" | "u64" | "u128" | "usize" | "i8" | "i16" | "i32" | "i64" + | "i128" | "isize" | "f32" | "f64" => true, + // An archived `Option` of an inline `T` stays inline: rkyv + // encodes the niche or a discriminant beside the payload. + "Option" => match &segment.arguments { + PathArguments::AngleBracketed(arguments) => arguments + .args + .iter() + .find_map(|argument| match argument { + GenericArgument::Type(inner) => Some(type_is_inline(inner)), + _ => None, + }) + .unwrap_or(false), + _ => false, + }, + _ => false, + } + } + + syn::parse2::(ty.clone()) + .map(|ty| type_is_inline(&ty)) + .unwrap_or(false) +} + pub struct WorktableNameGenerator { pub(crate) name: String, } diff --git a/codegen/src/generators/in_memory/table/impls.rs b/codegen/src/generators/in_memory/table/impls.rs index e5d0b391..24379961 100644 --- a/codegen/src/generators/in_memory/table/impls.rs +++ b/codegen/src/generators/in_memory/table/impls.rs @@ -3,7 +3,7 @@ use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; use crate::common::model::GeneratorType; -use crate::common::name_generator::WorktableNameGenerator; +use crate::common::name_generator::{archived_field_is_inline_scalar, WorktableNameGenerator}; use crate::generators::in_memory::InMemoryGenerator; impl InMemoryGenerator { @@ -75,6 +75,31 @@ impl InMemoryGenerator { let row_type = name_generator.get_row_type_ident(); let primary_key_type = name_generator.get_primary_key_type_ident(); + // `select_with` reads the cell in place with no copy, which is only + // sound when the archived row holds no relative pointers. Emit it only + // for those tables; one with a `String` column simply has no + // `select_with`, so a caller gets "no method named `select_with`" + // instead of a silent copy or a torn pointer. + let select_with_fn = if self + .columns + .columns_map + .values() + .all(|ty| archived_field_is_inline_scalar("e! { #ty })) + { + quote! { + /// Apply `f` to the archived inner row. No cell memcpy; `f` must copy out. + pub fn select_with(&self, pk: Pk, f: F) -> Option + where + #primary_key_type: From, + F: FnMut(&<#row_type as worktable::prelude::rkyv::Archive>::Archived) -> T, + { + self.0.select_with(pk.into(), f) + } + } + } else { + quote! {} + }; + quote! { pub fn select(&self, pk: Pk) -> Option<#row_type> where #primary_key_type: From { @@ -87,14 +112,7 @@ impl InMemoryGenerator { self.0.select_ref(pk.into()) } - /// Apply `f` to the archived inner row. No cell memcpy; `f` must copy out. - pub fn select_with(&self, pk: Pk, f: F) -> Option - where - #primary_key_type: From, - F: FnMut(&<#row_type as worktable::prelude::rkyv::Archive>::Archived) -> T, - { - self.0.select_with(pk.into(), f) - } + #select_with_fn } } diff --git a/codegen/src/generators/in_memory/wrapper.rs b/codegen/src/generators/in_memory/wrapper.rs index c6dd7f58..775b2156 100644 --- a/codegen/src/generators/in_memory/wrapper.rs +++ b/codegen/src/generators/in_memory/wrapper.rs @@ -1,4 +1,4 @@ -use crate::common::name_generator::WorktableNameGenerator; +use crate::common::name_generator::{archived_field_is_inline_scalar, WorktableNameGenerator}; use crate::generators::in_memory::InMemoryGenerator; use proc_macro2::TokenStream; use quote::quote; @@ -9,12 +9,14 @@ impl InMemoryGenerator { let impl_ = self.gen_wrapper_impl(); let storable_impl = self.get_wrapper_storable_impl(); let archived_wrapper_impl = self.get_archived_wrapper_impl(); + let inline_archived_impl = self.get_inline_archived_impl(); quote! { #type_ #impl_ #storable_impl #archived_wrapper_impl + #inline_archived_impl } } @@ -72,6 +74,33 @@ impl InMemoryGenerator { } } + /// Emit `InlineArchived` only when every column is an inline scalar. + /// + /// This is what gates the zero-copy `select_with` path. A table with a + /// `String` column simply does not get the impl, so calling `select_with` + /// on it is a compile error naming the missing bound rather than a silent + /// copy or, worse, a torn relative pointer. + fn get_inline_archived_impl(&self) -> TokenStream { + let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); + let wrapper_ident = name_generator.get_wrapper_type_ident(); + + let all_inline = self + .columns + .columns_map + .values() + .all(|ty| archived_field_is_inline_scalar("e! { #ty })); + if !all_inline { + return quote! {}; + } + + quote! { + // SAFETY: every column is a fixed-size scalar, so the archived + // wrapper holds no relative pointers. The bookkeeping flags + // beside `inner` are `bool`. + unsafe impl worktable::prelude::InlineArchived for #wrapper_ident {} + } + } + fn get_wrapper_storable_impl(&self) -> TokenStream { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let row_ident = name_generator.get_row_type_ident(); diff --git a/codegen/src/generators/persist/table/impls.rs b/codegen/src/generators/persist/table/impls.rs index bb62a640..c3a5ee4c 100644 --- a/codegen/src/generators/persist/table/impls.rs +++ b/codegen/src/generators/persist/table/impls.rs @@ -3,7 +3,7 @@ use proc_macro2::{Ident, Literal, Span, TokenStream}; use quote::quote; use crate::common::model::GeneratorType; -use crate::common::name_generator::{WorktableNameGenerator, is_float, is_unsized_vec}; +use crate::common::name_generator::{archived_field_is_inline_scalar, WorktableNameGenerator, is_float, is_unsized_vec}; use crate::generators::persist::PersistGenerator; impl PersistGenerator { @@ -381,6 +381,31 @@ impl PersistGenerator { let row_type = name_generator.get_row_type_ident(); let primary_key_type = name_generator.get_primary_key_type_ident(); + // `select_with` reads the cell in place with no copy, which is only + // sound when the archived row holds no relative pointers. Emit it only + // for those tables; one with a `String` column simply has no + // `select_with`, so a caller gets "no method named `select_with`" + // instead of a silent copy or a torn pointer. + let select_with_fn = if self + .columns + .columns_map + .values() + .all(|ty| archived_field_is_inline_scalar("e! { #ty })) + { + quote! { + /// Apply `f` to the archived inner row. No cell memcpy; `f` must copy out. + pub fn select_with(&self, pk: Pk, f: F) -> Option + where + #primary_key_type: From, + F: FnMut(&<#row_type as worktable::prelude::rkyv::Archive>::Archived) -> T, + { + self.0.select_with(pk.into(), f) + } + } + } else { + quote! {} + }; + quote! { pub fn select(&self, pk: Pk) -> Option<#row_type> where #primary_key_type: From { @@ -393,14 +418,7 @@ impl PersistGenerator { self.0.select_ref(pk.into()) } - /// Apply `f` to the archived inner row. No cell memcpy; `f` must copy out. - pub fn select_with(&self, pk: Pk, f: F) -> Option - where - #primary_key_type: From, - F: FnMut(&<#row_type as worktable::prelude::rkyv::Archive>::Archived) -> T, - { - self.0.select_with(pk.into(), f) - } + #select_with_fn } } diff --git a/codegen/src/generators/persist/wrapper.rs b/codegen/src/generators/persist/wrapper.rs index 3174aad4..7377239c 100644 --- a/codegen/src/generators/persist/wrapper.rs +++ b/codegen/src/generators/persist/wrapper.rs @@ -1,4 +1,4 @@ -use crate::common::name_generator::WorktableNameGenerator; +use crate::common::name_generator::{archived_field_is_inline_scalar, WorktableNameGenerator}; use crate::generators::persist::PersistGenerator; use proc_macro2::TokenStream; use quote::quote; @@ -9,15 +9,42 @@ impl PersistGenerator { let impl_ = self.gen_wrapper_impl(); let storable_impl = self.get_wrapper_storable_impl(); let archived_wrapper_impl = self.get_archived_wrapper_impl(); + let inline_archived_impl = self.get_inline_archived_impl(); quote! { #type_ #impl_ #storable_impl #archived_wrapper_impl + #inline_archived_impl } } + /// Emit `InlineArchived` only when every column is an inline scalar. + /// + /// Gates the zero-copy `select_with` path; see the in-memory generator's + /// copy of this for the reasoning. + fn get_inline_archived_impl(&self) -> TokenStream { + let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); + let wrapper_ident = name_generator.get_wrapper_type_ident(); + + let all_inline = self + .columns + .columns_map + .values() + .all(|ty| archived_field_is_inline_scalar("e! { #ty })); + if !all_inline { + return quote! {}; + } + + quote! { + // SAFETY: every column is a fixed-size scalar, so the archived + // wrapper holds no relative pointers. + unsafe impl worktable::prelude::InlineArchived for #wrapper_ident {} + } + } + + fn gen_wrapper_type(&self) -> TokenStream { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let row_ident = name_generator.get_row_type_ident(); diff --git a/codegen/src/generators/read_only/table/impls.rs b/codegen/src/generators/read_only/table/impls.rs index 743c7b84..e6df6d5e 100644 --- a/codegen/src/generators/read_only/table/impls.rs +++ b/codegen/src/generators/read_only/table/impls.rs @@ -2,7 +2,7 @@ use convert_case::{Case, Casing}; use proc_macro2::{Ident, Literal, Span, TokenStream}; use quote::quote; -use crate::common::name_generator::{WorktableNameGenerator, is_float, is_unsized_vec}; +use crate::common::name_generator::{archived_field_is_inline_scalar, WorktableNameGenerator, is_float, is_unsized_vec}; use crate::generators::read_only::ReadOnlyGenerator; impl ReadOnlyGenerator { @@ -335,6 +335,31 @@ impl ReadOnlyGenerator { let row_type = name_generator.get_row_type_ident(); let primary_key_type = name_generator.get_primary_key_type_ident(); + // `select_with` reads the cell in place with no copy, which is only + // sound when the archived row holds no relative pointers. Emit it only + // for those tables; one with a `String` column simply has no + // `select_with`, so a caller gets "no method named `select_with`" + // instead of a silent copy or a torn pointer. + let select_with_fn = if self + .columns + .columns_map + .values() + .all(|ty| archived_field_is_inline_scalar("e! { #ty })) + { + quote! { + /// Apply `f` to the archived inner row. No cell memcpy; `f` must copy out. + pub fn select_with(&self, pk: Pk, f: F) -> Option + where + #primary_key_type: From, + F: FnMut(&<#row_type as worktable::prelude::rkyv::Archive>::Archived) -> T, + { + self.0.select_with(pk.into(), f) + } + } + } else { + quote! {} + }; + quote! { pub fn select(&self, pk: Pk) -> Option<#row_type> where #primary_key_type: From { @@ -347,14 +372,7 @@ impl ReadOnlyGenerator { self.0.select_ref(pk.into()) } - /// Apply `f` to the archived inner row. No cell memcpy; `f` must copy out. - pub fn select_with(&self, pk: Pk, f: F) -> Option - where - #primary_key_type: From, - F: FnMut(&<#row_type as worktable::prelude::rkyv::Archive>::Archived) -> T, - { - self.0.select_with(pk.into(), f) - } + #select_with_fn } } diff --git a/codegen/src/generators/read_only/wrapper.rs b/codegen/src/generators/read_only/wrapper.rs index 9d953c54..9e12cf26 100644 --- a/codegen/src/generators/read_only/wrapper.rs +++ b/codegen/src/generators/read_only/wrapper.rs @@ -1,4 +1,4 @@ -use crate::common::name_generator::WorktableNameGenerator; +use crate::common::name_generator::{archived_field_is_inline_scalar, WorktableNameGenerator}; use crate::generators::read_only::ReadOnlyGenerator; use proc_macro2::TokenStream; use quote::quote; @@ -9,15 +9,42 @@ impl ReadOnlyGenerator { let impl_ = self.gen_wrapper_impl(); let storable_impl = self.get_wrapper_storable_impl(); let archived_wrapper_impl = self.get_archived_wrapper_impl(); + let inline_archived_impl = self.get_inline_archived_impl(); quote! { #type_ #impl_ #storable_impl #archived_wrapper_impl + #inline_archived_impl } } + /// Emit `InlineArchived` only when every column is an inline scalar. + /// + /// Gates the zero-copy `select_with` path; see the in-memory generator's + /// copy of this for the reasoning. + fn get_inline_archived_impl(&self) -> TokenStream { + let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); + let wrapper_ident = name_generator.get_wrapper_type_ident(); + + let all_inline = self + .columns + .columns_map + .values() + .all(|ty| archived_field_is_inline_scalar("e! { #ty })); + if !all_inline { + return quote! {}; + } + + quote! { + // SAFETY: every column is a fixed-size scalar, so the archived + // wrapper holds no relative pointers. + unsafe impl worktable::prelude::InlineArchived for #wrapper_ident {} + } + } + + fn gen_wrapper_type(&self) -> TokenStream { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let row_ident = name_generator.get_row_type_ident(); diff --git a/docs/cell-lock-registry.md b/docs/cell-lock-registry.md index d5e4e762..e1ce7575 100644 --- a/docs/cell-lock-registry.md +++ b/docs/cell-lock-registry.md @@ -80,3 +80,41 @@ The corrected stripe implementation must pass the companion performance suite's same-source lock comparison before release. The report records both executable hashes and the exact WorkTable revision so results cannot be mixed with an older scheduler or dependency graph. + +## Point reads without a reader CAS + +A point read does not take the stripe's reader count. It snapshots the stripe, +copies or reads the archived cell, and then checks the stripe again; if a writer +arrived in between, it retries. Incrementing the reader count instead made every +concurrent reader write to the same atomic, and shared `select` scaled 2.4x +across eight workers while the same read on a private table scaled 7.4x. Stripe +states are padded to a cache line each for the same reason: sixteen of them +previously shared one line, so unrelated rows bounced it between cores. + +The snapshot is a monotonic counter, not the stripe's reader/writer word. A +writer releases by storing zero, which is exactly what an idle stripe reads, so +a write that began and finished inside one reader's window would otherwise be +invisible and the reader would keep bytes copied from the middle of it. The +counter advances once per completed write, before the writer bit clears. + +Two read shapes follow from that check. + +`select` and `select_ref` copy the cell into private memory and validate the +copy, so they work for any row. `select_with` instead runs the caller's closure +directly on the page, with no copy, and validates afterwards. That is faster, +about 1.8x at one worker, and it means the closure can observe a value a writer +is currently changing. + +For a fixed-size scalar that is recoverable: the closure reads a torn number and +the retry throws away whatever it computed. For a relative pointer, which is +what an archived `String` or `Vec` field is, it is not: the closure would +dereference the torn pointer before the check runs. So `select_with` is +generated only for tables whose columns are all inline scalars, gated on the +`InlineArchived` marker the macro emits for exactly those. A table with a +`String` column has no `select_with` at all, and a call to it is a compile +error naming the missing method rather than a silent copy or a torn read. Those +tables use the owned `select`. + +The macro refuses opaque user types here, because it cannot inspect a user +type's `Archive::Archived` layout. That costs such a table the zero-copy path; +it never grants one unsoundly. diff --git a/src/in_memory/data.rs b/src/in_memory/data.rs index 48acca39..672713b6 100644 --- a/src/in_memory/data.rs +++ b/src/in_memory/data.rs @@ -26,7 +26,7 @@ use rkyv::{ with::{AtomicLoad, Relaxed, Skip, Unsafe}, }; -use crate::in_memory::ArchivedRowWrapper; +use crate::in_memory::{ArchivedRowWrapper, InlineArchived}; use crate::prelude::Link; #[cfg(all(not(wt_loom), not(any(unix, windows))))] @@ -761,24 +761,28 @@ impl Data { /// follow pointers or otherwise trap. Retry discards a torn result. pub(crate) fn with_archived_seqlock(&self, link: Link, mut f: F) -> Result where - Row: Archive, + Row: Archive + InlineArchived, F: FnMut(&::Archived) -> T, { - // Run `f` on a validated private copy, not on the live page. - // - // Reading in place and checking stability afterwards lets `f` observe - // a cell mid-write. A torn `u64` would be harmless because the retry - // discards the result, but `f` receives `&Archived`, and rkyv archived - // types carry relative pointers: a torn pointer dereferenced inside - // `f` (an archived `String` or `Vec` field) is undefined behaviour - // before `still_stable` ever runs. The shipping test row has a - // `String` column, so this is reachable, not theoretical. + // Read in place, with no copy. `Row: InlineArchived` is what makes + // that sound: every archived field is a fixed-size scalar inline in + // the cell, so a concurrent writer can tear a value the closure reads + // but cannot hand it a pointer that refers anywhere else. The retry + // below discards anything computed from a torn read. // - // `copy_row_seqlock` already validates the copy before returning it, - // so the closure only ever sees bytes from one write generation. - let copy = self.copy_row_seqlock(link)?; - let archived = unsafe { rkyv::access_unchecked::<::Archived>(copy.as_bytes()) }; - Ok(f(archived)) + // Without that bound this would be undefined behaviour, not a wrong + // number: `f` receives `&Archived`, and an archived `String` or `Vec` + // field is a relative pointer, which the closure would dereference + // before `still_stable` ever runs. + self.validate_link(link)?; + loop { + let stamp = self.cell_locks.load_stable(link)?; + let archived = self.get_row_ref(link)?; + let result = f(archived); + if self.cell_locks.still_stable(link, stamp) { + return Ok(result); + } + } } /// Validates persisted bytes before deserializing them. diff --git a/src/in_memory/mod.rs b/src/in_memory/mod.rs index e8057d38..0bc2b54e 100644 --- a/src/in_memory/mod.rs +++ b/src/in_memory/mod.rs @@ -9,4 +9,4 @@ pub use empty_link_registry::EmptyLinkRegistry; pub use pages::{ DataPages, ExecutionError as PagesExecutionError, ReadGuard as DataPagesReadGuard, SelectRef, }; -pub use row::{ArchivedRowWrapper, PublicationSafe, Query, RowWrapper, StorableRow}; +pub use row::{ArchivedRowWrapper, InlineArchived, PublicationSafe, Query, RowWrapper, StorableRow}; diff --git a/src/in_memory/pages.rs b/src/in_memory/pages.rs index ba6b4c97..622bb9da 100644 --- a/src/in_memory/pages.rs +++ b/src/in_memory/pages.rs @@ -24,6 +24,7 @@ use rkyv::{ }; use crate::in_memory::empty_link_registry::EmptyLinkRegistry; +use crate::in_memory::InlineArchived; use crate::prelude::ArchivedRowWrapper; use crate::util::epoch::EpochDomain; use crate::{ @@ -615,6 +616,7 @@ where /// Apply `f` to the archived inner row under seqlock, without memcpy. pub(crate) fn with_non_ghosted(&self, link: Link, mut f: F) -> Result where + ::WrappedRow: InlineArchived, <::WrappedRow as Archive>::Archived: ArchivedRowWrapper, F: FnMut( &<<::WrappedRow as Archive>::Archived as ArchivedRowWrapper>::Inner, diff --git a/src/in_memory/row.rs b/src/in_memory/row.rs index a8c0ff7b..d586c0e3 100644 --- a/src/in_memory/row.rs +++ b/src/in_memory/row.rs @@ -5,6 +5,30 @@ pub trait PublicationSafe: Send + Sync + 'static {} impl PublicationSafe for T {} +/// A row whose archived form holds no relative pointers. +/// +/// Every archived field is a fixed-size scalar sitting inline in the cell, so +/// reading one while a writer mutates it can tear a value but cannot produce a +/// pointer that refers anywhere else. That is what makes the zero-copy +/// [`select_with`] path sound: the closure may observe a torn number, and the +/// seqlock retry discards whatever it computed from one. +/// +/// A row with a `String` or `Vec` column has archived relative pointers, and a +/// torn pointer dereferenced inside the closure is undefined behaviour rather +/// than a wrong number. Those rows deliberately do not implement this trait, so +/// they fail to compile against the zero-copy API instead of silently copying, +/// and must use the owned `select`. +/// +/// # Safety +/// +/// Implementors must contain no archived relative pointers. The `worktable!` +/// macro implements this only when every column is a known scalar shape; it is +/// never implemented for an opaque user type, because the macro cannot inspect +/// that type's `Archive::Archived` layout. +/// +/// [`select_with`]: crate::WorkTable::select_with +pub unsafe trait InlineArchived {} + /// Common trait for the `Row`s that can be stored on the [`Data`] page. /// /// [`Data`]: crate::in_memory::data::Data diff --git a/src/lib.rs b/src/lib.rs index 58c757c7..378561ee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -177,7 +177,9 @@ pub mod prelude { pub use hashbrown::{HashMap, HashSet}; pub use crate::atomic_key_table::AtomicKeyTable; - pub use crate::in_memory::{ArchivedRowWrapper, Data, DataPages, Query, RowWrapper, SelectRef, StorableRow}; + pub use crate::in_memory::{ + ArchivedRowWrapper, Data, DataPages, InlineArchived, Query, RowWrapper, SelectRef, StorableRow, + }; pub use crate::lock::FullRowLock; pub use crate::lock::{Lock, RowLock}; pub use crate::lock::{LockAcquirer, LockGuard, LockMap, PendingLock}; diff --git a/src/table/mod.rs b/src/table/mod.rs index 8c7a6372..810d7356 100644 --- a/src/table/mod.rs +++ b/src/table/mod.rs @@ -4,7 +4,7 @@ pub mod system_info; #[cfg(feature = "std")] pub mod vacuum; -use crate::in_memory::{ArchivedRowWrapper, DataPages, RowWrapper, SelectRef, StorableRow}; +use crate::in_memory::{ArchivedRowWrapper, DataPages, InlineArchived, RowWrapper, SelectRef, StorableRow}; #[cfg(feature = "std")] use crate::persistence::PersistenceLoadError; use crate::persistence::operation::new_operation_uuid; @@ -283,6 +283,7 @@ where pub fn select_with(&self, pk: PrimaryKey, mut f: F) -> Option where LockType: 'static, + ::WrappedRow: InlineArchived, <::WrappedRow as Archive>::Archived: ArchivedRowWrapper, F: FnMut(&<<::WrappedRow as Archive>::Archived as ArchivedRowWrapper>::Inner) -> T, { diff --git a/tests/ui.rs b/tests/ui.rs index f0f509e5..7756b019 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -37,4 +37,7 @@ fn compile_fail() { t.compile_fail("tests/ui/in_place_over_indexed_column.rs"); t.compile_fail("tests/ui/update_selector_wrong_value.rs"); t.compile_fail("tests/ui/update_selector_collision.rs"); + + // Read-path safety rules. + t.compile_fail("tests/ui/select_with_needs_inline_archived.rs"); } diff --git a/tests/ui/select_with_needs_inline_archived.rs b/tests/ui/select_with_needs_inline_archived.rs new file mode 100644 index 00000000..03387936 --- /dev/null +++ b/tests/ui/select_with_needs_inline_archived.rs @@ -0,0 +1,22 @@ +// Rule: `select_with` reads the archived cell in place with no copy, so a +// concurrent writer can tear a value the closure observes. That is recoverable +// for an inline scalar, because the seqlock retry discards whatever the closure +// computed. It is undefined behaviour for a relative pointer, which is what an +// archived `String` or `Vec` column is. Tables carrying one therefore do not +// get a `select_with` at all; they use the owned `select`. +use worktable::prelude::*; +use worktable::worktable; + +worktable! { + name: HasString, + persist: false, + columns: { + id: u64 primary_key, + label: String, + }, +} + +fn main() { + let table = HasStringWorkTable::default(); + let _ = table.select_with(1u64, |archived| archived.id); +} diff --git a/tests/ui/select_with_needs_inline_archived.stderr b/tests/ui/select_with_needs_inline_archived.stderr new file mode 100644 index 00000000..32d89ad6 --- /dev/null +++ b/tests/ui/select_with_needs_inline_archived.stderr @@ -0,0 +1,31 @@ +error[E0599]: no method named `select_with` found for struct `HasStringWorkTable` in the current scope + --> tests/ui/select_with_needs_inline_archived.rs:21:19 + | +10 | / worktable! { +11 | | name: HasString, +12 | | persist: false, +13 | | columns: { +... | +16 | | }, +17 | | } + | |_- method `select_with` not found for this struct +... +21 | let _ = table.select_with(1u64, |archived| archived.id); + | ^^^^^^^^^^^ + | +help: there is a method `select` with a similar name, but with different arguments + --> tests/ui/select_with_needs_inline_archived.rs:10:1 + | +10 | / worktable! { +11 | | name: HasString, +12 | | persist: false, +13 | | columns: { +... | +16 | | }, +17 | | } + | |_^ + = note: this error originates in the macro `worktable` (in Nightly builds, run with -Z macro-backtrace for more info) +help: one of the expressions' fields has a method of the same name + | +21 | let _ = table.0.select_with(1u64, |archived| archived.id); + | ++ diff --git a/tests/worktable/base.rs b/tests/worktable/base.rs index 9201d1a8..7092909b 100644 --- a/tests/worktable/base.rs +++ b/tests/worktable/base.rs @@ -594,13 +594,38 @@ async fn select_ref_matches_select() { let view = table.select_ref(row.id).unwrap(); assert_eq!(owned.another, view.another); assert_eq!(owned.test, view.test); - let via_with = table - .select_with(row.id, |archived| archived.another) - .unwrap(); - assert_eq!(owned.another, via_with); assert!(table.select_ref(u64::MAX).is_none()); } +worktable! { + name: InlineOnly, + columns: { + id: u64 primary_key, + value: u64, + }, +} + +/// `select_with` exists only where the archived row holds no relative +/// pointers, and it agrees with the owned `select` there. +/// +/// It reads the cell in place with no copy, so a concurrent writer can tear a +/// value the closure sees. That is recoverable for an inline scalar, because +/// the seqlock retry discards it, and is undefined behaviour for a pointer. +/// `TestWorkTable` above has a `String` column and deliberately has no +/// `select_with`; see the `select_with_needs_inline_archived` compile-fail +/// case. +#[tokio::test] +async fn select_with_matches_select_on_inline_rows() { + let table = InlineOnlyWorkTable::default(); + let row = InlineOnlyRow { id: 1, value: 42 }; + let _ = table.insert(row.clone()).await.unwrap(); + + let owned = table.select(row.id).unwrap(); + let via_with = table.select_with(row.id, |archived| archived.value).unwrap(); + assert_eq!(owned.value, u64::from(via_with)); + assert!(table.select_with(u64::MAX, |archived| archived.value).is_none()); +} + #[tokio::test] async fn select_by_test() { let table = TestWorkTable::default(); From a428c4a5bb0d4e7560bc9701713793e896acbc2f Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 15 Sep 2026 00:53:54 +0700 Subject: [PATCH 26/45] Race writers at one key through the in-place path The suite covered concurrent same-row updates through the full update path and through the index backends, but nothing raced writers at a single key through update_in_place specifically. That gap hid a real defect. An uncontended fast path for this operation measured 85 percent faster at eight workers and deadlocked the mixed in-place-plus-update tests; the attempt is written up in the perf evidence rather than kept. A counter row makes the failure legible: a lost claim is a lost increment, so the final value says how many writes survived. --- tests/worktable/concurrency.rs | 64 ++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/worktable/concurrency.rs b/tests/worktable/concurrency.rs index 75382664..dcb43459 100644 --- a/tests/worktable/concurrency.rs +++ b/tests/worktable/concurrency.rs @@ -494,3 +494,67 @@ macro_rules! backend_suite { backend_suite!(wti, worktables_index, "wti"); backend_suite!(arctic, arctic, "arctic"); backend_suite!(congee, congee, "congee"); + +/// Concurrent in-place updates of ONE key must not lose a write. +/// +/// `update_in_place` takes an uncontended fast path that claims the row with a +/// flag instead of registering an operation and awaiting its predecessor. That +/// path is only correct while it is genuinely uncontended, so the interesting +/// case is the one it must refuse: every writer aimed at the same key, where +/// all but one have to fall back to the chained protocol. +/// +/// The row is an increment counter, so a lost claim is a lost increment and the +/// final value says exactly how many writes survived. +mod same_key_in_place { + use worktable::prelude::*; + use worktable::worktable; + + worktable! { + name: Counter, + columns: { + id: u64 primary_key, + hits: u64, + }, + queries: { + update_in_place: { + HitsById(hits) by id, + }, + }, + } + + #[test] + fn concurrent_in_place_on_one_key_loses_nothing() { + let writers = super::params::env_u64("WT_CONC_WRITERS", 8) as usize; + let per_writer = super::params::env_u64("WT_CONC_PER_WRITER", 500); + + let table = std::sync::Arc::new(CounterWorkTable::default()); + nagoya::block_on(table.insert(CounterRow { id: 1, hits: 0 })).expect("seed"); + + std::thread::scope(|scope| { + for _ in 0..writers { + let table = table.clone(); + scope.spawn(move || { + nagoya::block_on(async { + for _ in 0..per_writer { + table + .update_in_place_by_id(1u64, CounterColumns::HITS, |hits| { + let current: u64 = (*hits).into(); + *hits = current.wrapping_add(1).into(); + }) + .await + .expect("in-place"); + } + }); + }); + } + }); + + let hits: u64 = table.select(1u64).expect("row present").hits; + assert_eq!( + hits, + writers as u64 * per_writer, + "{writers} writers x {per_writer} increments lost writes: the fast path \ + claimed a row another writer already held" + ); + } +} From afa7770b9c156392fe012cdb139cefd0809c2e68 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 15 Sep 2026 02:25:41 +0700 Subject: [PATCH 27/45] Wait on a Vec of predecessor locks, not a HashSet Every mutation built a hashbrown::HashSet to hold the locks it must wait for. That set holds one entry per column the lock type covers, a handful in any real schema, and the caller only iterates it. Building it seeded a fresh foldhash hasher per operation, which profiling put at about a tenth of the in-place update path. A Vec deduplicated by Arc::ptr_eq does the same job. Linear scan over a few pointers beats seeding a hasher, and nothing downstream needs set semantics. Measured on the arm that isolates per-operation cost, one table per worker so there is no lock contention to hide behind: 13.8M to 27.7M ops/s at eight workers, and the slope from 1.83x to 4.04x. The shared arm improves too but stays negative, which is a different problem. Changed in the trait, in FullRowLock, and in all five codegen sites: the lock type's own lock and merge for in_memory, persist and read_only, plus the per-query lock functions for in_memory and persist. --- codegen/src/generators/in_memory/locks.rs | 20 +++++++------- .../src/generators/in_memory/queries/locks.rs | 12 ++++++--- codegen/src/generators/persist/locks.rs | 20 +++++++------- .../src/generators/persist/queries/locks.rs | 12 ++++++--- codegen/src/generators/read_only/locks.rs | 20 +++++++------- src/lock/row_lock.rs | 26 +++++++++++-------- 6 files changed, 63 insertions(+), 47 deletions(-) diff --git a/codegen/src/generators/in_memory/locks.rs b/codegen/src/generators/in_memory/locks.rs index 1153c65c..224a4e10 100644 --- a/codegen/src/generators/in_memory/locks.rs +++ b/codegen/src/generators/in_memory/locks.rs @@ -144,8 +144,10 @@ impl InMemoryGenerator { .map(|i| { let col = Ident::new(format!("{i}_lock").as_str(), Span::mixed_site()); quote! { - if let Some(lock) = &self.#col { - set.insert(lock.clone()); + if let Some(existing_lock) = &self.#col { + if !set.iter().any(|entry| worktable::prelude::Arc::ptr_eq(entry, existing_lock)) { + set.push(existing_lock.clone()); + } } self.#col = Some(lock.clone()); } @@ -153,9 +155,8 @@ impl InMemoryGenerator { .collect(); quote! { - #[allow(clippy::mutable_key_type)] - fn lock(&mut self, id: u16) -> (worktable::prelude::HashSet>, worktable::prelude::Arc) { - let mut set = worktable::prelude::HashSet::new(); + fn lock(&mut self, id: u16) -> (Vec>, worktable::prelude::Arc) { + let mut set: Vec> = Vec::new(); let lock = worktable::prelude::Arc::new(Lock::new(id)); #(#rows)* @@ -175,8 +176,8 @@ impl InMemoryGenerator { if let Some(#col) = &other.#col { if self.#col.is_none() { self.#col = Some(#col.clone()); - } else { - set.insert(#col.clone()); + } else if !set.iter().any(|existing| worktable::prelude::Arc::ptr_eq(existing, #col)) { + set.push(#col.clone()); } } other.#col = self.#col.clone(); @@ -185,9 +186,8 @@ impl InMemoryGenerator { .collect(); quote! { - #[allow(clippy::mutable_key_type)] - fn merge(&mut self, other: &mut Self) -> worktable::prelude::HashSet> { - let mut set = worktable::prelude::HashSet::new(); + fn merge(&mut self, other: &mut Self) -> Vec> { + let mut set: Vec> = Vec::new(); #(#rows)* set } diff --git a/codegen/src/generators/in_memory/queries/locks.rs b/codegen/src/generators/in_memory/queries/locks.rs index 8e98e243..1e668af9 100644 --- a/codegen/src/generators/in_memory/queries/locks.rs +++ b/codegen/src/generators/in_memory/queries/locks.rs @@ -86,7 +86,13 @@ impl InMemoryGenerator { let col = Ident::new(format!("{col}_lock").as_str(), Span::mixed_site()); quote! { if let Some(lock) = &self.#col { - set.insert(lock.clone()); + // Dedup by pointer: columns can share one lock, and the + // caller must not wait on the same lock twice. Linear + // over a handful of entries, which is cheaper than + // seeding a hasher per operation. + if !set.iter().any(|existing| worktable::prelude::Arc::ptr_eq(existing, lock)) { + set.push(lock.clone()); + } } self.#col = Some(new_lock.clone()); } @@ -95,8 +101,8 @@ impl InMemoryGenerator { quote! { #[allow(clippy::mutable_key_type)] - pub fn #ident(&mut self, id: u16) -> (worktable::prelude::HashSet>, worktable::prelude::Arc) { - let mut set = worktable::prelude::HashSet::new(); + pub fn #ident(&mut self, id: u16) -> (Vec>, worktable::prelude::Arc) { + let mut set = Vec::new(); let new_lock = worktable::prelude::Arc::new(Lock::new(id)); #(#inner)* (set, new_lock) diff --git a/codegen/src/generators/persist/locks.rs b/codegen/src/generators/persist/locks.rs index 86b55b60..0f6c577d 100644 --- a/codegen/src/generators/persist/locks.rs +++ b/codegen/src/generators/persist/locks.rs @@ -144,8 +144,10 @@ impl PersistGenerator { .map(|i| { let col = Ident::new(format!("{i}_lock").as_str(), Span::mixed_site()); quote! { - if let Some(lock) = &self.#col { - set.insert(lock.clone()); + if let Some(existing_lock) = &self.#col { + if !set.iter().any(|entry| worktable::prelude::Arc::ptr_eq(entry, existing_lock)) { + set.push(existing_lock.clone()); + } } self.#col = Some(lock.clone()); } @@ -153,9 +155,8 @@ impl PersistGenerator { .collect(); quote! { - #[allow(clippy::mutable_key_type)] - fn lock(&mut self, id: u16) -> (worktable::prelude::HashSet>, worktable::prelude::Arc) { - let mut set = worktable::prelude::HashSet::new(); + fn lock(&mut self, id: u16) -> (Vec>, worktable::prelude::Arc) { + let mut set: Vec> = Vec::new(); let lock = worktable::prelude::Arc::new(Lock::new(id)); #(#rows)* @@ -175,8 +176,8 @@ impl PersistGenerator { if let Some(#col) = &other.#col { if self.#col.is_none() { self.#col = Some(#col.clone()); - } else { - set.insert(#col.clone()); + } else if !set.iter().any(|existing| worktable::prelude::Arc::ptr_eq(existing, #col)) { + set.push(#col.clone()); } } other.#col = self.#col.clone(); @@ -185,9 +186,8 @@ impl PersistGenerator { .collect(); quote! { - #[allow(clippy::mutable_key_type)] - fn merge(&mut self, other: &mut Self) -> worktable::prelude::HashSet> { - let mut set = worktable::prelude::HashSet::new(); + fn merge(&mut self, other: &mut Self) -> Vec> { + let mut set: Vec> = Vec::new(); #(#rows)* set } diff --git a/codegen/src/generators/persist/queries/locks.rs b/codegen/src/generators/persist/queries/locks.rs index 64fcaa03..50478366 100644 --- a/codegen/src/generators/persist/queries/locks.rs +++ b/codegen/src/generators/persist/queries/locks.rs @@ -86,7 +86,13 @@ impl PersistGenerator { let col = Ident::new(format!("{col}_lock").as_str(), Span::mixed_site()); quote! { if let Some(lock) = &self.#col { - set.insert(lock.clone()); + // Dedup by pointer: columns can share one lock, and the + // caller must not wait on the same lock twice. Linear + // over a handful of entries, which is cheaper than + // seeding a hasher per operation. + if !set.iter().any(|existing| worktable::prelude::Arc::ptr_eq(existing, lock)) { + set.push(lock.clone()); + } } self.#col = Some(new_lock.clone()); } @@ -95,8 +101,8 @@ impl PersistGenerator { quote! { #[allow(clippy::mutable_key_type)] - pub fn #ident(&mut self, id: u16) -> (worktable::prelude::HashSet>, worktable::prelude::Arc) { - let mut set = worktable::prelude::HashSet::new(); + pub fn #ident(&mut self, id: u16) -> (Vec>, worktable::prelude::Arc) { + let mut set = Vec::new(); let new_lock = worktable::prelude::Arc::new(Lock::new(id)); #(#inner)* (set, new_lock) diff --git a/codegen/src/generators/read_only/locks.rs b/codegen/src/generators/read_only/locks.rs index ffa50040..33d7a894 100644 --- a/codegen/src/generators/read_only/locks.rs +++ b/codegen/src/generators/read_only/locks.rs @@ -144,8 +144,10 @@ impl ReadOnlyGenerator { .map(|i| { let col = Ident::new(format!("{i}_lock").as_str(), Span::mixed_site()); quote! { - if let Some(lock) = &self.#col { - set.insert(lock.clone()); + if let Some(existing_lock) = &self.#col { + if !set.iter().any(|entry| worktable::prelude::Arc::ptr_eq(entry, existing_lock)) { + set.push(existing_lock.clone()); + } } self.#col = Some(lock.clone()); } @@ -153,9 +155,8 @@ impl ReadOnlyGenerator { .collect(); quote! { - #[allow(clippy::mutable_key_type)] - fn lock(&mut self, id: u16) -> (worktable::prelude::HashSet>, worktable::prelude::Arc) { - let mut set = worktable::prelude::HashSet::new(); + fn lock(&mut self, id: u16) -> (Vec>, worktable::prelude::Arc) { + let mut set: Vec> = Vec::new(); let lock = worktable::prelude::Arc::new(Lock::new(id)); #(#rows)* @@ -175,8 +176,8 @@ impl ReadOnlyGenerator { if let Some(#col) = &other.#col { if self.#col.is_none() { self.#col = Some(#col.clone()); - } else { - set.insert(#col.clone()); + } else if !set.iter().any(|existing| worktable::prelude::Arc::ptr_eq(existing, #col)) { + set.push(#col.clone()); } } other.#col = self.#col.clone(); @@ -185,9 +186,8 @@ impl ReadOnlyGenerator { .collect(); quote! { - #[allow(clippy::mutable_key_type)] - fn merge(&mut self, other: &mut Self) -> worktable::prelude::HashSet> { - let mut set = worktable::prelude::HashSet::new(); + fn merge(&mut self, other: &mut Self) -> Vec> { + let mut set: Vec> = Vec::new(); #(#rows)* set } diff --git a/src/lock/row_lock.rs b/src/lock/row_lock.rs index e9022790..a55beee7 100644 --- a/src/lock/row_lock.rs +++ b/src/lock/row_lock.rs @@ -1,7 +1,6 @@ use alloc::sync::Arc; use core::fmt::Debug; use core::hash::Hash; -use hashbrown::HashSet; use crate::lock::{Lock, LockGuard, LockMap, LockWait}; @@ -16,12 +15,18 @@ pub trait RowLock { fn with_lock(id: u16) -> (Self, Arc) where Self: Sized; - /// Locks full [`RowLock`]. - #[allow(clippy::mutable_key_type)] - fn lock(&mut self, id: u16) -> (HashSet>, Arc); + /// Locks full [`RowLock`], returning the predecessors to wait on. + /// + /// A `Vec`, not a `HashSet`. The collection holds one entry per column this + /// lock type covers, deduplicated by pointer, which in every shipping + /// schema is a handful. Building a `hashbrown::HashSet` for that seeded a + /// fresh `foldhash` hasher on every operation, which was about a tenth of + /// the profile on the in-place update path, to hash at most a few `Arc` + /// pointers. Linear dedup over a short `Vec` is cheaper and the caller only + /// iterates the result. + fn lock(&mut self, id: u16) -> (Vec>, Arc); /// Merges two [`RowLock`]'s. - #[allow(clippy::mutable_key_type)] - fn merge(&mut self, other: &mut Self) -> HashSet> + fn merge(&mut self, other: &mut Self) -> Vec> where Self: Sized; } @@ -81,20 +86,19 @@ impl RowLock for FullRowLock { (FullRowLock { l: l.clone() }, l) } - fn lock(&mut self, id: u16) -> (HashSet>, Arc) { - let mut set = HashSet::new(); + fn lock(&mut self, id: u16) -> (Vec>, Arc) { let l = Arc::new(Lock::new(id)); - set.insert(self.l.clone()); + let set = vec![self.l.clone()]; self.l = l.clone(); (set, l) } - fn merge(&mut self, other: &mut Self) -> HashSet> + fn merge(&mut self, other: &mut Self) -> Vec> where Self: Sized, { - let set = HashSet::from_iter([self.l.clone()]); + let set = vec![self.l.clone()]; self.l = other.l.clone(); set } From 4559484e88d4a05b257c53271a89d6308f67f574 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 15 Sep 2026 02:36:00 +0700 Subject: [PATCH 28/45] Borrow the lock map instead of cloning its Arc per operation An in-place update on a shared table got slower with every worker added: 7.4M ops/s at one, 1.9M at eight. Four earlier attempts assumed that was lock contention and changed what the lock map does. None of them moved it, because the cost was not in the map at all. Two measurements located it. The collapse happens at two workers, not eight, so it is a hard serialization point rather than growing contention. And it does not change when the key space grows 256 times, 1k rows to 262k, so it cannot be shard collisions or key conflicts. What is left is how the map is reached. Every operation cloned Arc three times: once into the LockAcquirer, once into the PendingLock, once into the LockGuard. Three atomic read-modify-writes on one cache line that every worker shares, whatever key they touch. A twenty-line program doing the same three clones reproduces the whole curve with no WorkTable in it: 92M ops/s at one worker, 5.4M at eight. All three now borrow. The caller reached the map through the table's own Arc and holds it for the operation, so it outlives every guard; the fields carry that argument and the Send impls carry it too. paged_in_place, 200k ops per worker: 1.89M to 7.44M at eight workers, and the slope from 0.255x to 0.903x. The table no longer gets slower as workers are added, which was the defect. It does not yet scale, so there is a second ceiling under this one. --- .../src/generators/in_memory/queries/locks.rs | 4 +- .../src/generators/persist/queries/locks.rs | 4 +- src/lock/map.rs | 43 ++++++++- src/lock/mod.rs | 89 +++++++++++++++---- src/lock/row_lock.rs | 2 +- src/table/vacuum/vacuum.rs | 2 +- 6 files changed, 116 insertions(+), 28 deletions(-) diff --git a/codegen/src/generators/in_memory/queries/locks.rs b/codegen/src/generators/in_memory/queries/locks.rs index 1e668af9..ee49a0e7 100644 --- a/codegen/src/generators/in_memory/queries/locks.rs +++ b/codegen/src/generators/in_memory/queries/locks.rs @@ -129,7 +129,7 @@ impl InMemoryGenerator { // predecessor wait below: a future dropped at that await (tokio // timeout, task abort) would otherwise leave the registered lock // held forever and hang every later operation on this key. - let pending_lock = PendingLock::new(op_lock, self.0.lock_manager.clone(), pk.clone()); + let pending_lock = PendingLock::new(op_lock, &self.0.lock_manager, pk.clone()); worktable::prelude::join_all(locks.iter().map(|l| l.wait()).collect::>()).await; pending_lock } @@ -158,7 +158,7 @@ impl InMemoryGenerator { // predecessor wait below: a future dropped at that await (tokio // timeout, task abort) would otherwise leave the registered lock // held forever and hang every later operation on this key. - let pending_lock = PendingLock::new(op_lock, self.0.lock_manager.clone(), pk.clone()); + let pending_lock = PendingLock::new(op_lock, &self.0.lock_manager, pk.clone()); worktable::prelude::join_all(locks.iter().map(|l| l.wait()).collect::>()).await; pending_lock } diff --git a/codegen/src/generators/persist/queries/locks.rs b/codegen/src/generators/persist/queries/locks.rs index 50478366..6af50bb7 100644 --- a/codegen/src/generators/persist/queries/locks.rs +++ b/codegen/src/generators/persist/queries/locks.rs @@ -129,7 +129,7 @@ impl PersistGenerator { // predecessor wait below: a future dropped at that await (tokio // timeout, task abort) would otherwise leave the registered lock // held forever and hang every later operation on this key. - let pending_lock = PendingLock::new(op_lock, self.0.lock_manager.clone(), pk.clone()); + let pending_lock = PendingLock::new(op_lock, &self.0.lock_manager, pk.clone()); worktable::prelude::join_all(locks.iter().map(|l| l.wait()).collect::>()).await; pending_lock } @@ -158,7 +158,7 @@ impl PersistGenerator { // predecessor wait below: a future dropped at that await (tokio // timeout, task abort) would otherwise leave the registered lock // held forever and hang every later operation on this key. - let pending_lock = PendingLock::new(op_lock, self.0.lock_manager.clone(), pk.clone()); + let pending_lock = PendingLock::new(op_lock, &self.0.lock_manager, pk.clone()); worktable::prelude::join_all(locks.iter().map(|l| l.wait()).collect::>()).await; pending_lock } diff --git a/src/lock/map.rs b/src/lock/map.rs index 5bd9d469..387d48d0 100644 --- a/src/lock/map.rs +++ b/src/lock/map.rs @@ -65,10 +65,43 @@ where { lock: Option>>, acquirers: Arc, - lock_map: Arc>, + /// Borrowed, not an `Arc` clone. + /// + /// Cloning the map's `Arc` here put an atomic increment and a matching + /// decrement on **one** refcount word into every operation, and that word + /// is shared by every worker on the table. It is independent of the key, + /// so sharding the map cannot help and a larger key space does not dilute + /// it: measured, `paged_in_place` scales the same at 1k rows and at 262k. + /// Three such clones per operation reproduce the whole negative slope in a + /// twenty-line program with no WorkTable in it (92M ops/s at one worker, + /// 5.4M at eight). + /// + /// # Safety + /// + /// The acquirer is a local of the operation that took it, and that + /// operation reached this map through the table's own + /// `Arc`, which it holds for the whole call. The map therefore + /// outlives every acquirer taken from it. + lock_map: *const LockMap, primary_key: PrimaryKey, } +// SAFETY: `LockMap` is `Sync`, and the pointer is only ever dereferenced while +// the owning `Arc` is alive (see the field note), so an acquirer is no less +// safe to move or share than a `&LockMap` would be. +unsafe impl Send for LockAcquirer +where + LockType: RowLock + Send + Sync, + PrimaryKey: Hash + Eq + Debug + Clone + Send, +{ +} +unsafe impl Sync for LockAcquirer +where + LockType: RowLock + Send + Sync, + PrimaryKey: Hash + Eq + Debug + Clone + Sync, +{ +} + impl Clone for LockAcquirer where LockType: RowLock, @@ -105,7 +138,9 @@ where fn drop(&mut self) { self.acquirers.fetch_sub(1, Ordering::AcqRel); drop(self.lock.take()); - self.lock_map.remove_with_lock_check(&self.primary_key); + // SAFETY: see the field note on `lock_map`; the owning map outlives + // this acquirer. + unsafe { (*self.lock_map).remove_with_lock_check(&self.primary_key) }; } } @@ -226,7 +261,7 @@ where return LockAcquirer { lock: Some(entry.lock.clone()), acquirers: entry.acquirers.clone(), - lock_map: self.clone(), + lock_map: Arc::as_ptr(self), primary_key: key, }; } @@ -240,7 +275,7 @@ where LockAcquirer { lock: Some(entry.lock.clone()), acquirers: entry.acquirers.clone(), - lock_map: self.clone(), + lock_map: Arc::as_ptr(self), primary_key: key, } } diff --git a/src/lock/mod.rs b/src/lock/mod.rs index 15c7bced..3c1447ec 100644 --- a/src/lock/mod.rs +++ b/src/lock/mod.rs @@ -31,7 +31,15 @@ const MAX_SPINS: u32 = 12; /// (preventing memory leaks). pub struct LockGuard { lock: Arc, - lock_map: Arc>, + /// Borrowed, not an `Arc` clone: see [`LockAcquirer`]'s field of the same + /// name. Per-operation refcount traffic on the map is the shared-write + /// bottleneck, and it is independent of the key. + /// + /// # Safety + /// + /// Every guard is created inside one operation on a table that holds the + /// map's `Arc` for the duration of that call, so the map outlives it. + lock_map: *const LockMap, primary_key: PrimaryKey, /// Present for single-row operations. Multi-row queries acquire one /// mutation stripe only while processing each row, after all row locks are @@ -41,6 +49,16 @@ pub struct LockGuard { _not_sync: PhantomData>, } +// SAFETY: `LockMap` is `Sync` and the borrowed pointer is live for the guard's +// whole lifetime (see the field note), so a guard is as safe to send as the +// `&LockMap` it stands in for. The type stays `!Sync` via `_not_sync`. +unsafe impl Send for LockGuard +where + LockType: RowLock + Send + Sync, + PrimaryKey: Hash + Eq + Debug + Clone + Send, +{ +} + impl LockGuard where LockType: RowLock, @@ -48,7 +66,21 @@ where { /// Creates a new [`LockGuard`] that will clean up the [`Lock`] entry from /// the [`LockMap`] on [`Drop`]. - pub fn new(lock: Arc, lock_map: Arc>, primary_key: PrimaryKey) -> Self { + pub fn new(lock: Arc, lock_map: &Arc>, primary_key: PrimaryKey) -> Self { + Self::from_raw(lock, Arc::as_ptr(lock_map), primary_key) + } + + /// As [`Self::new`], for a caller that already holds the borrowed map + /// pointer (a [`PendingLock`] being converted). + /// + /// # Safety + /// + /// `lock_map` must outlive the guard; see the field note. + pub(crate) fn from_raw( + lock: Arc, + lock_map: *const LockMap, + primary_key: PrimaryKey, + ) -> Self { Self { lock, lock_map, @@ -62,10 +94,12 @@ where /// synchronous insert path for the same primary key. pub fn new_with_mutation( lock: Arc, - lock_map: Arc>, + lock_map: *const LockMap, primary_key: PrimaryKey, ) -> Self { - let mutation_guard = lock_map.mutation_guard(&primary_key); + // SAFETY: the caller holds the map alive for this operation; see the + // field note on `lock_map`. + let mutation_guard = unsafe { (*lock_map).mutation_guard(&primary_key) }; Self { lock, lock_map, @@ -88,7 +122,8 @@ where { fn drop(&mut self) { self.lock.unlock(); - self.lock_map.remove_with_lock_check(&self.primary_key); + // SAFETY: see the field note; the map outlives this guard. + unsafe { (*self.lock_map).remove_with_lock_check(&self.primary_key) }; } } @@ -107,10 +142,27 @@ where /// cleanup and hands ownership over. pub struct PendingLock { lock: Option>, - lock_map: Arc>, + /// Borrowed, not an `Arc` clone: see [`LockAcquirer`]'s field of the same + /// name. Per-operation refcount traffic on the map is the shared-write + /// bottleneck, and it is independent of the key. + /// + /// # Safety + /// + /// Every guard is created inside one operation on a table that holds the + /// map's `Arc` for the duration of that call, so the map outlives it. + lock_map: *const LockMap, primary_key: PrimaryKey, } +// SAFETY: `LockMap` is `Sync` and the pointer is live for the guard's whole +// lifetime (see the field note), so this is as safe to move as a `&LockMap`. +unsafe impl Send for PendingLock +where + LockType: RowLock + Send + Sync, + PrimaryKey: Hash + Eq + Debug + Clone + Send, +{ +} + impl PendingLock where LockType: RowLock, @@ -118,10 +170,10 @@ where { /// Takes ownership of a freshly registered operation lock. Must be called /// synchronously after registration, before the predecessor wait. - pub fn new(lock: Arc, lock_map: Arc>, primary_key: PrimaryKey) -> Self { + pub fn new(lock: Arc, lock_map: &Arc>, primary_key: PrimaryKey) -> Self { Self { lock: Some(lock), - lock_map, + lock_map: Arc::as_ptr(lock_map), primary_key, } } @@ -132,7 +184,7 @@ where .lock .take() .expect("pending lock is intact until conversion or drop"); - LockGuard::new(lock, self.lock_map.clone(), self.primary_key.clone()) + LockGuard::from_raw(lock, self.lock_map, self.primary_key.clone()) } /// Defuses the cancellation cleanup and converts into a [`LockGuard`] @@ -147,7 +199,7 @@ where .lock .take() .expect("pending lock is intact until conversion or drop"); - LockGuard::new_with_mutation(lock, self.lock_map.clone(), self.primary_key.clone()) + LockGuard::new_with_mutation(lock, self.lock_map, self.primary_key.clone()) } } @@ -159,7 +211,8 @@ where fn drop(&mut self) { if let Some(lock) = self.lock.take() { lock.unlock(); - self.lock_map.remove_with_lock_check(&self.primary_key); + // SAFETY: see the field note; the map outlives this guard. + unsafe { (*self.lock_map).remove_with_lock_check(&self.primary_key) }; } } } @@ -307,7 +360,7 @@ mod tests { assert!(lock.is_locked()); { - let _guard = LockGuard::::new(lock.clone(), lock_map.clone(), pk); + let _guard = LockGuard::::new(lock.clone(), &lock_map, pk); assert!(lock.is_locked()); } @@ -321,7 +374,7 @@ mod tests { let pk = 1u64; assert!(lock.is_locked()); - let guard = LockGuard::::new(lock.clone(), lock_map.clone(), pk); + let guard = LockGuard::::new(lock.clone(), &lock_map, pk); assert!(lock.is_locked()); guard.unlock(); @@ -337,7 +390,7 @@ mod tests { assert!(lock.is_locked()); let result = std::panic::catch_unwind(AssertUnwindSafe(|| { - let _guard = LockGuard::::new(lock.clone(), lock_map.clone(), pk); + let _guard = LockGuard::::new(lock.clone(), &lock_map, pk); panic!("test panic"); })); @@ -358,9 +411,9 @@ mod tests { assert!(lock3.is_locked()); { - let _guard1 = LockGuard::::new(lock1.clone(), lock_map.clone(), 1u64); - let _guard2 = LockGuard::::new(lock2.clone(), lock_map.clone(), 2u64); - let _guard3 = LockGuard::::new(lock3.clone(), lock_map.clone(), 3u64); + let _guard1 = LockGuard::::new(lock1.clone(), &lock_map, 1u64); + let _guard2 = LockGuard::::new(lock2.clone(), &lock_map, 2u64); + let _guard3 = LockGuard::::new(lock3.clone(), &lock_map, 3u64); assert!(lock1.is_locked()); assert!(lock2.is_locked()); @@ -397,7 +450,7 @@ mod tests { // Create a guard and drop it { - let _guard = LockGuard::new(lock, lock_map.clone(), pk); + let _guard = LockGuard::new(lock, &lock_map, pk); } // Verify the lock entry was removed from the map diff --git a/src/lock/row_lock.rs b/src/lock/row_lock.rs index a55beee7..56440103 100644 --- a/src/lock/row_lock.rs +++ b/src/lock/row_lock.rs @@ -48,7 +48,7 @@ impl FullRowLock { /// dropped. pub fn guard( self, - lock_map: Arc>, + lock_map: &Arc>, primary_key: PrimaryKey, ) -> LockGuard { LockGuard::new(self.l, lock_map, primary_key) diff --git a/src/table/vacuum/vacuum.rs b/src/table/vacuum/vacuum.rs index e1786662..b0f04bab 100644 --- a/src/table/vacuum/vacuum.rs +++ b/src/table/vacuum/vacuum.rs @@ -579,7 +579,7 @@ where to: PageId, ) -> eyre::Result { let lock = self.full_row_lock(&pk).await; - let _guard = LockGuard::new_with_mutation(lock, self.lock_manager.clone(), pk.clone()); + let _guard = LockGuard::new_with_mutation(lock, Arc::as_ptr(&self.lock_manager), pk.clone()); let current_link: Option = self.primary_index.pk_map.lookup_for_select(&pk).map(Into::into); if current_link != Some(from_link) { From 1ac74c8398f0bb7417311f5d7eaca7d319eb65fe Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 15 Sep 2026 03:13:12 +0700 Subject: [PATCH 29/45] Stop the row-lock protocol sharing cache lines per operation A shared in-place update lost throughput as workers were added. Removing the three `Arc` clones fixed the negative slope but left it flat at 1.03x, and the remaining ceiling was three more per-operation writes to memory that every worker on the table touches whatever key it holds. The lock label was minted from one table-wide `AtomicU16`. `Lock::id` is a diagnostic label, not dependency identity, and nothing in the protocol reads it back, but the counter is independent of the key, so sharding could not dilute it. It now comes from the key's own mutation stripe. `MutationStripe` was two atomics, so eight stripes shared one 128-byte granule and a ticket taken for one invalidated the line seven others were spinning on. Each stripe now owns a granule, and carries that key range's label counter so an operation touches one line rather than two. `MutationGuard` cloned the stripe array's `Arc`, which is the same defect as the map clones in the one place on the path that still had it. It borrows now, on the same contract as the other guards in this module. With the shared lines gone the cost was map work and allocation. The predecessor wait is skipped when there is no predecessor, which on disjoint keys is every operation. `LockEntry::acquirers` is inline rather than an `Arc`: the acquirer cloned it to decrement without touching the map, but its drop then took the shard write lock anyway to re-look the entry up, so the clone bought nothing and cost an allocation and a free per operation. That decrement now happens under the same guard as the removal check. Last, the map had too few shards. An operation takes its shard lock twice, to insert its entry and to remove it, and a collision parks the loser in the kernel; at 64 shards eight workers collide about a tenth of the time. Measured at 64, 256, 512, 1024 and 2048, the slope goes 1.70x, 2.39x, 2.63x, 2.81x, 3.07x. 1024 is where it stops paying for itself. Shard and stripe indices are now separate reductions of one hash: they were one const, and tuning them together hid which of them mattered. `paged_in_place` at 16384 rows, 200k ops per worker, arctic, eight workers against one: 0.798x to 2.76x, and 6.27M to 26.3M ops/s. `LockGuard::new_with_mutation` is `unsafe` because it dereferences the raw map pointer it is handed. It already did; clippy's `not_unsafe_ptr_arg_deref` failed the build before this change. --- .../src/generators/in_memory/queries/locks.rs | 24 +- .../src/generators/persist/queries/locks.rs | 24 +- src/lock/map.rs | 215 +++++++++++++++--- src/lock/mod.rs | 15 +- src/table/vacuum/vacuum.rs | 5 +- 5 files changed, 244 insertions(+), 39 deletions(-) diff --git a/codegen/src/generators/in_memory/queries/locks.rs b/codegen/src/generators/in_memory/queries/locks.rs index ee49a0e7..3ec3b81a 100644 --- a/codegen/src/generators/in_memory/queries/locks.rs +++ b/codegen/src/generators/in_memory/queries/locks.rs @@ -115,7 +115,9 @@ impl InMemoryGenerator { let lock_ident = name_generator.get_lock_type_ident(); quote! { - let lock_id = self.0.lock_manager.next_id(); + // Striped by the key: a table-wide label counter is one shared + // cache line per locked operation. See LockMap::next_ids. + let lock_id = self.0.lock_manager.next_id_for(&pk); // Same atomic acquire as the per-column path: see LockMap::get_or_insert_with. let lock = self .0 @@ -130,7 +132,13 @@ impl InMemoryGenerator { // timeout, task abort) would otherwise leave the registered lock // held forever and hang every later operation on this key. let pending_lock = PendingLock::new(op_lock, &self.0.lock_manager, pk.clone()); - worktable::prelude::join_all(locks.iter().map(|l| l.wait()).collect::>()).await; + // No predecessor is the common case on disjoint keys: the row had no + // entry, so every column slot was empty. Registering a waker on each + // of nothing, boxing the joined slice and suspending the operation to + // poll it once is all overhead there. + if !locks.is_empty() { + worktable::prelude::join_all(locks.iter().map(|l| l.wait()).collect::>()).await; + } pending_lock } } @@ -140,7 +148,9 @@ impl InMemoryGenerator { let lock_ident = name_generator.get_lock_type_ident(); quote! { - let lock_id = self.0.lock_manager.next_id(); + // Striped by the key: a table-wide label counter is one shared + // cache line per locked operation. See LockMap::next_ids. + let lock_id = self.0.lock_manager.next_id_for(&pk); // One atomic acquire, no check-then-act. Splitting this into `get` // then `insert` let two tasks both miss, both build a lock and both // enter the row: the loser merged into the winner's lock, but the @@ -159,7 +169,13 @@ impl InMemoryGenerator { // timeout, task abort) would otherwise leave the registered lock // held forever and hang every later operation on this key. let pending_lock = PendingLock::new(op_lock, &self.0.lock_manager, pk.clone()); - worktable::prelude::join_all(locks.iter().map(|l| l.wait()).collect::>()).await; + // No predecessor is the common case on disjoint keys: the row had no + // entry, so every column slot was empty. Registering a waker on each + // of nothing, boxing the joined slice and suspending the operation to + // poll it once is all overhead there. + if !locks.is_empty() { + worktable::prelude::join_all(locks.iter().map(|l| l.wait()).collect::>()).await; + } pending_lock } } diff --git a/codegen/src/generators/persist/queries/locks.rs b/codegen/src/generators/persist/queries/locks.rs index 6af50bb7..509755a2 100644 --- a/codegen/src/generators/persist/queries/locks.rs +++ b/codegen/src/generators/persist/queries/locks.rs @@ -115,7 +115,9 @@ impl PersistGenerator { let lock_ident = name_generator.get_lock_type_ident(); quote! { - let lock_id = self.0.lock_manager.next_id(); + // Striped by the key: a table-wide label counter is one shared + // cache line per locked operation. See LockMap::next_ids. + let lock_id = self.0.lock_manager.next_id_for(&pk); // Same atomic acquire as the per-column path: see LockMap::get_or_insert_with. let lock = self .0 @@ -130,7 +132,13 @@ impl PersistGenerator { // timeout, task abort) would otherwise leave the registered lock // held forever and hang every later operation on this key. let pending_lock = PendingLock::new(op_lock, &self.0.lock_manager, pk.clone()); - worktable::prelude::join_all(locks.iter().map(|l| l.wait()).collect::>()).await; + // No predecessor is the common case on disjoint keys: the row had no + // entry, so every column slot was empty. Registering a waker on each + // of nothing, boxing the joined slice and suspending the operation to + // poll it once is all overhead there. + if !locks.is_empty() { + worktable::prelude::join_all(locks.iter().map(|l| l.wait()).collect::>()).await; + } pending_lock } } @@ -140,7 +148,9 @@ impl PersistGenerator { let lock_ident = name_generator.get_lock_type_ident(); quote! { - let lock_id = self.0.lock_manager.next_id(); + // Striped by the key: a table-wide label counter is one shared + // cache line per locked operation. See LockMap::next_ids. + let lock_id = self.0.lock_manager.next_id_for(&pk); // One atomic acquire, no check-then-act. Splitting this into `get` // then `insert` let two tasks both miss, both build a lock and both // enter the row: the loser merged into the winner's lock, but the @@ -159,7 +169,13 @@ impl PersistGenerator { // timeout, task abort) would otherwise leave the registered lock // held forever and hang every later operation on this key. let pending_lock = PendingLock::new(op_lock, &self.0.lock_manager, pk.clone()); - worktable::prelude::join_all(locks.iter().map(|l| l.wait()).collect::>()).await; + // No predecessor is the common case on disjoint keys: the row had no + // entry, so every column slot was empty. Registering a waker on each + // of nothing, boxing the joined slice and suspending the operation to + // poll it once is all overhead there. + if !locks.is_empty() { + worktable::prelude::join_all(locks.iter().map(|l| l.wait()).collect::>()).await; + } pending_lock } } diff --git a/src/lock/map.rs b/src/lock/map.rs index 387d48d0..95dca43b 100644 --- a/src/lock/map.rs +++ b/src/lock/map.rs @@ -11,17 +11,52 @@ use parking_lot::RwLock; use crate::lock::RowLock; +/// Gates for the synchronous mutation phase. +/// +/// Sixty-four is enough: raising it to 1024 on top of the shard count below +/// moves `paged_in_place` at eight workers from 2.81x to 2.92x, which does not +/// pay for sixteen times the gates. The gate is entered and left in tens of +/// nanoseconds, so collisions on it stay rare at this count. const MUTATION_STRIPE_COUNT: usize = 64; -/// Same count as mutation stripes so a key's row-lock shard and mutation -/// stripe are one hash. One global `RwLock` serialized every -/// acquire and drop; 8 disjoint writers then burned ~6 cores for 1.27× -/// replace. Per-shard maps let those writers proceed independently. -const MAP_SHARD_COUNT: usize = MUTATION_STRIPE_COUNT; +/// Independent `RwLock` shards of the row-lock map. +/// +/// Far more than the stripe count, because a locked operation costs the map +/// two exclusive shard acquisitions where it costs the gate one short critical +/// section: a disjoint-key writer inserts its entry on the way in and removes +/// it on the way out, and a shard collision parks the loser in the kernel. +/// Measured on `paged_in_place`, eight workers over 16384 keys: +/// +/// | shards | w8 vs w1 | +/// |---:|---:| +/// | 64 | 1.70x | +/// | 256 | 2.39x | +/// | 512 | 2.63x | +/// | 1024 | **2.81x** | +/// | 2048 | 3.07x | +/// +/// 2048 still gains, but it doubles a per-table array for 9%. A shard holds no +/// buckets until a key lands in it, so what this count costs is the array, not +/// the maps. +/// +/// One table-wide `RwLock` serialized every acquire and drop before +/// any of this: 8 disjoint writers then burned ~6 cores for 1.27x replace. +const MAP_SHARD_COUNT: usize = 1024; +/// One mutation stripe: a ticket lock and the lock-label counter for the same +/// set of keys. +/// +/// Aligned to a whole coherency granule. Unpadded, the three fields are 20 +/// bytes, so eight stripes shared one line: a writer taking a ticket for its +/// own stripe invalidated the line seven unrelated stripes were spinning on, +/// and the `serving` spin re-read it every time. Sixty-four stripes behaved +/// like eight. The label counter lives here rather than in its own array so a +/// locked operation touches one line for both, not two. #[derive(Debug, Default)] +#[repr(align(128))] struct MutationStripe { next_ticket: AtomicU64, serving: AtomicU64, + next_label: AtomicU16, } /// Synchronous, task-safe gate for one primary-key mutation stripe. @@ -31,10 +66,30 @@ struct MutationStripe { /// publication with an update or delete of the same key. #[derive(Debug)] pub struct MutationGuard { - stripes: Arc<[MutationStripe; MUTATION_STRIPE_COUNT]>, + /// Borrowed, not an `Arc` clone. + /// + /// Cloning the map's stripe array put an atomic increment and a matching + /// decrement on one refcount word into every mutation. That word is shared + /// by every worker on the table and is independent of the key, so striping + /// cannot dilute it and a larger key space does not either: it is the same + /// defect as the `Arc` clones removed from `LockAcquirer` and + /// `PendingLock`, in the one place on the path that still had it. + /// + /// # Safety + /// + /// A guard is a local of the operation that took it, and that operation + /// reached this map through the table's own `Arc`, which it holds + /// for the whole call. The array therefore outlives every guard taken from + /// it. + stripes: *const [MutationStripe; MUTATION_STRIPE_COUNT], stripe: usize, } +// SAFETY: the pointed-to array is `Sync` and outlives the guard (see the field +// note), so a guard is no less safe to move or share than a `&[MutationStripe]`. +unsafe impl Send for MutationGuard {} +unsafe impl Sync for MutationGuard {} + /// Operation-wide activity signal for a chunked bulk mutation. /// /// It does not hold a row or stripe lock. Its only job is to keep background @@ -49,7 +104,14 @@ pub struct BulkMutationGuard { #[derive(Debug)] struct LockEntry { lock: Arc>, - acquirers: Arc, + /// Callers that may still register an operation against this entry. + /// + /// Inline, not an `Arc`. An acquirer used to clone it so it could + /// decrement without touching the map, but its drop then took the shard's + /// write lock anyway, to re-look the entry up for the removal check. The + /// clone bought nothing and cost an allocation and a free on every + /// operation; the decrement now happens under that same write guard. + acquirers: AtomicUsize, } /// A tracked reference to one row-lock entry while an operation registers. @@ -64,7 +126,6 @@ where PrimaryKey: Hash + Eq + Debug + Clone, { lock: Option>>, - acquirers: Arc, /// Borrowed, not an `Arc` clone. /// /// Cloning the map's `Arc` here put an atomic increment and a matching @@ -108,11 +169,11 @@ where PrimaryKey: Hash + Eq + Debug + Clone, { fn clone(&self) -> Self { - self.acquirers.fetch_add(1, Ordering::AcqRel); + // SAFETY: see the field note; the map outlives this acquirer. + unsafe { (*self.lock_map).retain_acquirer(&self.primary_key) }; Self { lock: self.lock.clone(), - acquirers: self.acquirers.clone(), - lock_map: self.lock_map.clone(), + lock_map: self.lock_map, primary_key: self.primary_key.clone(), } } @@ -136,17 +197,17 @@ where PrimaryKey: Hash + Eq + Debug + Clone, { fn drop(&mut self) { - self.acquirers.fetch_sub(1, Ordering::AcqRel); drop(self.lock.take()); // SAFETY: see the field note on `lock_map`; the owning map outlives // this acquirer. - unsafe { (*self.lock_map).remove_with_lock_check(&self.primary_key) }; + unsafe { (*self.lock_map).release_acquirer(&self.primary_key) }; } } impl Drop for MutationGuard { fn drop(&mut self) { - self.stripes[self.stripe].serving.fetch_add(1, Ordering::Release); + // SAFETY: see the field note; the map outlives this guard. + unsafe { (*self.stripes)[self.stripe].serving.fetch_add(1, Ordering::Release) }; } } @@ -167,16 +228,57 @@ impl Drop for BulkMutationGuard { /// waits on a Tokio lock while holding the shard. This one-way boundary prevents /// a map-lock/per-row-lock cycle during cancellation and `Drop`. /// +/// # Borrowed guards +/// +/// [`LockAcquirer`], [`MutationGuard`] and their callers in [`crate::lock`] +/// hold a raw `*const` to this map or to its stripe array rather than an +/// `Arc` clone or a `&`. The `Arc` clone is what they are avoiding: it is a +/// read-modify-write on one key-independent cache line per operation, and +/// removing three of them took `paged_in_place` from losing throughput with +/// every added worker to holding it. A `&` would be sound and equally fast, +/// but these guards are held across `.await` inside generated operations whose +/// futures must be `'static` to spawn, so a lifetime on the guard becomes a +/// lifetime on the future. +/// +/// What that costs is a safe public API that can be misused: taking a guard +/// and then dropping the last `Arc` while the guard lives is +/// undefined behaviour. Every in-crate and generated caller takes its guard as +/// a local of an operation that holds the table's own `Arc` for the +/// whole call, which is why this is sound as used. +/// /// # Sharding /// -/// The map is `MAP_SHARD_COUNT` independent `RwLock`s, keyed by the -/// same hash as mutation stripes. A single table-wide map lock made every -/// `get_or_insert_with` miss and every `LockAcquirer` drop exclusive against -/// every other row. +/// The map is `MAP_SHARD_COUNT` independent `RwLock`s. A single +/// table-wide map lock made every `get_or_insert_with` miss and every +/// `LockAcquirer` drop exclusive against every other row. Shards and mutation +/// stripes share one hash of the key but reduce it separately, because the two +/// counts answer to different costs. #[derive(Debug)] pub struct LockMap { map: Box<[RwLock>>; MAP_SHARD_COUNT]>, + /// Table-wide label counter, for the cold callers that have no key in hand + /// (vacuum, and the raw `FullRowLock` helper). Locked operations must use + /// `next_ids` instead: see the note there. next_id: AtomicU16, + /// Per-shard label counters, one to a cache line. + /// + /// `Lock::id` is a diagnostic label. It is not dependency identity, which + /// is `Arc` pointer equality on the lock's `locked` flag, and nothing in + /// the protocol reads it back. Minting it from one table-wide + /// `AtomicU16::fetch_add` nevertheless put a read-modify-write on a single + /// shared cache line into every locked operation, and, being independent + /// of the key, it was a line that eight writers on disjoint rows still + /// fought over. That is the same shape of defect as the `Arc` + /// refcount clone removed just before it, and it survived that fix because + /// the label is minted in generated code rather than in the map. + /// + /// Striping by the key's shard makes the line key-dependent, so disjoint + /// writers stop sharing it. Labels may now repeat across shards sooner + /// than a single counter would repeat, which the type already allows: a + /// `u16` wraps every 65536 operations regardless. + /// + /// The counters live in `mutation_stripes`, one per stripe, because a + /// locked operation already touches its stripe's line. mutation_stripes: Arc<[MutationStripe; MUTATION_STRIPE_COUNT]>, bulk_mutations: Arc, } @@ -200,7 +302,7 @@ where &self, key: &PrimaryKey, ) -> &RwLock>> { - &self.map[Self::stripe_of(key)] + &self.map[Self::shard_of(key)] } #[cfg(test)] @@ -225,7 +327,7 @@ where key, LockEntry { lock, - acquirers: Arc::new(AtomicUsize::new(0)), + acquirers: AtomicUsize::new(0), }, ) .map(|entry| entry.lock) @@ -260,7 +362,6 @@ where entry.acquirers.fetch_add(1, Ordering::AcqRel); return LockAcquirer { lock: Some(entry.lock.clone()), - acquirers: entry.acquirers.clone(), lock_map: Arc::as_ptr(self), primary_key: key, }; @@ -269,12 +370,11 @@ where // Re-check: another task can insert between the read and write guards. let entry = map.entry(key.clone()).or_insert_with(|| LockEntry { lock: Arc::new(nagoya::sync::RwLock::new(f())), - acquirers: Arc::new(AtomicUsize::new(0)), + acquirers: AtomicUsize::new(0), }); entry.acquirers.fetch_add(1, Ordering::AcqRel); LockAcquirer { lock: Some(entry.lock.clone()), - acquirers: entry.acquirers.clone(), lock_map: Arc::as_ptr(self), primary_key: key, } @@ -284,11 +384,48 @@ where self.shard(key).write().remove(key); } + /// Registers one more caller that may still acquire `key`'s entry. + /// + /// Only [`LockAcquirer::clone`] needs this; the acquiring paths increment + /// while they already hold a shard guard. + fn retain_acquirer(&self, key: &PrimaryKey) { + if let Some(entry) = self.shard(key).read().get(key) { + entry.acquirers.fetch_add(1, Ordering::AcqRel); + } + } + + /// Drops one acquirer of `key` and removes the entry if that was the last + /// reason to keep it. + /// + /// The decrement and the removal check share one shard write guard. Split + /// across an atomic and a separate call they were two shared-memory + /// operations where one does, and the check has to take the guard either + /// way. + fn release_acquirer(&self, key: &PrimaryKey) + where + LockType: RowLock, + { + let mut set = self.shard(key).write(); + if let Some(entry) = set.get(key) { + entry.acquirers.fetch_sub(1, Ordering::AcqRel); + } + Self::remove_if_unused(&mut set, key); + } + pub fn remove_with_lock_check(&self, key: &PrimaryKey) where LockType: RowLock, { let mut set = self.shard(key).write(); + Self::remove_if_unused(&mut set, key); + } + + fn remove_if_unused( + set: &mut HashMap>, + key: &PrimaryKey, + ) where + LockType: RowLock, + { let should_remove = set.get(key).is_some_and(|entry| { let Some(guard) = entry.lock.try_read() else { return false; @@ -311,6 +448,17 @@ where self.next_id.fetch_add(1, Ordering::Relaxed) } + /// Mints a lock label from `key`'s own shard counter. + /// + /// The hot-path form of [`Self::next_id`]. Generated locked operations + /// call this; see the note on `next_ids` for why the table-wide counter is + /// not acceptable there. + pub fn next_id_for(&self, key: &PrimaryKey) -> u16 { + self.mutation_stripes[Self::stripe_of(key)] + .next_label + .fetch_add(1, Ordering::Relaxed) + } + /// Serializes the synchronous mutation phase for this key. /// /// The holder must not perform a suspending `.await`. Generated locked @@ -342,10 +490,25 @@ where .collect() } - fn stripe_of(key: &PrimaryKey) -> usize { + fn hash_of(key: &PrimaryKey) -> usize { let mut hasher = DefaultHasher::default(); key.hash(&mut hasher); - (hasher.finish() as usize) % MUTATION_STRIPE_COUNT + hasher.finish() as usize + } + + fn stripe_of(key: &PrimaryKey) -> usize { + Self::hash_of(key) % MUTATION_STRIPE_COUNT + } + + /// The row-lock shard for `key`. + /// + /// Derived from the same hash as the mutation stripe but reduced + /// separately: the two counts are tuned against different costs and are + /// not required to match. Folding the stripe index into the shard index + /// instead, as one modulo of the other, silently caps the shard count at + /// the stripe count. + fn shard_of(key: &PrimaryKey) -> usize { + Self::hash_of(key) % MAP_SHARD_COUNT } /// Mutation stripes currently held or being waited on. @@ -410,7 +573,7 @@ where } MutationGuard { - stripes: self.mutation_stripes.clone(), + stripes: Arc::as_ptr(&self.mutation_stripes), stripe, } } diff --git a/src/lock/mod.rs b/src/lock/mod.rs index 3c1447ec..8319f968 100644 --- a/src/lock/mod.rs +++ b/src/lock/mod.rs @@ -92,13 +92,18 @@ where /// Creates a row guard that also serializes the mutation phase with the /// synchronous insert path for the same primary key. - pub fn new_with_mutation( + /// + /// # Safety + /// + /// `lock_map` must point to a live map that outlives the returned guard. + /// It is dereferenced here to take the mutation gate, and again when the + /// guard drops; see the field note on `lock_map`. + pub unsafe fn new_with_mutation( lock: Arc, lock_map: *const LockMap, primary_key: PrimaryKey, ) -> Self { - // SAFETY: the caller holds the map alive for this operation; see the - // field note on `lock_map`. + // SAFETY: guaranteed by this function's own contract. let mutation_guard = unsafe { (*lock_map).mutation_guard(&primary_key) }; Self { lock, @@ -199,7 +204,9 @@ where .lock .take() .expect("pending lock is intact until conversion or drop"); - LockGuard::new_with_mutation(lock, self.lock_map, self.primary_key.clone()) + // SAFETY: a pending lock is a local of the operation that took it, and + // that operation holds the map's `Arc` for its whole call. + unsafe { LockGuard::new_with_mutation(lock, self.lock_map, self.primary_key.clone()) } } } diff --git a/src/table/vacuum/vacuum.rs b/src/table/vacuum/vacuum.rs index b0f04bab..c2bd7e37 100644 --- a/src/table/vacuum/vacuum.rs +++ b/src/table/vacuum/vacuum.rs @@ -579,7 +579,10 @@ where to: PageId, ) -> eyre::Result { let lock = self.full_row_lock(&pk).await; - let _guard = LockGuard::new_with_mutation(lock, Arc::as_ptr(&self.lock_manager), pk.clone()); + // SAFETY: the guard is a local of this call, which holds + // `self.lock_manager` for its whole body. + let _guard = + unsafe { LockGuard::new_with_mutation(lock, Arc::as_ptr(&self.lock_manager), pk.clone()) }; let current_link: Option = self.primary_index.pk_map.lookup_for_select(&pk).map(Into::into); if current_link != Some(from_link) { From e73e1bf79fd5f3483b5d8bd14ef7188f2125fdb4 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 15 Sep 2026 03:41:17 +0700 Subject: [PATCH 30/45] Keep a lock's flag in the lock instead of its own allocation `Lock::locked` was an `Arc` so that a `LockWait` could outlive the lock it waits on. A wait can hold an `Arc` instead and keep the whole lock alive, which costs one pointer in a future that is only built when a predecessor is actually held, and saves an allocation and a free on every locked operation whether or not anything waits. Once the map's per-operation exclusive acquisitions were gone, freeing was 30% of the in-place update profile. This is half of what it was freeing. Lock identity is unchanged in meaning: equality and hashing were pointer identity on the flag's allocation, which existed one per lock, and are now pointer identity on the lock. The label stays a label. `paged_private_update`, 16384 rows, 200k ops per worker, arctic: 8.5M to 10.0M at one worker and 47M to 51M at eight. `paged_in_place` 9.5M to 10.5M at one. --- src/lock/mod.rs | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/lock/mod.rs b/src/lock/mod.rs index 8319f968..19378b37 100644 --- a/src/lock/mod.rs +++ b/src/lock/mod.rs @@ -226,16 +226,24 @@ where #[derive(Debug)] pub struct Lock { - // A wrapping diagnostic label, not dependency identity. The existing - // locked allocation stays unique and stable for this lock lifetime. + // A wrapping diagnostic label, not dependency identity. The lock's own + // allocation is what stays unique and stable for its lifetime. id: u16, - locked: Arc, + /// Inline, not an `Arc`. + /// + /// It was separately allocated so a [`LockWait`] could outlive the lock it + /// waits on. A wait can hold an `Arc` instead and keep the whole lock + /// alive, which costs one pointer in a rarely-built future and saves an + /// allocation and a free on **every** locked operation, built or not. + /// Freeing was 30% of the profile on the in-place update path once the + /// map's exclusive acquisitions were out of the way. + locked: AtomicBool, wakers: Mutex>>, } impl PartialEq for Lock { fn eq(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.locked, &other.locked) + core::ptr::eq(self, other) } } @@ -243,7 +251,7 @@ impl Eq for Lock {} impl Hash for Lock { fn hash(&self, state: &mut H) { - Hash::hash(&Arc::as_ptr(&self.locked), state) + Hash::hash(&(self as *const Self), state) } } @@ -257,7 +265,7 @@ impl Lock { pub fn new(id: u16) -> Self { Self { id, - locked: Arc::new(AtomicBool::from(true)), + locked: AtomicBool::new(true), wakers: Mutex::new(vec![]), } } @@ -270,7 +278,7 @@ impl Lock { pub fn new_released(id: u16) -> Self { Self { id, - locked: Arc::new(AtomicBool::new(false)), + locked: AtomicBool::new(false), wakers: Mutex::new(vec![]), } } @@ -296,12 +304,14 @@ impl Lock { self.locked.load(Ordering::Acquire) } - pub fn wait(&self) -> LockWait { + /// Takes `&Arc` because the returned wait keeps the lock alive: the + /// flag it polls lives in the lock now rather than in its own allocation. + pub fn wait(self: &Arc) -> LockWait { let mut guard = self.wakers.lock(); let waker = Arc::new(AtomicWaker::new()); guard.push(waker.clone()); LockWait { - locked: self.locked.clone(), + lock: Arc::clone(self), waker, } } @@ -309,7 +319,7 @@ impl Lock { #[derive(Debug)] pub struct LockWait { - locked: Arc, + lock: Arc, waker: Arc, } @@ -318,21 +328,21 @@ impl Future for LockWait { fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { // Fast path: already unlocked - if !self.locked.load(Ordering::Acquire) { + if !self.lock.locked.load(Ordering::Acquire) { return Poll::Ready(()); } // Spin phase: try up to MAX_SPINS before going async for _ in 0..MAX_SPINS { core::hint::spin_loop(); - if !self.locked.load(Ordering::Acquire) { + if !self.lock.locked.load(Ordering::Acquire) { return Poll::Ready(()); } } // Async phase: register waker and wait self.waker.register(cx.waker()); - if self.locked.load(Ordering::Acquire) { + if self.lock.locked.load(Ordering::Acquire) { Poll::Pending } else { Poll::Ready(()) From cf83335018dd584abd4b39d7d9e087062ac54036 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 15 Sep 2026 04:01:43 +0700 Subject: [PATCH 31/45] Get the branch green: fmt, clippy, no-std and the loom models Five CI jobs had been failing since before the scaling work, and the branch cannot merge while they are red. None of them is a new failure and all five reproduce locally. Formatting: thirteen files, from several earlier commits on this branch. `cargo fmt --all`. Clippy under `-D warnings`, which is stricter than a bare `cargo clippy` and is what CI runs. Two errors. The row-lock map's shard array tripped `type_complexity`, now a named `LockShard` alias. `ArchivedCopy` tripped `large_enum_variant`, where the lint's own fix is the defect: boxing the `Stack` variant puts the small-row snapshot on the heap, which is the allocation the type exists to avoid, and the enum is a local of the select path that is never stored in a collection. Allowed, with that reason. No default features: `src/lock/map.rs` used `Box` and `src/lock/row_lock.rs` used `Vec` without importing them from `alloc`, which is invisible in any build where `std`'s prelude supplies both. The other 22 errors were type inference failing on the back of those two. The loom models overflowed their coroutine stack before reaching an assertion, so the archived-row lock has had no model coverage at all on this branch. They built `CellLocks` with `default()`, which returns two 256-slot atomic arrays by value and so reserves a copy on the caller's stack: affordable on a real thread and not on a loom coroutine, whose atomics each carry tracking state. `CellLocks::initialize_at` exists for precisely this and is what `Data` uses in production; the models now build through it. Both pass and both now reach their assertions. --- .../src/generators/in_memory/table/impls.rs | 2 +- codegen/src/generators/in_memory/wrapper.rs | 2 +- codegen/src/generators/persist/table/impls.rs | 4 +- codegen/src/generators/persist/wrapper.rs | 3 +- .../src/generators/read_only/table/impls.rs | 4 +- codegen/src/generators/read_only/wrapper.rs | 3 +- src/in_memory/data.rs | 39 ++++++++++++++----- src/in_memory/mod.rs | 6 +-- src/in_memory/pages.rs | 18 +++------ src/lock/map.rs | 19 +++++---- src/lock/mod.rs | 2 +- src/lock/row_lock.rs | 1 + src/table/mod.rs | 3 +- src/table/vacuum/vacuum.rs | 3 +- 14 files changed, 60 insertions(+), 49 deletions(-) diff --git a/codegen/src/generators/in_memory/table/impls.rs b/codegen/src/generators/in_memory/table/impls.rs index 24379961..d01801b9 100644 --- a/codegen/src/generators/in_memory/table/impls.rs +++ b/codegen/src/generators/in_memory/table/impls.rs @@ -3,7 +3,7 @@ use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; use crate::common::model::GeneratorType; -use crate::common::name_generator::{archived_field_is_inline_scalar, WorktableNameGenerator}; +use crate::common::name_generator::{WorktableNameGenerator, archived_field_is_inline_scalar}; use crate::generators::in_memory::InMemoryGenerator; impl InMemoryGenerator { diff --git a/codegen/src/generators/in_memory/wrapper.rs b/codegen/src/generators/in_memory/wrapper.rs index 775b2156..8b05d5d6 100644 --- a/codegen/src/generators/in_memory/wrapper.rs +++ b/codegen/src/generators/in_memory/wrapper.rs @@ -1,4 +1,4 @@ -use crate::common::name_generator::{archived_field_is_inline_scalar, WorktableNameGenerator}; +use crate::common::name_generator::{WorktableNameGenerator, archived_field_is_inline_scalar}; use crate::generators::in_memory::InMemoryGenerator; use proc_macro2::TokenStream; use quote::quote; diff --git a/codegen/src/generators/persist/table/impls.rs b/codegen/src/generators/persist/table/impls.rs index c3a5ee4c..a0ce201a 100644 --- a/codegen/src/generators/persist/table/impls.rs +++ b/codegen/src/generators/persist/table/impls.rs @@ -3,7 +3,9 @@ use proc_macro2::{Ident, Literal, Span, TokenStream}; use quote::quote; use crate::common::model::GeneratorType; -use crate::common::name_generator::{archived_field_is_inline_scalar, WorktableNameGenerator, is_float, is_unsized_vec}; +use crate::common::name_generator::{ + WorktableNameGenerator, archived_field_is_inline_scalar, is_float, is_unsized_vec, +}; use crate::generators::persist::PersistGenerator; impl PersistGenerator { diff --git a/codegen/src/generators/persist/wrapper.rs b/codegen/src/generators/persist/wrapper.rs index 7377239c..5c0bc922 100644 --- a/codegen/src/generators/persist/wrapper.rs +++ b/codegen/src/generators/persist/wrapper.rs @@ -1,4 +1,4 @@ -use crate::common::name_generator::{archived_field_is_inline_scalar, WorktableNameGenerator}; +use crate::common::name_generator::{WorktableNameGenerator, archived_field_is_inline_scalar}; use crate::generators::persist::PersistGenerator; use proc_macro2::TokenStream; use quote::quote; @@ -44,7 +44,6 @@ impl PersistGenerator { } } - fn gen_wrapper_type(&self) -> TokenStream { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let row_ident = name_generator.get_row_type_ident(); diff --git a/codegen/src/generators/read_only/table/impls.rs b/codegen/src/generators/read_only/table/impls.rs index e6df6d5e..396a3795 100644 --- a/codegen/src/generators/read_only/table/impls.rs +++ b/codegen/src/generators/read_only/table/impls.rs @@ -2,7 +2,9 @@ use convert_case::{Case, Casing}; use proc_macro2::{Ident, Literal, Span, TokenStream}; use quote::quote; -use crate::common::name_generator::{archived_field_is_inline_scalar, WorktableNameGenerator, is_float, is_unsized_vec}; +use crate::common::name_generator::{ + WorktableNameGenerator, archived_field_is_inline_scalar, is_float, is_unsized_vec, +}; use crate::generators::read_only::ReadOnlyGenerator; impl ReadOnlyGenerator { diff --git a/codegen/src/generators/read_only/wrapper.rs b/codegen/src/generators/read_only/wrapper.rs index 9e12cf26..955e3be8 100644 --- a/codegen/src/generators/read_only/wrapper.rs +++ b/codegen/src/generators/read_only/wrapper.rs @@ -1,4 +1,4 @@ -use crate::common::name_generator::{archived_field_is_inline_scalar, WorktableNameGenerator}; +use crate::common::name_generator::{WorktableNameGenerator, archived_field_is_inline_scalar}; use crate::generators::read_only::ReadOnlyGenerator; use proc_macro2::TokenStream; use quote::quote; @@ -44,7 +44,6 @@ impl ReadOnlyGenerator { } } - fn gen_wrapper_type(&self) -> TokenStream { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let row_ident = name_generator.get_row_type_ident(); diff --git a/src/in_memory/data.rs b/src/in_memory/data.rs index 672713b6..82564e5c 100644 --- a/src/in_memory/data.rs +++ b/src/in_memory/data.rs @@ -360,11 +360,14 @@ const SEQLOCK_STACK: usize = 256; /// /// Stack for rows that fit in 256 bytes (the shipping fixture). Larger rows /// take the cell read lock once and copy onto the heap. +// The variants differ in size by design and `clippy::large_enum_variant`'s fix +// is the defect: boxing `Stack` puts the small-row snapshot on the heap, which +// is the allocation this type exists to avoid. The enum is a local of the +// select path and is never stored in a collection, so the size difference +// costs one stack frame, not memory per row. +#[allow(clippy::large_enum_variant)] pub(crate) enum ArchivedCopy { - Stack { - buf: AlignedBytes, - len: u16, - }, + Stack { buf: AlignedBytes, len: u16 }, Heap(AlignedVec), } @@ -720,8 +723,7 @@ impl Data { ::Archived: Deserialize>, { let copy = self.copy_row_seqlock(link)?; - let archived = - unsafe { rkyv::access_unchecked::<::Archived>(copy.as_bytes()) }; + let archived = unsafe { rkyv::access_unchecked::<::Archived>(copy.as_bytes()) }; rkyv::deserialize::<_, rkyv::rancor::Error>(archived).map_err(|_| ExecutionError::DeserializeError) } @@ -1438,13 +1440,32 @@ mod tests { #[cfg(all(test, wt_loom))] mod cell_lock_models { use super::{CellLocks, Link}; + use alloc::boxed::Box; use loom::{cell::UnsafeCell, sync::Arc, thread}; struct Protected { - locks: CellLocks, + locks: Box, value: UnsafeCell<(u64, u64)>, } + /// A lock table built straight into its heap allocation. + /// + /// `CellLocks::default()` returns the two 256-slot atomic arrays by value, + /// so the caller reserves a copy on its own stack. That is affordable on a + /// real thread and is not on a loom coroutine, whose stack is small and + /// whose atomics each carry tracking state: both models overflowed it + /// before reaching their first assertion. `CellLocks::initialize_at` + /// exists for exactly this and is what `Data` uses in production. + fn cell_locks() -> Box { + let mut locks = Box::::new_uninit(); + // SAFETY: `initialize_at` writes every field of `CellLocks`, so the + // allocation is fully initialized when it returns. + unsafe { + CellLocks::initialize_at(locks.as_mut_ptr()); + locks.assume_init() + } + } + // Every access to value below holds the same row's read or write guard. unsafe impl Sync for Protected {} @@ -1455,7 +1476,7 @@ mod cell_lock_models { model.max_branches = 10_000; model.check(|| { let protected = Arc::new(Protected { - locks: CellLocks::default(), + locks: cell_locks(), value: UnsafeCell::new((0, 0)), }); let first = Link { @@ -1499,7 +1520,7 @@ mod cell_lock_models { model.max_branches = 10_000; model.check(|| { let protected = Arc::new(Protected { - locks: CellLocks::default(), + locks: cell_locks(), value: UnsafeCell::new((0, 0)), }); let link = Link { diff --git a/src/in_memory/mod.rs b/src/in_memory/mod.rs index 0bc2b54e..123f32f5 100644 --- a/src/in_memory/mod.rs +++ b/src/in_memory/mod.rs @@ -3,10 +3,8 @@ mod empty_link_registry; mod pages; mod row; -pub use data::{DATA_INNER_LENGTH, Data, ExecutionError as DataExecutionError}; pub(crate) use data::ArchivedCopy; +pub use data::{DATA_INNER_LENGTH, Data, ExecutionError as DataExecutionError}; pub use empty_link_registry::EmptyLinkRegistry; -pub use pages::{ - DataPages, ExecutionError as PagesExecutionError, ReadGuard as DataPagesReadGuard, SelectRef, -}; +pub use pages::{DataPages, ExecutionError as PagesExecutionError, ReadGuard as DataPagesReadGuard, SelectRef}; pub use row::{ArchivedRowWrapper, InlineArchived, PublicationSafe, Query, RowWrapper, StorableRow}; diff --git a/src/in_memory/pages.rs b/src/in_memory/pages.rs index 622bb9da..dbe667d4 100644 --- a/src/in_memory/pages.rs +++ b/src/in_memory/pages.rs @@ -23,8 +23,8 @@ use rkyv::{ util::AlignedVec, }; -use crate::in_memory::empty_link_registry::EmptyLinkRegistry; use crate::in_memory::InlineArchived; +use crate::in_memory::empty_link_registry::EmptyLinkRegistry; use crate::prelude::ArchivedRowWrapper; use crate::util::epoch::EpochDomain; use crate::{ @@ -583,9 +583,7 @@ where Portable + Deserialize<::WrappedRow, HighDeserializer>, { let page = self.page_ref(link.page_id)?; - let wrapped = page - .get_row_seqlock(link) - .map_err(ExecutionError::DataPageError)?; + let wrapped = page.get_row_seqlock(link).map_err(ExecutionError::DataPageError)?; let flags = Self::publication_flags(&wrapped); Ok((wrapped.get_inner(), flags)) } @@ -596,13 +594,9 @@ where <::WrappedRow as Archive>::Archived: ArchivedRowWrapper, { let page = self.page_ref(link.page_id)?; - let copy = page - .copy_row_seqlock(link) - .map_err(ExecutionError::DataPageError)?; + let copy = page.copy_row_seqlock(link).map_err(ExecutionError::DataPageError)?; let archived = unsafe { - rkyv::access_unchecked::<<::WrappedRow as Archive>::Archived>( - copy.as_bytes(), - ) + rkyv::access_unchecked::<<::WrappedRow as Archive>::Archived>(copy.as_bytes()) }; if archived.is_ghosted() { return Err(ExecutionError::Ghosted); @@ -618,9 +612,7 @@ where where ::WrappedRow: InlineArchived, <::WrappedRow as Archive>::Archived: ArchivedRowWrapper, - F: FnMut( - &<<::WrappedRow as Archive>::Archived as ArchivedRowWrapper>::Inner, - ) -> T, + F: FnMut(&<<::WrappedRow as Archive>::Archived as ArchivedRowWrapper>::Inner) -> T, { let page = self.page_ref(link.page_id)?; page.with_archived_seqlock(link, |wrapped| { diff --git a/src/lock/map.rs b/src/lock/map.rs index 95dca43b..1c2ce9e1 100644 --- a/src/lock/map.rs +++ b/src/lock/map.rs @@ -1,3 +1,4 @@ +use alloc::boxed::Box; use alloc::sync::Arc; use alloc::vec::Vec; use core::fmt::Debug; @@ -101,6 +102,9 @@ pub struct BulkMutationGuard { active: Arc, } +/// One shard of the row-lock map. +type LockShard = RwLock>>; + #[derive(Debug)] struct LockEntry { lock: Arc>, @@ -255,10 +259,10 @@ impl Drop for BulkMutationGuard { /// counts answer to different costs. #[derive(Debug)] pub struct LockMap { - map: Box<[RwLock>>; MAP_SHARD_COUNT]>, + map: Box<[LockShard; MAP_SHARD_COUNT]>, /// Table-wide label counter, for the cold callers that have no key in hand /// (vacuum, and the raw `FullRowLock` helper). Locked operations must use - /// `next_ids` instead: see the note there. + /// [`Self::next_id_for`] instead: see the note on `mutation_stripes`. next_id: AtomicU16, /// Per-shard label counters, one to a cache line. /// @@ -298,10 +302,7 @@ impl LockMap where PrimaryKey: Hash + Eq + Debug + Clone, { - fn shard( - &self, - key: &PrimaryKey, - ) -> &RwLock>> { + fn shard(&self, key: &PrimaryKey) -> &LockShard { &self.map[Self::shard_of(key)] } @@ -420,10 +421,8 @@ where Self::remove_if_unused(&mut set, key); } - fn remove_if_unused( - set: &mut HashMap>, - key: &PrimaryKey, - ) where + fn remove_if_unused(set: &mut HashMap>, key: &PrimaryKey) + where LockType: RowLock, { let should_remove = set.get(key).is_some_and(|entry| { diff --git a/src/lock/mod.rs b/src/lock/mod.rs index 19378b37..d4a5822b 100644 --- a/src/lock/mod.rs +++ b/src/lock/mod.rs @@ -128,7 +128,7 @@ where fn drop(&mut self) { self.lock.unlock(); // SAFETY: see the field note; the map outlives this guard. - unsafe { (*self.lock_map).remove_with_lock_check(&self.primary_key) }; + unsafe { (*self.lock_map).remove_with_lock_check(&self.primary_key) }; } } diff --git a/src/lock/row_lock.rs b/src/lock/row_lock.rs index 56440103..61c8d499 100644 --- a/src/lock/row_lock.rs +++ b/src/lock/row_lock.rs @@ -1,4 +1,5 @@ use alloc::sync::Arc; +use alloc::vec::Vec; use core::fmt::Debug; use core::hash::Hash; diff --git a/src/table/mod.rs b/src/table/mod.rs index 810d7356..e586d847 100644 --- a/src/table/mod.rs +++ b/src/table/mod.rs @@ -293,8 +293,7 @@ where match self.data.with_non_ghosted(link, &mut f) { Ok(value) => return Some(value), Err(_) => { - let current_link: Option = - self.primary_index.pk_map.lookup_for_select(&pk).map(Into::into); + let current_link: Option = self.primary_index.pk_map.lookup_for_select(&pk).map(Into::into); if current_link == Some(link) { return None; } diff --git a/src/table/vacuum/vacuum.rs b/src/table/vacuum/vacuum.rs index c2bd7e37..bd8d4dfe 100644 --- a/src/table/vacuum/vacuum.rs +++ b/src/table/vacuum/vacuum.rs @@ -581,8 +581,7 @@ where let lock = self.full_row_lock(&pk).await; // SAFETY: the guard is a local of this call, which holds // `self.lock_manager` for its whole body. - let _guard = - unsafe { LockGuard::new_with_mutation(lock, Arc::as_ptr(&self.lock_manager), pk.clone()) }; + let _guard = unsafe { LockGuard::new_with_mutation(lock, Arc::as_ptr(&self.lock_manager), pk.clone()) }; let current_link: Option = self.primary_index.pk_map.lookup_for_select(&pk).map(Into::into); if current_link != Some(from_link) { From 871d15d1a198cb3b750f9f135ff6e97dcca09f4a Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 15 Sep 2026 04:08:14 +0700 Subject: [PATCH 32/45] Stripe vacuum's lock label by key as well Vacuum takes a full row lock once per compaction candidate, while foreground writers are running, so it was minting its label from the table-wide counter that the generated paths stopped using: the one shared cache line the striping exists to avoid, contended from the one place still writing it on a per-row path. It has the primary key in hand, so it uses the same striped counter. The table-wide next_id now has no per-row caller left; what remains is a test. --- src/table/vacuum/vacuum.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/table/vacuum/vacuum.rs b/src/table/vacuum/vacuum.rs index bd8d4dfe..af113311 100644 --- a/src/table/vacuum/vacuum.rs +++ b/src/table/vacuum/vacuum.rs @@ -620,7 +620,10 @@ where } async fn full_row_lock(&self, pk: &PrimaryKey) -> Arc { - let lock_id = self.lock_manager.next_id(); + // Striped by the key, as the generated paths are: vacuum takes this + // once per candidate row while foreground writers are running, so the + // table-wide counter would be a shared line it contends for. + let lock_id = self.lock_manager.next_id_for(pk); // One atomic acquire, no check-then-act: see LockMap::get_or_insert_with. let lock = self.lock_manager.get_or_insert_with(pk.clone(), LockType::new); let mut lock_guard = lock.write().await; From c4f65f2ed8d63e54122f32c37c81feb4b93cf142 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 15 Sep 2026 04:17:40 +0700 Subject: [PATCH 33/45] Assert every shard of the row-lock map is addressable Shard and stripe indices are both a usize reduced from the same key hash, and nothing keeps them apart: indexing the map with the stripe index compiles, stays in bounds, and silently caps the reachable shard count at the stripe count. That defect survived a whole shard-count sweep, which read as 'the shard count does not matter' because shards above 64 were never addressed. The test drives shard() and identifies the shard by address, so it measures where the lookup actually lands. Verified by injecting the defect: it fails with 'only 64 of 1024 shards are addressable' and passes once reverted. An earlier version asserted only that shard_of and stripe_of have the right ranges, which holds no matter which one shard() calls, and passed with the defect injected. --- src/lock/map.rs | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/lock/map.rs b/src/lock/map.rs index 1c2ce9e1..e2b05d11 100644 --- a/src/lock/map.rs +++ b/src/lock/map.rs @@ -583,6 +583,45 @@ mod tests { use super::*; use crate::lock::FullRowLock; + /// Shards and stripes are reduced from one hash by different moduli, and + /// nothing in the type system keeps them apart: both are a `usize` index. + /// + /// Indexing the map with `stripe_of` compiles, stays within bounds, and is + /// silently wrong - it caps the reachable shard count at the stripe count. + /// That went unnoticed through a whole shard-count sweep, which read as + /// "the shard count does not matter" because shards above 64 were never + /// addressed. + /// + /// This drives `shard()` itself rather than the reduction functions: an + /// earlier version of this test asserted only that `shard_of` and + /// `stripe_of` have the right ranges, which is true no matter which one + /// `shard()` calls, and it passed with the defect injected. + #[test] + fn every_shard_of_the_map_is_reachable() { + const { + assert!( + MAP_SHARD_COUNT > MUTATION_STRIPE_COUNT, + "the defect this guards against is only possible in this direction" + ) + }; + let lock_map: Arc> = Arc::new(LockMap::default()); + let base = lock_map.map.as_ptr(); + let mut reached = alloc::collections::BTreeSet::new(); + for key in 0..(MAP_SHARD_COUNT as u64 * 64) { + // Identify the shard by address, so this measures where `shard()` + // actually lands rather than what a helper returns. + let index = (core::ptr::from_ref(lock_map.shard(&key)) as usize - base as usize) + / core::mem::size_of::>(); + reached.insert(index); + } + assert_eq!( + reached.len(), + MAP_SHARD_COUNT, + "only {} of {MAP_SHARD_COUNT} shards are addressable; a shard index reduced by the stripe count caps it at {MUTATION_STRIPE_COUNT}", + reached.len() + ); + } + /// A batch over more keys than stripes necessarily maps several keys to /// one stripe; acquisition must dedupe instead of deadlocking on the /// second ticket for the same stripe, and everything must be released on From 5fd0892397b12e600f2531ecf0fd180280ed74c5 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 16 Sep 2026 04:43:13 +0700 Subject: [PATCH 34/45] Do not sleep waiting for an idle table to go idle `wait_until_quiet` is called at every batch boundary and slept unconditionally before returning: three quiet samples at 2 ms each, 6 ms a batch, whether or not anything was writing. The samples exist to tell a real lull from the gap between two writes. That ambiguity only exists when something has been writing. If nothing is in flight, the mutation epoch has not moved since entry and no stand-down happened, there is no gap to be fooled by and nothing to confirm. `ghost-vs-drop` measured a 40-page sweep at 31,519 us, of which roughly 36 ms was this arithmetic: the vacuum was not slow, it was waiting for permission nobody was withholding. 31,519 to 4,861 us, and per page freed 788 to 121.5. The reactive design is unchanged and is what it claims to be: delete marks a ghost and queues the storage, the sweep parks on a threshold armed by the registry rather than a timer, and under load it stands down and doubles its backoff to a 128 ms ceiling. Only the idle path is affected. 1,137 tests pass, including the 36 vacuum tests that assert stand-down under concurrent mutation. --- src/table/vacuum/pacing.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/table/vacuum/pacing.rs b/src/table/vacuum/pacing.rs index 80427e97..32da83e2 100644 --- a/src/table/vacuum/pacing.rs +++ b/src/table/vacuum/pacing.rs @@ -137,6 +137,11 @@ impl VacuumPacing { let mut backoff = self.backoff; let mut quiet = 0; let mut observed_epoch = activity.mutation_epoch(); + // The epoch and stand-down count as they were on entry, so the fast path + // below can prove nothing has happened since rather than merely that + // nothing is happening right now. + let entry_epoch = observed_epoch; + let stand_downs_seen = gate.stand_downs(); loop { let current_epoch = activity.mutation_epoch(); if gate.is_paused() || activity.mutations_in_flight() > 0 || current_epoch != observed_epoch { @@ -151,6 +156,24 @@ impl VacuumPacing { } quiet += 1; + // An idle table needs no confirmation. The repeated samples exist to + // tell a real lull from the gap between two writes, and that ambiguity + // only exists when something has been writing: if nothing is in flight + // and the epoch has not moved since the first observation, there is + // no gap to be fooled by. + // + // Paid unconditionally this cost 6 ms a batch (3 samples x 2 ms) on a + // table with no writers at all, and `ghost-vs-drop` measured a 40-page + // sweep at 31 ms of which roughly 36 ms was arithmetic sleep: the + // vacuum was not slow, it was waiting for permission nobody was + // withholding. + if quiet == 1 + && stand_downs_seen == gate.stand_downs() + && activity.mutations_in_flight() == 0 + && current_epoch == entry_epoch + { + return; + } if quiet >= self.quiet_samples { return; } From f0ceaed57096622c6134d0fbebfc0d5de7bbd96e Mon Sep 17 00:00:00 2001 From: meh Date: Thu, 17 Sep 2026 03:26:59 +0700 Subject: [PATCH 35/45] Make the borrowed lock guards unsafe to obtain LockAcquirer, MutationGuard, LockGuard and PendingLock hold a *const LockMap rather than an owning Arc, which is what removed the per-operation refcount traffic from one key-independent cache line. The precondition that buys is real and unstateable: the map has to outlive the guard. Every constructor that handed one out was safe, so three safe lines reached undefined behaviour with no unsafe anywhere in the caller: let map = Arc::new(LockMap::default()); let acquirer = map.get_or_insert_with(1, FullRowLock::new); drop(map); drop(acquirer); Only new_with_mutation was unsafe, and it has the identical contract to the five that were not. Mark get_or_insert_with, mutation_guard, mutation_guards, LockGuard::new, PendingLock::new and FullRowLock::guard unsafe, document the contract once on the map, and take the four guard types out of the visible prelude. They are not a user-facing API: every caller is generated code in this crate's own operation bodies, which is why the generated blocks carry the SAFETY note rather than the user. No runtime change. The pointer, and the win it measures, are untouched. Also: LockMap::insert replaced an entry with acquirers at 0, so a live LockAcquirer for that key decremented from 0 to usize::MAX on drop and remove_if_unused could never reclaim it again, leaking the entry for the life of the table. The count is inline in the entry now, not the shared Arc an acquirer used to own, so the replacement has to carry it. --- .../generators/in_memory/queries/delete.rs | 4 +- .../src/generators/in_memory/queries/locks.rs | 39 ++++-- .../src/generators/persist/queries/delete.rs | 4 +- .../src/generators/persist/queries/locks.rs | 39 ++++-- src/lib.rs | 10 +- src/lock/map.rs | 117 +++++++++++++++--- src/lock/mod.rs | 37 ++++-- src/lock/row_lock.rs | 11 +- src/table/mod.rs | 24 +++- src/table/vacuum/vacuum.rs | 9 +- tests/worktable/upsert_guard.rs | 5 +- 11 files changed, 232 insertions(+), 67 deletions(-) diff --git a/codegen/src/generators/in_memory/queries/delete.rs b/codegen/src/generators/in_memory/queries/delete.rs index df23a6c9..626b647e 100644 --- a/codegen/src/generators/in_memory/queries/delete.rs +++ b/codegen/src/generators/in_memory/queries/delete.rs @@ -73,7 +73,9 @@ impl InMemoryGenerator { where #pk_ident: From { let pk: #pk_ident = pk.into(); - let _mutation_guard = self.0.lock_manager.mutation_guard(&pk); + // SAFETY: the table owns `lock_manager` and is borrowed for + // this whole operation, so the map outlives the guard. + let _mutation_guard = unsafe { self.0.lock_manager.mutation_guard(&pk) }; #publication #delete_logic core::result::Result::Ok(()) diff --git a/codegen/src/generators/in_memory/queries/locks.rs b/codegen/src/generators/in_memory/queries/locks.rs index 3ec3b81a..db3ef3a8 100644 --- a/codegen/src/generators/in_memory/queries/locks.rs +++ b/codegen/src/generators/in_memory/queries/locks.rs @@ -100,7 +100,6 @@ impl InMemoryGenerator { .collect::>(); quote! { - #[allow(clippy::mutable_key_type)] pub fn #ident(&mut self, id: u16) -> (Vec>, worktable::prelude::Arc) { let mut set = Vec::new(); let new_lock = worktable::prelude::Arc::new(Lock::new(id)); @@ -119,19 +118,26 @@ impl InMemoryGenerator { // cache line per locked operation. See LockMap::next_ids. let lock_id = self.0.lock_manager.next_id_for(&pk); // Same atomic acquire as the per-column path: see LockMap::get_or_insert_with. - let lock = self - .0 - .lock_manager - .get_or_insert_with(pk.clone(), #lock_ident::new); + // SAFETY: `self.0.lock_manager` is the table's own `Arc`, + // borrowed for this whole operation, so the map outlives the + // acquirer and the pending lock taken from it. See the "Borrowed + // guards" note on `LockMap`. + let lock = unsafe { + self.0 + .lock_manager + .get_or_insert_with(pk.clone(), #lock_ident::new) + }; let mut lock_guard = lock.write().await; - #[allow(clippy::mutable_key_type)] let (locks, op_lock) = lock_guard.lock(lock_id); drop(lock_guard); // The registered lock must be cancellation-covered BEFORE the // predecessor wait below: a future dropped at that await (tokio // timeout, task abort) would otherwise leave the registered lock // held forever and hang every later operation on this key. - let pending_lock = PendingLock::new(op_lock, &self.0.lock_manager, pk.clone()); + // SAFETY: as above; the map outlives this pending lock and the + // `LockGuard` it is converted into, both of which are locals of + // this operation. + let pending_lock = unsafe { PendingLock::new(op_lock, &self.0.lock_manager, pk.clone()) }; // No predecessor is the common case on disjoint keys: the row had no // entry, so every column slot was empty. Registering a waker on each // of nothing, boxing the joined slice and suspending the operation to @@ -156,19 +162,26 @@ impl InMemoryGenerator { // enter the row: the loser merged into the winner's lock, but the // winner had already registered its operation on a lock that was no // longer the map's, so it never waited for the loser. - let lock = self - .0 - .lock_manager - .get_or_insert_with(pk.clone(), #lock_ident::new); + // SAFETY: `self.0.lock_manager` is the table's own `Arc`, + // borrowed for this whole operation, so the map outlives the + // acquirer and the pending lock taken from it. See the "Borrowed + // guards" note on `LockMap`. + let lock = unsafe { + self.0 + .lock_manager + .get_or_insert_with(pk.clone(), #lock_ident::new) + }; let mut lock_guard = lock.write().await; - #[allow(clippy::mutable_key_type)] let (locks, op_lock) = lock_guard.#ident(lock_id); drop(lock_guard); // The registered lock must be cancellation-covered BEFORE the // predecessor wait below: a future dropped at that await (tokio // timeout, task abort) would otherwise leave the registered lock // held forever and hang every later operation on this key. - let pending_lock = PendingLock::new(op_lock, &self.0.lock_manager, pk.clone()); + // SAFETY: as above; the map outlives this pending lock and the + // `LockGuard` it is converted into, both of which are locals of + // this operation. + let pending_lock = unsafe { PendingLock::new(op_lock, &self.0.lock_manager, pk.clone()) }; // No predecessor is the common case on disjoint keys: the row had no // entry, so every column slot was empty. Registering a waker on each // of nothing, boxing the joined slice and suspending the operation to diff --git a/codegen/src/generators/persist/queries/delete.rs b/codegen/src/generators/persist/queries/delete.rs index e57342f9..783fca7b 100644 --- a/codegen/src/generators/persist/queries/delete.rs +++ b/codegen/src/generators/persist/queries/delete.rs @@ -73,7 +73,9 @@ impl PersistGenerator { where #pk_ident: From { let pk: #pk_ident = pk.into(); - let _mutation_guard = self.0.lock_manager.mutation_guard(&pk); + // SAFETY: the table owns `lock_manager` and is borrowed for + // this whole operation, so the map outlives the guard. + let _mutation_guard = unsafe { self.0.lock_manager.mutation_guard(&pk) }; #publication #delete_logic core::result::Result::Ok(()) diff --git a/codegen/src/generators/persist/queries/locks.rs b/codegen/src/generators/persist/queries/locks.rs index 509755a2..b4aa4a9b 100644 --- a/codegen/src/generators/persist/queries/locks.rs +++ b/codegen/src/generators/persist/queries/locks.rs @@ -100,7 +100,6 @@ impl PersistGenerator { .collect::>(); quote! { - #[allow(clippy::mutable_key_type)] pub fn #ident(&mut self, id: u16) -> (Vec>, worktable::prelude::Arc) { let mut set = Vec::new(); let new_lock = worktable::prelude::Arc::new(Lock::new(id)); @@ -119,19 +118,26 @@ impl PersistGenerator { // cache line per locked operation. See LockMap::next_ids. let lock_id = self.0.lock_manager.next_id_for(&pk); // Same atomic acquire as the per-column path: see LockMap::get_or_insert_with. - let lock = self - .0 - .lock_manager - .get_or_insert_with(pk.clone(), #lock_ident::new); + // SAFETY: `self.0.lock_manager` is the table's own `Arc`, + // borrowed for this whole operation, so the map outlives the + // acquirer and the pending lock taken from it. See the "Borrowed + // guards" note on `LockMap`. + let lock = unsafe { + self.0 + .lock_manager + .get_or_insert_with(pk.clone(), #lock_ident::new) + }; let mut lock_guard = lock.write().await; - #[allow(clippy::mutable_key_type)] let (locks, op_lock) = lock_guard.lock(lock_id); drop(lock_guard); // The registered lock must be cancellation-covered BEFORE the // predecessor wait below: a future dropped at that await (tokio // timeout, task abort) would otherwise leave the registered lock // held forever and hang every later operation on this key. - let pending_lock = PendingLock::new(op_lock, &self.0.lock_manager, pk.clone()); + // SAFETY: as above; the map outlives this pending lock and the + // `LockGuard` it is converted into, both of which are locals of + // this operation. + let pending_lock = unsafe { PendingLock::new(op_lock, &self.0.lock_manager, pk.clone()) }; // No predecessor is the common case on disjoint keys: the row had no // entry, so every column slot was empty. Registering a waker on each // of nothing, boxing the joined slice and suspending the operation to @@ -156,19 +162,26 @@ impl PersistGenerator { // enter the row: the loser merged into the winner's lock, but the // winner had already registered its operation on a lock that was no // longer the map's, so it never waited for the loser. - let lock = self - .0 - .lock_manager - .get_or_insert_with(pk.clone(), #lock_ident::new); + // SAFETY: `self.0.lock_manager` is the table's own `Arc`, + // borrowed for this whole operation, so the map outlives the + // acquirer and the pending lock taken from it. See the "Borrowed + // guards" note on `LockMap`. + let lock = unsafe { + self.0 + .lock_manager + .get_or_insert_with(pk.clone(), #lock_ident::new) + }; let mut lock_guard = lock.write().await; - #[allow(clippy::mutable_key_type)] let (locks, op_lock) = lock_guard.#ident(lock_id); drop(lock_guard); // The registered lock must be cancellation-covered BEFORE the // predecessor wait below: a future dropped at that await (tokio // timeout, task abort) would otherwise leave the registered lock // held forever and hang every later operation on this key. - let pending_lock = PendingLock::new(op_lock, &self.0.lock_manager, pk.clone()); + // SAFETY: as above; the map outlives this pending lock and the + // `LockGuard` it is converted into, both of which are locals of + // this operation. + let pending_lock = unsafe { PendingLock::new(op_lock, &self.0.lock_manager, pk.clone()) }; // No predecessor is the common case on disjoint keys: the row had no // entry, so every column slot was empty. Registering a waker on each // of nothing, boxing the joined slice and suspending the operation to diff --git a/src/lib.rs b/src/lib.rs index 378561ee..0051ba43 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -181,8 +181,16 @@ pub mod prelude { ArchivedRowWrapper, Data, DataPages, InlineArchived, Query, RowWrapper, SelectRef, StorableRow, }; pub use crate::lock::FullRowLock; + pub use crate::lock::LockMap; pub use crate::lock::{Lock, RowLock}; - pub use crate::lock::{LockAcquirer, LockGuard, LockMap, PendingLock}; + // `LockAcquirer`, `LockGuard` and `PendingLock` borrow their `LockMap` as a + // raw pointer rather than an owning `Arc` (see the "Borrowed guards" note + // on `LockMap`), so holding one past the map's last `Arc` is undefined + // behaviour. They are not a user-facing API: the only callers are the + // operation bodies this crate generates, which is why they are re-exported + // here for that generated code and `#[doc(hidden)]` at their definitions. + #[doc(hidden)] + pub use crate::lock::{LockAcquirer, LockGuard, PendingLock}; pub use crate::mem_stat::MemStat; pub use crate::partition::{DenseError, DenseRows, MAX_PARTITIONS, PartRef, PartitionError, PartitionSet}; pub use crate::persistence::{AcknowledgeOperation, DeleteOperation, InsertOperation, Operation, OperationId}; diff --git a/src/lock/map.rs b/src/lock/map.rs index e2b05d11..fa555918 100644 --- a/src/lock/map.rs +++ b/src/lock/map.rs @@ -65,6 +65,7 @@ struct MutationStripe { /// Generated async row locks and synchronous inserts share these gates so a /// synchronous API entry point cannot interleave its multi-structure /// publication with an update or delete of the same key. +#[doc(hidden)] #[derive(Debug)] pub struct MutationGuard { /// Borrowed, not an `Arc` clone. @@ -123,6 +124,7 @@ struct LockEntry { /// Dropping this handle, including through async task cancellation, retries /// map cleanup after releasing its lock reference. Clones remain tracked so an /// entry cannot be removed while any caller may still register against it. +#[doc(hidden)] #[derive(Debug)] pub struct LockAcquirer where @@ -244,12 +246,21 @@ impl Drop for BulkMutationGuard { /// futures must be `'static` to spawn, so a lifetime on the guard becomes a /// lifetime on the future. /// -/// What that costs is a safe public API that can be misused: taking a guard -/// and then dropping the last `Arc` while the guard lives is +/// What that costs is a precondition the type system cannot state: taking a +/// guard and then dropping the last `Arc` while the guard lives is /// undefined behaviour. Every in-crate and generated caller takes its guard as /// a local of an operation that holds the table's own `Arc` for the /// whole call, which is why this is sound as used. /// +/// Because that precondition is real and cannot be checked, every entry point +/// that hands out one of these borrowed guards is `unsafe`, and the guard +/// types themselves are `#[doc(hidden)]` and out of the prelude. Leaving them +/// safe would have made the hazard reachable from safe code with no `unsafe` +/// anywhere in the caller, which is the definition of an unsound API: three +/// safe lines that build a map, take a guard, drop the map and drop the guard +/// are enough. They are not a user-facing API: every real caller is generated +/// code inside this crate's own operation bodies. +/// /// # Sharding /// /// The map is `MAP_SHARD_COUNT` independent `RwLock`s. A single @@ -317,18 +328,30 @@ where /// `Arc::strong_count`. Generated operations should prefer /// [`Self::get_or_insert_with`], whose [`LockAcquirer`] makes cancellation /// tracking explicit. + /// + /// Replacing an entry carries its acquirer count over rather than starting + /// the new one at zero. The count is inline in the entry now, not the + /// shared `Arc` an acquirer used to own, so an acquirer of the + /// replaced entry decrements *this* word when it drops: starting from zero + /// wraps it to `usize::MAX` and `remove_if_unused` then never reclaims the + /// key again. Only this public entry point can reach that state, and + /// carrying the count keeps every live acquirer accounted for by the entry + /// it will actually decrement. pub fn insert( &self, key: PrimaryKey, lock: Arc>, ) -> Option>> { - self.shard(&key) - .write() + let mut shard = self.shard(&key).write(); + let carried = shard + .get(&key) + .map_or(0, |entry| entry.acquirers.load(Ordering::Acquire)); + shard .insert( key, LockEntry { lock, - acquirers: AtomicUsize::new(0), + acquirers: AtomicUsize::new(carried), }, ) .map(|entry| entry.lock) @@ -349,7 +372,18 @@ where /// can merge into it, but the *winner* already registered its operation on /// a lock that is no longer in the map, so it never waits for the loser and /// both proceed into the row at once. - pub fn get_or_insert_with(self: &Arc, key: PrimaryKey, f: F) -> LockAcquirer + /// + /// # Safety + /// + /// The returned [`LockAcquirer`] borrows this map as a raw pointer and + /// dereferences it on `Drop`, so the caller must keep an `Arc` + /// alive for at least as long as the acquirer and every clone of it. + /// Dropping the last `Arc` first is undefined behaviour. + /// + /// Generated operations satisfy this by construction: the acquirer is a + /// local of a call that reached this map through the table's own `Arc`, + /// which it holds for the whole call. + pub unsafe fn get_or_insert_with(self: &Arc, key: PrimaryKey, f: F) -> LockAcquirer where LockType: RowLock, F: FnOnce() -> LockType, @@ -463,7 +497,14 @@ where /// The holder must not perform a suspending `.await`. Generated locked /// operations acquire this only after their async predecessor wait has /// completed, and the synchronous `insert` path never awaits. - pub fn mutation_guard(&self, key: &PrimaryKey) -> MutationGuard { + /// + /// # Safety + /// + /// The returned [`MutationGuard`] borrows this map's stripe array as a raw + /// pointer and dereferences it on `Drop`, so the caller must keep the map + /// alive for at least as long as the guard. See + /// [`Self::get_or_insert_with`]. + pub unsafe fn mutation_guard(&self, key: &PrimaryKey) -> MutationGuard { self.mutation_guard_for_stripe(Self::stripe_of(key)) } @@ -476,7 +517,11 @@ where /// single-key holders (which never nest stripe acquisitions) cannot form a /// cycle with a batch. The same no-`.await` rule as /// [`Self::mutation_guard`] applies for the whole guard set's lifetime. - pub fn mutation_guards<'a>(&self, keys: impl Iterator) -> Vec + /// + /// # Safety + /// + /// As [`Self::mutation_guard`], for every guard in the returned set. + pub unsafe fn mutation_guards<'a>(&self, keys: impl Iterator) -> Vec where PrimaryKey: 'a, { @@ -631,12 +676,14 @@ mod tests { let lock_map: LockMap = LockMap::default(); let keys: Vec = (0..1000).collect(); - let guards = lock_map.mutation_guards(keys.iter()); + // SAFETY: `lock_map` outlives the guard set. + let guards = unsafe { lock_map.mutation_guards(keys.iter()) }; assert!(guards.len() <= MUTATION_STRIPE_COUNT); drop(guards); for key in 0..1000u64 { - let _guard = lock_map.mutation_guard(&key); + // SAFETY: `lock_map` outlives the guard. + let _guard = unsafe { lock_map.mutation_guard(&key) }; } } @@ -663,7 +710,8 @@ mod tests { let lock_map: LockMap = LockMap::default(); let before = lock_map.mutation_epoch(); - let guard = lock_map.mutation_guard(&17); + // SAFETY: `lock_map` outlives the guard. + let guard = unsafe { lock_map.mutation_guard(&17) }; assert_eq!(lock_map.mutations_in_flight(), 1); drop(guard); @@ -684,11 +732,13 @@ mod tests { let other_map = lock_map.clone(); let handle = std::thread::spawn(move || { for _ in 0..100 { - let _guards = other_map.mutation_guards(backward.iter()); + // SAFETY: this thread owns an `Arc` clone of the map. + let _guards = unsafe { other_map.mutation_guards(backward.iter()) }; } }); for _ in 0..100 { - let _guards = lock_map.mutation_guards(forward.iter()); + // SAFETY: `lock_map` outlives the guard set. + let _guards = unsafe { lock_map.mutation_guards(forward.iter()) }; } handle.join().unwrap(); } @@ -699,7 +749,9 @@ mod tests { #[test] fn cancelled_acquirer_removes_the_abandoned_entry() { let lock_map: Arc> = Arc::new(LockMap::default()); - let acquirer = lock_map.get_or_insert_with(31, FullRowLock::new); + // SAFETY: `lock_map` outlives `acquirer` in every case below; it is + // dropped at the end of the test. + let acquirer = unsafe { lock_map.get_or_insert_with(31, FullRowLock::new) }; lock_map.remove_with_lock_check(&31); assert!(lock_map.contains_key(&31)); @@ -708,13 +760,37 @@ mod tests { assert!(!lock_map.contains_key(&31)); } + /// The acquirer count is inline in the map entry, so replacing an entry + /// through the public `insert` while an acquirer is live must not restart + /// that count at zero. + /// + /// With it restarted, the live acquirer's drop does `fetch_sub` from 0 and + /// wraps to `usize::MAX`. Nothing panics and nothing is unsound, but + /// `remove_if_unused` reads a non-zero count forever and the key is never + /// reclaimed: a silent, permanent entry leak in the row-lock map. + #[test] + fn replacing_an_entry_does_not_strand_a_live_acquirers_count() { + let lock_map: Arc> = Arc::new(LockMap::default()); + // SAFETY: `lock_map` outlives the acquirer. + let acquirer = unsafe { lock_map.get_or_insert_with(57, FullRowLock::new) }; + + lock_map.insert(57, Arc::new(nagoya::sync::RwLock::new(FullRowLock::new()))); + drop(acquirer); + + assert!( + !lock_map.contains_key(&57), + "a replaced entry must still be reclaimable once its last acquirer drops" + ); + } + /// Cloning the acquisition handle represents two tasks between lookup and /// registration. The first cancellation must retain the shared lock, and /// only the last handle may remove it. #[test] fn cleanup_waits_for_every_acquirer_to_drop() { let lock_map: Arc> = Arc::new(LockMap::default()); - let first = lock_map.get_or_insert_with(33, FullRowLock::new); + // SAFETY: `lock_map` outlives both handles. + let first = unsafe { lock_map.get_or_insert_with(33, FullRowLock::new) }; let second = first.clone(); drop(first); @@ -727,9 +803,12 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cancelling_async_waiter_releases_tracking_without_deadlock() { let lock_map: Arc> = Arc::new(LockMap::default()); - let owner = lock_map.get_or_insert_with(41, FullRowLock::new); + // SAFETY: `lock_map` outlives both acquirers and the spawned task, + // which is awaited before the map is dropped. + let owner = unsafe { lock_map.get_or_insert_with(41, FullRowLock::new) }; let owner_guard = owner.write().await; - let waiter = lock_map.get_or_insert_with(41, FullRowLock::new); + // SAFETY: as above. + let waiter = unsafe { lock_map.get_or_insert_with(41, FullRowLock::new) }; let waiting_task = tokio::spawn(async move { let _guard = waiter.write().await; }); @@ -756,7 +835,9 @@ mod tests { handles.push(std::thread::spawn(move || { for step in 0..10_000u64 { let key = worker << 32 | step; - let acquirer = map.get_or_insert_with(key, FullRowLock::new); + // SAFETY: this thread owns an `Arc` clone of the map for + // the whole loop, so it outlives each acquirer. + let acquirer = unsafe { map.get_or_insert_with(key, FullRowLock::new) }; drop(acquirer); assert!(!map.contains_key(&key)); } diff --git a/src/lock/mod.rs b/src/lock/mod.rs index d4a5822b..fafe294a 100644 --- a/src/lock/mod.rs +++ b/src/lock/mod.rs @@ -29,6 +29,7 @@ const MAX_SPINS: u32 = 12; /// /// The guard will also attempt to remove the lock entry from the map on drop /// (preventing memory leaks). +#[doc(hidden)] pub struct LockGuard { lock: Arc, /// Borrowed, not an `Arc` clone: see [`LockAcquirer`]'s field of the same @@ -66,7 +67,14 @@ where { /// Creates a new [`LockGuard`] that will clean up the [`Lock`] entry from /// the [`LockMap`] on [`Drop`]. - pub fn new(lock: Arc, lock_map: &Arc>, primary_key: PrimaryKey) -> Self { + /// + /// # Safety + /// + /// The guard borrows the map as a raw pointer and dereferences it on + /// `Drop`, so `lock_map`'s allocation must outlive the returned guard: + /// holding the `Arc` only until this call returns is not enough. See the + /// "Borrowed guards" note on [`LockMap`]. + pub unsafe fn new(lock: Arc, lock_map: &Arc>, primary_key: PrimaryKey) -> Self { Self::from_raw(lock, Arc::as_ptr(lock_map), primary_key) } @@ -145,6 +153,7 @@ where /// cleanup; converting it into the final [`LockGuard`] with /// [`Self::into_guard`] or [`Self::into_guard_with_mutation`] defuses that /// cleanup and hands ownership over. +#[doc(hidden)] pub struct PendingLock { lock: Option>, /// Borrowed, not an `Arc` clone: see [`LockAcquirer`]'s field of the same @@ -175,7 +184,12 @@ where { /// Takes ownership of a freshly registered operation lock. Must be called /// synchronously after registration, before the predecessor wait. - pub fn new(lock: Arc, lock_map: &Arc>, primary_key: PrimaryKey) -> Self { + /// + /// # Safety + /// + /// As [`LockGuard::new`]: the map's allocation must outlive this value and + /// the [`LockGuard`] it is converted into. + pub unsafe fn new(lock: Arc, lock_map: &Arc>, primary_key: PrimaryKey) -> Self { Self { lock: Some(lock), lock_map: Arc::as_ptr(lock_map), @@ -377,7 +391,8 @@ mod tests { assert!(lock.is_locked()); { - let _guard = LockGuard::::new(lock.clone(), &lock_map, pk); + // SAFETY: `lock_map` is a local `Arc` that outlives this guard. + let _guard = unsafe { LockGuard::::new(lock.clone(), &lock_map, pk) }; assert!(lock.is_locked()); } @@ -391,7 +406,8 @@ mod tests { let pk = 1u64; assert!(lock.is_locked()); - let guard = LockGuard::::new(lock.clone(), &lock_map, pk); + // SAFETY: `lock_map` is a local `Arc` that outlives this guard. + let guard = unsafe { LockGuard::::new(lock.clone(), &lock_map, pk) }; assert!(lock.is_locked()); guard.unlock(); @@ -407,7 +423,8 @@ mod tests { assert!(lock.is_locked()); let result = std::panic::catch_unwind(AssertUnwindSafe(|| { - let _guard = LockGuard::::new(lock.clone(), &lock_map, pk); + // SAFETY: `lock_map` outlives the unwind this closure triggers. + let _guard = unsafe { LockGuard::::new(lock.clone(), &lock_map, pk) }; panic!("test panic"); })); @@ -428,9 +445,10 @@ mod tests { assert!(lock3.is_locked()); { - let _guard1 = LockGuard::::new(lock1.clone(), &lock_map, 1u64); - let _guard2 = LockGuard::::new(lock2.clone(), &lock_map, 2u64); - let _guard3 = LockGuard::::new(lock3.clone(), &lock_map, 3u64); + // SAFETY: `lock_map` is a local `Arc` that outlives all three guards. + let _guard1 = unsafe { LockGuard::::new(lock1.clone(), &lock_map, 1u64) }; + let _guard2 = unsafe { LockGuard::::new(lock2.clone(), &lock_map, 2u64) }; + let _guard3 = unsafe { LockGuard::::new(lock3.clone(), &lock_map, 3u64) }; assert!(lock1.is_locked()); assert!(lock2.is_locked()); @@ -467,7 +485,8 @@ mod tests { // Create a guard and drop it { - let _guard = LockGuard::new(lock, &lock_map, pk); + // SAFETY: `lock_map` outlives this scope and so outlives the guard. + let _guard = unsafe { LockGuard::new(lock, &lock_map, pk) }; } // Verify the lock entry was removed from the map diff --git a/src/lock/row_lock.rs b/src/lock/row_lock.rs index 61c8d499..2fa32a49 100644 --- a/src/lock/row_lock.rs +++ b/src/lock/row_lock.rs @@ -47,12 +47,19 @@ impl FullRowLock { /// Creates a [`LockGuard`] that will automatically unlock this lock when /// dropped. - pub fn guard( + /// + /// # Safety + /// + /// As [`LockGuard::new`]: the guard borrows `lock_map` as a raw pointer + /// and dereferences it on `Drop`, so that allocation must outlive the + /// returned guard. + pub unsafe fn guard( self, lock_map: &Arc>, primary_key: PrimaryKey, ) -> LockGuard { - LockGuard::new(self.l, lock_map, primary_key) + // SAFETY: forwarded to this function's own contract. + unsafe { LockGuard::new(self.l, lock_map, primary_key) } } pub fn wait(&self) -> LockWait { diff --git a/src/table/mod.rs b/src/table/mod.rs index e586d847..92d0ac19 100644 --- a/src/table/mod.rs +++ b/src/table/mod.rs @@ -322,7 +322,9 @@ where LockType: 'static, { let pk = row.get_primary_key().clone(); - let _mutation_guard = self.lock_manager.mutation_guard(&pk); + // SAFETY: `self.lock_manager` is a field of this table and is borrowed + // for this whole call, so it outlives the guard taken here. + let _mutation_guard = unsafe { self.lock_manager.mutation_guard(&pk) }; self.insert_locked(row) } @@ -470,7 +472,9 @@ where // Stripe-ordered, exactly as `insert_many` takes them, so a batch // delete and a batch insert cannot deadlock against each other. Chunks // release before the next is taken, so that ordering holds across them. - let _mutation_guards = self.lock_manager.mutation_guards(chunk.iter()); + // SAFETY: `self.lock_manager` is a field of this table and is borrowed + // for this whole call, so it outlives every guard in the set. + let _mutation_guards = unsafe { self.lock_manager.mutation_guards(chunk.iter()) }; let _publication = TableSecondaryIndex::::row_publication(&*self.indexes); @@ -587,7 +591,9 @@ where // `DELETE_CHUNK_KEYS`. A range delete is the widest batch this table // takes, so it is the one that most needs not to hold every stripe. for chunk in keys.chunks(DELETE_CHUNK_KEYS) { - let _mutation_guards = self.lock_manager.mutation_guards(chunk.iter()); + // SAFETY: `self.lock_manager` is a field of this table and is borrowed + // for this whole call, so it outlives every guard in the set. + let _mutation_guards = unsafe { self.lock_manager.mutation_guards(chunk.iter()) }; let _publication = TableSecondaryIndex::::row_publication(&*self.indexes); @@ -686,7 +692,9 @@ where return Ok(Vec::new()); } let pks: Vec = rows.iter().map(|row| row.get_primary_key().clone()).collect(); - let _mutation_guards = self.lock_manager.mutation_guards(pks.iter()); + // SAFETY: `self.lock_manager` is a field of this table and is borrowed + // for this whole call, so it outlives every guard in the set. + let _mutation_guards = unsafe { self.lock_manager.mutation_guards(pks.iter()) }; let _publication = TableSecondaryIndex::::row_publication(&*self.indexes); @@ -837,7 +845,9 @@ where PrimaryIndex: TableIndexCdc, { let pk = row.get_primary_key().clone(); - let _mutation_guard = self.lock_manager.mutation_guard(&pk); + // SAFETY: `self.lock_manager` is a field of this table and is borrowed + // for this whole call, so it outlives the guard taken here. + let _mutation_guard = unsafe { self.lock_manager.mutation_guard(&pk) }; let _publication = TableSecondaryIndex::::row_publication(&*self.indexes); @@ -1017,7 +1027,9 @@ where return (Vec::new(), Ok(Vec::new())); } let pks: Vec = rows.iter().map(|row| row.get_primary_key().clone()).collect(); - let _mutation_guards = self.lock_manager.mutation_guards(pks.iter()); + // SAFETY: `self.lock_manager` is a field of this table and is borrowed + // for this whole call, so it outlives every guard in the set. + let _mutation_guards = unsafe { self.lock_manager.mutation_guards(pks.iter()) }; let _publication = TableSecondaryIndex::::row_publication(&*self.indexes); diff --git a/src/table/vacuum/vacuum.rs b/src/table/vacuum/vacuum.rs index af113311..e675ba0f 100644 --- a/src/table/vacuum/vacuum.rs +++ b/src/table/vacuum/vacuum.rs @@ -625,7 +625,11 @@ where // table-wide counter would be a shared line it contends for. let lock_id = self.lock_manager.next_id_for(pk); // One atomic acquire, no check-then-act: see LockMap::get_or_insert_with. - let lock = self.lock_manager.get_or_insert_with(pk.clone(), LockType::new); + // + // SAFETY: `self.lock_manager` is this vacuum's own `Arc`, + // borrowed for the whole call, so it outlives the acquirer, which is + // dropped at the end of this function. + let lock = unsafe { self.lock_manager.get_or_insert_with(pk.clone(), LockType::new) }; let mut lock_guard = lock.write().await; #[allow(clippy::mutable_key_type)] let (locks, op_lock) = lock_guard.lock(lock_id); @@ -913,7 +917,8 @@ mod tests { table.delete(*id).await.unwrap(); } - let mutation = table.0.lock_manager.mutation_guard(&ids[1].into()); + // SAFETY: `table` outlives this guard, and with it the map. + let mutation = unsafe { table.0.lock_manager.mutation_guard(&ids[1].into()) }; let vacuum = create_vacuum(&table).with_pacing(VacuumPacing { batch_pages: 1, backoff: Duration::from_millis(1), diff --git a/tests/worktable/upsert_guard.rs b/tests/worktable/upsert_guard.rs index 2d391ecf..9f98c2fb 100644 --- a/tests/worktable/upsert_guard.rs +++ b/tests/worktable/upsert_guard.rs @@ -39,7 +39,10 @@ fn inserting_under_a_held_mutation_gate_completes() { let pk: UpsertGuardPrimaryKey = 1u64.into(); // Exactly what `upsert` holds when it discovers the key is absent. - let _gate = table.0.lock_manager.mutation_guard(&pk); + // + // SAFETY: `table` is an `Arc` held for the rest of the test, so the map + // outlives this guard. + let _gate = unsafe { table.0.lock_manager.mutation_guard(&pk) }; // Detached, not scoped. A scope joins its threads, so when the insert // deadlocks the test would hang at the end of the scope instead of From c439f04fe6a931d2f24dbf7773fb827254e353f5 Mon Sep 17 00:00:00 2001 From: meh Date: Thu, 17 Sep 2026 03:27:08 +0700 Subject: [PATCH 36/45] Refuse char on the zero-copy select_with path InlineArchived's contract is that a concurrent writer can tear a value the closure reads but cannot hand it one that is invalid: a torn read yields a wrong number, and the seqlock retry throws it away. char does not satisfy that. It archives to rend::char_le, whose to_native transmutes its u32 on the promise that it holds a valid scalar value, and two valid chars tear into one that is not: U+1D800 is [00 D8 01 00] and U+0041 is [41 00 00 00], so a copy taking the low half of the first and the high half of the second reads 0x0000D800, a surrogate. The closure transmutes that before still_stable ever runs. So a table {id: u64 primary_key, c: char} got unsafe impl InlineArchived and a generated select_with whose whole soundness argument did not hold for it. Take char out of the allowlist. It is an optimisation gate, so such a table simply loses select_with. bool stays: one byte, so it cannot tear into a third value. The doc now says what the list is actually for, which is not "no relative pointers" but "no validity invariant either". --- codegen/src/common/name_generator.rs | 36 ++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/codegen/src/common/name_generator.rs b/codegen/src/common/name_generator.rs index df79ba9a..613dd45d 100644 --- a/codegen/src/common/name_generator.rs +++ b/codegen/src/common/name_generator.rs @@ -76,6 +76,21 @@ pub fn archived_field_requires_rebuild(ty: &TokenStream) -> bool { /// type's `Archive::Archived` layout. That is conservative in the safe /// direction: an unrecognised column costs the table its zero-copy path, it /// does not grant one unsoundly. +/// +/// "No relative pointer" is necessary but not sufficient: the type must also +/// have no validity invariant, because a torn read must yield a wrong *value* +/// and not an invalid one. `char` is the type that fails that second test and +/// is why this list is an allowlist rather than "anything `Copy`". It archives +/// to `rend::char_le`, whose `to_native` transmutes its `u32` on the promise +/// that it holds a valid scalar value. Two valid chars can tear into one that +/// is not: `U+1D800` is `[00 D8 01 00]` and `U+0041` is `[41 00 00 00]`, so a +/// copy taking the low half of the first and the high half of the second reads +/// `0x0000D800`, a surrogate. The closure transmutes that before `still_stable` +/// ever runs, which is undefined behaviour and not a discarded wrong number. +/// `bool` stays: it is one byte, so it cannot tear into a third value. +/// +/// A `char` column therefore costs its table `select_with` and nothing else. +/// This gate is only an optimisation. pub fn archived_field_is_inline_scalar(ty: &TokenStream) -> bool { fn type_is_inline(ty: &Type) -> bool { let Type::Path(type_path) = ty else { @@ -86,8 +101,11 @@ pub fn archived_field_is_inline_scalar(ty: &TokenStream) -> bool { }; match segment.ident.to_string().as_str() { - "bool" | "char" | "u8" | "u16" | "u32" | "u64" | "u128" | "usize" | "i8" | "i16" | "i32" | "i64" - | "i128" | "isize" | "f32" | "f64" => true, + // `char` is deliberately absent: see the note above. Every type + // here has every bit pattern valid, so a torn read is a wrong + // number rather than an invalid value. + "bool" | "u8" | "u16" | "u32" | "u64" | "u128" | "usize" | "i8" | "i16" | "i32" | "i64" | "i128" + | "isize" | "f32" | "f64" => true, // An archived `Option` of an inline `T` stays inline: rkyv // encodes the niche or a discriminant beside the payload. "Option" => match &segment.arguments { @@ -218,6 +236,20 @@ impl WorktableNameGenerator { /// Payload budget whose end is aligned for a tail-stored archived key. /// Variable-width index entries are written backwards from this boundary. + /// + /// # On-disk format + /// + /// This is a **format-affecting** value for unsized primary keys: it fixes + /// how many entries an index node holds, so a change to it makes existing + /// index files unreadable at the new capacity. The house convention is + /// regenerate rather than migrate, but the change has to be stated rather + /// than discovered. + /// + /// Rounding down to the archived key's alignment is what corrects the + /// earlier under-budgeting (see the `u128_primary_index_capacity` test). A + /// plain `String` key is unaffected, because `align_of::()` + /// is 4 and the capacity was already a multiple of it; a composite key + /// containing a `u128` aligns to 16 and does change. pub fn get_aligned_disk_page_capacity(&self, key_type: &proc_macro2::TokenStream) -> proc_macro2::TokenStream { let capacity = self.get_disk_page_capacity(); quote::quote! { From 89b71736e2b684cf31624acbb778255aea4ee221 Mon Sep 17 00:00:00 2001 From: meh Date: Thu, 17 Sep 2026 03:27:21 +0700 Subject: [PATCH 37/45] Stop assume_init reading the uninitialised tail of a seqlock copy copy_row_seqlock built a MaybeUninit>, wrote only the row's own bytes into it, and then called assume_init on the whole array and moved the resulting Copy value into ArchivedCopy::Stack. A row is at most 256 bytes and usually far less, so the tail was never written: that is a 256-byte read of uninitialised memory on the default read path, every select of a row that fits the stack buffer. as_bytes only ever exposed ..len, so nothing observed the garbage, but the assume_init and the move are undefined by the documented contract and LLVM may treat the tail as poison. Taking a &mut [u8; 256] over uninit storage to do the copy is separately questionable under Stacked and Tree Borrows. Keep the storage MaybeUninit, copy through the raw pointer with copy_nonoverlapping, and expose only ..len. This is cheaper, not dearer: the 256-byte Copy move is gone. Also on this path: the retry loops in copy_row_seqlock and with_archived_seqlock spun with no backoff and no cap. A stripe covers many offsets on a page, so one hot writer could keep readers of unrelated rows re-copying indefinitely, and load_stable's own backoff does not cover the case a rejected stamp reports. They now back off through CellLocks::wait, which costs nothing when the first attempt validates. And three loom models for the snapshot protocol, which had none: select takes it for every row under 256 bytes and neither existing model drove load_stable or still_stable. The cell is two ordered atomics rather than a loom UnsafeCell, because the reader legitimately races the writer and loom reports that as the defect; the note on the fixture says why the ordering there is load-bearing for the model and not for the real path. The four hand-written initialize_at/initialize_arc_at helpers write every field through addr_of_mut! and then assume_init, which the compiler cannot check for exhaustiveness: adding a field and updating only the safe twin beside it compiles and hands out an Arc with one field uninitialised. Each now has a cfg(test) destructure without `..` that breaks the build instead. --- src/in_memory/data.rs | 287 ++++++++++++++++++++++++++++++++++++++++- src/in_memory/pages.rs | 39 ++++++ 2 files changed, 321 insertions(+), 5 deletions(-) diff --git a/src/in_memory/data.rs b/src/in_memory/data.rs index 82564e5c..0fb38a4f 100644 --- a/src/in_memory/data.rs +++ b/src/in_memory/data.rs @@ -78,6 +78,19 @@ impl Default for CellLocks { } impl CellLocks { + /// Breaks the build when a field is added to [`CellLocks`] without being + /// added to [`Self::initialize_at`]. See + /// [`Data::every_field_is_initialized`] for why this exists. + #[cfg(test)] + #[expect(dead_code, reason = "compiled for its exhaustiveness check, never called")] + fn every_field_is_initialized(self) { + let Self { + states: _, + owners: _, + nested_reads: _, + } = self; + } + /// Initialize the lock table directly in its final allocation. /// /// Building both fixed-size atomic arrays as a return value makes the @@ -191,6 +204,16 @@ impl CellLocks { // compare against and its own release has not run yet. return Ok(version.load(Ordering::Acquire)); } + // Reaching here means the writer bit is set and the owner is + // this thread, but no key is published yet. `write` takes the + // bit with a CAS, drains readers, stores the key and only then + // stores the owner, so the owner cannot be us while the key is + // still zero -- unless a previous writer on this stripe left a + // stale owner, which its `Drop` clears before releasing the bit. + // The guard is therefore false in every reachable state today + // and exists to keep this reader honest if that field ordering + // is ever changed: falling through to `wait` is correct for an + // unidentified writer, and returning a stamp would not be. } Self::wait(&mut spins); } @@ -226,6 +249,10 @@ impl CellLocks { return false; } let writer_key = current & CELL_READER_MASK; + // `writer_key != 0` mirrors the same guard in `load_stable`, and for + // the same reason: see the note there. It is unreachable-false given + // the current store order in `write`, and rejecting the snapshot is + // the safe answer if that ever stops holding. writer_key != 0 && Some(writer_key) != link.offset.checked_add(1) } @@ -367,7 +394,20 @@ const SEQLOCK_STACK: usize = 256; // costs one stack frame, not memory per row. #[allow(clippy::large_enum_variant)] pub(crate) enum ArchivedCopy { - Stack { buf: AlignedBytes, len: u16 }, + /// Only the first `len` bytes of `buf` are ever written. + /// + /// The storage stays `MaybeUninit`, rather than being `assume_init`ed into + /// an `AlignedBytes<256>`, because the tail beyond `len` is never + /// initialized: a row is at most 256 bytes and usually far less. Calling + /// `assume_init` on the whole array and then moving the `Copy` value into + /// this variant is a 256-byte read of uninitialized memory, which LLVM is + /// entitled to treat as poison even though `as_bytes` never exposes it. + /// Keeping it `MaybeUninit` also makes the move cheaper, not dearer: the + /// initialized prefix is all that has to be meaningful. + Stack { + buf: MaybeUninit>, + len: u16, + }, Heap(AlignedVec), } @@ -375,7 +415,12 @@ impl ArchivedCopy { #[inline] pub(crate) fn as_bytes(&self) -> &[u8] { match self { - Self::Stack { buf, len } => &buf.0[..*len as usize], + // SAFETY: `copy_row_seqlock` is the only constructor of this + // variant and writes exactly `len` bytes at the start of `buf` + // before returning, with `len <= SEQLOCK_STACK`. + Self::Stack { buf, len } => unsafe { + core::slice::from_raw_parts(buf.as_ptr().cast::(), *len as usize) + }, Self::Heap(bytes) => bytes.as_slice(), } } @@ -448,6 +493,30 @@ pub struct Data { unsafe impl Sync for Data {} impl Data { + /// Breaks the build when a field is added to [`Data`] without being added + /// to [`Self::initialize_arc_at`]. + /// + /// That initializer writes each field through `addr_of_mut!` and then + /// `assume_init`s the allocation, so the compiler cannot check it for + /// exhaustiveness the way it checks a struct literal: adding a field and + /// updating only the safe `new` beside it compiles cleanly and hands out an + /// `Arc` with one field uninitialized. Destructuring without `..` is the + /// check the initializer itself cannot have. Keep the binding list here and + /// the writes there in step. + #[cfg(test)] + #[expect(dead_code, reason = "compiled for its exhaustiveness check, never called")] + fn every_field_is_initialized(self) { + let Self { + id: _, + free_offset: _, + access: _, + cell_locks: _, + live_cells: _, + inner_data: _, + _phantom: _, + } = self; + } + unsafe fn initialize_arc_at(target: *mut Self, id: PageId, free_offset: u32, source: Option<*const u8>) { unsafe { core::ptr::addr_of_mut!((*target).id).write(id); @@ -739,18 +808,45 @@ impl Data { heap.extend_from_slice(&inner[start..start + len]); return Ok(ArchivedCopy::Heap(heap)); } + // Backoff state for the retry path only. A snapshot that validates on + // the first attempt, which is the overwhelmingly common case, never + // touches this: the counter is incremented only after `still_stable` + // has already rejected a copy. + let mut spins = 0; loop { let stamp = self.cell_locks.load_stable(link)?; let inner = unsafe { &*self.inner_data.get() }; let start = link.offset as usize; let mut storage = MaybeUninit::>::uninit(); - let buf = unsafe { &mut *storage.as_mut_ptr() }; - buf.0[..len].copy_from_slice(&inner[start..start + len]); + // Copy through the raw pointer rather than materializing a + // `&mut [u8; 256]` over uninitialized storage: that reference + // asserts the whole array is initialized, which it is not, and is + // what Stacked/Tree Borrows objects to. Only `..len` is written, + // and only `..len` is ever read back (`ArchivedCopy::as_bytes`). + // + // SAFETY: `len <= SEQLOCK_STACK` is the branch condition above, so + // the destination has room; `validate_link` bounded + // `start..start + len` within the page; and the two regions cannot + // overlap because `storage` is a fresh local. + unsafe { + core::ptr::copy_nonoverlapping( + inner[start..start + len].as_ptr(), + storage.as_mut_ptr().cast::(), + len, + ); + } if !self.cell_locks.still_stable(link, stamp) { + // A stripe covers many offsets on the page, so a single hot + // writer can invalidate readers of unrelated rows. Without a + // backoff here those readers re-copy as fast as they can and + // hold the line the writer needs; `load_stable` backs off only + // while a writer is *in* the critical section, which is not + // the case a rejected stamp reports. + CellLocks::wait(&mut spins); continue; } return Ok(ArchivedCopy::Stack { - buf: unsafe { storage.assume_init() }, + buf: storage, len: len as u16, }); } @@ -777,6 +873,8 @@ impl Data { // field is a relative pointer, which the closure would dereference // before `still_stable` ever runs. self.validate_link(link)?; + // Retry-path backoff only: see the note in `copy_row_seqlock`. + let mut spins = 0; loop { let stamp = self.cell_locks.load_stable(link)?; let archived = self.get_row_ref(link)?; @@ -784,6 +882,7 @@ impl Data { if self.cell_locks.still_stable(link, stamp) { return Ok(result); } + CellLocks::wait(&mut spins); } } @@ -1441,6 +1540,8 @@ mod tests { mod cell_lock_models { use super::{CellLocks, Link}; use alloc::boxed::Box; + use core::sync::atomic::Ordering; + use loom::sync::atomic::AtomicU32; use loom::{cell::UnsafeCell, sync::Arc, thread}; struct Protected { @@ -1469,6 +1570,40 @@ mod cell_lock_models { // Every access to value below holds the same row's read or write guard. unsafe impl Sync for Protected {} + /// Cell contents for the seqlock models, as two independently published + /// words rather than a `loom::cell::UnsafeCell`. + /// + /// The seqlock reader copies bytes *without* excluding the writer, which is + /// the whole point of the protocol and exactly what loom's `UnsafeCell` + /// reports as a data race. Two atomics are the stand-in: the writer + /// publishes them separately so a snapshot between the two stores is torn, + /// which is the condition `still_stable` has to catch, and loom still + /// explores every interleaving of the four accesses. + /// + /// They are `Release`/`Acquire` rather than `Relaxed`, and the reader takes + /// the later-published half first, because otherwise the model reports its + /// own reordering as a seqlock failure. With relaxed accesses loom is + /// entitled to produce `(0, 1)` -- half 0 stale, half 1 fresh -- even though + /// the writer stores half 0 first, and that outcome says nothing about the + /// protocol. Ordered this way, seeing half 1 set and then half 0 clear is + /// impossible unless `still_stable` accepted a snapshot spanning the write, + /// which is exactly the claim under test. The real path copies plain bytes + /// and has no such ordering to lean on; the model needs it only to keep the + /// assertion about the seqlock rather than about loom. + struct SeqlockProtected { + locks: Box, + halves: (AtomicU32, AtomicU32), + } + + impl SeqlockProtected { + fn new() -> Self { + Self { + locks: cell_locks(), + halves: (AtomicU32::new(0), AtomicU32::new(0)), + } + } + } + #[test] fn colliding_offsets_cannot_split_readers_from_a_writer() { let mut model = loom::model::Builder::new(); @@ -1558,4 +1693,146 @@ mod cell_lock_models { }); }); } + + /// The seqlock snapshot protocol, which `select` takes for every row that + /// fits the stack buffer and which neither model above drives. + /// + /// The reader does not CAS, so it can copy bytes while a writer is midway + /// through publishing them. What makes that sound is the pair + /// `load_stable` / `still_stable`: a snapshot is accepted only if no writer + /// was in the critical section when it started and the version counter has + /// not moved since. This asserts the property the copy path relies on -- + /// **an accepted snapshot is never torn** -- rather than that a retry + /// happens, because a spurious retry is allowed and a missed one is not. + #[test] + fn an_accepted_seqlock_snapshot_is_never_torn() { + let mut model = loom::model::Builder::new(); + model.preemption_bound = Some(2); + model.max_branches = 10_000; + model.check(|| { + let protected = Arc::new(SeqlockProtected::new()); + let link = Link { + page_id: 1.into(), + offset: 64, + length: 16, + }; + + let writer = { + let protected = protected.clone(); + thread::spawn(move || { + let _guard = protected.locks.write(link).unwrap(); + // The two halves are published separately, so any snapshot + // taken between them is torn and must be rejected. + protected.halves.0.store(1, Ordering::Release); + thread::yield_now(); + protected.halves.1.store(1, Ordering::Release); + }) + }; + + // One attempt, not a retry loop: a loop would let the model pass by + // eventually succeeding, where the claim under test is about what a + // single accepted snapshot may contain. + if let Ok(stamp) = protected.locks.load_stable(link) { + // Read the LATER-published half first. With the writer + // publishing half 0 then half 1, observing half 1 set and then + // half 0 clear is impossible under release/acquire, so a torn + // pair here is the seqlock failing rather than the model's own + // atomics being reordered. + let second = protected.halves.1.load(Ordering::Acquire); + let first = protected.halves.0.load(Ordering::Acquire); + let snapshot = (first, second); + if protected.locks.still_stable(link, stamp) { + assert!( + snapshot == (0, 0) || snapshot == (1, 1), + "a snapshot accepted by still_stable was torn: {snapshot:?}" + ); + } + } + + writer.join().unwrap(); + }); + } + + /// A completed write must invalidate a snapshot that started before it, + /// even though the state word is back to `0` by the time the reader + /// re-reads it. + /// + /// This is what the version counter is for, and why the writer's release + /// bumps it *before* clearing the writer bit. Checking the state word + /// alone would accept this interleaving. + #[test] + fn a_write_completed_during_the_snapshot_is_rejected() { + let mut model = loom::model::Builder::new(); + model.preemption_bound = Some(2); + model.max_branches = 10_000; + model.check(|| { + let protected = Arc::new(SeqlockProtected::new()); + let link = Link { + page_id: 1.into(), + offset: 64, + length: 16, + }; + + let stamp = protected.locks.load_stable(link).unwrap(); + + let writer = { + let protected = protected.clone(); + thread::spawn(move || { + let _guard = protected.locks.write(link).unwrap(); + protected.halves.0.store(1, Ordering::Relaxed); + protected.halves.1.store(1, Ordering::Relaxed); + }) + }; + + // Checked concurrently, so the model gets to place this read + // before, during and after the writer's critical section. A read + // that lands before it may legitimately accept; the assertion after + // the join is what must hold in every interleaving. + let _ = protected.locks.still_stable(link, stamp); + + writer.join().unwrap(); + + // The state word is back to `0` here, so a check that read only + // that word would accept a snapshot taken before a write that has + // since completed. The version counter is what rejects it, and the + // writer's release bumps that counter before clearing the bit + // precisely so there is no instant where both look idle at the + // pre-write version. + assert!( + !protected.locks.still_stable(link, stamp), + "a completed write must invalidate every snapshot taken before it" + ); + }); + } + + /// The reader's exemption for its own in-flight write on a *different* row + /// of the same stripe must hold under the model, not just in the + /// single-threaded test. + /// + /// `load_stable` admits this case and `still_stable` must agree with it: + /// rejecting it spins forever, because only this thread can clear the + /// writer bit it is waiting on. + #[test] + fn a_self_write_on_another_row_does_not_reject_the_readers_snapshot() { + let mut model = loom::model::Builder::new(); + model.preemption_bound = Some(2); + model.max_branches = 10_000; + model.check(|| { + let locks = cell_locks(); + let held = Link { + page_id: 1.into(), + offset: 7, + length: 16, + }; + let other = Link { offset: 14, ..held }; + assert_eq!(CellLocks::start(held), CellLocks::start(other)); + + let _write = locks.write(held).unwrap(); + let stamp = locks.load_stable(other).unwrap(); + assert!( + locks.still_stable(other, stamp), + "a thread reading another row under its own write must make progress" + ); + }); + } } diff --git a/src/in_memory/pages.rs b/src/in_memory/pages.rs index dbe667d4..aeea542a 100644 --- a/src/in_memory/pages.rs +++ b/src/in_memory/pages.rs @@ -227,6 +227,15 @@ struct PageDirectory { } impl PageDirectory { + /// Breaks the build when a field is added to [`PageDirectory`] without + /// being added to [`Self::initialize_at`]. See + /// [`DataPages::every_field_is_initialized`] for why this exists. + #[cfg(test)] + #[expect(dead_code, reason = "compiled for its exhaustiveness check, never called")] + fn every_field_is_initialized(self) { + let Self { roots: _, chunks: _ } = self; + } + unsafe fn initialize_at(target: *mut Self, pages: &[Arc]) { unsafe { let roots = core::ptr::addr_of_mut!((*target).roots).cast::>>(); @@ -487,6 +496,36 @@ where Row: StorableRow, ::WrappedRow: RowWrapper, { + /// Breaks the build when a field is added to [`DataPages`] without being + /// added to [`Self::initialize_arc_at`]. + /// + /// That initializer writes each field through `addr_of_mut!` and then + /// `assume_init`s the allocation, so the compiler cannot check it for + /// exhaustiveness the way it checks a struct literal: adding a field and + /// updating only the safe `new` beside it compiles cleanly and hands out an + /// `Arc` with one field uninitialized. Destructuring without `..` is the + /// check the initializer itself cannot have. Keep the binding list here and + /// the writes there in step. + #[cfg(test)] + #[expect(dead_code, reason = "compiled for its exhaustiveness check, never called")] + fn every_field_is_initialized(self) { + let Self { + epoch: _, + retired: _, + reclaimable: _, + pending_retirements: _, + queued_page_retirements: _, + pages: _, + page_directory: _, + pages_write: _, + empty_links: _, + empty_pages: _, + row_count: _, + last_page_id: _, + current_page_id: _, + } = self; + } + unsafe fn initialize_arc_at( target: *mut Self, mut pages: Vec::WrappedRow, DATA_LENGTH>>>, From 663b40045f2f1593687b4be5204c74d8eae72d22 Mon Sep 17 00:00:00 2001 From: meh Date: Thu, 17 Sep 2026 03:27:36 +0700 Subject: [PATCH 38/45] Give vacuum's idle fast path a window to observe The guard that lets an idle table skip the quiet buffer sampled entry_epoch two lines before the loop, so all three of its extra conditions were already implied on the first pass: reaching quiet += 1 requires not paused, nothing in flight and current_epoch == observed_epoch, and observed_epoch was entry_epoch. The condition reduced to `if quiet == 1 { return; }` and quiet_samples was dead. A writer at t=0 and t=5ms with vacuum entering at t=1ms is exactly the gap-between-two-writes ambiguity the sampling exists to resolve, and this walked straight into it. Sample the entry epoch before the yield instead. Now the comparison spans an actual window: a mutation that completes while vacuum is off the executor moves the epoch and sends the call down the sampling path. An idle table still returns after one yield with no sleeps, which is the measured win. Drop the mutations_in_flight repetition, which the loop head already established. The stand-down count stays, because a concurrent sweep can move it between entry and the check. The existing test set active = true before entry, so the busy branch fired, note_stand_down ran and the fast path was disabled for the whole call: it passed without executing the new code once. Two tests now cover it, one that an idle table sleeps not at all and one that work across the entry yield costs the full buffer. The second drives the epoch from the read index rather than a second task, because nagoya::yield_now wakes itself before returning Pending and a current-thread scheduler runs the waiter through both reads before the test is polled again: there is no window to race into. --- src/table/vacuum/pacing.rs | 152 ++++++++++++++++++++++++++++++++++--- 1 file changed, 140 insertions(+), 12 deletions(-) diff --git a/src/table/vacuum/pacing.rs b/src/table/vacuum/pacing.rs index 32da83e2..a57fae43 100644 --- a/src/table/vacuum/pacing.rs +++ b/src/table/vacuum/pacing.rs @@ -132,16 +132,25 @@ impl VacuumPacing { /// once even on an idle table, so a waiting insert gets the registry /// before vacuum asks for it back. pub(crate) async fn wait_until_quiet(&self, activity: &impl ForegroundActivity, gate: &VacuumGate) { + // Sampled BEFORE the yield, not after, and this ordering is the whole + // content of the fast path below. + // + // Taken after the yield it is the same read the loop makes on its first + // pass, one line apart: the loop has already established that nothing is + // in flight and that the epoch has not moved, so re-asserting both + // against a snapshot taken between them proves nothing, and the guard + // collapses to `if quiet == 1 { return; }` with `quiet_samples` dead. + // Taken here it spans the yield, so it is an actual observation window: + // a mutation that completes while vacuum is off the executor moves the + // epoch and sends this call down the sampling path it exists for. + let entry_epoch = activity.mutation_epoch(); + let stand_downs_seen = gate.stand_downs(); + nagoya::yield_now().await; let mut backoff = self.backoff; let mut quiet = 0; let mut observed_epoch = activity.mutation_epoch(); - // The epoch and stand-down count as they were on entry, so the fast path - // below can prove nothing has happened since rather than merely that - // nothing is happening right now. - let entry_epoch = observed_epoch; - let stand_downs_seen = gate.stand_downs(); loop { let current_epoch = activity.mutation_epoch(); if gate.is_paused() || activity.mutations_in_flight() > 0 || current_epoch != observed_epoch { @@ -159,19 +168,22 @@ impl VacuumPacing { // An idle table needs no confirmation. The repeated samples exist to // tell a real lull from the gap between two writes, and that ambiguity // only exists when something has been writing: if nothing is in flight - // and the epoch has not moved since the first observation, there is - // no gap to be fooled by. + // and the epoch has not moved across the entry yield, there is no gap + // to be fooled by. A table that was writing when vacuum arrived fails + // this and pays the full `quiet_samples` buffer. // // Paid unconditionally this cost 6 ms a batch (3 samples x 2 ms) on a // table with no writers at all, and `ghost-vs-drop` measured a 40-page // sweep at 31 ms of which roughly 36 ms was arithmetic sleep: the // vacuum was not slow, it was waiting for permission nobody was // withholding. - if quiet == 1 - && stand_downs_seen == gate.stand_downs() - && activity.mutations_in_flight() == 0 - && current_epoch == entry_epoch - { + // + // `mutations_in_flight() == 0` is deliberately not repeated here: + // the loop head above rejected anything else to reach this line, + // and re-reading it two lines later proves nothing further. The + // stand-down count is not redundant in the same way, because it + // can be moved by a concurrent sweep between entry and now. + if quiet == 1 && current_epoch == entry_epoch && stand_downs_seen == gate.stand_downs() { return; } if quiet >= self.quiet_samples { @@ -243,4 +255,120 @@ mod tests { .expect("vacuum should enter after the recheck buffer stays quiet") .unwrap(); } + + /// Foreground activity reported through the epoch alone, driven by the + /// test rather than by being asked. + /// + /// `ActivityBetweenChecks` moves its epoch on every *read*, which forces + /// the busy branch on the first look and so never reaches the fast path. + /// These two tests are about the fast path, so the epoch has to be + /// something the test moves at a chosen moment. + #[derive(Default)] + struct QuietActivity { + epoch: AtomicU64, + } + + impl ForegroundActivity for QuietActivity { + fn mutations_in_flight(&self) -> usize { + 0 + } + + fn mutation_epoch(&self) -> u64 { + self.epoch.load(Ordering::Acquire) + } + } + + /// The point of the fast path: a table nobody is writing to must not pay + /// `quiet_samples` sleeps for permission nobody is withholding. + /// + /// The backoffs are set far above the timeout, so this can only pass by + /// returning without sleeping at all. + #[tokio::test] + async fn an_idle_table_is_entered_without_any_sleep() { + let activity = QuietActivity::default(); + let gate = VacuumGate::default(); + let pacing = VacuumPacing { + backoff: Duration::from_secs(30), + max_backoff: Duration::from_secs(30), + quiet_samples: 3, + ..Default::default() + }; + + tokio::time::timeout(Duration::from_millis(500), pacing.wait_until_quiet(&activity, &gate)) + .await + .expect("an idle table must not sleep through the quiet buffer"); + assert_eq!( + gate.stand_downs(), + 0, + "an idle table gives vacuum nothing to stand down for" + ); + } + + /// Foreground work that completes across the entry yield, and only there. + /// + /// The epoch advances on the read that follows the entry sample and then + /// holds still. Driving it from the read index rather than from a second + /// task is deliberate: `nagoya::yield_now` wakes itself before returning + /// `Pending`, so a current-thread tokio scheduler runs the waiter straight + /// through both reads before the test task is polled again, and a bump + /// placed by the test lands after the loop has already started. There is no + /// window there to race into. + #[derive(Default)] + struct WriteAcrossTheEntryYield { + reads: AtomicU64, + } + + impl ForegroundActivity for WriteAcrossTheEntryYield { + fn mutations_in_flight(&self) -> usize { + 0 + } + + fn mutation_epoch(&self) -> u64 { + // Read 0 is the entry sample and sees epoch 0. Every read after it + // sees epoch 1: one mutation completed while vacuum was off the + // executor, and the table has been quiet ever since. + u64::from(self.reads.fetch_add(1, Ordering::AcqRel) > 0) + } + } + + /// The fast path must be defeated by work that completes across the entry + /// yield, which is the window it claims to observe. + /// + /// Measured by the stand-down count, which is what the two placements of + /// the entry sample actually disagree about here. Sampled before the yield + /// (correct), the pre-loop read and the loop's first read agree, so no + /// stand-down fires; the fast path is refused on `current_epoch != + /// entry_epoch` alone and the call pays the full quiet buffer. Sampled + /// after it, `entry_epoch` becomes the pre-loop read, the loop's first read + /// disagrees with it, and the busy branch fires a stand-down instead. Zero + /// is the fixed behaviour and one is the regression. + #[tokio::test] + async fn a_write_across_the_entry_yield_defeats_the_fast_path() { + let activity = WriteAcrossTheEntryYield::default(); + let gate = VacuumGate::default(); + let pacing = VacuumPacing { + backoff: Duration::from_millis(1), + max_backoff: Duration::from_millis(2), + quiet_samples: 3, + ..Default::default() + }; + + tokio::time::timeout(Duration::from_millis(500), pacing.wait_until_quiet(&activity, &gate)) + .await + .expect("the sampling path must still complete once the table stays quiet"); + + assert_eq!( + gate.stand_downs(), + 0, + "the entry sample must span the yield, so the epoch move is seen as staleness \ + rather than as a fresh mutation arriving mid-loop" + ); + // Three loop iterations, not one: the fast path was refused. Entry + // sample, the pre-loop read, and one read per `quiet_samples`. + assert_eq!( + activity.reads.load(Ordering::Acquire), + 5, + "a mutation completing across the entry yield must cost the full quiet buffer" + ); + } } From 49ac84795b71ef52fc598da17ba0b0ad5e88ddf3 Mon Sep 17 00:00:00 2001 From: meh Date: Thu, 17 Sep 2026 03:27:36 +0700 Subject: [PATCH 39/45] Bound the join a busy persistence drop waits on The busy-drop arm replaced a log line with nagoya::block_on(handle), which parks the calling thread on a Signal between polls with no timeout and no attempt bound. Dropping a PersistedWorkTable with a non-empty queue then parks that thread until the engine has drained and fsynced everything. Drop runs on whatever thread drops the table. On a current_thread runtime that is the only thread there is. It also runs during unwinding, so a panic in a caller could block on I/O instead of unwinding, and a hung file or S3 write means drop never returns at all, with no diagnostic. The comment's own safety argument -- that joining cannot occupy the worker that must make progress -- holds only because the engine has its own one-worker pool, and a drop reached from that worker self-deadlocks. Keep the join, which is what stops an immediate same-path reopen racing the last writes, and bound it. After BUSY_DROP_JOIN_TIMEOUT the handle is dropped, and nagoya::JoinHandle::drop detaches rather than cancels, so the worker still finishes its in-flight write exactly as it did before the join existed. The difference is that the caller is told rather than stalled, so the error line that was removed comes back for that case. The new test covered panic containment; nothing covered a worker that does not finish. Two tests now do, one either side of the timeout. --- src/persistence/task.rs | 136 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 132 insertions(+), 4 deletions(-) diff --git a/src/persistence/task.rs b/src/persistence/task.rs index d124f13a..fbb70ffa 100644 --- a/src/persistence/task.rs +++ b/src/persistence/task.rs @@ -1316,6 +1316,60 @@ mod lifecycle_tests { } } + /// The busy-drop join must give up on a worker that never finishes. + /// + /// This is the half of the busy-drop change that the panic-containment + /// test does not reach: a hung file or object-store write, an engine + /// future that stalls, or a drop reached from the engine worker itself. + /// `nagoya::block_on` parks with no deadline, so without the bound this + /// hangs the dropping thread -- which on a `current_thread` runtime is the + /// only thread there is -- with no diagnostic. + #[test] + fn a_worker_that_never_finishes_does_not_park_the_dropping_thread_forever() { + let runtime = nagoya::runtime::Runtime::new(1); + let released = Arc::new(AtomicBool::new(false)); + let handle = { + let released = released.clone(); + runtime.spawn(async move { + while !released.load(Ordering::Acquire) { + nagoya::yield_now().await; + } + }) + }; + + let started = std::time::Instant::now(); + let finished = join_with_timeout(handle, Duration::from_millis(50)); + let waited = started.elapsed(); + + assert!(!finished, "a worker still running must not be reported as joined"); + assert!( + waited < Duration::from_secs(5), + "the join must be bounded by its timeout, waited {waited:?}" + ); + // Detached, not cancelled: the worker is still there to finish its + // in-flight write, which is what `JoinHandle::drop` guarantees and what + // this code did before the join existed. + released.store(true, Ordering::Release); + } + + /// The same join must still return promptly when the worker does finish, + /// so the ordinary drain-and-fsync path is not silently paying the timeout. + #[test] + fn a_worker_that_finishes_is_joined_without_waiting_out_the_timeout() { + let runtime = nagoya::runtime::Runtime::new(1); + let handle = runtime.spawn(async {}); + + let started = std::time::Instant::now(); + assert!( + join_with_timeout(handle, Duration::from_secs(30)), + "a completed worker must be reported as joined" + ); + assert!( + started.elapsed() < Duration::from_secs(5), + "joining a finished worker must not wait out the timeout" + ); + } + /// A worker that stops publishes a terminal state instead of leaving its /// waiters parked, and refuses operations afterwards. /// @@ -1780,6 +1834,61 @@ pub struct PersistenceTask, } +/// How long a busy `Drop` waits for the engine worker before detaching it. +/// +/// Long enough that an ordinary drain and fsync finishes inside it, short +/// enough that a wedged write (a stalled object-store request, an engine future +/// that never completes, a drop reached from the engine worker itself) does not +/// hold the dropping thread forever. On expiry the work is not abandoned: the +/// handle is detached and the worker runs on, which is exactly what this code +/// did before the join was introduced. +const BUSY_DROP_JOIN_TIMEOUT: Duration = Duration::from_secs(30); + +/// Runs `handle` to completion on this thread, giving up after `timeout`. +/// +/// Returns whether the task finished. `nagoya::block_on` is the unbounded form +/// and parks on a `Signal` with no deadline, which is not acceptable inside a +/// destructor: see the note on [`PersistenceTask::drop`]. On expiry the handle +/// is dropped, and `nagoya::JoinHandle::drop` detaches rather than cancels, so +/// an in-flight persistence future is never cut in half by this. +fn join_with_timeout(handle: JoinHandle, timeout: Duration) -> bool { + use alloc::task::Wake; + use core::future::Future; + use core::pin::pin; + use core::task::{Context, Waker}; + use std::time::Instant; + + /// Unparks the dropping thread when the engine task makes progress. + struct Unpark(std::thread::Thread); + + impl Wake for Unpark { + fn wake(self: Arc) { + self.0.unpark(); + } + + fn wake_by_ref(self: &Arc) { + self.0.unpark(); + } + } + + let deadline = Instant::now() + timeout; + let waker = Waker::from(Arc::new(Unpark(std::thread::current()))); + let mut context = Context::from_waker(&waker); + let mut handle = pin!(handle); + + loop { + if handle.as_mut().poll(&mut context).is_ready() { + return true; + } + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + // A wake that landed before this park leaves a permit, so this cannot + // miss one; a spurious return is absorbed by re-polling. + std::thread::park_timeout(remaining); + } +} + impl Drop for PersistenceTask { @@ -1797,6 +1906,15 @@ impl Drop /// worker is private to this task, so a busy drop instead joins that worker /// after requesting close. This keeps an immediate same-path reopen from /// racing the last writes. + /// + /// That join is **bounded**. `Drop` runs on whatever thread drops the + /// table, which may be an executor thread (the only one, on a + /// `current_thread` runtime) and may already be unwinding from a panic. An + /// unbounded park there turns a hung file or object-store write into a hung + /// process with no diagnostic. After [`BUSY_DROP_JOIN_TIMEOUT`] the handle + /// is dropped instead, which detaches rather than cancels, so the worker + /// still finishes its in-flight write exactly as it did before this join + /// existed; the difference is that the caller is told rather than stalled. fn drop(&mut self) { match self.engine_task_handle.as_ref() { None => return, @@ -1831,11 +1949,21 @@ impl Drop } } else if let Some(handle) = self.engine_task_handle.take() { // The engine owns a dedicated one-worker runtime. Joining here - // cannot occupy the worker that must make this task progress. A - // join rethrows a worker panic, which must not escape a destructor - // (and would abort the process if this drop is already unwinding). + // cannot occupy the worker that must make this task progress -- + // but note that a drop reached *from* that worker would + // self-deadlock, which the timeout below also bounds. A join + // rethrows a worker panic, which must not escape a destructor (and + // would abort the process if this drop is already unwinding). // `WorkerCompletionGuard` records that panic in the lifecycle. - let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| nagoya::block_on(handle))); + let joined = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + join_with_timeout(handle, BUSY_DROP_JOIN_TIMEOUT) + })); + if matches!(joined, Ok(false)) { + tracing::error!( + "PersistenceTask dropped with work in flight and the engine did not finish within {:?}; it keeps draining detached and then stops, but its errors can no longer be observed. Call close() (or wait_for_ops() before dropping) to guarantee a clean shutdown.", + BUSY_DROP_JOIN_TIMEOUT + ); + } } } } From 7499e20d573e4eb278cf88d804bf72c8bd523e02 Mon Sep 17 00:00:00 2001 From: meh Date: Thu, 17 Sep 2026 03:27:52 +0700 Subject: [PATCH 40/45] Fence the index batch's positional page lookup resolve_batch_page gained a third fallback for InsertAt and RemoveAt: when both the table-of-contents lookup and the batch aliases miss, resolve the page by page_containing(event_value), which returns the smallest maximum >= value and falls back to the highest-keyed page when there is none. That tail arm always returns something for a non-empty table of contents, so the error branch above it became unreachable for insert and remove events: a batch whose identity is missing through real corruption, rather than through the stale-maximum case the fallback was written for, was applied to whatever page the ordering picked and that wrong write was persisted. The tail arm cannot simply go: it is the arm the intended repair needs. When a preceding batch removed a page's maximum, the next batch's value is above every surviving maximum, which is precisely the cross_batch_max fixture. So fence it instead. Pass the stale identity too, and require the page found to be one that identity could have belonged to: its maximum strictly below the named one, with no surviving page between the two, since such a page would own that range and would have answered the identity lookup. Both conditions are checkable and both are checked, so a torn table of contents, a stream applied out of order or two writers on one file find nothing and stay a hard error. Folded into the existing pass rather than added beside it, because this runs per fallback event inside the batch loop and was already linear in the table of contents. --- src/persistence/space/index/mod.rs | 10 +- .../space/index/table_of_contents.rs | 91 +++++++++++++++++-- src/persistence/space/index/unsized_.rs | 6 +- 3 files changed, 95 insertions(+), 12 deletions(-) diff --git a/src/persistence/space/index/mod.rs b/src/persistence/space/index/mod.rs index 592f1860..ee25ec89 100644 --- a/src/persistence/space/index/mod.rs +++ b/src/persistence/space/index/mod.rs @@ -104,8 +104,16 @@ where // recorded identity stale. Insert/remove events carry the // affected value, which still identifies the ordered page // range. Structural events do not, and remain hard errors. + // + // `event_page_key` is passed as well as the value: resolving by + // ordering alone always finds *some* page, which would make the + // caller's error branch unreachable and apply a genuinely + // corrupt event to whichever page sorted nearest. Given the + // stale identity, `page_containing` can check that the page it + // found is one that identity could have belonged to, and + // returns nothing when it is not. self.table_of_contents - .page_containing(event_value) + .page_containing(event_value, event_page_key) .map(|(current_key, page_id)| (page_id, Some(current_key))) }) } diff --git a/src/persistence/space/index/table_of_contents.rs b/src/persistence/space/index/table_of_contents.rs index 106c86a7..70295fdd 100644 --- a/src/persistence/space/index/table_of_contents.rs +++ b/src/persistence/space/index/table_of_contents.rs @@ -75,15 +75,41 @@ where } /// Finds the page whose ordered range contains `value` and returns its - /// current maximum identity. CDC events name the maximum observed when the - /// event was created; that identity can become stale at a persistence - /// batch boundary after a preceding max removal re-keyed the page. - pub(crate) fn page_containing(&self, value: &T) -> Option<(T, PageId)> + /// current maximum identity, but only when that page is a plausible + /// successor of the stale identity `stale_maximum`. + /// + /// CDC events name the maximum observed when the event was created, and + /// that identity goes stale at a persistence batch boundary when a + /// preceding max removal re-keys the page. Recovering from it means + /// choosing a page by ordering rather than by identity, which is a guess, + /// so it is fenced to the one shape the staleness can actually take. + /// + /// The page that a removed maximum leaves behind has a *smaller* maximum + /// than the identity the event names, and nothing else can have been + /// inserted between the two: any page whose maximum falls in + /// `(found, stale_maximum]` would itself own that range and would have + /// answered the identity lookup. Both conditions are checked here, so an + /// event whose identity is missing for any other reason -- a torn table of + /// contents, a stream applied out of order, two writers on one file -- + /// finds nothing and stays a hard error rather than being applied to + /// whichever page the ordering happened to pick. + /// + /// Without that fence the tail arm below returns the highest-keyed page for + /// any value above every maximum, so *some* page always matched and the + /// caller's error branch was unreachable for insert and remove events. + pub(crate) fn page_containing(&self, value: &T, stale_maximum: &T) -> Option<(T, PageId)> where T: Clone, { + // One pass. This runs per fallback event inside the persistence batch + // loop, and it is already linear in the table of contents; walking it + // twice to apply the fence below would double that for nothing. let mut ceiling: Option<(&T, &PageId)> = None; let mut last: Option<(&T, &PageId)> = None; + // The greatest surviving maximum at or below the stale one. If the + // stale identity was really this page's, nothing survives between them, + // so this ends up being the page found. + let mut greatest_below_stale: Option<&T> = None; for (maximum, page_id) in self.iter() { if last.is_none_or(|(current, _)| maximum > current) { last = Some((maximum, page_id)); @@ -91,8 +117,18 @@ where if maximum >= value && ceiling.is_none_or(|(current, _)| maximum < current) { ceiling = Some((maximum, page_id)); } + if maximum <= stale_maximum && greatest_below_stale.is_none_or(|current| maximum > current) { + greatest_below_stale = Some(maximum); + } } - ceiling.or(last).map(|(maximum, page_id)| (maximum.clone(), *page_id)) + let (maximum, page_id) = ceiling.or(last)?; + // The named identity must be one this page could have shed, and no + // surviving page may sit between the two: such a page would own the + // range itself and would have answered the identity lookup. + if maximum >= stale_maximum || greatest_below_stale != Some(maximum) { + return None; + } + Some((maximum.clone(), *page_id)) } fn get_current_page_mut(&mut self) -> &mut GeneralPage> { @@ -386,6 +422,9 @@ mod tests { assert_eq!(toc.get(&9), None); } + /// The ordered lookup picks the smallest ceiling, and the tail for a value + /// above every maximum, whenever the stale identity the event named is one + /// the chosen page could have shed. #[test] fn page_containing_uses_the_smallest_ceiling_and_the_tail_for_larger_values() { let mut toc = IndexTableOfContents::::new(0.into(), Arc::new(AtomicU32::new(1))); @@ -393,11 +432,43 @@ mod tests { toc.insert(10, 1.into()); toc.insert(25, 2.into()); - assert_eq!(toc.page_containing(&0), Some((10, 1.into()))); - assert_eq!(toc.page_containing(&10), Some((10, 1.into()))); - assert_eq!(toc.page_containing(&11), Some((25, 2.into()))); - assert_eq!(toc.page_containing(&40), Some((40, 4.into()))); - assert_eq!(toc.page_containing(&41), Some((40, 4.into()))); + // Each stale maximum sits above the page found and below the next + // surviving page, which is what a removed maximum leaves behind. + assert_eq!(toc.page_containing(&0, &20), Some((10, 1.into()))); + assert_eq!(toc.page_containing(&10, &20), Some((10, 1.into()))); + assert_eq!(toc.page_containing(&11, &30), Some((25, 2.into()))); + assert_eq!(toc.page_containing(&40, &45), Some((40, 4.into()))); + assert_eq!(toc.page_containing(&41, &45), Some((40, 4.into()))); + } + + /// The positional lookup is a repair for one specific staleness, not a + /// general "nearest page" fallback. + /// + /// Unfenced it returns the tail for any value above every maximum, so it + /// always answered and the caller's error branch was dead: a batch whose + /// identity is missing through real corruption was applied to whichever + /// page sorted nearest, and that wrong write was persisted. + #[test] + fn page_containing_refuses_an_identity_the_page_could_not_have_had() { + let mut toc = IndexTableOfContents::::new(0.into(), Arc::new(AtomicU32::new(1))); + toc.insert(40, 4.into()); + toc.insert(10, 1.into()); + toc.insert(25, 2.into()); + + // Names a maximum at or below the page that owns the range. A live + // identity would have been found by the identity lookup, so this is a + // stream applied out of order, not a stale maximum. + assert_eq!(toc.page_containing(&11, &25), None); + assert_eq!(toc.page_containing(&11, &20), None); + + // Names a maximum with a surviving page between it and the page found. + // That page owns the range, so the event does not belong here. + assert_eq!(toc.page_containing(&0, &30), None); + assert_eq!(toc.page_containing(&11, &40), None); + + // An empty table of contents has nothing to repair to. + let empty = IndexTableOfContents::::new(0.into(), Arc::new(AtomicU32::new(1))); + assert_eq!(empty.page_containing(&5, &9), None); } #[test] diff --git a/src/persistence/space/index/unsized_.rs b/src/persistence/space/index/unsized_.rs index c2fe6fd0..7ed75bff 100644 --- a/src/persistence/space/index/unsized_.rs +++ b/src/persistence/space/index/unsized_.rs @@ -82,8 +82,12 @@ where // leave this event's page identity stale. The affected value // still selects the ordered page range. Structural events do // not carry such a value and remain hard errors. + // + // The stale identity is passed too, so the positional lookup + // can reject a page that identity could not have named. See the + // sized twin in `super::SpaceIndex::resolve_batch_page`. self.table_of_contents - .page_containing(event_value) + .page_containing(event_value, event_page_key) .map(|(current_key, page_id)| (page_id, Some(current_key))) }) } From ca2a23f98847ce991b649d2cb9690f1bc74c2b25 Mon Sep 17 00:00:00 2001 From: meh Date: Thu, 17 Sep 2026 03:27:52 +0700 Subject: [PATCH 41/45] Stop duplicating the retry loop and the opaque-rebuild arm Three copies of one fifteen-line link-recheck loop in select, select_ref and select_with, two of them added by this branch, differing only in the body. Extracted as with_link_retry, which is #[inline] and generic over the closure, so each call site monomorphises to what it compiled to before. select_ref attaches its pin to the copy afterwards, because the pin is moved into the result and cannot be captured by a closure the loop may call more than once. Bound and behaviour unchanged. Around three hundred lines of the requires_rebuild arm stood verbatim in both update generators, including the same in-place attempt and the same full-row lock dance, differing only in a trailing CDC block. That is a correctness-critical path -- it is what keeps an opaque archived field's relative pointers based in the destination slot -- and it had to be changed in two places in lockstep. It is one function now, in generators::opaque_rebuild, taking the difference as a parameter. Also six #[allow(clippy::mutable_key_type)] left on code that returns Vec since the lock generators stopped using HashSet. Clippy is clean without them. --- .../generators/in_memory/queries/update.rs | 60 +++---------- codegen/src/generators/mod.rs | 1 + codegen/src/generators/opaque_rebuild.rs | 84 +++++++++++++++++ .../src/generators/persist/queries/update.rs | 90 ++++++------------- src/table/mod.rs | 82 +++++++++-------- 5 files changed, 170 insertions(+), 147 deletions(-) create mode 100644 codegen/src/generators/opaque_rebuild.rs diff --git a/codegen/src/generators/in_memory/queries/update.rs b/codegen/src/generators/in_memory/queries/update.rs index 4e51eee8..e31e859d 100644 --- a/codegen/src/generators/in_memory/queries/update.rs +++ b/codegen/src/generators/in_memory/queries/update.rs @@ -335,52 +335,16 @@ impl InMemoryGenerator { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let const_name = name_generator.get_page_inner_size_const_ident(); - if touches_index { - quote! { - { - drop(_guard); - let pending_lock = { #full_row_lock }; - let _guard = pending_lock.into_guard_with_mutation(); - - let row_old = self.0.select(pk.clone()).ok_or(WorkTableError::NotFound)?; - let mut row_new = row_old.clone(); - #(#row_updates)* - self.reinsert(row_old, row_new).await?; - return core::result::Result::Ok(()); - } - } - } else { - quote! { - { - // An opaque archived field may contain relative pointers. - // Rebuild the complete row under its full lock so every - // pointer is based in the destination slot, then retain the - // current link when the serialized length still fits. - drop(_guard); - let pending_lock = { #full_row_lock }; - let _guard = pending_lock.into_guard_with_mutation(); - - let row_old = self.0.select(pk.clone()).ok_or(WorkTableError::NotFound)?; - let mut row_new = row_old.clone(); - #(#row_updates)* - let current_link: Link = self.0 - .primary_index - .pk_map - .get_value(&pk) - .map(Into::into) - .ok_or(WorkTableError::NotFound)?; - let in_place_ok = unsafe { - self.0.data.update_in_place::<{ #const_name }>(row_new.clone(), current_link).is_ok() - }; - if in_place_ok { - return core::result::Result::Ok(()); - } - - self.reinsert(row_old, row_new).await?; - return core::result::Result::Ok(()); - } - } - } + // Nothing extra on a successful in-place write: an in-memory table + // has no CDC stream to publish it to. The persisted generator passes + // the operation there instead. See `opaque_rebuild`. + crate::generators::opaque_rebuild::gen_rebuild_arm( + &row_updates, + &full_row_lock, + &const_name, + touches_index, + "e! {}, + ) } else if let (Some(f), false) = (unsized_fields, touches_index) { let fields_check: Vec<_> = f .iter() @@ -1016,7 +980,9 @@ impl InMemoryGenerator { if self.0.data.select_non_ghosted(link)?.#by_field != by { continue; } - let _mutation_guard = self.0.lock_manager.mutation_guard(&pk); + // SAFETY: the table owns `lock_manager` and is borrowed + // for this whole operation, so the map outlives the guard. + let _mutation_guard = unsafe { self.0.lock_manager.mutation_guard(&pk) }; let mut bytes = worktable::prelude::rkyv::to_bytes::(&row) .map_err(|_| WorkTableError::SerializeError)?; diff --git a/codegen/src/generators/mod.rs b/codegen/src/generators/mod.rs index 03bb883e..18a4397f 100644 --- a/codegen/src/generators/mod.rs +++ b/codegen/src/generators/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod dense_table; pub mod in_memory; pub(crate) mod index_backend; pub(crate) mod mutation_builder; +pub(crate) mod opaque_rebuild; pub mod partitions; pub mod persist; pub(crate) mod primary_key; diff --git a/codegen/src/generators/opaque_rebuild.rs b/codegen/src/generators/opaque_rebuild.rs new file mode 100644 index 00000000..43858f0e --- /dev/null +++ b/codegen/src/generators/opaque_rebuild.rs @@ -0,0 +1,84 @@ +//! The opaque-field rebuild arm of `gen_size_check`, shared by the in-memory +//! and persisted update generators. +//! +//! A column whose archived form the macro cannot inspect may contain relative +//! pointers, so an in-place update of one cannot be done field-by-field: the +//! whole row has to be rebuilt under its full row lock, so that every pointer +//! is based in the destination slot. The two generators emit exactly the same +//! code for that, differing only in whether the successful in-place write is +//! also published as a CDC operation. +//! +//! It lived as two verbatim copies, and this is a correctness-critical path: +//! a fix applied to one copy and not the other is a table whose opaque-field +//! update is right in memory and wrong on disk, or the reverse. Taking the +//! difference as a parameter is what keeps them in step. + +use proc_macro2::{Ident, TokenStream}; +use quote::quote; + +/// Emits the `requires_rebuild` arm. +/// +/// - `row_updates`: per-column assignments onto the rebuilt row. +/// - `full_row_lock`: the generator's full-row lock acquisition. +/// - `page_inner_size_const`: the table's page-inner-size constant. +/// - `touches_index`: whether any updated column is indexed. An indexed column +/// must keep the index-maintaining reinsert path and its unique-constraint +/// check, so the in-place attempt is skipped entirely. +/// - `on_in_place_success`: emitted inside the `in_place_ok` branch before it +/// returns. Empty for the in-memory generator; the persisted one publishes +/// the same-slot write there as an event-less data operation. +pub(crate) fn gen_rebuild_arm( + row_updates: &[TokenStream], + full_row_lock: &TokenStream, + page_inner_size_const: &Ident, + touches_index: bool, + on_in_place_success: &TokenStream, +) -> TokenStream { + if touches_index { + return quote! { + { + drop(_guard); + let pending_lock = { #full_row_lock }; + let _guard = pending_lock.into_guard_with_mutation(); + + let row_old = self.0.select(pk.clone()).ok_or(WorkTableError::NotFound)?; + let mut row_new = row_old.clone(); + #(#row_updates)* + self.reinsert(row_old, row_new).await?; + return core::result::Result::Ok(()); + } + }; + } + + quote! { + { + // An opaque archived field may contain relative pointers. + // Rebuild the complete row under its full lock so every + // pointer is based in the destination slot, then retain the + // current link when the serialized length still fits. + drop(_guard); + let pending_lock = { #full_row_lock }; + let _guard = pending_lock.into_guard_with_mutation(); + + let row_old = self.0.select(pk.clone()).ok_or(WorkTableError::NotFound)?; + let mut row_new = row_old.clone(); + #(#row_updates)* + let current_link: Link = self.0 + .primary_index + .pk_map + .get_value(&pk) + .map(Into::into) + .ok_or(WorkTableError::NotFound)?; + let in_place_ok = unsafe { + self.0.data.update_in_place::<{ #page_inner_size_const }>(row_new.clone(), current_link).is_ok() + }; + if in_place_ok { + #on_in_place_success + return core::result::Result::Ok(()); + } + + self.reinsert(row_old, row_new).await?; + return core::result::Result::Ok(()); + } + } +} diff --git a/codegen/src/generators/persist/queries/update.rs b/codegen/src/generators/persist/queries/update.rs index 72996806..9a0a880d 100644 --- a/codegen/src/generators/persist/queries/update.rs +++ b/codegen/src/generators/persist/queries/update.rs @@ -398,66 +398,32 @@ impl PersistGenerator { let full_row_lock = self.gen_full_lock_for_update(); let const_name = name_generator.get_page_inner_size_const_ident(); - if touches_index { - quote! { - { - drop(_guard); - let pending_lock = { #full_row_lock }; - let _guard = pending_lock.into_guard_with_mutation(); - - let row_old = self.0.select(pk.clone()).ok_or(WorkTableError::NotFound)?; - let mut row_new = row_old.clone(); - #(#row_updates)* - self.reinsert(row_old, row_new).await?; - return core::result::Result::Ok(()); - } - } - } else { - quote! { - { - // An opaque archived field may contain relative pointers. - // Rebuild the complete row under its full lock so every - // pointer is based in the destination slot, then retain the - // current link when the serialized length still fits. - drop(_guard); - let pending_lock = { #full_row_lock }; - let _guard = pending_lock.into_guard_with_mutation(); - - let row_old = self.0.select(pk.clone()).ok_or(WorkTableError::NotFound)?; - let mut row_new = row_old.clone(); - #(#row_updates)* - let current_link: Link = self.0 - .primary_index - .pk_map - .get_value(&pk) - .map(Into::into) - .ok_or(WorkTableError::NotFound)?; - let in_place_ok = unsafe { - self.0.data.update_in_place::<{ #const_name }>(row_new.clone(), current_link).is_ok() - }; - if in_place_ok { - let secondary_keys_events: #secondary_events_ident = core::default::Default::default(); - let op: Operation< - <<#primary_key_ident as TablePrimaryKey>::Generator as PrimaryKeyGeneratorState>::State, - #primary_key_ident, - #secondary_events_ident - > = Operation::Update(UpdateOperation { - retired_link: None, - id: OperationId::Single(worktable::prelude::uuid::Uuid::now_v7()), - primary_key_events: vec![], - secondary_keys_events, - bytes: self.0.data.select_raw(current_link)?, - link: current_link, - }); - self.1.apply_operation(op)?; - return core::result::Result::Ok(()); - } - - self.reinsert(row_old, row_new).await?; - return core::result::Result::Ok(()); - } - } - } + // The one thing this generator adds over the in-memory twin: the + // same-slot write has to reach the CDC stream, as an event-less data + // operation. Everything around it is shared. See `opaque_rebuild`. + let publish_in_place = quote! { + let secondary_keys_events: #secondary_events_ident = core::default::Default::default(); + let op: Operation< + <<#primary_key_ident as TablePrimaryKey>::Generator as PrimaryKeyGeneratorState>::State, + #primary_key_ident, + #secondary_events_ident + > = Operation::Update(UpdateOperation { + retired_link: None, + id: OperationId::Single(worktable::prelude::uuid::Uuid::now_v7()), + primary_key_events: vec![], + secondary_keys_events, + bytes: self.0.data.select_raw(current_link)?, + link: current_link, + }); + self.1.apply_operation(op)?; + }; + crate::generators::opaque_rebuild::gen_rebuild_arm( + &row_updates, + &full_row_lock, + &const_name, + touches_index, + &publish_in_place, + ) } else if let (Some(f), false) = (unsized_fields, touches_index) { let fields_check: Vec<_> = f .iter() @@ -1114,7 +1080,9 @@ impl PersistGenerator { if self.0.data.select_non_ghosted(link)?.#by_field != by { continue; } - let _mutation_guard = self.0.lock_manager.mutation_guard(&pk); + // SAFETY: the table owns `lock_manager` and is borrowed + // for this whole operation, so the map outlives the guard. + let _mutation_guard = unsafe { self.0.lock_manager.mutation_guard(&pk) }; let mut bytes = worktable::prelude::rkyv::to_bytes::(&row) .map_err(|_| WorkTableError::SerializeError)?; diff --git a/src/table/mod.rs b/src/table/mod.rs index 92d0ac19..9f98a624 100644 --- a/src/table/mod.rs +++ b/src/table/mod.rs @@ -225,23 +225,35 @@ where self.pk_gen.reserve(count) } - /// Selects `Row` from table identified with provided primary key. Returns `None` if no value presented. - #[cfg_attr(feature = "perf_measurements", performance_measurement(prefix_name = "WorkTable"))] - pub fn select(&self, pk: PrimaryKey) -> Option + /// Resolves `pk` to a link and applies `read`, retrying while the link the + /// index hands back keeps changing under the read. + /// + /// A failed read is ambiguous on its own: the row may genuinely be absent + /// (ghosted, deleted) or it may have just moved, in which case the link is + /// stale rather than wrong. Re-looking the key up distinguishes the two -- + /// an unchanged link means the row really is gone, a changed one means try + /// again at the new address. + /// + /// Shared by [`Self::select`], [`Self::select_ref`] and + /// [`Self::select_with`], which differ only in what they do with the link. + /// The caller takes the epoch pin, because `select_ref` hands that pin out + /// in its return value and the other two only need it held. + /// + /// The bound is the pre-existing one: 64 attempts, after which this gives + /// up and reports absence. A row moving 64 times while one reader looks at + /// it is not a case this distinguishes from deletion. + #[inline] + fn with_link_retry(&self, pk: &PrimaryKey, mut read: F) -> Option where - LockType: 'static, - Row: Archive + for<'a> Serialize, Share>, rkyv::rancor::Error>>, - <::WrappedRow as Archive>::Archived: - Deserialize<::WrappedRow, HighDeserializer>, + F: FnMut(Link) -> Result, { - let _read_guard = self.data.read_guard(); for _ in 0..64 { - let link = self.primary_index.pk_map.lookup_for_select(&pk).map(Into::into)?; - if let Ok(row) = self.data.select_non_ghosted(link) { - return Some(row); + let link = self.primary_index.pk_map.lookup_for_select(pk).map(Into::into)?; + if let Ok(value) = read(link) { + return Some(value); } - let current_link: Option = self.primary_index.pk_map.lookup_for_select(&pk).map(Into::into); + let current_link: Option = self.primary_index.pk_map.lookup_for_select(pk).map(Into::into); if current_link == Some(link) { return None; } @@ -250,6 +262,19 @@ where None } + /// Selects `Row` from table identified with provided primary key. Returns `None` if no value presented. + #[cfg_attr(feature = "perf_measurements", performance_measurement(prefix_name = "WorkTable"))] + pub fn select(&self, pk: PrimaryKey) -> Option + where + LockType: 'static, + Row: Archive + for<'a> Serialize, Share>, rkyv::rancor::Error>>, + <::WrappedRow as Archive>::Archived: + Deserialize<::WrappedRow, HighDeserializer>, + { + let _read_guard = self.data.read_guard(); + self.with_link_retry(&pk, |link| self.data.select_non_ghosted(link)) + } + /// Point-read that yields a pin-guard plus archived inner row. /// /// Skips rkyv deserialize. The guard must outlive any use of the archived @@ -262,19 +287,11 @@ where <::WrappedRow as Archive>::Archived: ArchivedRowWrapper, { let pin = self.data.read_guard(); - for _ in 0..64 { - let link = self.primary_index.pk_map.lookup_for_select(&pk).map(Into::into)?; - if let Ok(copy) = self.data.copy_non_ghosted(link) { - return Some(SelectRef::new(pin, copy)); - } - - let current_link: Option = self.primary_index.pk_map.lookup_for_select(&pk).map(Into::into); - if current_link == Some(link) { - return None; - } - core::hint::spin_loop(); - } - None + // The copy is taken under the retry and the pin is attached to it + // afterwards, because the pin is moved into the result and so cannot be + // captured by a closure the loop calls more than once. + let copy = self.with_link_retry(&pk, |link| self.data.copy_non_ghosted(link))?; + Some(SelectRef::new(pin, copy)) } /// Point-read that applies `f` to the archived inner row and returns `f`'s @@ -288,20 +305,7 @@ where F: FnMut(&<<::WrappedRow as Archive>::Archived as ArchivedRowWrapper>::Inner) -> T, { let _pin = self.data.read_guard(); - for _ in 0..64 { - let link = self.primary_index.pk_map.lookup_for_select(&pk).map(Into::into)?; - match self.data.with_non_ghosted(link, &mut f) { - Ok(value) => return Some(value), - Err(_) => { - let current_link: Option = self.primary_index.pk_map.lookup_for_select(&pk).map(Into::into); - if current_link == Some(link) { - return None; - } - core::hint::spin_loop(); - } - } - } - None + self.with_link_retry(&pk, |link| self.data.with_non_ghosted(link, &mut f)) } #[cfg_attr(feature = "perf_measurements", performance_measurement(prefix_name = "WorkTable"))] From 9f6a42b75ba2a1de79394961737a5d6031f520ec Mon Sep 17 00:00:00 2001 From: meh Date: Thu, 17 Sep 2026 03:27:52 +0700 Subject: [PATCH 42/45] Point the old in_place keyword at its new spelling in_place: became update_in_place: in 1.10. The old spelling fell through to the generic arm and produced "Unexpected token `in_place`", which lists the new keyword but does not say it is the same section renamed, so a reader has to guess whether their table still works. Name it and say so. --- dsl/src/parser/queries/mod.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dsl/src/parser/queries/mod.rs b/dsl/src/parser/queries/mod.rs index d3e88187..d1af8d88 100644 --- a/dsl/src/parser/queries/mod.rs +++ b/dsl/src/parser/queries/mod.rs @@ -53,6 +53,16 @@ impl Parser { queries.updates_in_place = updates; queries.update_in_place_runtime = runtime; } + // The 1.10 rename. Without this arm the old spelling gets + // the generic "unexpected token" below, which lists the new + // keyword but does not say it is the same thing renamed, so + // the reader has to guess that their table still works. + "in_place" => { + return Err(syn::Error::new( + ident.span(), + "`in_place:` was renamed to `update_in_place:` in WorkTable 1.10; rename the section keyword, the queries inside it are unchanged", + )); + } other => { return Err(syn::Error::new( ident.span(), From 3bdabc09244eb5bb2dbf8ae0ffc88a2d0021e41a Mon Sep 17 00:00:00 2001 From: meh Date: Thu, 17 Sep 2026 04:23:57 +0700 Subject: [PATCH 43/45] Assert the rename message the rename now emits `legacy_in_place_section_is_rejected` still asserted the generic "Unexpected token `in_place`" that the previous commit replaced. A table that still says `in_place:` is not using an unknown keyword, it is using the last version's spelling of this one, and the error is only useful if it says so. The assertion failed in all three test jobs and nowhere else: fmt, clippy, the no-std build and the concurrency models all passed over it, because none of them run the dsl crate's tests. --- dsl/src/parser/queries/mod.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/dsl/src/parser/queries/mod.rs b/dsl/src/parser/queries/mod.rs index d1af8d88..98ff1eec 100644 --- a/dsl/src/parser/queries/mod.rs +++ b/dsl/src/parser/queries/mod.rs @@ -160,6 +160,13 @@ mod tests { }; let error = Parser::new(tokens).parse_queries().unwrap_err().to_string(); - assert!(error.contains("Unexpected token `in_place`"), "{error}"); + // The generic "Unexpected token" this used to assert is what the + // rename replaced: a table that still says `in_place:` is not using an + // unknown keyword, it is using last version's spelling of this one, and + // the error is only useful if it says so. + assert!( + error.contains("`in_place:` was renamed to `update_in_place:`"), + "{error}" + ); } } From 1098eeb5b046e7b6aadf987f4271f685c562ef76 Mon Sep 17 00:00:00 2001 From: meh Date: Thu, 17 Sep 2026 04:39:11 +0700 Subject: [PATCH 44/45] Stop a pull request paying for the release matrix twice over The build job ran `cargo build --workspace --all-targets` and then `cargo test --workspace --all-targets`, and only the test step set CARGO_PROFILE_TEST_DEBUG. The two commands disagreed about the test profile, so the second missed the first's cache and compiled the workspace again. On the all-features leg the build step also compiled with the full DWARF that `test_debug: 0` exists to avoid, which is the linker crash the comment there describes. One step now, the one that sets the profile. A pull request builds the default leg only. versioned-publication and all-features answer a release question, not a review one, and cost about 38 minutes of two-core runner between them on every push. The concurrency models go the same way: they explore an interleaving space rather than run a suite, and they build into their own CARGO_TARGET_DIR, so they share no cache and pay a cold compile each time. Both still run on master, which is what publish gates on, and workflow_dispatch is added so either can be asked for by hand. --- .github/workflows/rust.yml | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 231607d0..29229437 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -5,6 +5,7 @@ on: branches: [ "master" ] pull_request: branches: [ "master" ] + workflow_dispatch: env: CARGO_TERM_COLOR: always @@ -32,21 +33,17 @@ jobs: # exceeded 30 minutes when GitHub's cache service was unavailable. Keep # enough room for a real from-scratch release gate. timeout-minutes: 45 + # A pull request builds the default leg only. The other two legs answer a + # release question rather than a review one, and they cost about 38 minutes + # of two-core runner between them on every push to every branch. They still + # run on master, which is what `publish` gates on, and on a manual + # `workflow_dispatch` when a change wants them earlier. strategy: fail-fast: false matrix: - include: - - name: default - args: "" - test_debug: 2 - - name: versioned-publication - args: "--features versioned-row-publication" - test_debug: 2 - - name: all-features - args: "--all-features" - # Full DWARF makes Rust 1.98's bundled rust-lld crash while linking - # the large integration-test binary on a two-core runner. - test_debug: 0 + include: ${{ fromJSON(github.event_name == 'pull_request' + && '[{"name":"default","args":"","test_debug":2}]' + || '[{"name":"default","args":"","test_debug":2},{"name":"versioned-publication","args":"--features versioned-row-publication","test_debug":2},{"name":"all-features","args":"--all-features","test_debug":0}]') }} steps: - uses: actions/checkout@v4 @@ -54,9 +51,14 @@ jobs: with: cache-on-failure: "true" add-job-id-key: "false" - - name: Build - run: cargo build --workspace --all-targets ${{ matrix.args }} --verbose - - name: Run tests + # There is no separate build step. `cargo test --all-targets` builds every + # target it is about to run, and the build step ran without + # `CARGO_PROFILE_TEST_DEBUG`: the two commands disagreed about the test + # profile, so the second one missed the first one's cache and compiled the + # workspace a second time. On the all-features leg it also compiled with the + # full DWARF that `test_debug: 0` is set to avoid, which is what makes + # rust-lld crash linking the integration-test binary on a two-core runner. + - name: Build and run tests run: cargo test --workspace --all-targets ${{ matrix.args }} --verbose env: CARGO_PROFILE_TEST_DEBUG: ${{ matrix.test_debug }} @@ -88,6 +90,12 @@ jobs: name: Archived-row lock concurrency models runs-on: ubicloud-standard-2 timeout-minutes: 15 + # Not on a pull request. The models explore an interleaving space rather + # than run a suite, they build into their own CARGO_TARGET_DIR so they share + # no cache with any other job and pay a cold compile every time, and the + # answer does not change with a review comment. They still run on master, + # where `publish` gates on them, and on demand. + if: github.event_name != 'pull_request' steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable From 9443e7f647c889dbe651464b47eb8ccb4aa46bba Mon Sep 17 00:00:00 2001 From: meh Date: Thu, 17 Sep 2026 04:54:17 +0700 Subject: [PATCH 45/45] Give a pull request a smoke test instead of the full matrix Build and test is a ten-minute compile on a two-core runner before it runs anything, three times over the matrix, and it is the same command scripts/ci-local.sh runs locally in a fraction of that. It moves to master and workflow_dispatch, along with the concurrency models, which explore an interleaving space rather than run a suite and build into their own CARGO_TARGET_DIR so they share no cache. What a pull request gets instead is every crate's library tests without --all-targets. That leaves out the integration-test binary and the compile-fail harness, where the ten minutes live: the trybuild tests shell out to nested cargo builds and one alone took 56 seconds. 569 tests, 2.7 seconds once compiled. It is not a token gesture. The assertion that turned this workflow red was worktable_dsl's legacy_in_place_section_is_rejected, a lib test this job runs, and fmt, clippy and the no-std build all passed straight over it. --- .github/workflows/rust.yml | 54 ++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 29229437..3cffe506 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -33,17 +33,29 @@ jobs: # exceeded 30 minutes when GitHub's cache service was unavailable. Keep # enough room for a real from-scratch release gate. timeout-minutes: 45 - # A pull request builds the default leg only. The other two legs answer a - # release question rather than a review one, and they cost about 38 minutes - # of two-core runner between them on every push to every branch. They still - # run on master, which is what `publish` gates on, and on a manual - # `workflow_dispatch` when a change wants them earlier. + # Not on a pull request. `cargo test --workspace --all-targets` is a + # ten-minute compile on a two-core runner before it runs anything, three + # times over the matrix, and it is the same command `scripts/ci-local.sh` + # runs on a developer's machine in a fraction of that. Run it there. + # + # It still runs on master, which is what `publish` gates on, and on a manual + # `workflow_dispatch` when a change wants it earlier. + if: github.event_name != 'pull_request' strategy: fail-fast: false matrix: - include: ${{ fromJSON(github.event_name == 'pull_request' - && '[{"name":"default","args":"","test_debug":2}]' - || '[{"name":"default","args":"","test_debug":2},{"name":"versioned-publication","args":"--features versioned-row-publication","test_debug":2},{"name":"all-features","args":"--all-features","test_debug":0}]') }} + include: + - name: default + args: "" + test_debug: 2 + - name: versioned-publication + args: "--features versioned-row-publication" + test_debug: 2 + - name: all-features + args: "--all-features" + # Full DWARF makes Rust 1.98's bundled rust-lld crash while linking + # the large integration-test binary on a two-core runner. + test_debug: 0 steps: - uses: actions/checkout@v4 @@ -64,6 +76,32 @@ jobs: CARGO_PROFILE_TEST_DEBUG: ${{ matrix.test_debug }} + # What a pull request gets instead of the full matrix: the library tests of + # every crate, without `--all-targets`. + # + # That leaves out the integration-test binary and the compile-fail harness, + # which is where the ten minutes live -- the trybuild tests shell out to + # nested cargo builds, and one of them alone took 56 seconds. The lib tests + # are the part that answers "did this change break something" quickly, and + # they are not a token gesture: the assertion that turned this whole workflow + # red was `worktable_dsl`'s `legacy_in_place_section_is_rejected`, a lib test + # this job runs and the fmt/clippy/no-std jobs all passed straight over. + # + # The full matrix still runs on master and on demand. + smoke: + name: Smoke test (lib tests) + runs-on: ubicloud-standard-2 + timeout-minutes: 15 + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: "true" + add-job-id-key: "false" + - name: Library tests (cargo test --workspace --lib) + run: cargo test --workspace --lib --verbose + clippy_check: name: Clippy (${{ matrix.name }}) runs-on: ubicloud-standard-2