From 5d9604cee3afe8802c3edd994a9945d716fcb35c Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Tue, 9 Jun 2026 18:59:24 +0530 Subject: [PATCH 01/25] runtime: enforce backend selection --- crates/runtime/Cargo.toml | 4 +-- crates/runtime/src/lib.rs | 76 ++++++++++++++++++++++++--------------- 2 files changed, 50 insertions(+), 30 deletions(-) diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index 4cd0af60869..d17a0224797 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -10,7 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] -tokio = { workspace = true, optional = true } +tokio.workspace = true async-task = { version = "4.4", default-features = false, optional = true } spin = { version = "0.9", default-features = false, features = ["mutex", "spin_mutex"], optional = true } libc = { version = "0.2", optional = true } @@ -20,5 +20,5 @@ futures.workspace = true [features] default = ["tokio"] -tokio = ["dep:tokio"] +tokio = [] simulation = ["dep:async-task", "dep:spin", "dep:libc"] diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index eaed2f35f46..c1795df8ab8 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -1,3 +1,11 @@ +#[cfg(all(feature = "tokio", feature = "simulation"))] +compile_error!( + "spacetimedb-runtime requires exactly one runtime backend: enable either `tokio` or `simulation`, not both" +); + +#[cfg(not(any(feature = "tokio", feature = "simulation")))] +compile_error!("spacetimedb-runtime requires exactly one runtime backend: enable either `tokio` or `simulation`"); + #[cfg(feature = "simulation")] extern crate alloc; @@ -15,8 +23,11 @@ pub mod sim; #[cfg(feature = "simulation")] pub mod sim_std; -#[cfg(feature = "tokio")] pub type TokioHandle = tokio::runtime::Handle; +pub type TokioRuntime = tokio::runtime::Runtime; +pub type TokioRuntimeBuilder = tokio::runtime::Builder; + +pub use tokio::sync; #[derive(Clone)] pub enum Handle { @@ -74,6 +85,15 @@ enum JoinErrorInner { Simulation(sim::JoinError), } +#[cfg(feature = "tokio")] +impl From for AbortHandle { + fn from(handle: tokio::task::AbortHandle) -> Self { + Self { + inner: AbortHandleInner::Tokio(handle), + } + } +} + impl AbortHandle { pub fn abort(&self) { match &self.inner { @@ -81,8 +101,6 @@ impl AbortHandle { AbortHandleInner::Tokio(handle) => handle.abort(), #[cfg(feature = "simulation")] AbortHandleInner::Simulation(handle) => handle.abort(), - #[cfg(not(any(feature = "tokio", feature = "simulation")))] - _ => unreachable!("runtime abort handle has no enabled backend"), } } } @@ -100,16 +118,10 @@ impl JoinErrorInner { impl fmt::Display for JoinError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - #[cfg(not(any(feature = "tokio", feature = "simulation")))] - let _ = f; - #[cfg(any(feature = "tokio", feature = "simulation"))] - return self.inner.fmt(f); - #[cfg(not(any(feature = "tokio", feature = "simulation")))] - unreachable!("runtime join error has no enabled backend") + self.inner.fmt(f) } } -#[cfg(any(feature = "tokio", feature = "simulation"))] impl std::error::Error for JoinError {} impl JoinHandleInner { @@ -160,8 +172,6 @@ impl Future for JoinHandle { type Output = Result; fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - #[cfg(not(any(feature = "tokio", feature = "simulation")))] - let _ = cx; match self.inner.poll_result(cx) { Poll::Ready(Ok(output)) => { self.inner = JoinHandleInner::Detached(PhantomData); @@ -197,17 +207,30 @@ impl fmt::Display for RuntimeTimeout { } } -#[cfg(any(feature = "tokio", feature = "simulation"))] impl std::error::Error for RuntimeTimeout {} -#[cfg(feature = "tokio")] impl Handle { pub fn tokio(handle: TokioHandle) -> Self { - Self::Tokio(handle) + #[cfg(feature = "tokio")] + { + Self::Tokio(handle) + } + #[cfg(not(feature = "tokio"))] + { + let _ = handle; + panic!("spacetimedb-runtime tokio handle requested without the `tokio` backend enabled") + } } pub fn tokio_current() -> Self { - Self::tokio(TokioHandle::current()) + #[cfg(feature = "tokio")] + { + Self::tokio(TokioHandle::current()) + } + #[cfg(not(feature = "tokio"))] + { + panic!("spacetimedb-runtime current tokio handle requested without the `tokio` backend enabled") + } } } @@ -220,8 +243,6 @@ impl Handle { impl Handle { pub fn spawn(&self, future: impl Future + Send + 'static) -> JoinHandle { - #[cfg(not(any(feature = "tokio", feature = "simulation")))] - let _ = future; match self { #[cfg(feature = "tokio")] Self::Tokio(handle) => JoinHandle { @@ -231,8 +252,6 @@ impl Handle { Self::Simulation(handle) => JoinHandle { inner: JoinHandleInner::Simulation(handle.spawn_on(sim::NodeId::MAIN, future)), }, - #[cfg(not(any(feature = "tokio", feature = "simulation")))] - _ => unreachable!("runtime dispatch has no enabled backend"), } } @@ -241,8 +260,6 @@ impl Handle { F: FnOnce() -> R + Send + 'static, R: Send + 'static, { - #[cfg(not(any(feature = "tokio", feature = "simulation")))] - let _ = &f; match self { #[cfg(feature = "tokio")] Self::Tokio(_) => tokio::task::spawn_blocking(f) @@ -261,8 +278,6 @@ impl Handle { .spawn_on(sim::NodeId::MAIN, async move { f() }) .await .expect("simulation spawn_blocking task should not be cancelled"), - #[cfg(not(any(feature = "tokio", feature = "simulation")))] - _ => unreachable!("runtime dispatch has no enabled backend"), } } @@ -271,8 +286,6 @@ impl Handle { timeout_after: Duration, future: impl Future, ) -> Result { - #[cfg(not(any(feature = "tokio", feature = "simulation")))] - let _ = (timeout_after, future); match self { #[cfg(feature = "tokio")] Self::Tokio(_) => tokio::time::timeout(timeout_after, future) @@ -280,8 +293,15 @@ impl Handle { .map_err(|_| RuntimeTimeout), #[cfg(feature = "simulation")] Self::Simulation(handle) => handle.timeout(timeout_after, future).await.map_err(|_| RuntimeTimeout), - #[cfg(not(any(feature = "tokio", feature = "simulation")))] - _ => unreachable!("runtime dispatch has no enabled backend"), + } + } + + pub async fn sleep(&self, duration: Duration) { + match self { + #[cfg(feature = "tokio")] + Self::Tokio(_) => tokio::time::sleep(duration).await, + #[cfg(feature = "simulation")] + Self::Simulation(handle) => handle.sleep(duration).await, } } } From 0fd6c69f81da195e12d9550d1e9e9610115bfee8 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Tue, 9 Jun 2026 19:21:45 +0530 Subject: [PATCH 02/25] comment --- crates/runtime/src/lib.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index c1795df8ab8..8093ca61ca4 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -1,6 +1,6 @@ #[cfg(all(feature = "tokio", feature = "simulation"))] compile_error!( - "spacetimedb-runtime requires exactly one runtime backend: enable either `tokio` or `simulation`, not both" + "spacetimedb-runtime requires exactly one runtime backend: enable either `tokio` or `simulation`, not both" ); #[cfg(not(any(feature = "tokio", feature = "simulation")))] @@ -27,6 +27,14 @@ pub type TokioHandle = tokio::runtime::Handle; pub type TokioRuntime = tokio::runtime::Runtime; pub type TokioRuntimeBuilder = tokio::runtime::Builder; +// We intentionally re-export `tokio::sync` even when the simulation backend is +// selected. Async and non-blocking synchronization operations are +// executor-agnostic, so driving them from the deterministic simulation runtime +// remains deterministic. +// +// Callers must avoid APIs that block or park OS threads on their own, such as +// `blocking_send`, because those semantics are outside the simulation runtime's +// deterministic scheduler. pub use tokio::sync; #[derive(Clone)] From 60ea93f346f80a1084f0eae5fcf1233d66094944 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Tue, 9 Jun 2026 19:00:19 +0530 Subject: [PATCH 03/25] move engine into its own crate --- .github/workflows/ci.yml | 3 + Cargo.lock | 43 +++ Cargo.toml | 4 +- crates/core/Cargo.toml | 5 +- crates/core/src/database_logger.rs | 6 + crates/core/src/error.rs | 267 ++---------------- crates/core/src/host/host_controller.rs | 19 +- crates/core/src/host/module_host.rs | 21 +- crates/core/src/lib.rs | 3 +- crates/core/src/sql/execute.rs | 6 +- crates/core/src/sql/mod.rs | 1 - crates/core/src/subscription/mod.rs | 75 +---- crates/core/src/worker_metrics/mod.rs | 108 ------- crates/datastore/Cargo.toml | 8 +- crates/durability/Cargo.toml | 5 +- crates/engine/Cargo.toml | 59 ++++ crates/engine/LICENSE | 1 + crates/{core => engine}/src/db/durability.rs | 0 crates/{core => engine}/src/db/mod.rs | 33 ++- crates/{core => engine}/src/db/persistence.rs | 30 +- .../{core => engine}/src/db/relational_db.rs | 101 +++---- crates/{core => engine}/src/db/snapshot.rs | 24 +- crates/{core => engine}/src/db/update.rs | 30 +- crates/engine/src/error.rs | 262 +++++++++++++++++ crates/engine/src/lib.rs | 10 + crates/engine/src/metrics.rs | 181 ++++++++++++ .../src/sql/parser.rs => engine/src/rls.rs} | 0 crates/engine/src/sql/ast.rs | 77 +++++ crates/engine/src/sql/mod.rs | 1 + crates/engine/src/util.rs | 21 ++ crates/{core => engine}/testdata/README.md | 0 .../clog/00000000000000000000.stdb.log | Bin .../clog/00000000000000000000.stdb.ofs | Bin .../testdata/v1.2/replicas/22000001/db.lock | 0 .../22000001/module_logs/2025-08-18.log | 0 .../00000000000000000000.snapshot_bsatn | Bin ...4ae0065d053adb2efbe1b5b7af457331d330e481e8 | Bin ...0d8175307d6f205756ed163f4237c6cba2936798dc | Bin ...de72d6277eb285fc2d6b0a5ac6f92498e08a9e5ecc | Bin ...e12161246a1d5917c61ada5d81b8dcce12fd5780b3 | Bin ...d471f5203209169321083ef99de254ad24af0f6d5a | Bin ...6be31fdef38f83c2fd3bcc05f4934e53bdbfa21f10 | Bin ...bc4de886ce0281f11037c3424494e58fee92411241 | Bin crates/snapshot/Cargo.toml | 7 +- crates/standalone/src/lib.rs | 2 + 45 files changed, 849 insertions(+), 564 deletions(-) create mode 100644 crates/engine/Cargo.toml create mode 120000 crates/engine/LICENSE rename crates/{core => engine}/src/db/durability.rs (100%) rename crates/{core => engine}/src/db/mod.rs (86%) rename crates/{core => engine}/src/db/persistence.rs (92%) rename crates/{core => engine}/src/db/relational_db.rs (98%) rename crates/{core => engine}/src/db/snapshot.rs (95%) rename crates/{core => engine}/src/db/update.rs (97%) create mode 100644 crates/engine/src/error.rs create mode 100644 crates/engine/src/lib.rs create mode 100644 crates/engine/src/metrics.rs rename crates/{core/src/sql/parser.rs => engine/src/rls.rs} (100%) create mode 100644 crates/engine/src/sql/ast.rs create mode 100644 crates/engine/src/sql/mod.rs create mode 100644 crates/engine/src/util.rs rename crates/{core => engine}/testdata/README.md (100%) rename crates/{core => engine}/testdata/v1.2/replicas/22000001/clog/00000000000000000000.stdb.log (100%) rename crates/{core => engine}/testdata/v1.2/replicas/22000001/clog/00000000000000000000.stdb.ofs (100%) rename crates/{core => engine}/testdata/v1.2/replicas/22000001/db.lock (100%) rename crates/{core => engine}/testdata/v1.2/replicas/22000001/module_logs/2025-08-18.log (100%) rename crates/{core => engine}/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/00000000000000000000.snapshot_bsatn (100%) rename crates/{core => engine}/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/19/30ce81246a4cdc25e9024ae0065d053adb2efbe1b5b7af457331d330e481e8 (100%) rename crates/{core => engine}/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/41/bb11b6d2cdc488192ee70d8175307d6f205756ed163f4237c6cba2936798dc (100%) rename crates/{core => engine}/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/45/4d2e2c62ff5d46c5b3e6de72d6277eb285fc2d6b0a5ac6f92498e08a9e5ecc (100%) rename crates/{core => engine}/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/62/22df0e5ca93d3fb22762e12161246a1d5917c61ada5d81b8dcce12fd5780b3 (100%) rename crates/{core => engine}/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/79/4dced5633eca2ffee784d471f5203209169321083ef99de254ad24af0f6d5a (100%) rename crates/{core => engine}/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/95/74dd6d2857fa771a1cd16be31fdef38f83c2fd3bcc05f4934e53bdbfa21f10 (100%) rename crates/{core => engine}/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/9a/b95f5aaed7541289faa8bc4de886ce0281f11037c3424494e58fee92411241 (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ab0e18e9ad..e91936edcdf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -261,6 +261,9 @@ jobs: wasm-bindgen --version + - name: Check engine simulation build + run: cargo check -p spacetimedb-engine --no-default-features --features simulation + # Source emsdk environment to make emcc (Emscripten compiler) available in PATH. - name: Run tests run: | diff --git a/Cargo.lock b/Cargo.lock index ef5953bbd2e..f0bd9f88e83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8081,6 +8081,7 @@ dependencies = [ "spacetimedb-data-structures", "spacetimedb-datastore", "spacetimedb-durability", + "spacetimedb-engine", "spacetimedb-execution", "spacetimedb-expr", "spacetimedb-fs-utils", @@ -8199,6 +8200,48 @@ dependencies = [ "tracing", ] +[[package]] +name = "spacetimedb-engine" +version = "2.4.1" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "enum-map", + "env_logger 0.10.2", + "fs_extra", + "futures", + "hex", + "itertools 0.12.1", + "log", + "once_cell", + "parking_lot 0.12.5", + "pretty_assertions", + "prometheus", + "serde", + "sled", + "spacetimedb-commitlog", + "spacetimedb-data-structures", + "spacetimedb-datastore", + "spacetimedb-durability", + "spacetimedb-expr", + "spacetimedb-fs-utils", + "spacetimedb-lib", + "spacetimedb-metrics", + "spacetimedb-paths", + "spacetimedb-primitives", + "spacetimedb-runtime", + "spacetimedb-sats", + "spacetimedb-schema", + "spacetimedb-snapshot", + "spacetimedb-table", + "sqlparser", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", +] + [[package]] name = "spacetimedb-execution" version = "2.4.1" diff --git a/Cargo.toml b/Cargo.toml index ebc8df65d10..63851fb5ae0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/data-structures", "crates/datastore", "crates/durability", + "crates/engine", "crates/execution", "crates/expr", "crates/guard", @@ -132,6 +133,7 @@ spacetimedb-core = { path = "crates/core", version = "=2.4.1" } spacetimedb-data-structures = { path = "crates/data-structures", version = "=2.4.1" } spacetimedb-datastore = { path = "crates/datastore", version = "=2.4.1" } spacetimedb-durability = { path = "crates/durability", version = "=2.4.1" } +spacetimedb-engine = { path = "crates/engine", version = "=2.4.1" } spacetimedb-execution = { path = "crates/execution", version = "=2.4.1" } spacetimedb-expr = { path = "crates/expr", version = "=2.4.1" } spacetimedb-guard = { path = "crates/guard", version = "=2.4.1" } @@ -143,6 +145,7 @@ spacetimedb-pg = { path = "crates/pg", version = "=2.4.1" } spacetimedb-physical-plan = { path = "crates/physical-plan", version = "=2.4.1" } spacetimedb-primitives = { path = "crates/primitives", version = "=2.4.1" } spacetimedb-query = { path = "crates/query", version = "=2.4.1" } +spacetimedb-runtime = { path = "crates/runtime", version = "=2.4.1" } spacetimedb-sats = { path = "crates/sats", version = "=2.4.1" } spacetimedb-schema = { path = "crates/schema", version = "=2.4.1" } spacetimedb-standalone = { path = "crates/standalone", version = "=2.4.1" } @@ -152,7 +155,6 @@ spacetimedb-fs-utils = { path = "crates/fs-utils", version = "=2.4.1" } spacetimedb-snapshot = { path = "crates/snapshot", version = "=2.4.1" } spacetimedb-subscription = { path = "crates/subscription", version = "=2.4.1" } spacetimedb-query-builder = { path = "crates/query-builder", version = "=2.4.1" } -spacetimedb-runtime = { path = "crates/runtime", version = "=2.4.1" } # Prevent `ahash` from pulling in `getrandom` by disabling default features. # Modules use `getrandom02` and we need to prevent an incompatible version diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 6e7075536c2..83f44967d6e 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -22,13 +22,14 @@ spacetimedb-client-api-messages.workspace = true spacetimedb-commitlog.workspace = true spacetimedb-datastore.workspace = true spacetimedb-durability.workspace = true +spacetimedb-engine.workspace = true spacetimedb-memory-usage.workspace = true spacetimedb-metrics.workspace = true spacetimedb-primitives.workspace = true spacetimedb-paths.workspace = true spacetimedb-physical-plan.workspace = true spacetimedb-query.workspace = true -spacetimedb-runtime = { workspace = true, features = ["tokio"] } +spacetimedb-runtime.workspace = true spacetimedb-sats = { workspace = true, features = ["serde"] } spacetimedb-schema.workspace = true spacetimedb-table.workspace = true @@ -144,7 +145,7 @@ allow_loopback_http_for_tests = [] # Enable timing for wasm ABI calls spacetimedb-wasm-instance-env-times = [] # Enable test helpers and utils -test = ["spacetimedb-commitlog/test", "spacetimedb-datastore/test"] +test = ["spacetimedb-commitlog/test", "spacetimedb-datastore/test", "spacetimedb-engine/test"] # Perfmaps for profiling modules perfmap = [] # Enables core pinning. diff --git a/crates/core/src/database_logger.rs b/crates/core/src/database_logger.rs index 0e202229dea..3b5db5d7e70 100644 --- a/crates/core/src/database_logger.rs +++ b/crates/core/src/database_logger.rs @@ -674,6 +674,12 @@ impl SystemLogger { } } +impl spacetimedb_engine::db::update::UpdateLogger for SystemLogger { + fn info(&self, msg: &str) { + self.info(msg); + } +} + #[cfg(test)] mod tests { use std::{ops::Range, sync::Arc}; diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 4ba103979ec..efee537dbbe 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -1,240 +1,33 @@ -use std::io; -use std::num::ParseIntError; -use std::path::PathBuf; -use std::sync::{MutexGuard, PoisonError}; +pub use spacetimedb_engine::error::*; -use hex::FromHexError; -use spacetimedb_commitlog::repo::TxOffset; -use spacetimedb_durability::DurabilityExited; -use spacetimedb_expr::errors::TypingError; -use spacetimedb_fs_utils::lockfile::advisory::LockError; -use spacetimedb_lib::Identity; -use spacetimedb_schema::error::ValidationErrors; -use spacetimedb_schema::table_name::TableName; -use spacetimedb_snapshot::SnapshotError; -use spacetimedb_table::table::ReadViaBsatnError; -use thiserror::Error; - -use crate::client::ClientActorId; use crate::host::module_host::ViewCallError; use crate::host::scheduler::ScheduleError; use crate::host::AbiCall; use spacetimedb_lib::buffer::DecodeError; -use spacetimedb_primitives::*; -use spacetimedb_sats::hash::Hash; -use spacetimedb_sats::product_value::InvalidFieldError; -use spacetimedb_schema::def::error::{LibError, RelationError, SchemaErrors}; -use spacetimedb_schema::relation::FieldName; - -pub use spacetimedb_datastore::error::{DatastoreError, IndexError, SequenceError, TableError}; - -#[derive(Error, Debug)] -pub enum ClientError { - #[error("Client not found: {0}")] - NotFound(ClientActorId), -} - -#[derive(Error, Debug, PartialEq, Eq)] -pub enum SubscriptionError { - #[error("Index not found: {0:?}")] - NotFound(IndexId), - #[error("Empty string")] - Empty, - #[error("Unsupported query on subscription: {0:?}")] - Unsupported(String), - #[error("Subscribing to queries in one call is not supported")] - Multiple, -} - -#[derive(Error, Debug)] -pub enum PlanError { - #[error("Unsupported feature: `{feature}`")] - Unsupported { feature: String }, - #[error("Unknown table: `{table}`")] - UnknownTable { table: Box }, - #[error("Qualified Table `{expect}` not found")] - TableNotFoundQualified { expect: String }, - #[error("Unknown field: `{field}` not found in the table(s): `{tables:?}`")] - UnknownField { field: String, tables: Vec }, - #[error("Unknown field name: `{field}` not found in the table(s): `{tables:?}`")] - UnknownFieldName { field: FieldName, tables: Vec }, - #[error("Field(s): `{fields:?}` not found in the table(s): `{tables:?}`")] - UnknownFields { - fields: Vec, - tables: Vec, - }, - #[error("Ambiguous field: `{field}`. Also found in {found:?}")] - AmbiguousField { field: String, found: Vec }, - #[error("Plan error: `{0}`")] - Unstructured(String), - #[error("Internal DBError: `{0}`")] - DatabaseInternal(Box), - #[error("Relation Error: `{0}`")] - Relation(#[from] RelationError), -} - -#[derive(Error, Debug)] -pub enum DatabaseError { - #[error("Replica not found: {0}")] - NotFound(u64), - #[error("Database is already opened. Path: `{0}`. Error: {1}")] - DatabasedOpened(PathBuf, anyhow::Error), -} - -impl From for DatabaseError { - fn from(LockError { path, source, .. }: LockError) -> Self { - Self::DatabasedOpened(path, source.into()) - } -} - -#[derive(Error, Debug)] -pub enum DBError { - #[error("LibError: {0}")] - Lib(#[from] LibError), - #[error("BufferError: {0}")] - Buffer(#[from] DecodeError), - #[error("DatastoreError: {0}")] - Datastore(#[from] DatastoreError), - #[error("SequenceError: {0}")] - Sequence2(#[from] SequenceError), - #[error("SchemaError: {0}")] - Schema(SchemaErrors), - #[error("IOError: {0}.")] - IoError(#[from] std::io::Error), - #[error("ParseIntError: {0}.")] - ParseInt(#[from] ParseIntError), - #[error("Hex representation of hash decoded to incorrect number of bytes: {0}.")] - DecodeHexHash(usize), - #[error("DecodeHexError: {0}.")] - DecodeHex(#[from] FromHexError), - #[error("DatabaseError: {0}.")] - Database(#[from] DatabaseError), - #[error("SledError: {0}.")] - SledDbError(#[from] sled::Error), - #[error("Mutex was poisoned acquiring lock on MessageLog: {0}")] - MessageLogPoisoned(String), - #[error("SubscriptionError: {0}")] - Subscription(#[from] SubscriptionError), - #[error("ClientError: {0}")] - Client(#[from] ClientError), - #[error("SqlParserError: {error}, executing: `{sql}`")] - SqlParser { - sql: String, - error: sqlparser::parser::ParserError, - }, - #[error("SqlError: {error}, executing: `{sql}`")] - Plan { sql: String, error: PlanError }, - #[error("Error replaying the commit log: {0}")] - LogReplay(#[from] LogReplayError), - #[error(transparent)] - // Box the inner [`SnapshotError`] to keep Clippy quiet about large `Err` variants. - Snapshot(#[from] Box), - #[error("Error reading a value from a table through BSATN: {0}")] - ReadViaBsatnError(#[from] ReadViaBsatnError), - #[error("Module validation errors: {0}")] - ModuleValidationErrors(#[from] ValidationErrors), - #[error(transparent)] - Other(#[from] anyhow::Error), - #[error(transparent)] - TypeError(#[from] TypingError), - #[error("{error}, executing: `{sql}`")] - WithSql { - #[source] - error: Box, - sql: Box, - }, - #[error(transparent)] - RestoreSnapshot(#[from] RestoreSnapshotError), - #[error(transparent)] - DurabilityGone(#[from] DurabilityExited), - #[error(transparent)] - View(#[from] ViewCallError), -} - -impl From for DBError { - fn from(value: InvalidFieldError) -> Self { - LibError::from(value).into() - } -} - -impl From for DBError { - fn from(err: spacetimedb_table::read_column::TypeError) -> Self { - DatastoreError::Table(TableError::from(err)).into() - } -} - -impl From for PlanError { - fn from(err: DBError) -> Self { - PlanError::DatabaseInternal(Box::new(err)) - } -} - -impl<'a, T: ?Sized + 'a> From>> for DBError { - fn from(err: PoisonError>) -> Self { - DBError::MessageLogPoisoned(err.to_string()) - } -} - -impl From for DBError { - fn from(e: spacetimedb_durability::local::OpenError) -> Self { - use spacetimedb_durability::local::OpenError::*; +use spacetimedb_schema::def::error::RelationError; +use spacetimedb_schema::table_name::TableName; +use thiserror::Error; - match e { - Lock(e) => Self::from(DatabaseError::from(e)), - Commitlog(e) => Self::Other(e.into()), +impl From for DBError { + fn from(err: ViewCallError) -> Self { + match err { + ViewCallError::Args(err) => spacetimedb_engine::error::ViewError::Args(err.to_string()).into(), + ViewCallError::NoSuchModule(err) => { + spacetimedb_engine::error::ViewError::NoSuchModule(err.to_string()).into() + } + ViewCallError::NoSuchView => spacetimedb_engine::error::ViewError::NoSuchView.into(), + ViewCallError::TableDoesNotExist(view_id) => { + spacetimedb_engine::error::ViewError::TableDoesNotExist(view_id).into() + } + ViewCallError::MissingClientConnection => { + spacetimedb_engine::error::ViewError::MissingClientConnection.into() + } + ViewCallError::DatastoreError(err) => spacetimedb_engine::error::ViewError::DatastoreError(err).into(), + ViewCallError::InternalError(err) => spacetimedb_engine::error::ViewError::InternalError(err).into(), } } } -#[derive(Debug, Error)] -pub enum LogReplayError { - #[error( - "Out-of-order commit detected: {} in segment {} after offset {}", - .commit_offset, - .segment_offset, - .last_commit_offset - )] - OutOfOrderCommit { - commit_offset: u64, - segment_offset: usize, - last_commit_offset: u64, - }, - #[error( - "Error reading segment {}/{} at commit {}: {}", - .segment_offset, - .total_segments, - .commit_offset, - .source - )] - TrailingSegments { - segment_offset: usize, - total_segments: usize, - commit_offset: u64, - #[source] - source: io::Error, - }, - #[error("Could not reset log to offset {}: {}", .offset, .source)] - Reset { - offset: u64, - #[source] - source: io::Error, - }, - #[error("Missing object {} referenced from commit {}", .hash, .commit_offset)] - MissingObject { hash: Hash, commit_offset: u64 }, - #[error( - "Unexpected I/O error reading commit {} from segment {}: {}", - .commit_offset, - .segment_offset, - .source - )] - Io { - segment_offset: usize, - commit_offset: u64, - #[source] - source: io::Error, - }, -} - #[derive(Error, Debug)] pub enum NodesError { #[error("Failed to decode row: {0}")] @@ -294,23 +87,3 @@ impl From for NodesError { } } } - -#[derive(Debug, Error)] -pub enum RestoreSnapshotError { - #[error("Snapshot has incorrect database_identity: expected {expected} but found {actual}")] - IdentityMismatch { expected: Identity, actual: Identity }, - #[error("Failed to restore datastore from snapshot")] - Datastore(#[source] Box), - #[error("Failed to read snapshot")] - Snapshot(#[from] Box), - #[error("Failed to bootstrap datastore without snapshot")] - Bootstrap(#[source] Box), - #[error("No connected snapshot found, commitlog starts at {min_commitlog_offset}")] - NoConnectedSnapshot { min_commitlog_offset: TxOffset }, - #[error("Failed to invalidate snapshots at or newer than {offset}")] - Invalidate { - offset: TxOffset, - #[source] - source: Box, - }, -} diff --git a/crates/core/src/host/host_controller.rs b/crates/core/src/host/host_controller.rs index f2092412639..b28c5ea01cd 100644 --- a/crates/core/src/host/host_controller.rs +++ b/crates/core/src/host/host_controller.rs @@ -38,6 +38,7 @@ use spacetimedb_datastore::traits::Program; use spacetimedb_durability::{self as durability}; use spacetimedb_lib::{AlgebraicValue, Identity, Timestamp}; use spacetimedb_paths::server::{ModuleLogsDir, ServerDataDir}; +use spacetimedb_runtime::AbortHandle; use spacetimedb_sats::hash::Hash; use spacetimedb_schema::auto_migrate::{ponder_migrate, AutoMigrateError, MigrationPolicy, PrettyPrintStyle}; use spacetimedb_schema::def::{ModuleDef, RawModuleDefVersion}; @@ -47,7 +48,6 @@ use std::ops::Deref; use std::sync::Arc; use std::time::Duration; use tokio::sync::{watch, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock as AsyncRwLock}; -use tokio::task::AbortHandle; use tokio::time::error::Elapsed; use tokio::time::{interval_at, timeout, Instant}; @@ -889,7 +889,8 @@ impl Host { .. } = host_controller; let replica_dir = data_dir.replica(replica_id); - let (tx_metrics_queue, tx_metrics_recorder_task) = spawn_tx_metrics_recorder(); + let runtime = spacetimedb_runtime::Handle::tokio_current(); + let (tx_metrics_queue, tx_metrics_recorder_task) = spawn_tx_metrics_recorder(&runtime); let (db, connected_clients) = match config.storage { db::Storage::Memory => RelationalDB::open( @@ -902,8 +903,13 @@ impl Host { )?, db::Storage::Disk => { // Replay from the local state. - let history = relational_db::local_history(&replica_dir).await?; - let persistence = persistence.persistence(&database, replica_id).await?; + let history = relational_db::local_history(&replica_dir, &runtime).await?; + let persistence_db = db::persistence::Database { + id: database.id, + database_identity: database.database_identity, + owner_identity: database.owner_identity, + }; + let persistence = persistence.persistence(&persistence_db, replica_id).await?; // Loading a database from persistent storage involves heavy // blocking I/O. `asyncify` to avoid blocking the async worker. let (db, clients) = asyncify({ @@ -1084,8 +1090,9 @@ impl Host { module_host.clear_all_clients().await?; scheduler_starter.start(&module_host)?; - let disk_metrics_recorder_task = tokio::spawn(metric_reporter(replica_ctx.clone())).abort_handle(); - let view_cleanup_task = spawn_view_cleanup_loop(replica_ctx.relational_db().clone()); + let disk_metrics_recorder_task: spacetimedb_runtime::AbortHandle = + tokio::spawn(metric_reporter(replica_ctx.clone())).abort_handle().into(); + let view_cleanup_task = spawn_view_cleanup_loop(replica_ctx.relational_db().clone(), &runtime); let module = watch::Sender::new(module_host); diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index 9ce704426f7..d10239bd7f0 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -20,7 +20,6 @@ use crate::messages::control_db::{Database, HostType}; use crate::replica_context::ReplicaContext; use crate::sql::ast::SchemaViewer; use crate::sql::execute::SqlResult; -use crate::sql::parser::RowLevelExpr; use crate::subscription::module_subscription_actor::ModuleSubscriptions; use crate::subscription::module_subscription_manager::BroadcastError; pub use crate::subscription::module_subscription_manager::TransactionOffset; @@ -51,6 +50,7 @@ use spacetimedb_datastore::execution_context::{Workload, WorkloadType}; use spacetimedb_datastore::locking_tx_datastore::{MutTxId, ViewCallInfo}; use spacetimedb_datastore::traits::{IsolationLevel, Program, TxData}; pub use spacetimedb_durability::{DurabilityExited, DurableOffset}; +use spacetimedb_engine::rls::RowLevelExpr; use spacetimedb_execution::pipelined::{PipelinedProject, ViewProject}; use spacetimedb_execution::RelValue; use spacetimedb_expr::expr::CollectViews; @@ -64,10 +64,10 @@ use spacetimedb_query::compile_subscription; use spacetimedb_sats::raw_identifier::RawIdentifier; use spacetimedb_sats::{AlgebraicType, AlgebraicTypeRef, ProductValue}; use spacetimedb_schema::auto_migrate::{AutoMigrateError, MigrationPolicy}; -use spacetimedb_schema::def::{ModuleDef, ProcedureDef, ReducerDef, TableDef, ViewDef}; +use spacetimedb_schema::def::{ModuleDef, ProcedureDef, ReducerDef, ViewDef}; use spacetimedb_schema::identifier::Identifier; use spacetimedb_schema::reducer_name::ReducerName; -use spacetimedb_schema::schema::{Schema, TableSchema}; + use spacetimedb_schema::table_name::TableName; use std::collections::VecDeque; use std::fmt; @@ -549,19 +549,6 @@ impl GenericModuleInstance for super::v8::JsProcedureInstance { } } -/// Creates the table for `table_def` in `stdb`. -pub fn create_table_from_def( - stdb: &RelationalDB, - tx: &mut MutTxId, - module_def: &ModuleDef, - table_def: &TableDef, -) -> anyhow::Result<()> { - let schema = TableSchema::from_module_def(module_def, table_def, (), TableId::SENTINEL); - stdb.create_table(tx, schema) - .with_context(|| format!("failed to create table {}", &table_def.name))?; - Ok(()) -} - /// Creates the table for `view_def` in `stdb`. pub fn create_table_from_view_def( stdb: &RelationalDB, @@ -614,7 +601,7 @@ fn init_database_inner( table_defs.sort_by_key(|x| &x.name); for def in table_defs { logger.info(&format!("Creating table `{}`", &def.name)); - create_table_from_def(stdb, tx, module_def, def)?; + spacetimedb_engine::db::update::create_table_from_def(stdb, tx, module_def, def)?; } // Create all in-memory views defined by the module. diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 26b35230b1f..04140b7036c 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -4,7 +4,8 @@ pub mod energy; pub mod sql; pub mod auth; -pub mod db; +pub use spacetimedb_engine::db; +pub use spacetimedb_engine::metrics; pub mod messages; pub use spacetimedb_lib::Identity; pub mod error; diff --git a/crates/core/src/sql/execute.rs b/crates/core/src/sql/execute.rs index 6ff2cd05566..255f57ff8b7 100644 --- a/crates/core/src/sql/execute.rs +++ b/crates/core/src/sql/execute.rs @@ -7,8 +7,8 @@ use crate::energy::FunctionBudget; use crate::error::DBError; use crate::estimation::{check_row_limit, estimate_rows_scanned}; use crate::host::module_host::{ - DatabaseUpdate, EventStatus, ModuleEvent, ModuleFunctionCall, RefInstance, ViewCallError, ViewCallResult, - ViewOutcome, WasmInstance, + DatabaseUpdate, EventStatus, ModuleEvent, ModuleFunctionCall, RefInstance, ViewCallResult, ViewOutcome, + WasmInstance, }; use crate::host::{ArgsTuple, ModuleHost}; use crate::subscription::module_subscription_actor::{commit_and_broadcast_event, ModuleSubscriptions}; @@ -163,7 +163,7 @@ fn run_inner( if let ViewOutcome::Failed(err) = result.outcome { let (_, metrics, reducer) = db.rollback_mut_tx(result.tx); db.report_mut_tx_metrics(reducer, metrics, None); - return Err(DBError::View(ViewCallError::InternalError(err))); + return Err(DBError::View(spacetimedb_engine::error::ViewError::InternalError(err))); } let tx = result.tx; diff --git a/crates/core/src/sql/mod.rs b/crates/core/src/sql/mod.rs index 741105f0e75..78c7f6bbfe7 100644 --- a/crates/core/src/sql/mod.rs +++ b/crates/core/src/sql/mod.rs @@ -1,3 +1,2 @@ pub mod ast; pub mod execute; -pub mod parser; diff --git a/crates/core/src/subscription/mod.rs b/crates/core/src/subscription/mod.rs index 40d6d712504..3a06571db57 100644 --- a/crates/core/src/subscription/mod.rs +++ b/crates/core/src/subscription/mod.rs @@ -1,19 +1,16 @@ +use crate::error::DBError; use crate::subscription::websocket_building::{BuildableWebsocketFormat, RowListBuilder as _, RowListBuilderSource}; -use crate::{error::DBError, worker_metrics::WORKER_METRICS}; use anyhow::Result; use metrics::QueryMetrics; use module_subscription_manager::Plan; -use prometheus::IntCounter; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use spacetimedb_client_api_messages::websocket::common::ByteListLen as _; use spacetimedb_client_api_messages::websocket::v1::{self as ws_v1}; -use spacetimedb_datastore::{ - db_metrics::DB_METRICS, execution_context::WorkloadType, locking_tx_datastore::datastore::MetricsRecorder, -}; +pub use spacetimedb_engine::metrics::ExecutionCounters; use spacetimedb_execution::pipelined::ViewProject; use spacetimedb_execution::{pipelined::PipelinedProject, Datastore, DeltaStore}; use spacetimedb_lib::identity::AuthCtx; -use spacetimedb_lib::{metrics::ExecutionMetrics, Identity}; +use spacetimedb_lib::metrics::ExecutionMetrics; use spacetimedb_primitives::TableId; use spacetimedb_sats::bsatn::ToBsatn; use spacetimedb_sats::Serialize; @@ -32,72 +29,6 @@ pub mod subscription; pub mod tx; pub mod websocket_building; -#[derive(Debug)] -pub struct ExecutionCounters { - rdb_num_index_seeks: IntCounter, - rdb_num_rows_scanned: IntCounter, - rdb_num_bytes_scanned: IntCounter, - rdb_num_bytes_written: IntCounter, - bytes_sent_to_clients: IntCounter, - delta_queries_matched: IntCounter, - delta_queries_evaluated: IntCounter, - duplicate_rows_evaluated: IntCounter, - duplicate_rows_sent: IntCounter, -} - -impl ExecutionCounters { - pub fn new(workload: &WorkloadType, db: &Identity) -> Self { - Self { - rdb_num_index_seeks: DB_METRICS.rdb_num_index_seeks.with_label_values(workload, db), - rdb_num_rows_scanned: DB_METRICS.rdb_num_rows_scanned.with_label_values(workload, db), - rdb_num_bytes_scanned: DB_METRICS.rdb_num_bytes_scanned.with_label_values(workload, db), - rdb_num_bytes_written: DB_METRICS.rdb_num_bytes_written.with_label_values(workload, db), - bytes_sent_to_clients: WORKER_METRICS.bytes_sent_to_clients.with_label_values(workload, db), - delta_queries_matched: DB_METRICS.delta_queries_matched.with_label_values(db), - delta_queries_evaluated: DB_METRICS.delta_queries_evaluated.with_label_values(db), - duplicate_rows_evaluated: DB_METRICS.duplicate_rows_evaluated.with_label_values(db), - duplicate_rows_sent: DB_METRICS.duplicate_rows_sent.with_label_values(db), - } - } - - /// Update the global system metrics with transaction-level execution metrics. - pub(crate) fn record(&self, metrics: &ExecutionMetrics) { - if metrics.index_seeks > 0 { - self.rdb_num_index_seeks.inc_by(metrics.index_seeks as u64); - } - if metrics.rows_scanned > 0 { - self.rdb_num_rows_scanned.inc_by(metrics.rows_scanned as u64); - } - if metrics.bytes_scanned > 0 { - self.rdb_num_bytes_scanned.inc_by(metrics.bytes_scanned as u64); - } - if metrics.bytes_written > 0 { - self.rdb_num_bytes_written.inc_by(metrics.bytes_written as u64); - } - if metrics.bytes_sent_to_clients > 0 { - self.bytes_sent_to_clients.inc_by(metrics.bytes_sent_to_clients as u64); - } - if metrics.delta_queries_matched > 0 { - self.delta_queries_matched.inc_by(metrics.delta_queries_matched); - } - if metrics.delta_queries_evaluated > 0 { - self.delta_queries_evaluated.inc_by(metrics.delta_queries_evaluated); - } - if metrics.duplicate_rows_evaluated > 0 { - self.duplicate_rows_evaluated.inc_by(metrics.duplicate_rows_evaluated); - } - if metrics.duplicate_rows_sent > 0 { - self.duplicate_rows_sent.inc_by(metrics.duplicate_rows_sent); - } - } -} - -impl MetricsRecorder for ExecutionCounters { - fn record(&self, metrics: &ExecutionMetrics) { - self.record(metrics); - } -} - /// Execute a subscription query over a view. /// /// Specifically this utility is for queries that return rows from a view. diff --git a/crates/core/src/worker_metrics/mod.rs b/crates/core/src/worker_metrics/mod.rs index 9246022ced2..20b7d437a57 100644 --- a/crates/core/src/worker_metrics/mod.rs +++ b/crates/core/src/worker_metrics/mod.rs @@ -352,11 +352,6 @@ metrics_group!( #[labels(db: Identity, reducer: str)] pub reducer_plus_query_duration: HistogramVec, - #[name = spacetime_num_bytes_sent_to_clients_total] - #[help = "The cumulative number of bytes sent to clients"] - #[labels(txn_type: WorkloadType, db: Identity)] - pub bytes_sent_to_clients: IntCounterVec, - #[name = spacetime_subscription_send_queue_length] #[help = "The number of `ComputedQueries` waiting in the queue to be aggregated and broadcast by the `send_worker`"] #[labels(database_identity: Identity)] @@ -372,34 +367,6 @@ metrics_group!( #[labels(db: Identity)] pub total_outgoing_queue_length: IntGaugeVec, - #[name = spacetime_replay_total_time_seconds] - #[help = "Total time spent replaying a database upon restart, including snapshot read, snapshot restore and commitlog replay"] - #[labels(db: Identity)] - // We expect a small number of observations per label - // (exactly one, for non-replicated databases, and one per leader change for replicated databases) - // so we'll just store a `Gauge` with the most recent observation for each database. - pub replay_total_time_seconds: GaugeVec, - - #[name = spacetime_replay_snapshot_read_time_seconds] - #[help = "Time spent reading a snapshot from disk before restoring the snapshot upon restart"] - #[labels(db: Identity)] - pub replay_snapshot_read_time_seconds: GaugeVec, - - #[name = spacetime_replay_snapshot_restore_time_seconds] - #[help = "Time spent restoring a database from a snapshot after reading the snapshot and before commitlog replay upon restart"] - #[labels(db: Identity)] - pub replay_snapshot_restore_time_seconds: GaugeVec, - - #[name = spacetime_replay_commitlog_time_seconds] - #[help = "Time spent replaying the commitlog after restoring from a snapshot upon restart"] - #[labels(db: Identity)] - pub replay_commitlog_time_seconds: GaugeVec, - - #[name = spacetime_replay_commitlog_num_commits] - #[help = "Number of commits replayed after restoring from a snapshot upon restart"] - #[labels(db: Identity)] - pub replay_commitlog_num_commits: IntGaugeVec, - #[name = spacetime_module_create_instance_time_seconds] #[help = "Time taken to construct a WASM instance or V8 isolate to run module code"] #[labels(db: Identity, module_type: HostType)] @@ -411,75 +378,6 @@ metrics_group!( #[buckets(0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10, 50, 100)] pub module_create_instance_time_seconds: HistogramVec, - #[name = spacetime_snapshot_creation_time_total_sec] - #[help = "The time (in seconds) it took to take and store a database snapshot, including scheduling overhead"] - #[labels(db: Identity)] - // Snapshot creation should take in the order of milliseconds, - // but log data suggests that there are outliers. - // So let's track a wide range of buckets to get a better picture. - // - // We also track the timing without `asyncify` scheduling overhead - // (`snapshot_creation_time_inner`), and the snapshot compression - // timing with / without scheduling overhead (`snapshot_compression_time_total` - // and `snapshot_compression_time_inner`, respectively). - // - // Compression may have contributed to observed outliers, but is no - // longer included in the snapshot creation timing. - #[buckets(0.0005, 0.001, 0.005, 0.01, 0.1, 1.0, 5.0, 10.0)] - pub snapshot_creation_time_total: HistogramVec, - - #[name = spacetime_snapshot_creation_time_inner_sec] - #[help = "The time (in seconds) it took to take and store a database snapshot, excluding scheduling overhead"] - #[labels(db: Identity)] - #[buckets(0.0005, 0.001, 0.005, 0.01, 0.1, 1.0, 5.0, 10.0)] - pub snapshot_creation_time_inner: HistogramVec, - - #[name = spacetime_snapshot_creation_time_fsync_sec] - #[help = "The time (in seconds) it took to fsync a database snapshot, excluding scheduling overhead"] - #[labels(db: Identity)] - #[buckets(0.0005, 0.001, 0.005, 0.01, 0.1, 1.0, 5.0, 10.0)] - pub snapshot_creation_time_fsync: HistogramVec, - - #[name = spacetime_snapshot_compression_time_total_sec] - #[help = "The time (in seconds) it took to do a compression pass on the snapshot repository, including scheduling overhead"] - #[labels(db: Identity)] - // Not sure what range to expect, but certainly slower than snapshot - // creation. - #[buckets(0.001, 0.01, 0.1, 1.0, 5.0, 10.0)] - pub snapshot_compression_time_total: HistogramVec, - - #[name = spacetime_snapshot_compression_time_inner_sec] - #[help = "The time (in seconds) it took to do a compression pass on the snapshot repository, excluding scheduling overhead"] - #[labels(db: Identity)] - #[buckets(0.001, 0.01, 0.1, 1.0, 5.0, 10.0)] - pub snapshot_compression_time_inner: HistogramVec, - - #[name = spacetime_snapshot_compression_time_per_snapshot_sec] - #[help = "The time (in seconds) it took to compress a single snapshot"] - #[labels(db: Identity)] - #[buckets(0.001, 0.01, 0.1, 1.0, 5.0, 10.0)] - pub snapshot_compression_time_single: HistogramVec, - - #[name = spacetime_snapshot_compression_skipped] - #[help = "The number of snapshots skipped in a single compression pass because they were already compressed"] - #[labels(db: Identity)] - pub snapshot_compression_skipped: IntGaugeVec, - - #[name = spacetime_snapshot_compression_compressed] - #[help = "The number of snapshots compressed in a single compression pass"] - #[labels(db: Identity)] - pub snapshot_compression_compressed: IntGaugeVec, - - #[name = spacetime_snapshot_compression_objects_compressed] - #[help = "The number of snapshot objects compressed in a single compression pass"] - #[labels(db: Identity)] - pub snapshot_compression_objects_compressed: IntGaugeVec, - - #[name = spacetime_snapshot_compression_objects_hardlinked] - #[help = "The number of snapshot objects hardlinked in a single compression pass"] - #[labels(db: Identity)] - pub snapshot_compression_objects_hardlinked: IntGaugeVec, - #[name = spacetime_subscription_rows_examined] #[help = "Distribution of rows examined per subscription query"] #[labels(db: Identity, scan_type: str, table: str, unindexed_columns: str)] @@ -496,12 +394,6 @@ metrics_group!( #[help = "Total number of subscription queries by scan strategy"] #[labels(db: Identity, scan_type: str, table: str, unindexed_columns: str)] pub subscription_queries_total: IntCounterVec, - - #[name = spacetime_durability_blocking_send_duration_sec] - #[help = "Latency of blocking sends in request_durability (seconds); _count gives the number of times the channel was full"] - #[labels(database_identity: Identity)] - #[buckets(0.001, 0.01, 0.1, 1.0, 10.0)] - pub durability_blocking_send_duration: HistogramVec, } ); diff --git a/crates/datastore/Cargo.toml b/crates/datastore/Cargo.toml index 86cb2aca0b6..ad31b5cd931 100644 --- a/crates/datastore/Cargo.toml +++ b/crates/datastore/Cargo.toml @@ -10,14 +10,14 @@ rust-version.workspace = true spacetimedb-data-structures.workspace = true spacetimedb-lib = { workspace = true, features = ["serde", "metrics_impls"] } spacetimedb-commitlog.workspace = true -spacetimedb-durability.workspace = true +spacetimedb-durability = { path = "../durability", default-features = false } spacetimedb-metrics.workspace = true spacetimedb-primitives.workspace = true spacetimedb-paths.workspace = true spacetimedb-sats = { workspace = true, features = ["serde"] } spacetimedb-schema.workspace = true spacetimedb-table.workspace = true -spacetimedb-snapshot.workspace = true +spacetimedb-snapshot = { path = "../snapshot", default-features = false } spacetimedb-execution.workspace = true anyhow = { workspace = true, features = ["backtrace"] } @@ -39,7 +39,9 @@ thin-vec.workspace = true [features] # Print a warning when doing an unindexed `iter_by_col_range` on a large table. unindexed_iter_by_col_range_warn = [] -default = ["unindexed_iter_by_col_range_warn"] +default = ["unindexed_iter_by_col_range_warn", "tokio"] +tokio = ["spacetimedb-durability/tokio", "spacetimedb-snapshot/tokio"] +simulation = ["spacetimedb-durability/simulation", "spacetimedb-snapshot/simulation"] # Enable test helpers and utils test = ["spacetimedb-commitlog/test", "spacetimedb-schema/test"] diff --git a/crates/durability/Cargo.toml b/crates/durability/Cargo.toml index 4eaa3870001..198dd4461fc 100644 --- a/crates/durability/Cargo.toml +++ b/crates/durability/Cargo.toml @@ -8,6 +8,9 @@ license-file = "LICENSE" description = "Traits and single-node implementation of durability for SpacetimeDB." [features] +default = ["tokio"] +tokio = ["spacetimedb-runtime/tokio"] +simulation = ["spacetimedb-runtime/simulation"] test = [] fallocate = ["spacetimedb-commitlog/fallocate"] @@ -21,7 +24,7 @@ scopeguard.workspace = true spacetimedb-commitlog.workspace = true spacetimedb-fs-utils.workspace = true spacetimedb-paths.workspace = true -spacetimedb-runtime = { workspace = true, features = ["tokio"] } +spacetimedb-runtime = { path = "../runtime", default-features = false } spacetimedb-sats.workspace = true thiserror.workspace = true tokio.workspace = true diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml new file mode 100644 index 00000000000..7eafb2914a6 --- /dev/null +++ b/crates/engine/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "spacetimedb-engine" +version.workspace = true +edition.workspace = true +license-file = "LICENSE" +description = "Database engine and local persistence runtime for SpacetimeDB" +rust-version.workspace = true + +[lints] +workspace = true + +[features] +default = ["tokio"] +tokio = ["spacetimedb-runtime/tokio", "spacetimedb-datastore/tokio", "spacetimedb-durability/tokio", "spacetimedb-snapshot/tokio"] +simulation = ["spacetimedb-runtime/simulation", "spacetimedb-datastore/simulation", "spacetimedb-durability/simulation", "spacetimedb-snapshot/simulation"] +test = ["spacetimedb-commitlog/test", "spacetimedb-datastore/test"] + +[dependencies] +anyhow = { workspace = true, features = ["backtrace"] } +async-trait.workspace = true +enum-map.workspace = true +futures.workspace = true +hex.workspace = true +itertools.workspace = true +log.workspace = true +once_cell.workspace = true +parking_lot.workspace = true +prometheus.workspace = true +serde.workspace = true +sled.workspace = true +spacetimedb-commitlog.workspace = true +spacetimedb-data-structures.workspace = true +spacetimedb-datastore = { path = "../datastore", default-features = false } +spacetimedb-durability = { path = "../durability", default-features = false } +spacetimedb-expr.workspace = true +spacetimedb-fs-utils.workspace = true +spacetimedb-lib = { workspace = true, features = ["serde", "metrics_impls"] } +spacetimedb-metrics.workspace = true +spacetimedb-paths.workspace = true +spacetimedb-primitives.workspace = true +spacetimedb-runtime = { path = "../runtime", default-features = false } +spacetimedb-sats = { workspace = true, features = ["serde"] } +spacetimedb-schema.workspace = true +spacetimedb-snapshot = { path = "../snapshot", default-features = false } +spacetimedb-table.workspace = true +sqlparser.workspace = true +tempfile.workspace = true +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +bytes.workspace = true +env_logger.workspace = true +fs_extra.workspace = true +pretty_assertions.workspace = true +spacetimedb-commitlog = { workspace = true, features = ["test"] } +spacetimedb-datastore = { path = "../datastore", default-features = false, features = ["test"] } +spacetimedb-schema = { workspace = true, features = ["test"] } +tokio.workspace = true diff --git a/crates/engine/LICENSE b/crates/engine/LICENSE new file mode 120000 index 00000000000..8540cf8a991 --- /dev/null +++ b/crates/engine/LICENSE @@ -0,0 +1 @@ +../../licenses/BSL.txt \ No newline at end of file diff --git a/crates/core/src/db/durability.rs b/crates/engine/src/db/durability.rs similarity index 100% rename from crates/core/src/db/durability.rs rename to crates/engine/src/db/durability.rs diff --git a/crates/core/src/db/mod.rs b/crates/engine/src/db/mod.rs similarity index 86% rename from crates/core/src/db/mod.rs rename to crates/engine/src/db/mod.rs index 45777fbd612..363123c0dba 100644 --- a/crates/core/src/db/mod.rs +++ b/crates/engine/src/db/mod.rs @@ -2,10 +2,8 @@ use std::sync::Arc; use enum_map::EnumMap; use spacetimedb_schema::reducer_name::ReducerName; -use tokio::sync::mpsc; -use tokio::time::MissedTickBehavior; -use crate::subscription::ExecutionCounters; +use crate::metrics::ExecutionCounters; use spacetimedb_datastore::execution_context::WorkloadType; use spacetimedb_datastore::{locking_tx_datastore::datastore::TxMetrics, traits::TxData}; @@ -56,7 +54,7 @@ pub struct MetricsMessage { /// The handle used to send work to the tx metrics recorder. #[derive(Clone)] pub struct MetricsRecorderQueue { - tx: mpsc::UnboundedSender, + tx: spacetimedb_runtime::sync::mpsc::UnboundedSender, } impl MetricsRecorderQueue { @@ -127,19 +125,20 @@ const TX_METRICS_RECORDING_INTERVAL: std::time::Duration = std::time::Duration:: /// Spawns a task for recording transaction metrics. /// Returns the handle for pushing metrics to the recorder. -pub fn spawn_tx_metrics_recorder() -> (MetricsRecorderQueue, tokio::task::AbortHandle) { - let (tx, mut rx) = mpsc::unbounded_channel(); - let abort_handle = tokio::spawn(async move { - let mut interval = tokio::time::interval(TX_METRICS_RECORDING_INTERVAL); - interval.set_missed_tick_behavior(MissedTickBehavior::Skip); - - loop { - interval.tick().await; - while let Ok(metrics) = rx.try_recv() { - record_metrics(metrics); +pub fn spawn_tx_metrics_recorder( + handle: &spacetimedb_runtime::Handle, +) -> (MetricsRecorderQueue, spacetimedb_runtime::AbortHandle) { + let handle_clone = handle.clone(); + let (tx, mut rx) = spacetimedb_runtime::sync::mpsc::unbounded_channel(); + let abort_handle = handle + .spawn(async move { + loop { + handle_clone.sleep(TX_METRICS_RECORDING_INTERVAL).await; + while let Ok(metrics) = rx.try_recv() { + record_metrics(metrics); + } } - } - }) - .abort_handle(); + }) + .abort_handle(); (MetricsRecorderQueue { tx }, abort_handle) } diff --git a/crates/core/src/db/persistence.rs b/crates/engine/src/db/persistence.rs similarity index 92% rename from crates/core/src/db/persistence.rs rename to crates/engine/src/db/persistence.rs index 0edef7ab4b8..de152b0c6f7 100644 --- a/crates/core/src/db/persistence.rs +++ b/crates/engine/src/db/persistence.rs @@ -10,7 +10,8 @@ use spacetimedb_durability::{DurabilityExited, TxOffset}; use spacetimedb_paths::server::ServerDataDir; use spacetimedb_snapshot::DynSnapshotRepo; -use crate::{messages::control_db::Database, util::asyncify}; +use crate::util::asyncify; +use spacetimedb_lib::Identity; use spacetimedb_runtime::Handle; use super::{ @@ -84,6 +85,13 @@ pub type Durability = dyn spacetimedb_durability::Durability; /// configured or the database is in follower state. pub type DiskSizeFn = Arc io::Result + Send + Sync>; +#[derive(Clone, Copy, Debug)] +pub struct Database { + pub id: u64, + pub database_identity: Identity, + pub owner_identity: Identity, +} + /// Persistence services for a database. pub struct Persistence { /// The [Durability] to use, for persisting transactions. @@ -109,9 +117,9 @@ impl Persistence { durability: impl spacetimedb_durability::Durability + 'static, disk_size: impl Fn() -> io::Result + Send + Sync + 'static, snapshots: Option, - runtime: tokio::runtime::Handle, + runtime: Handle, ) -> Self { - Self::new_with_runtime(durability, disk_size, snapshots, Handle::tokio(runtime)) + Self::new_with_runtime(durability, disk_size, snapshots, runtime) } pub fn new_with_runtime( @@ -175,7 +183,7 @@ impl Persistence { /// A persistence provider is a "factory" of sorts that can produce [Persistence] /// services for a given replica. /// -/// The [crate::host::HostController] uses this to obtain [Persistence]s from +/// The host controller uses this to obtain [Persistence]s from /// an external source, and construct [relational_db::RelationalDB]s with it. /// /// This is an `async_trait` to allow it to be used as a trait object. @@ -215,15 +223,16 @@ impl LocalPersistenceProvider { #[async_trait] impl PersistenceProvider for LocalPersistenceProvider { async fn persistence(&self, database: &Database, replica_id: u64) -> anyhow::Result { + let database_identity = database.database_identity; let replica_dir = self.data_dir.replica(replica_id); let snapshot_dir = replica_dir.snapshots(); let runtime = Handle::tokio_current(); - let database_identity = database.database_identity; - let snapshot_worker = - asyncify(move || relational_db::open_snapshot_repo(snapshot_dir, database_identity, replica_id)) - .await - .map(|repo| SnapshotWorker::new(repo, snapshot::Compression::Enabled, runtime.clone()))?; + let snapshot_worker = asyncify(&runtime, move || { + relational_db::open_snapshot_repo(snapshot_dir, database_identity, replica_id) + }) + .await + .map(|repo| SnapshotWorker::new(repo, snapshot::Compression::Enabled, runtime.clone()))?; let (durability, disk_size) = relational_db::local_durability_with_options( replica_dir, runtime.clone(), @@ -232,11 +241,12 @@ impl PersistenceProvider for LocalPersistenceProvider { ) .await?; - tokio::spawn(relational_db::snapshot_watching_commitlog_compressor( + runtime.spawn(relational_db::snapshot_watching_commitlog_compressor( snapshot_worker.subscribe(), None, None, durability.clone(), + runtime.clone(), )); Ok(Persistence { diff --git a/crates/core/src/db/relational_db.rs b/crates/engine/src/db/relational_db.rs similarity index 98% rename from crates/core/src/db/relational_db.rs rename to crates/engine/src/db/relational_db.rs index 3bbcc0ba20f..0882e1e8243 100644 --- a/crates/core/src/db/relational_db.rs +++ b/crates/engine/src/db/relational_db.rs @@ -1,9 +1,9 @@ use crate::db::durability::{request_durability, spawn_close as spawn_durability_close}; use crate::db::MetricsRecorderQueue; use crate::error::{DBError, RestoreSnapshotError}; -use crate::subscription::ExecutionCounters; +use crate::metrics::ExecutionCounters; +use crate::metrics::ENGINE_METRICS; use crate::util::asyncify; -use crate::worker_metrics::WORKER_METRICS; use anyhow::{anyhow, Context}; use enum_map::EnumMap; use spacetimedb_commitlog::repo::OnNewSegmentFn; @@ -43,6 +43,7 @@ use spacetimedb_lib::ConnectionId; use spacetimedb_lib::Identity; use spacetimedb_paths::server::{ReplicaDir, SnapshotsPath}; use spacetimedb_primitives::*; +use spacetimedb_runtime::sync::watch; use spacetimedb_runtime::Handle; use spacetimedb_sats::memory_usage::MemoryUsage; use spacetimedb_sats::raw_identifier::RawIdentifier; @@ -62,7 +63,6 @@ use std::borrow::Cow; use std::io; use std::ops::RangeBounds; use std::sync::Arc; -use tokio::sync::watch; pub use super::persistence::{DiskSizeFn, Durability, Persistence}; pub use super::snapshot::SnapshotWorker; @@ -303,7 +303,7 @@ impl RelationalDB { apply_history(&inner, database_identity, history)?; let elapsed_time = start_time.elapsed(); - WORKER_METRICS + ENGINE_METRICS .replay_total_time_seconds .with_label_values(&database_identity) .set(elapsed_time.as_secs_f64()); @@ -495,7 +495,7 @@ impl RelationalDB { let elapsed_time = start.elapsed(); - WORKER_METRICS + ENGINE_METRICS .replay_snapshot_read_time_seconds .with_label_values(database_identity) .set(elapsed_time.as_secs_f64()); @@ -519,7 +519,7 @@ impl RelationalDB { .inspect(|_| { let elapsed_time = start.elapsed(); - WORKER_METRICS.replay_snapshot_restore_time_seconds.with_label_values(database_identity).set(elapsed_time.as_secs_f64()); + ENGINE_METRICS.replay_snapshot_restore_time_seconds.with_label_values(database_identity).set(elapsed_time.as_secs_f64()); log::info!( "[{database_identity}] DATABASE: restored from snapshot of tx_offset {snapshot_offset} in {elapsed_time:?}", @@ -1050,7 +1050,7 @@ impl RelationalDB { /// Reports the `TxMetrics`s passed. /// /// Should only be called after the tx lock has been fully released. - pub(crate) fn report_tx_metrics( + pub fn report_tx_metrics( &self, reducer: Option, tx_data: Option>, @@ -1079,7 +1079,10 @@ const VIEWS_EXPIRATION: std::time::Duration = std::time::Duration::from_secs(10 const VIEW_CLEANUP_BUDGET: std::time::Duration = std::time::Duration::from_millis(10); /// Spawn a background task that periodically cleans up expired views -pub fn spawn_view_cleanup_loop(db: Arc) -> tokio::task::AbortHandle { +pub fn spawn_view_cleanup_loop( + db: Arc, + handle: &spacetimedb_runtime::Handle, +) -> spacetimedb_runtime::AbortHandle { fn run_view_cleanup(db: &RelationalDB) { match db.with_auto_commit(Workload::Internal, |tx| { tx.clear_expired_views(VIEWS_EXPIRATION, VIEW_CLEANUP_BUDGET) @@ -1106,23 +1109,19 @@ pub fn spawn_view_cleanup_loop(db: Arc) -> tokio::task::AbortHandl } } - tokio::spawn(async move { - loop { - // Offload actual cleanup to blocking thread pool, as `VIEW_CLEANUP_BUDGET` is defined - // in milliseconds, which may be too long for async tasks. - let db = db.clone(); - let db_identity = db.database_identity(); - tokio::task::spawn_blocking(move || run_view_cleanup(&db)) - .await - .inspect_err(|e| { - log::error!("[{}] DATABASE: failed to run view cleanup task: {}", db_identity, e); - }) - .ok(); + let handle_clone = handle.clone(); + handle + .spawn(async move { + loop { + // Offload actual cleanup to blocking thread pool, as `VIEW_CLEANUP_BUDGET` is defined + // in milliseconds, which may be too long for async tasks. + let db = db.clone(); + handle_clone.spawn_blocking(move || run_view_cleanup(&db)).await; - tokio::time::sleep(VIEWS_EXPIRATION).await; - } - }) - .abort_handle() + handle_clone.sleep(VIEWS_EXPIRATION).await; + } + }) + .abort_handle() } impl RelationalDB { pub fn create_table(&self, tx: &mut MutTx, schema: TableSchema) -> Result { @@ -1528,7 +1527,7 @@ impl RelationalDB { } /// Read the value of [ST_VARNAME_ROW_LIMIT] from `st_var` - pub(crate) fn row_limit(&self, tx: &Tx) -> Result, DBError> { + pub fn row_limit(&self, tx: &Tx) -> Result, DBError> { let data = self.read_var(tx, StVarName::RowLimit); if let Some(StVarValue::U64(limit)) = data? { @@ -1669,10 +1668,10 @@ fn apply_history( history: impl durability::History, ) -> Result<(), DBError> { let counters = ApplyHistoryCounters { - replay_commitlog_time_seconds: WORKER_METRICS + replay_commitlog_time_seconds: ENGINE_METRICS .replay_commitlog_time_seconds .with_label_values(&database_identity), - replay_commitlog_num_commits: WORKER_METRICS + replay_commitlog_num_commits: ENGINE_METRICS .replay_commitlog_num_commits .with_label_values(&database_identity), }; @@ -1715,10 +1714,11 @@ pub async fn local_durability_with_options( snapshot_worker.request_snapshot_ignore_closed(); }) as Arc }); - let local = asyncify(move || { + let durability_runtime = runtime.clone(); + let local = asyncify(&runtime, move || { durability::Local::open( replica_dir.clone(), - runtime, + durability_runtime, opts, // Give the durability a handle to request a new snapshot run, // which it will send down whenever we rotate commitlog segments. @@ -1738,9 +1738,12 @@ pub async fn local_durability_with_options( /// Open a [History] for replay from the local durable state. /// /// Currently, this is simply a read-only copy of the commitlog. -pub async fn local_history(replica_dir: &ReplicaDir) -> io::Result + use<>> { +pub async fn local_history( + replica_dir: &ReplicaDir, + runtime: &Handle, +) -> io::Result + use<>> { let commitlog_dir = replica_dir.commit_log(); - asyncify(move || Commitlog::open(commitlog_dir, <_>::default(), None)).await + asyncify(runtime, move || Commitlog::open(commitlog_dir, <_>::default(), None)).await } /// Watches snapshot creation events and compresses all commitlog segments older @@ -1749,9 +1752,10 @@ pub async fn local_history(replica_dir: &ReplicaDir) -> io::Result, - mut clog_tx: Option>, - mut snap_tx: Option>, + mut clog_tx: Option>, + mut snap_tx: Option>, durability: LocalDurability, + runtime: Handle, ) { let mut prev_snapshot_offset = *snapshot_rx.borrow_and_update(); while snapshot_rx.changed().await.is_ok() { @@ -1764,7 +1768,7 @@ pub async fn snapshot_watching_commitlog_compressor( tracing::warn!("failed to send offset {snapshot_offset} after snapshot creation: {err}"); } - let res: io::Result<_> = asyncify(move || { + let res: io::Result<_> = asyncify(&runtime, move || { let segment_offsets = durability.existing_segment_offsets()?; let start_idx = segment_offsets .binary_search(&prev_snapshot_offset) @@ -1828,17 +1832,18 @@ fn default_row_count_fn(db: Identity) -> RowCountFn { pub mod tests_utils { use crate::db::snapshot; use crate::db::snapshot::SnapshotWorker; - use crate::messages::control_db::HostType; use super::*; use core::ops::Deref; use durability::{Durability, EmptyHistory}; use spacetimedb_datastore::locking_tx_datastore::MutTxId; use spacetimedb_datastore::locking_tx_datastore::TxId; + use spacetimedb_datastore::system_tables::ModuleKind; use spacetimedb_fs_utils::compression::CompressType; use spacetimedb_lib::{bsatn::to_vec, ser::Serialize}; use spacetimedb_paths::server::ReplicaDir; use spacetimedb_paths::FromPathUnchecked; + use spacetimedb_runtime::{TokioHandle, TokioRuntime, TokioRuntimeBuilder}; use tempfile::TempDir; pub enum TestDBDir { @@ -1867,7 +1872,7 @@ pub mod tests_utils { pub type TestDBParts = ( Arc, Option>>, - Option, + Option, Option, ); @@ -1912,7 +1917,7 @@ pub mod tests_utils { struct DurableState { durability: Arc>, - rt: tokio::runtime::Runtime, + rt: TokioRuntime, replica_dir: TestDBDir, } @@ -1938,7 +1943,7 @@ pub mod tests_utils { /// database. pub fn durable() -> Result { let dir = TempReplicaDir::new()?; - let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; + let rt = TokioRuntimeBuilder::new_multi_thread().enable_all().build()?; // Enter the runtime so that `Self::durable_internal` can spawn a `SnapshotWorker`. let _rt = rt.enter(); let (db, durability) = Self::durable_internal(&dir, rt.handle().clone(), true)?; @@ -1957,7 +1962,7 @@ pub mod tests_utils { pub fn durable_without_snapshot_repo() -> Result { let dir = TempReplicaDir::new()?; - let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; + let rt = TokioRuntimeBuilder::new_multi_thread().enable_all().build()?; // Enter the runtime so that `Self::durable_internal` can spawn a `SnapshotWorker`. let _rt = rt.enter(); let (db, durability) = Self::durable_internal(&dir, rt.handle().clone(), false)?; @@ -1976,7 +1981,7 @@ pub mod tests_utils { pub fn open_existing_durable( root: &ReplicaDir, - rt: tokio::runtime::Handle, + rt: TokioHandle, replica_id: u64, db_identity: Identity, owner_identity: Identity, @@ -1991,9 +1996,8 @@ pub mod tests_utils { .transpose()?; let runtime = Handle::tokio(rt.clone()); - let (local, disk_size_fn) = + let (local, disk_size_fn): (LocalDurability, DiskSizeFn) = rt.block_on(local_durability(root.clone(), runtime.clone(), snapshots.as_ref()))?; - let history = local.as_history(); let persistence = Persistence { durability: local.clone(), @@ -2005,7 +2009,7 @@ pub mod tests_utils { let (db, _) = RelationalDB::open( db_identity, owner_identity, - history, + local.as_history(), Some(persistence), None, PagePool::new_for_test(), @@ -2083,7 +2087,7 @@ pub mod tests_utils { /// Handle to the tokio runtime, available if [`Self::durable`] was used /// to create the [`TestDB`]. - pub fn runtime(&self) -> Option<&tokio::runtime::Handle> { + pub fn runtime(&self) -> Option<&TokioHandle> { self.durable.as_ref().map(|ds| ds.rt.handle()) } @@ -2107,7 +2111,7 @@ pub mod tests_utils { fn durable_internal( root: &ReplicaDir, - rt: tokio::runtime::Handle, + rt: TokioHandle, want_snapshot_repo: bool, ) -> Result<(RelationalDB, Arc>), DBError> { let snapshots = want_snapshot_repo @@ -2118,16 +2122,15 @@ pub mod tests_utils { }) .transpose()?; let runtime = Handle::tokio(rt.clone()); - let (local, disk_size_fn) = + let (local, disk_size_fn): (LocalDurability, DiskSizeFn) = rt.block_on(local_durability(root.clone(), runtime.clone(), snapshots.as_ref()))?; - let history = local.as_history(); let persistence = Persistence { durability: local.clone(), disk_size: disk_size_fn, snapshots, runtime, }; - let db = Self::open_db(history, Some(persistence), None, 0)?; + let db = Self::open_db(local.as_history(), Some(persistence), None, 0)?; Ok((db, local)) } @@ -2149,7 +2152,7 @@ pub mod tests_utils { assert_eq!(connected_clients.len(), expected_num_clients); let db = db.with_row_count(Self::row_count_fn()); db.with_auto_commit(Workload::Internal, |tx| { - db.set_initialized(tx, Program::empty(HostType::Wasm.into())) + db.set_initialized(tx, Program::empty(ModuleKind::WASM)) })?; Ok(db) } diff --git a/crates/core/src/db/snapshot.rs b/crates/engine/src/db/snapshot.rs similarity index 95% rename from crates/core/src/db/snapshot.rs rename to crates/engine/src/db/snapshot.rs index 7f2474e0d74..11b08727b7d 100644 --- a/crates/core/src/db/snapshot.rs +++ b/crates/engine/src/db/snapshot.rs @@ -14,10 +14,10 @@ use prometheus::{Histogram, IntGauge}; use spacetimedb_datastore::locking_tx_datastore::{committed_state::CommittedState, datastore::Locking}; use spacetimedb_durability::TxOffset; use spacetimedb_lib::Identity; +use spacetimedb_runtime::sync::watch; use spacetimedb_snapshot::{CompressionStats, DynSnapshotRepo}; -use tokio::sync::watch; -use crate::worker_metrics::WORKER_METRICS; +use crate::metrics::ENGINE_METRICS; use spacetimedb_runtime::Handle; pub type SnapshotDatabaseState = Arc>; @@ -158,9 +158,9 @@ struct SnapshotMetrics { impl SnapshotMetrics { fn new(db: Identity) -> Self { Self { - snapshot_timing_total: WORKER_METRICS.snapshot_creation_time_total.with_label_values(&db), - snapshot_timing_inner: WORKER_METRICS.snapshot_creation_time_inner.with_label_values(&db), - snapshot_timing_fsync: WORKER_METRICS.snapshot_creation_time_fsync.with_label_values(&db), + snapshot_timing_total: ENGINE_METRICS.snapshot_creation_time_total.with_label_values(&db), + snapshot_timing_inner: ENGINE_METRICS.snapshot_creation_time_inner.with_label_values(&db), + snapshot_timing_fsync: ENGINE_METRICS.snapshot_creation_time_fsync.with_label_values(&db), } } } @@ -280,15 +280,15 @@ struct CompressionMetrics { impl CompressionMetrics { fn new(db: Identity) -> Self { Self { - timing_total: WORKER_METRICS.snapshot_compression_time_total.with_label_values(&db), - timing_inner: WORKER_METRICS.snapshot_compression_time_inner.with_label_values(&db), - timing_single: WORKER_METRICS.snapshot_compression_time_single.with_label_values(&db), - skipped: WORKER_METRICS.snapshot_compression_skipped.with_label_values(&db), - compressed: WORKER_METRICS.snapshot_compression_compressed.with_label_values(&db), - objects_compressed: WORKER_METRICS + timing_total: ENGINE_METRICS.snapshot_compression_time_total.with_label_values(&db), + timing_inner: ENGINE_METRICS.snapshot_compression_time_inner.with_label_values(&db), + timing_single: ENGINE_METRICS.snapshot_compression_time_single.with_label_values(&db), + skipped: ENGINE_METRICS.snapshot_compression_skipped.with_label_values(&db), + compressed: ENGINE_METRICS.snapshot_compression_compressed.with_label_values(&db), + objects_compressed: ENGINE_METRICS .snapshot_compression_objects_compressed .with_label_values(&db), - objects_hardlinked: WORKER_METRICS + objects_hardlinked: ENGINE_METRICS .snapshot_compression_objects_hardlinked .with_label_values(&db), } diff --git a/crates/core/src/db/update.rs b/crates/engine/src/db/update.rs similarity index 97% rename from crates/core/src/db/update.rs rename to crates/engine/src/db/update.rs index f9ca4c110d9..6752ad78d10 100644 --- a/crates/core/src/db/update.rs +++ b/crates/engine/src/db/update.rs @@ -1,13 +1,13 @@ use super::relational_db::RelationalDB; -use crate::database_logger::SystemLogger; -use crate::sql::parser::RowLevelExpr; +use crate::rls::RowLevelExpr; +use anyhow::Context; use spacetimedb_datastore::locking_tx_datastore::MutTxId; use spacetimedb_lib::db::auth::StTableType; use spacetimedb_lib::identity::AuthCtx; use spacetimedb_lib::AlgebraicValue; use spacetimedb_primitives::{ColSet, TableId}; use spacetimedb_schema::auto_migrate::{AutoMigratePlan, ManualMigratePlan, MigratePlan}; -use spacetimedb_schema::def::{TableDef, ViewDef}; +use spacetimedb_schema::def::{ModuleDef, TableDef, ViewDef}; use spacetimedb_schema::schema::{column_schemas_from_defs, IndexSchema, Schema, SequenceSchema, TableSchema}; /// The logger used for by [`update_database`] and friends. @@ -15,12 +15,6 @@ pub trait UpdateLogger { fn info(&self, msg: &str); } -impl UpdateLogger for SystemLogger { - fn info(&self, msg: &str) { - self.info(msg); - } -} - /// The result of a database update. /// Indicates whether clients should be disconnected when the update is complete. #[must_use] @@ -332,13 +326,23 @@ fn auto_migrate_database( Ok(res) } +/// Creates the table for `table_def` in `stdb`. +pub fn create_table_from_def( + stdb: &RelationalDB, + tx: &mut MutTxId, + module_def: &ModuleDef, + table_def: &TableDef, +) -> anyhow::Result<()> { + let schema = TableSchema::from_module_def(module_def, table_def, (), TableId::SENTINEL); + stdb.create_table(tx, schema) + .with_context(|| format!("failed to create table {}", &table_def.name))?; + Ok(()) +} + #[cfg(test)] mod test { use super::*; - use crate::{ - db::relational_db::tests_utils::{begin_mut_tx, insert, TestDB}, - host::module_host::create_table_from_def, - }; + use crate::db::relational_db::tests_utils::{begin_mut_tx, insert, TestDB}; use spacetimedb_datastore::locking_tx_datastore::PendingSchemaChange; use spacetimedb_lib::db::raw_def::v9::{btree, RawIndexAlgorithm, RawModuleDefV9Builder, TableAccess}; use spacetimedb_sats::{product, AlgebraicType, AlgebraicType::U64}; diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs new file mode 100644 index 00000000000..7c1a3e11129 --- /dev/null +++ b/crates/engine/src/error.rs @@ -0,0 +1,262 @@ +use std::io; +use std::num::ParseIntError; +use std::path::PathBuf; +use std::sync::{MutexGuard, PoisonError}; + +use hex::FromHexError; +use spacetimedb_commitlog::repo::TxOffset; +use spacetimedb_durability::DurabilityExited; +use spacetimedb_expr::errors::TypingError; +use spacetimedb_fs_utils::lockfile::advisory::LockError; +use spacetimedb_lib::Identity; +use spacetimedb_schema::error::ValidationErrors; +use spacetimedb_schema::table_name::TableName; +use spacetimedb_snapshot::SnapshotError; +use spacetimedb_table::table::ReadViaBsatnError; +use thiserror::Error; + +use spacetimedb_lib::buffer::DecodeError; +use spacetimedb_primitives::*; +use spacetimedb_sats::hash::Hash; +use spacetimedb_sats::product_value::InvalidFieldError; +use spacetimedb_schema::def::error::{LibError, RelationError, SchemaErrors}; +use spacetimedb_schema::relation::FieldName; + +pub use spacetimedb_datastore::error::{DatastoreError, IndexError, SequenceError, TableError}; + +#[derive(Error, Debug, PartialEq, Eq)] +pub enum SubscriptionError { + #[error("Index not found: {0:?}")] + NotFound(IndexId), + #[error("Empty string")] + Empty, + #[error("Unsupported query on subscription: {0:?}")] + Unsupported(String), + #[error("Subscribing to queries in one call is not supported")] + Multiple, +} + +#[derive(Error, Debug)] +pub enum PlanError { + #[error("Unsupported feature: `{feature}`")] + Unsupported { feature: String }, + #[error("Unknown table: `{table}`")] + UnknownTable { table: Box }, + #[error("Qualified Table `{expect}` not found")] + TableNotFoundQualified { expect: String }, + #[error("Unknown field: `{field}` not found in the table(s): `{tables:?}`")] + UnknownField { field: String, tables: Vec }, + #[error("Unknown field name: `{field}` not found in the table(s): `{tables:?}`")] + UnknownFieldName { field: FieldName, tables: Vec }, + #[error("Field(s): `{fields:?}` not found in the table(s): `{tables:?}`")] + UnknownFields { + fields: Vec, + tables: Vec, + }, + #[error("Ambiguous field: `{field}`. Also found in {found:?}")] + AmbiguousField { field: String, found: Vec }, + #[error("Plan error: `{0}`")] + Unstructured(String), + #[error("Internal DBError: `{0}`")] + DatabaseInternal(Box), + #[error("Relation Error: `{0}`")] + Relation(#[from] RelationError), +} + +#[derive(Error, Debug)] +pub enum DatabaseError { + #[error("Replica not found: {0}")] + NotFound(u64), + #[error("Database is already opened. Path: `{0}`. Error: {1}")] + DatabasedOpened(PathBuf, anyhow::Error), +} + +impl From for DatabaseError { + fn from(LockError { path, source, .. }: LockError) -> Self { + Self::DatabasedOpened(path, source.into()) + } +} + +#[derive(Error, Debug)] +pub enum ViewError { + #[error("{0}")] + Args(String), + #[error("{0}")] + NoSuchModule(String), + #[error("no such view")] + NoSuchView, + #[error("Table does not exist for view `{0}`")] + TableDoesNotExist(ViewId), + #[error("missing client connection for view call trigged by subscription")] + MissingClientConnection, + #[error("DB error during view call: {0}")] + DatastoreError(#[from] DatastoreError), + #[error("The module instance encountered a fatal error: {0}")] + InternalError(String), +} + +#[derive(Error, Debug)] +pub enum DBError { + #[error("LibError: {0}")] + Lib(#[from] LibError), + #[error("BufferError: {0}")] + Buffer(#[from] DecodeError), + #[error("DatastoreError: {0}")] + Datastore(#[from] DatastoreError), + #[error("SequenceError: {0}")] + Sequence2(#[from] SequenceError), + #[error("SchemaError: {0}")] + Schema(SchemaErrors), + #[error("IOError: {0}.")] + IoError(#[from] std::io::Error), + #[error("ParseIntError: {0}.")] + ParseInt(#[from] ParseIntError), + #[error("Hex representation of hash decoded to incorrect number of bytes: {0}.")] + DecodeHexHash(usize), + #[error("DecodeHexError: {0}.")] + DecodeHex(#[from] FromHexError), + #[error("DatabaseError: {0}.")] + Database(#[from] DatabaseError), + #[error("SledError: {0}.")] + SledDbError(#[from] sled::Error), + #[error("Mutex was poisoned acquiring lock on MessageLog: {0}")] + MessageLogPoisoned(String), + #[error("SubscriptionError: {0}")] + Subscription(#[from] SubscriptionError), + #[error("SqlParserError: {error}, executing: `{sql}`")] + SqlParser { + sql: String, + error: sqlparser::parser::ParserError, + }, + #[error("SqlError: {error}, executing: `{sql}`")] + Plan { sql: String, error: PlanError }, + #[error("Error replaying the commit log: {0}")] + LogReplay(#[from] LogReplayError), + #[error(transparent)] + // Box the inner [`SnapshotError`] to keep Clippy quiet about large `Err` variants. + Snapshot(#[from] Box), + #[error("Error reading a value from a table through BSATN: {0}")] + ReadViaBsatnError(#[from] ReadViaBsatnError), + #[error("Module validation errors: {0}")] + ModuleValidationErrors(#[from] ValidationErrors), + #[error(transparent)] + Other(#[from] anyhow::Error), + #[error(transparent)] + TypeError(#[from] TypingError), + #[error("{error}, executing: `{sql}`")] + WithSql { + #[source] + error: Box, + sql: Box, + }, + #[error(transparent)] + RestoreSnapshot(#[from] RestoreSnapshotError), + #[error(transparent)] + DurabilityGone(#[from] DurabilityExited), + #[error(transparent)] + View(#[from] ViewError), +} + +impl From for DBError { + fn from(value: InvalidFieldError) -> Self { + LibError::from(value).into() + } +} + +impl From for DBError { + fn from(err: spacetimedb_table::read_column::TypeError) -> Self { + DatastoreError::Table(TableError::from(err)).into() + } +} + +impl From for PlanError { + fn from(err: DBError) -> Self { + PlanError::DatabaseInternal(Box::new(err)) + } +} + +impl<'a, T: ?Sized + 'a> From>> for DBError { + fn from(err: PoisonError>) -> Self { + DBError::MessageLogPoisoned(err.to_string()) + } +} + +impl From for DBError { + fn from(e: spacetimedb_durability::local::OpenError) -> Self { + use spacetimedb_durability::local::OpenError::*; + + match e { + Lock(e) => Self::from(DatabaseError::from(e)), + Commitlog(e) => Self::Other(e.into()), + } + } +} + +#[derive(Debug, Error)] +pub enum LogReplayError { + #[error( + "Out-of-order commit detected: {} in segment {} after offset {}", + .commit_offset, + .segment_offset, + .last_commit_offset + )] + OutOfOrderCommit { + commit_offset: u64, + segment_offset: usize, + last_commit_offset: u64, + }, + #[error( + "Error reading segment {}/{} at commit {}: {}", + .segment_offset, + .total_segments, + .commit_offset, + .source + )] + TrailingSegments { + segment_offset: usize, + total_segments: usize, + commit_offset: u64, + #[source] + source: io::Error, + }, + #[error("Could not reset log to offset {}: {}", .offset, .source)] + Reset { + offset: u64, + #[source] + source: io::Error, + }, + #[error("Missing object {} referenced from commit {}", .hash, .commit_offset)] + MissingObject { hash: Hash, commit_offset: u64 }, + #[error( + "Unexpected I/O error reading commit {} from segment {}: {}", + .commit_offset, + .segment_offset, + .source + )] + Io { + segment_offset: usize, + commit_offset: u64, + #[source] + source: io::Error, + }, +} + +#[derive(Debug, Error)] +pub enum RestoreSnapshotError { + #[error("Snapshot has incorrect database_identity: expected {expected} but found {actual}")] + IdentityMismatch { expected: Identity, actual: Identity }, + #[error("Failed to restore datastore from snapshot")] + Datastore(#[source] Box), + #[error("Failed to read snapshot")] + Snapshot(#[from] Box), + #[error("Failed to bootstrap datastore without snapshot")] + Bootstrap(#[source] Box), + #[error("No connected snapshot found, commitlog starts at {min_commitlog_offset}")] + NoConnectedSnapshot { min_commitlog_offset: TxOffset }, + #[error("Failed to invalidate snapshots at or newer than {offset}")] + Invalidate { + offset: TxOffset, + #[source] + source: Box, + }, +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs new file mode 100644 index 00000000000..3c9cb364d80 --- /dev/null +++ b/crates/engine/src/lib.rs @@ -0,0 +1,10 @@ +pub mod db; +pub mod error; +pub mod metrics; +pub mod rls; +mod sql; +pub mod util; + +pub use spacetimedb_lib::identity; +pub use spacetimedb_lib::Identity; +pub use spacetimedb_sats::hash; diff --git a/crates/engine/src/metrics.rs b/crates/engine/src/metrics.rs new file mode 100644 index 00000000000..f5ee0011b23 --- /dev/null +++ b/crates/engine/src/metrics.rs @@ -0,0 +1,181 @@ +use once_cell::sync::Lazy; +use prometheus::{GaugeVec, HistogramVec, IntCounter, IntCounterVec, IntGaugeVec}; +use spacetimedb_datastore::{ + db_metrics::DB_METRICS, execution_context::WorkloadType, locking_tx_datastore::datastore::MetricsRecorder, +}; +use spacetimedb_lib::{metrics::ExecutionMetrics, Identity}; +use spacetimedb_metrics::metrics_group; + +metrics_group!( + pub struct EngineMetrics { + #[name = spacetime_num_bytes_sent_to_clients_total] + #[help = "The cumulative number of bytes sent to clients"] + #[labels(txn_type: WorkloadType, db: Identity)] + pub bytes_sent_to_clients: IntCounterVec, + + #[name = spacetime_replay_total_time_seconds] + #[help = "Total time spent replaying a database upon restart, including snapshot read, snapshot restore and commitlog replay"] + #[labels(db: Identity)] + pub replay_total_time_seconds: GaugeVec, + + #[name = spacetime_replay_snapshot_read_time_seconds] + #[help = "Time spent reading a snapshot from disk before restoring the snapshot upon restart"] + #[labels(db: Identity)] + pub replay_snapshot_read_time_seconds: GaugeVec, + + #[name = spacetime_replay_snapshot_restore_time_seconds] + #[help = "Time spent restoring a database from a snapshot after reading the snapshot and before commitlog replay upon restart"] + #[labels(db: Identity)] + pub replay_snapshot_restore_time_seconds: GaugeVec, + + #[name = spacetime_replay_commitlog_time_seconds] + #[help = "Time spent replaying the commitlog after restoring from a snapshot upon restart"] + #[labels(db: Identity)] + pub replay_commitlog_time_seconds: GaugeVec, + + #[name = spacetime_replay_commitlog_num_commits] + #[help = "Number of commits replayed after restoring from a snapshot upon restart"] + #[labels(db: Identity)] + pub replay_commitlog_num_commits: IntGaugeVec, + + // Snapshot creation should take in the order of milliseconds, + // but log data suggests that there are outliers. + // So let's track a wide range of buckets to get a better picture. + // + // We also track the timing without `asyncify` scheduling overhead + // (`snapshot_creation_time_inner`), and the snapshot compression + // timing with / without scheduling overhead (`snapshot_compression_time_total` + // and `snapshot_compression_time_inner`, respectively). + // + // Compression may have contributed to observed outliers, but is no + // longer included in the snapshot creation timing. + #[name = spacetime_snapshot_creation_time_total_sec] + #[help = "The time (in seconds) it took to take and store a database snapshot, including scheduling overhead"] + #[labels(db: Identity)] + #[buckets(0.0005, 0.001, 0.005, 0.01, 0.1, 1.0, 5.0, 10.0)] + pub snapshot_creation_time_total: HistogramVec, + + #[name = spacetime_snapshot_creation_time_inner_sec] + #[help = "The time (in seconds) it took to take and store a database snapshot, excluding scheduling overhead"] + #[labels(db: Identity)] + #[buckets(0.0005, 0.001, 0.005, 0.01, 0.1, 1.0, 5.0, 10.0)] + pub snapshot_creation_time_inner: HistogramVec, + + #[name = spacetime_snapshot_creation_time_fsync_sec] + #[help = "The time (in seconds) it took to fsync a database snapshot, excluding scheduling overhead"] + #[labels(db: Identity)] + #[buckets(0.0005, 0.001, 0.005, 0.01, 0.1, 1.0, 5.0, 10.0)] + pub snapshot_creation_time_fsync: HistogramVec, + + #[name = spacetime_snapshot_compression_time_total_sec] + #[help = "The time (in seconds) it took to do a compression pass on the snapshot repository, including scheduling overhead"] + #[labels(db: Identity)] + #[buckets(0.001, 0.01, 0.1, 1.0, 5.0, 10.0)] + pub snapshot_compression_time_total: HistogramVec, + + #[name = spacetime_snapshot_compression_time_inner_sec] + #[help = "The time (in seconds) it took to do a compression pass on the snapshot repository, excluding scheduling overhead"] + #[labels(db: Identity)] + #[buckets(0.001, 0.01, 0.1, 1.0, 5.0, 10.0)] + pub snapshot_compression_time_inner: HistogramVec, + + #[name = spacetime_snapshot_compression_time_per_snapshot_sec] + #[help = "The time (in seconds) it took to compress a single snapshot"] + #[labels(db: Identity)] + #[buckets(0.001, 0.01, 0.1, 1.0, 5.0, 10.0)] + pub snapshot_compression_time_single: HistogramVec, + + #[name = spacetime_snapshot_compression_skipped] + #[help = "The number of snapshots skipped in a single compression pass because they were already compressed"] + #[labels(db: Identity)] + pub snapshot_compression_skipped: IntGaugeVec, + + #[name = spacetime_snapshot_compression_compressed] + #[help = "The number of snapshots compressed in a single compression pass"] + #[labels(db: Identity)] + pub snapshot_compression_compressed: IntGaugeVec, + + #[name = spacetime_snapshot_compression_objects_compressed] + #[help = "The number of snapshot objects compressed in a single compression pass"] + #[labels(db: Identity)] + pub snapshot_compression_objects_compressed: IntGaugeVec, + + #[name = spacetime_snapshot_compression_objects_hardlinked] + #[help = "The number of snapshot objects hardlinked in a single compression pass"] + #[labels(db: Identity)] + pub snapshot_compression_objects_hardlinked: IntGaugeVec, + + #[name = spacetime_durability_blocking_send_duration_sec] + #[help = "Latency of blocking sends in request_durability (seconds); _count gives the number of times the channel was full"] + #[labels(database_identity: Identity)] + #[buckets(0.001, 0.01, 0.1, 1.0, 10.0)] + pub durability_blocking_send_duration: HistogramVec, + } +); + +pub static ENGINE_METRICS: Lazy = Lazy::new(EngineMetrics::new); + +#[derive(Debug)] +pub struct ExecutionCounters { + rdb_num_index_seeks: IntCounter, + rdb_num_rows_scanned: IntCounter, + rdb_num_bytes_scanned: IntCounter, + rdb_num_bytes_written: IntCounter, + bytes_sent_to_clients: IntCounter, + delta_queries_matched: IntCounter, + delta_queries_evaluated: IntCounter, + duplicate_rows_evaluated: IntCounter, + duplicate_rows_sent: IntCounter, +} + +impl ExecutionCounters { + pub fn new(workload: &WorkloadType, db: &Identity) -> Self { + Self { + rdb_num_index_seeks: DB_METRICS.rdb_num_index_seeks.with_label_values(workload, db), + rdb_num_rows_scanned: DB_METRICS.rdb_num_rows_scanned.with_label_values(workload, db), + rdb_num_bytes_scanned: DB_METRICS.rdb_num_bytes_scanned.with_label_values(workload, db), + rdb_num_bytes_written: DB_METRICS.rdb_num_bytes_written.with_label_values(workload, db), + bytes_sent_to_clients: ENGINE_METRICS.bytes_sent_to_clients.with_label_values(workload, db), + delta_queries_matched: DB_METRICS.delta_queries_matched.with_label_values(db), + delta_queries_evaluated: DB_METRICS.delta_queries_evaluated.with_label_values(db), + duplicate_rows_evaluated: DB_METRICS.duplicate_rows_evaluated.with_label_values(db), + duplicate_rows_sent: DB_METRICS.duplicate_rows_sent.with_label_values(db), + } + } + + pub fn record(&self, metrics: &ExecutionMetrics) { + if metrics.index_seeks > 0 { + self.rdb_num_index_seeks.inc_by(metrics.index_seeks as u64); + } + if metrics.rows_scanned > 0 { + self.rdb_num_rows_scanned.inc_by(metrics.rows_scanned as u64); + } + if metrics.bytes_scanned > 0 { + self.rdb_num_bytes_scanned.inc_by(metrics.bytes_scanned as u64); + } + if metrics.bytes_written > 0 { + self.rdb_num_bytes_written.inc_by(metrics.bytes_written as u64); + } + if metrics.bytes_sent_to_clients > 0 { + self.bytes_sent_to_clients.inc_by(metrics.bytes_sent_to_clients as u64); + } + if metrics.delta_queries_matched > 0 { + self.delta_queries_matched.inc_by(metrics.delta_queries_matched); + } + if metrics.delta_queries_evaluated > 0 { + self.delta_queries_evaluated.inc_by(metrics.delta_queries_evaluated); + } + if metrics.duplicate_rows_evaluated > 0 { + self.duplicate_rows_evaluated.inc_by(metrics.duplicate_rows_evaluated); + } + if metrics.duplicate_rows_sent > 0 { + self.duplicate_rows_sent.inc_by(metrics.duplicate_rows_sent); + } + } +} + +impl MetricsRecorder for ExecutionCounters { + fn record(&self, metrics: &ExecutionMetrics) { + self.record(metrics); + } +} diff --git a/crates/core/src/sql/parser.rs b/crates/engine/src/rls.rs similarity index 100% rename from crates/core/src/sql/parser.rs rename to crates/engine/src/rls.rs diff --git a/crates/engine/src/sql/ast.rs b/crates/engine/src/sql/ast.rs new file mode 100644 index 00000000000..892430ba1ea --- /dev/null +++ b/crates/engine/src/sql/ast.rs @@ -0,0 +1,77 @@ +use anyhow::Context; +use spacetimedb_datastore::locking_tx_datastore::state_view::StateView; +use spacetimedb_datastore::system_tables::{StRowLevelSecurityFields, ST_ROW_LEVEL_SECURITY_ID}; +use spacetimedb_expr::check::SchemaView; +use spacetimedb_lib::identity::AuthCtx; +use spacetimedb_primitives::TableId; +use spacetimedb_sats::AlgebraicValue; +use spacetimedb_schema::schema::TableOrViewSchema; +use std::ops::Deref; +use std::sync::Arc; + +pub struct SchemaViewer<'a, T> { + tx: &'a T, + auth: &'a AuthCtx, +} + +impl Deref for SchemaViewer<'_, T> { + type Target = T; + + fn deref(&self) -> &Self::Target { + self.tx + } +} + +impl SchemaView for SchemaViewer<'_, T> { + fn table_id(&self, name: &str) -> Option { + self.tx + .table_id_from_name_or_alias(name) + .ok() + .flatten() + .and_then(|table_id| self.schema_for_table(table_id)) + .filter(|schema| self.auth.has_read_access(schema.table_access)) + .map(|schema| schema.table_id) + } + + fn schema_for_table(&self, table_id: TableId) -> Option> { + self.tx + .get_schema(table_id) + .filter(|schema| self.auth.has_read_access(schema.table_access)) + .map(Arc::clone) + .map(TableOrViewSchema::from) + .map(Arc::new) + } + + fn rls_rules_for_table(&self, table_id: TableId) -> anyhow::Result>> { + self.tx + .iter_by_col_eq( + ST_ROW_LEVEL_SECURITY_ID, + StRowLevelSecurityFields::TableId, + &AlgebraicValue::from(table_id), + )? + .map(|row| { + row.read_col::(StRowLevelSecurityFields::Sql) + .with_context(|| { + format!( + "Failed to read value from the `{}` column of `{}` for table_id `{}`", + "sql", "st_row_level_security", table_id + ) + }) + .and_then(|sql| { + sql.into_string().map_err(|_| { + anyhow::anyhow!(format!( + "Failed to read value from the `{}` column of `{}` for table_id `{}`", + "sql", "st_row_level_security", table_id + )) + }) + }) + }) + .collect::>() + } +} + +impl<'a, T> SchemaViewer<'a, T> { + pub fn new(tx: &'a T, auth: &'a AuthCtx) -> Self { + Self { tx, auth } + } +} diff --git a/crates/engine/src/sql/mod.rs b/crates/engine/src/sql/mod.rs new file mode 100644 index 00000000000..851c0bc27ff --- /dev/null +++ b/crates/engine/src/sql/mod.rs @@ -0,0 +1 @@ +pub mod ast; diff --git a/crates/engine/src/util.rs b/crates/engine/src/util.rs new file mode 100644 index 00000000000..4d07d7d3c06 --- /dev/null +++ b/crates/engine/src/util.rs @@ -0,0 +1,21 @@ +use spacetimedb_runtime::Handle; +use tracing::Span; + +/// Ergonomic wrapper for `runtime.spawn_blocking(f).await`. +/// +/// If `f` panics, it will be bubbled up to the calling task. +pub async fn asyncify(runtime: &Handle, f: F) -> R +where + F: FnOnce() -> R + Send + 'static, + R: Send + 'static, +{ + // Ensure that `f` executes in the current span context. + // If there is no current span, or it is disabled, `span` is disabled. + let span = Span::current(); + runtime + .spawn_blocking(move || { + let _enter = span.enter(); + f() + }) + .await +} diff --git a/crates/core/testdata/README.md b/crates/engine/testdata/README.md similarity index 100% rename from crates/core/testdata/README.md rename to crates/engine/testdata/README.md diff --git a/crates/core/testdata/v1.2/replicas/22000001/clog/00000000000000000000.stdb.log b/crates/engine/testdata/v1.2/replicas/22000001/clog/00000000000000000000.stdb.log similarity index 100% rename from crates/core/testdata/v1.2/replicas/22000001/clog/00000000000000000000.stdb.log rename to crates/engine/testdata/v1.2/replicas/22000001/clog/00000000000000000000.stdb.log diff --git a/crates/core/testdata/v1.2/replicas/22000001/clog/00000000000000000000.stdb.ofs b/crates/engine/testdata/v1.2/replicas/22000001/clog/00000000000000000000.stdb.ofs similarity index 100% rename from crates/core/testdata/v1.2/replicas/22000001/clog/00000000000000000000.stdb.ofs rename to crates/engine/testdata/v1.2/replicas/22000001/clog/00000000000000000000.stdb.ofs diff --git a/crates/core/testdata/v1.2/replicas/22000001/db.lock b/crates/engine/testdata/v1.2/replicas/22000001/db.lock similarity index 100% rename from crates/core/testdata/v1.2/replicas/22000001/db.lock rename to crates/engine/testdata/v1.2/replicas/22000001/db.lock diff --git a/crates/core/testdata/v1.2/replicas/22000001/module_logs/2025-08-18.log b/crates/engine/testdata/v1.2/replicas/22000001/module_logs/2025-08-18.log similarity index 100% rename from crates/core/testdata/v1.2/replicas/22000001/module_logs/2025-08-18.log rename to crates/engine/testdata/v1.2/replicas/22000001/module_logs/2025-08-18.log diff --git a/crates/core/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/00000000000000000000.snapshot_bsatn b/crates/engine/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/00000000000000000000.snapshot_bsatn similarity index 100% rename from crates/core/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/00000000000000000000.snapshot_bsatn rename to crates/engine/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/00000000000000000000.snapshot_bsatn diff --git a/crates/core/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/19/30ce81246a4cdc25e9024ae0065d053adb2efbe1b5b7af457331d330e481e8 b/crates/engine/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/19/30ce81246a4cdc25e9024ae0065d053adb2efbe1b5b7af457331d330e481e8 similarity index 100% rename from crates/core/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/19/30ce81246a4cdc25e9024ae0065d053adb2efbe1b5b7af457331d330e481e8 rename to crates/engine/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/19/30ce81246a4cdc25e9024ae0065d053adb2efbe1b5b7af457331d330e481e8 diff --git a/crates/core/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/41/bb11b6d2cdc488192ee70d8175307d6f205756ed163f4237c6cba2936798dc b/crates/engine/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/41/bb11b6d2cdc488192ee70d8175307d6f205756ed163f4237c6cba2936798dc similarity index 100% rename from crates/core/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/41/bb11b6d2cdc488192ee70d8175307d6f205756ed163f4237c6cba2936798dc rename to crates/engine/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/41/bb11b6d2cdc488192ee70d8175307d6f205756ed163f4237c6cba2936798dc diff --git a/crates/core/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/45/4d2e2c62ff5d46c5b3e6de72d6277eb285fc2d6b0a5ac6f92498e08a9e5ecc b/crates/engine/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/45/4d2e2c62ff5d46c5b3e6de72d6277eb285fc2d6b0a5ac6f92498e08a9e5ecc similarity index 100% rename from crates/core/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/45/4d2e2c62ff5d46c5b3e6de72d6277eb285fc2d6b0a5ac6f92498e08a9e5ecc rename to crates/engine/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/45/4d2e2c62ff5d46c5b3e6de72d6277eb285fc2d6b0a5ac6f92498e08a9e5ecc diff --git a/crates/core/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/62/22df0e5ca93d3fb22762e12161246a1d5917c61ada5d81b8dcce12fd5780b3 b/crates/engine/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/62/22df0e5ca93d3fb22762e12161246a1d5917c61ada5d81b8dcce12fd5780b3 similarity index 100% rename from crates/core/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/62/22df0e5ca93d3fb22762e12161246a1d5917c61ada5d81b8dcce12fd5780b3 rename to crates/engine/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/62/22df0e5ca93d3fb22762e12161246a1d5917c61ada5d81b8dcce12fd5780b3 diff --git a/crates/core/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/79/4dced5633eca2ffee784d471f5203209169321083ef99de254ad24af0f6d5a b/crates/engine/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/79/4dced5633eca2ffee784d471f5203209169321083ef99de254ad24af0f6d5a similarity index 100% rename from crates/core/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/79/4dced5633eca2ffee784d471f5203209169321083ef99de254ad24af0f6d5a rename to crates/engine/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/79/4dced5633eca2ffee784d471f5203209169321083ef99de254ad24af0f6d5a diff --git a/crates/core/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/95/74dd6d2857fa771a1cd16be31fdef38f83c2fd3bcc05f4934e53bdbfa21f10 b/crates/engine/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/95/74dd6d2857fa771a1cd16be31fdef38f83c2fd3bcc05f4934e53bdbfa21f10 similarity index 100% rename from crates/core/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/95/74dd6d2857fa771a1cd16be31fdef38f83c2fd3bcc05f4934e53bdbfa21f10 rename to crates/engine/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/95/74dd6d2857fa771a1cd16be31fdef38f83c2fd3bcc05f4934e53bdbfa21f10 diff --git a/crates/core/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/9a/b95f5aaed7541289faa8bc4de886ce0281f11037c3424494e58fee92411241 b/crates/engine/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/9a/b95f5aaed7541289faa8bc4de886ce0281f11037c3424494e58fee92411241 similarity index 100% rename from crates/core/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/9a/b95f5aaed7541289faa8bc4de886ce0281f11037c3424494e58fee92411241 rename to crates/engine/testdata/v1.2/replicas/22000001/snapshots/00000000000000000000.snapshot_dir/objects/9a/b95f5aaed7541289faa8bc4de886ce0281f11037c3424494e58fee92411241 diff --git a/crates/snapshot/Cargo.toml b/crates/snapshot/Cargo.toml index aa51c4e3bd8..ebf4d577670 100644 --- a/crates/snapshot/Cargo.toml +++ b/crates/snapshot/Cargo.toml @@ -6,10 +6,15 @@ rust-version.workspace = true license-file = "LICENSE" description = "Low-level interfaces for capturing and restoring snapshots of database states" +[features] +default = ["tokio"] +tokio = ["spacetimedb-durability/tokio"] +simulation = ["spacetimedb-durability/simulation"] + [dependencies] spacetimedb-table.workspace = true spacetimedb-data-structures.workspace = true -spacetimedb-durability.workspace = true +spacetimedb-durability = { path = "../durability", default-features = false } spacetimedb-lib.workspace = true spacetimedb-sats = { workspace = true, features = ["blake3"] } spacetimedb-primitives.workspace = true diff --git a/crates/standalone/src/lib.rs b/crates/standalone/src/lib.rs index 189bafa11bd..90597c29611 100644 --- a/crates/standalone/src/lib.rs +++ b/crates/standalone/src/lib.rs @@ -17,6 +17,7 @@ use spacetimedb::energy::{EnergyBalance, EnergyQuanta, NullEnergyMonitor}; use spacetimedb::host::{DiskStorage, HostController, HostRuntimeConfig, MigratePlanResult, UpdateDatabaseResult}; use spacetimedb::identity::{AuthCtx, Identity}; use spacetimedb::messages::control_db::{Database, Node, Replica}; +use spacetimedb::metrics::ENGINE_METRICS; use spacetimedb::subscription::row_list_builder_pool::BsatnRowListBuilderPool; use spacetimedb::util::jobs::JobCores; use spacetimedb::worker_metrics::WORKER_METRICS; @@ -95,6 +96,7 @@ impl StandaloneEnv { let metrics_registry = prometheus::Registry::new(); metrics_registry.register(Box::new(&*WORKER_METRICS)).unwrap(); + metrics_registry.register(Box::new(&*ENGINE_METRICS)).unwrap(); metrics_registry.register(Box::new(&*DB_METRICS)).unwrap(); metrics_registry.register(Box::new(&*DATA_SIZE_METRICS)).unwrap(); From e731109f153bb3d27492557fae47b8431eb0cf24 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Tue, 9 Jun 2026 19:50:46 +0530 Subject: [PATCH 04/25] flatten db --- crates/core/src/database_logger.rs | 2 +- crates/core/src/db/mod.rs | 43 ++++++ crates/core/src/host/module_host.rs | 2 +- crates/core/src/lib.rs | 2 +- crates/engine/src/{sql => }/ast.rs | 0 crates/engine/src/db/mod.rs | 144 -------------------- crates/engine/src/{db => }/durability.rs | 2 +- crates/engine/src/lib.rs | 8 +- crates/engine/src/{db => }/persistence.rs | 0 crates/engine/src/{db => }/relational_db.rs | 113 ++++++++++++++- crates/engine/src/rls.rs | 2 +- crates/engine/src/{db => }/snapshot.rs | 0 crates/engine/src/sql/mod.rs | 1 - crates/engine/src/{db => }/update.rs | 2 +- 14 files changed, 163 insertions(+), 158 deletions(-) create mode 100644 crates/core/src/db/mod.rs rename crates/engine/src/{sql => }/ast.rs (100%) delete mode 100644 crates/engine/src/db/mod.rs rename crates/engine/src/{db => }/durability.rs (98%) rename crates/engine/src/{db => }/persistence.rs (100%) rename crates/engine/src/{db => }/relational_db.rs (97%) rename crates/engine/src/{db => }/snapshot.rs (100%) delete mode 100644 crates/engine/src/sql/mod.rs rename crates/engine/src/{db => }/update.rs (99%) diff --git a/crates/core/src/database_logger.rs b/crates/core/src/database_logger.rs index 3b5db5d7e70..9a2891955f8 100644 --- a/crates/core/src/database_logger.rs +++ b/crates/core/src/database_logger.rs @@ -674,7 +674,7 @@ impl SystemLogger { } } -impl spacetimedb_engine::db::update::UpdateLogger for SystemLogger { +impl spacetimedb_engine::update::UpdateLogger for SystemLogger { fn info(&self, msg: &str) { self.info(msg); } diff --git a/crates/core/src/db/mod.rs b/crates/core/src/db/mod.rs new file mode 100644 index 00000000000..98d89edb1ea --- /dev/null +++ b/crates/core/src/db/mod.rs @@ -0,0 +1,43 @@ +pub mod persistence { + pub use spacetimedb_engine::persistence::*; +} + +pub mod relational_db { + pub use spacetimedb_engine::relational_db::*; +} + +pub mod snapshot { + pub use spacetimedb_engine::snapshot::*; +} + +pub mod update { + pub use spacetimedb_engine::update::*; +} + +/// Whether SpacetimeDB is run in memory, or persists objects and +/// a message log to disk. +#[derive(Clone, Copy)] +pub enum Storage { + /// The object store is in memory, and no message log is kept. + Memory, + + /// The object store is persisted to disk, and a message log is kept. + Disk, +} + +/// Internal database config parameters +#[derive(Clone, Copy)] +pub struct Config { + /// Specifies the object storage model. + pub storage: Storage, + /// Specifies the page pool max size in bytes. + pub page_pool_max_size: Option, +} + +pub type MetricsRecorderQueue = spacetimedb_engine::relational_db::MetricsRecorderQueue; + +pub fn spawn_tx_metrics_recorder( + handle: &spacetimedb_runtime::Handle, +) -> (MetricsRecorderQueue, spacetimedb_runtime::AbortHandle) { + spacetimedb_engine::relational_db::spawn_tx_metrics_recorder(handle) +} diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index d10239bd7f0..2a98e7fc4e7 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -601,7 +601,7 @@ fn init_database_inner( table_defs.sort_by_key(|x| &x.name); for def in table_defs { logger.info(&format!("Creating table `{}`", &def.name)); - spacetimedb_engine::db::update::create_table_from_def(stdb, tx, module_def, def)?; + spacetimedb_engine::update::create_table_from_def(stdb, tx, module_def, def)?; } // Create all in-memory views defined by the module. diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 04140b7036c..d630faab09a 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -4,7 +4,7 @@ pub mod energy; pub mod sql; pub mod auth; -pub use spacetimedb_engine::db; +pub mod db; pub use spacetimedb_engine::metrics; pub mod messages; pub use spacetimedb_lib::Identity; diff --git a/crates/engine/src/sql/ast.rs b/crates/engine/src/ast.rs similarity index 100% rename from crates/engine/src/sql/ast.rs rename to crates/engine/src/ast.rs diff --git a/crates/engine/src/db/mod.rs b/crates/engine/src/db/mod.rs deleted file mode 100644 index 363123c0dba..00000000000 --- a/crates/engine/src/db/mod.rs +++ /dev/null @@ -1,144 +0,0 @@ -use std::sync::Arc; - -use enum_map::EnumMap; -use spacetimedb_schema::reducer_name::ReducerName; - -use crate::metrics::ExecutionCounters; -use spacetimedb_datastore::execution_context::WorkloadType; -use spacetimedb_datastore::{locking_tx_datastore::datastore::TxMetrics, traits::TxData}; - -mod durability; -pub mod persistence; -pub mod relational_db; -pub mod snapshot; -pub mod update; - -/// Whether SpacetimeDB is run in memory, or persists objects and -/// a message log to disk. -#[derive(Clone, Copy)] -pub enum Storage { - /// The object store is in memory, and no message log is kept. - Memory, - - /// The object store is persisted to disk, and a message log is kept. - Disk, -} - -/// Internal database config parameters -#[derive(Clone, Copy)] -pub struct Config { - /// Specifies the object storage model. - pub storage: Storage, - /// Specifies the page pool max size in bytes. - pub page_pool_max_size: Option, -} - -/// A message that is processed by the [`spawn_metrics_recorder`] actor. -/// We use a separate task to record metrics to avoid blocking transactions. -pub struct MetricsMessage { - /// The reducer the produced these metrics. - reducer: Option, - /// Metrics from a mutable transaction. - metrics_for_writer: Option, - /// Metrics from a read-only transaction. - /// A message may have metrics for both types of transactions, - /// because metrics for a reducer and its subscription updates are recorded together. - metrics_for_reader: Option, - /// The row updates for an immutable transaction. - /// Needed for insert and delete counters. - tx_data: Option>, - /// Cached metrics counters for each workload type. - counters: Arc>, -} - -/// The handle used to send work to the tx metrics recorder. -#[derive(Clone)] -pub struct MetricsRecorderQueue { - tx: spacetimedb_runtime::sync::mpsc::UnboundedSender, -} - -impl MetricsRecorderQueue { - pub fn send_metrics( - &self, - reducer: Option, - metrics_for_writer: Option, - metrics_for_reader: Option, - tx_data: Option>, - counters: Arc>, - ) { - if let Err(err) = self.tx.send(MetricsMessage { - reducer, - metrics_for_writer, - metrics_for_reader, - tx_data, - counters, - }) { - log::warn!("failed to send metrics: {err}"); - } - } -} - -fn record_metrics( - MetricsMessage { - reducer, - metrics_for_writer, - metrics_for_reader, - tx_data, - counters, - }: MetricsMessage, -) { - if let Some(tx_metrics) = metrics_for_writer { - tx_metrics.report( - // If row updates are present, - // they will always belong to the writer transaction. - tx_data.as_deref(), - reducer.as_ref(), - |wl| &counters[wl], - ); - } - if let Some(tx_metrics) = metrics_for_reader { - tx_metrics.report( - // If row updates are present, - // they will never belong to the reader transaction. - // Passing row updates here will most likely panic. - None, - reducer.as_ref(), - |wl| &counters[wl], - ); - } -} - -/// The metrics recorder is a side channel that the main database thread forwards metrics to. -/// While we want to avoid unnecessary compute on the critical path, communicating with other -/// threads is not free, and for this case in particular waking a parked task is not free. -/// -/// Previously, each tx would send its metrics to the recorder task. As soon as the recorder -/// task `recv`d a message, it would update the counters and gauges, and immediately wait for -/// the next tx's message. This meant that the tx would need to be more expensive than the -/// recording of its metrics in order for the recorder task not to be parked on `recv` when -/// the tx would `send` its metrics. This would obviously never be the case, and so each `send` -/// would incur the overhead of waking the task. -/// -/// To mitigate this, we now record metrics, for potentially many transactions, periodically -/// every 5ms. -const TX_METRICS_RECORDING_INTERVAL: std::time::Duration = std::time::Duration::from_millis(5); - -/// Spawns a task for recording transaction metrics. -/// Returns the handle for pushing metrics to the recorder. -pub fn spawn_tx_metrics_recorder( - handle: &spacetimedb_runtime::Handle, -) -> (MetricsRecorderQueue, spacetimedb_runtime::AbortHandle) { - let handle_clone = handle.clone(); - let (tx, mut rx) = spacetimedb_runtime::sync::mpsc::unbounded_channel(); - let abort_handle = handle - .spawn(async move { - loop { - handle_clone.sleep(TX_METRICS_RECORDING_INTERVAL).await; - while let Ok(metrics) = rx.try_recv() { - record_metrics(metrics); - } - } - }) - .abort_handle(); - (MetricsRecorderQueue { tx }, abort_handle) -} diff --git a/crates/engine/src/db/durability.rs b/crates/engine/src/durability.rs similarity index 98% rename from crates/engine/src/db/durability.rs rename to crates/engine/src/durability.rs index f749f72850a..9c938a6401b 100644 --- a/crates/engine/src/db/durability.rs +++ b/crates/engine/src/durability.rs @@ -10,7 +10,7 @@ use spacetimedb_durability::Transaction; use spacetimedb_lib::Identity; use spacetimedb_sats::ProductValue; -use crate::db::persistence::Durability; +use crate::persistence::Durability; use spacetimedb_runtime::Handle; pub(super) fn request_durability( diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3c9cb364d80..d2e8860bb2a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1,8 +1,12 @@ -pub mod db; +mod ast; +pub(crate) mod durability; pub mod error; pub mod metrics; +pub mod persistence; +pub mod relational_db; pub mod rls; -mod sql; +pub mod snapshot; +pub mod update; pub mod util; pub use spacetimedb_lib::identity; diff --git a/crates/engine/src/db/persistence.rs b/crates/engine/src/persistence.rs similarity index 100% rename from crates/engine/src/db/persistence.rs rename to crates/engine/src/persistence.rs diff --git a/crates/engine/src/db/relational_db.rs b/crates/engine/src/relational_db.rs similarity index 97% rename from crates/engine/src/db/relational_db.rs rename to crates/engine/src/relational_db.rs index 0882e1e8243..00e001c2e45 100644 --- a/crates/engine/src/db/relational_db.rs +++ b/crates/engine/src/relational_db.rs @@ -1,8 +1,111 @@ -use crate::db::durability::{request_durability, spawn_close as spawn_durability_close}; -use crate::db::MetricsRecorderQueue; +use crate::durability::{request_durability, spawn_close as spawn_durability_close}; use crate::error::{DBError, RestoreSnapshotError}; use crate::metrics::ExecutionCounters; use crate::metrics::ENGINE_METRICS; + +/// Whether SpacetimeDB is run in memory, or persists objects and +/// a message log to disk. +#[derive(Clone, Copy)] +pub enum Storage { + /// The object store is in memory, and no message log is kept. + Memory, + + /// The object store is persisted to disk, and a message log is kept. + Disk, +} + +/// Internal database config parameters +#[derive(Clone, Copy)] +pub struct Config { + /// Specifies the object storage model. + pub storage: Storage, + /// Specifies the page pool max size in bytes. + pub page_pool_max_size: Option, +} + +/// A message that is processed by the [`spawn_metrics_recorder`] actor. +/// We use a separate task to record metrics to avoid blocking transactions. +pub struct MetricsMessage { + /// The reducer the produced these metrics. + reducer: Option, + /// Metrics from a mutable transaction. + metrics_for_writer: Option, + /// Metrics from a read-only transaction. + /// A message may have metrics for both types of transactions, + /// because metrics for a reducer and its subscription updates are recorded together. + metrics_for_reader: Option, + /// The row updates for an immutable transaction. + /// Needed for insert and delete counters. + tx_data: Option>, + /// Cached metrics counters for each workload type. + counters: Arc>, +} + +/// The handle used to send work to the tx metrics recorder. +#[derive(Clone)] +pub struct MetricsRecorderQueue { + tx: spacetimedb_runtime::sync::mpsc::UnboundedSender, +} + +impl MetricsRecorderQueue { + pub fn send_metrics( + &self, + reducer: Option, + metrics_for_writer: Option, + metrics_for_reader: Option, + tx_data: Option>, + counters: Arc>, + ) { + if let Err(err) = self.tx.send(MetricsMessage { + reducer, + metrics_for_writer, + metrics_for_reader, + tx_data, + counters, + }) { + log::warn!("failed to send metrics: {err}"); + } + } +} + +fn record_metrics( + MetricsMessage { + reducer, + metrics_for_writer, + metrics_for_reader, + tx_data, + counters, + }: MetricsMessage, +) { + if let Some(tx_metrics) = metrics_for_writer { + tx_metrics.report(tx_data.as_deref(), reducer.as_ref(), |wl| &counters[wl]); + } + if let Some(tx_metrics) = metrics_for_reader { + tx_metrics.report(None, reducer.as_ref(), |wl| &counters[wl]); + } +} + +const TX_METRICS_RECORDING_INTERVAL: std::time::Duration = std::time::Duration::from_millis(5); + +/// Spawns a task for recording transaction metrics. +/// Returns the handle for pushing metrics to the recorder. +pub fn spawn_tx_metrics_recorder( + handle: &spacetimedb_runtime::Handle, +) -> (MetricsRecorderQueue, spacetimedb_runtime::AbortHandle) { + let handle_clone = handle.clone(); + let (tx, mut rx) = spacetimedb_runtime::sync::mpsc::unbounded_channel(); + let abort_handle = handle + .spawn(async move { + loop { + handle_clone.sleep(TX_METRICS_RECORDING_INTERVAL).await; + while let Ok(metrics) = rx.try_recv() { + record_metrics(metrics); + } + } + }) + .abort_handle(); + (MetricsRecorderQueue { tx }, abort_handle) +} use crate::util::asyncify; use anyhow::{anyhow, Context}; use enum_map::EnumMap; @@ -1830,8 +1933,8 @@ fn default_row_count_fn(db: Identity) -> RowCountFn { #[cfg(any(test, feature = "test"))] pub mod tests_utils { - use crate::db::snapshot; - use crate::db::snapshot::SnapshotWorker; + use crate::snapshot; + use crate::snapshot::SnapshotWorker; use super::*; use core::ops::Deref; @@ -2331,7 +2434,7 @@ mod tests { use super::tests_utils::begin_mut_tx; use super::*; - use crate::db::relational_db::tests_utils::{begin_tx, create_view_for_test, insert, make_snapshot, TestDB}; + use crate::relational_db::tests_utils::{begin_tx, create_view_for_test, insert, make_snapshot, TestDB}; use anyhow::bail; use bytes::Bytes; use commitlog::payload::txdata; diff --git a/crates/engine/src/rls.rs b/crates/engine/src/rls.rs index 66d216a5590..5fc606352ec 100644 --- a/crates/engine/src/rls.rs +++ b/crates/engine/src/rls.rs @@ -1,4 +1,4 @@ -use crate::sql::ast::SchemaViewer; +use crate::ast::SchemaViewer; use spacetimedb_datastore::locking_tx_datastore::state_view::StateView; use spacetimedb_datastore::locking_tx_datastore::MutTxId; use spacetimedb_expr::check::parse_and_type_sub; diff --git a/crates/engine/src/db/snapshot.rs b/crates/engine/src/snapshot.rs similarity index 100% rename from crates/engine/src/db/snapshot.rs rename to crates/engine/src/snapshot.rs diff --git a/crates/engine/src/sql/mod.rs b/crates/engine/src/sql/mod.rs deleted file mode 100644 index 851c0bc27ff..00000000000 --- a/crates/engine/src/sql/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod ast; diff --git a/crates/engine/src/db/update.rs b/crates/engine/src/update.rs similarity index 99% rename from crates/engine/src/db/update.rs rename to crates/engine/src/update.rs index 6752ad78d10..bfd6d7648bd 100644 --- a/crates/engine/src/db/update.rs +++ b/crates/engine/src/update.rs @@ -342,7 +342,7 @@ pub fn create_table_from_def( #[cfg(test)] mod test { use super::*; - use crate::db::relational_db::tests_utils::{begin_mut_tx, insert, TestDB}; + use crate::relational_db::tests_utils::{begin_mut_tx, insert, TestDB}; use spacetimedb_datastore::locking_tx_datastore::PendingSchemaChange; use spacetimedb_lib::db::raw_def::v9::{btree, RawIndexAlgorithm, RawModuleDefV9Builder, TableAccess}; use spacetimedb_sats::{product, AlgebraicType, AlgebraicType::U64}; From 04b7ea8ba7bc5b84b74ef8a48a3c2815dceeaf2b Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Wed, 10 Jun 2026 18:49:45 +0530 Subject: [PATCH 05/25] fix db error --- crates/core/src/db/mod.rs | 4 +- crates/engine/src/lib.rs | 93 +++++++++++++++++++++++++ crates/engine/src/relational_db.rs | 106 +---------------------------- crates/runtime/src/lib.rs | 10 +-- 4 files changed, 102 insertions(+), 111 deletions(-) diff --git a/crates/core/src/db/mod.rs b/crates/core/src/db/mod.rs index 98d89edb1ea..992076a7917 100644 --- a/crates/core/src/db/mod.rs +++ b/crates/core/src/db/mod.rs @@ -34,10 +34,10 @@ pub struct Config { pub page_pool_max_size: Option, } -pub type MetricsRecorderQueue = spacetimedb_engine::relational_db::MetricsRecorderQueue; +pub type MetricsRecorderQueue = spacetimedb_engine::MetricsRecorderQueue; pub fn spawn_tx_metrics_recorder( handle: &spacetimedb_runtime::Handle, ) -> (MetricsRecorderQueue, spacetimedb_runtime::AbortHandle) { - spacetimedb_engine::relational_db::spawn_tx_metrics_recorder(handle) + spacetimedb_engine::spawn_tx_metrics_recorder(handle) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d2e8860bb2a..42466557835 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -9,6 +9,99 @@ pub mod snapshot; pub mod update; pub mod util; +use std::sync::Arc; + +use enum_map::EnumMap; +use spacetimedb_datastore::execution_context::WorkloadType; +use spacetimedb_datastore::locking_tx_datastore::datastore::TxMetrics; +use spacetimedb_datastore::traits::TxData; pub use spacetimedb_lib::identity; pub use spacetimedb_lib::Identity; pub use spacetimedb_sats::hash; +use spacetimedb_schema::reducer_name::ReducerName; + +use crate::metrics::ExecutionCounters; + +/// A message that is processed by the [`spawn_metrics_recorder`] actor. +/// We use a separate task to record metrics to avoid blocking transactions. +pub struct MetricsMessage { + /// The reducer the produced these metrics. + reducer: Option, + /// Metrics from a mutable transaction. + metrics_for_writer: Option, + /// Metrics from a read-only transaction. + /// A message may have metrics for both types of transactions, + /// because metrics for a reducer and its subscription updates are recorded together. + metrics_for_reader: Option, + /// The row updates for an immutable transaction. + /// Needed for insert and delete counters. + tx_data: Option>, + /// Cached metrics counters for each workload type. + counters: Arc>, +} + +/// The handle used to send work to the tx metrics recorder. +#[derive(Clone)] +pub struct MetricsRecorderQueue { + tx: spacetimedb_runtime::sync::mpsc::UnboundedSender, +} + +impl MetricsRecorderQueue { + pub fn send_metrics( + &self, + reducer: Option, + metrics_for_writer: Option, + metrics_for_reader: Option, + tx_data: Option>, + counters: Arc>, + ) { + if let Err(err) = self.tx.send(MetricsMessage { + reducer, + metrics_for_writer, + metrics_for_reader, + tx_data, + counters, + }) { + log::warn!("failed to send metrics: {err}"); + } + } +} + +fn record_metrics( + MetricsMessage { + reducer, + metrics_for_writer, + metrics_for_reader, + tx_data, + counters, + }: MetricsMessage, +) { + if let Some(tx_metrics) = metrics_for_writer { + tx_metrics.report(tx_data.as_deref(), reducer.as_ref(), |wl| &counters[wl]); + } + if let Some(tx_metrics) = metrics_for_reader { + tx_metrics.report(None, reducer.as_ref(), |wl| &counters[wl]); + } +} + +const TX_METRICS_RECORDING_INTERVAL: std::time::Duration = std::time::Duration::from_millis(5); + +/// Spawns a task for recording transaction metrics. +/// Returns the handle for pushing metrics to the recorder. +pub fn spawn_tx_metrics_recorder( + handle: &spacetimedb_runtime::Handle, +) -> (MetricsRecorderQueue, spacetimedb_runtime::AbortHandle) { + let handle_clone = handle.clone(); + let (tx, mut rx) = spacetimedb_runtime::sync::mpsc::unbounded_channel(); + let abort_handle = handle + .spawn(async move { + loop { + handle_clone.sleep(TX_METRICS_RECORDING_INTERVAL).await; + while let Ok(metrics) = rx.try_recv() { + record_metrics(metrics); + } + } + }) + .abort_handle(); + (MetricsRecorderQueue { tx }, abort_handle) +} diff --git a/crates/engine/src/relational_db.rs b/crates/engine/src/relational_db.rs index 00e001c2e45..a896c270765 100644 --- a/crates/engine/src/relational_db.rs +++ b/crates/engine/src/relational_db.rs @@ -2,111 +2,8 @@ use crate::durability::{request_durability, spawn_close as spawn_durability_clos use crate::error::{DBError, RestoreSnapshotError}; use crate::metrics::ExecutionCounters; use crate::metrics::ENGINE_METRICS; - -/// Whether SpacetimeDB is run in memory, or persists objects and -/// a message log to disk. -#[derive(Clone, Copy)] -pub enum Storage { - /// The object store is in memory, and no message log is kept. - Memory, - - /// The object store is persisted to disk, and a message log is kept. - Disk, -} - -/// Internal database config parameters -#[derive(Clone, Copy)] -pub struct Config { - /// Specifies the object storage model. - pub storage: Storage, - /// Specifies the page pool max size in bytes. - pub page_pool_max_size: Option, -} - -/// A message that is processed by the [`spawn_metrics_recorder`] actor. -/// We use a separate task to record metrics to avoid blocking transactions. -pub struct MetricsMessage { - /// The reducer the produced these metrics. - reducer: Option, - /// Metrics from a mutable transaction. - metrics_for_writer: Option, - /// Metrics from a read-only transaction. - /// A message may have metrics for both types of transactions, - /// because metrics for a reducer and its subscription updates are recorded together. - metrics_for_reader: Option, - /// The row updates for an immutable transaction. - /// Needed for insert and delete counters. - tx_data: Option>, - /// Cached metrics counters for each workload type. - counters: Arc>, -} - -/// The handle used to send work to the tx metrics recorder. -#[derive(Clone)] -pub struct MetricsRecorderQueue { - tx: spacetimedb_runtime::sync::mpsc::UnboundedSender, -} - -impl MetricsRecorderQueue { - pub fn send_metrics( - &self, - reducer: Option, - metrics_for_writer: Option, - metrics_for_reader: Option, - tx_data: Option>, - counters: Arc>, - ) { - if let Err(err) = self.tx.send(MetricsMessage { - reducer, - metrics_for_writer, - metrics_for_reader, - tx_data, - counters, - }) { - log::warn!("failed to send metrics: {err}"); - } - } -} - -fn record_metrics( - MetricsMessage { - reducer, - metrics_for_writer, - metrics_for_reader, - tx_data, - counters, - }: MetricsMessage, -) { - if let Some(tx_metrics) = metrics_for_writer { - tx_metrics.report(tx_data.as_deref(), reducer.as_ref(), |wl| &counters[wl]); - } - if let Some(tx_metrics) = metrics_for_reader { - tx_metrics.report(None, reducer.as_ref(), |wl| &counters[wl]); - } -} - -const TX_METRICS_RECORDING_INTERVAL: std::time::Duration = std::time::Duration::from_millis(5); - -/// Spawns a task for recording transaction metrics. -/// Returns the handle for pushing metrics to the recorder. -pub fn spawn_tx_metrics_recorder( - handle: &spacetimedb_runtime::Handle, -) -> (MetricsRecorderQueue, spacetimedb_runtime::AbortHandle) { - let handle_clone = handle.clone(); - let (tx, mut rx) = spacetimedb_runtime::sync::mpsc::unbounded_channel(); - let abort_handle = handle - .spawn(async move { - loop { - handle_clone.sleep(TX_METRICS_RECORDING_INTERVAL).await; - while let Ok(metrics) = rx.try_recv() { - record_metrics(metrics); - } - } - }) - .abort_handle(); - (MetricsRecorderQueue { tx }, abort_handle) -} use crate::util::asyncify; +use crate::MetricsRecorderQueue; use anyhow::{anyhow, Context}; use enum_map::EnumMap; use spacetimedb_commitlog::repo::OnNewSegmentFn; @@ -1935,6 +1832,7 @@ fn default_row_count_fn(db: Identity) -> RowCountFn { pub mod tests_utils { use crate::snapshot; use crate::snapshot::SnapshotWorker; + use crate::MetricsRecorderQueue; use super::*; use core::ops::Deref; diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 8093ca61ca4..11a0e4990fb 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -1,8 +1,8 @@ -#[cfg(all(feature = "tokio", feature = "simulation"))] -compile_error!( - "spacetimedb-runtime requires exactly one runtime backend: enable either `tokio` or `simulation`, not both" -); - +//#[cfg(all(feature = "tokio", feature = "simulation"))] +//compile_error!( +// "spacetimedb-runtime requires exactly one runtime backend: enable either `tokio` or `simulation`, not both" +//); +// #[cfg(not(any(feature = "tokio", feature = "simulation")))] compile_error!("spacetimedb-runtime requires exactly one runtime backend: enable either `tokio` or `simulation`"); From 52078b57895662eeb497f19669dcdc9302b039d6 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Wed, 10 Jun 2026 19:11:58 +0530 Subject: [PATCH 06/25] move ast --- crates/core/src/estimation.rs | 2 +- crates/core/src/host/module_host.rs | 3 +- .../src/host/wasm_common/module_host_actor.rs | 2 +- crates/core/src/sql/ast.rs | 77 ------------------- crates/core/src/sql/execute.rs | 4 +- crates/core/src/sql/mod.rs | 1 - .../module_subscription_manager.rs | 2 +- crates/core/src/subscription/query.rs | 2 +- crates/core/src/subscription/subscription.rs | 2 +- crates/engine/src/lib.rs | 2 +- 10 files changed, 9 insertions(+), 88 deletions(-) delete mode 100644 crates/core/src/sql/ast.rs diff --git a/crates/core/src/estimation.rs b/crates/core/src/estimation.rs index 70dda1b3e2a..47f196d1c4d 100644 --- a/crates/core/src/estimation.rs +++ b/crates/core/src/estimation.rs @@ -132,7 +132,7 @@ mod tests { use crate::db::relational_db::tests_utils::{begin_tx, insert, with_auto_commit}; use crate::db::relational_db::{tests_utils::TestDB, RelationalDB}; use crate::error::DBError; - use crate::sql::ast::SchemaViewer; + use spacetimedb_engine::ast::SchemaViewer; use spacetimedb_lib::{identity::AuthCtx, AlgebraicType}; use spacetimedb_query::compile_subscription; use spacetimedb_sats::product; diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index 2a98e7fc4e7..579068f77d4 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -18,7 +18,6 @@ use crate::host::{InvalidFunctionArguments, InvalidViewArguments}; use crate::identity::Identity; use crate::messages::control_db::{Database, HostType}; use crate::replica_context::ReplicaContext; -use crate::sql::ast::SchemaViewer; use crate::sql::execute::SqlResult; use crate::subscription::module_subscription_actor::ModuleSubscriptions; use crate::subscription::module_subscription_manager::BroadcastError; @@ -50,6 +49,7 @@ use spacetimedb_datastore::execution_context::{Workload, WorkloadType}; use spacetimedb_datastore::locking_tx_datastore::{MutTxId, ViewCallInfo}; use spacetimedb_datastore::traits::{IsolationLevel, Program, TxData}; pub use spacetimedb_durability::{DurabilityExited, DurableOffset}; +use spacetimedb_engine::ast::SchemaViewer; use spacetimedb_engine::rls::RowLevelExpr; use spacetimedb_execution::pipelined::{PipelinedProject, ViewProject}; use spacetimedb_execution::RelValue; @@ -67,7 +67,6 @@ use spacetimedb_schema::auto_migrate::{AutoMigrateError, MigrationPolicy}; use spacetimedb_schema::def::{ModuleDef, ProcedureDef, ReducerDef, ViewDef}; use spacetimedb_schema::identifier::Identifier; use spacetimedb_schema::reducer_name::ReducerName; - use spacetimedb_schema::table_name::TableName; use std::collections::VecDeque; use std::fmt; diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs index 4489fb9f8bd..cdb185f8e4e 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -22,7 +22,6 @@ use crate::identity::Identity; use crate::messages::control_db::HostType; use crate::module_host_context::ModuleCreationContext; use crate::replica_context::ReplicaContext; -use crate::sql::ast::SchemaViewer; use crate::sql::execute::run_with_instance; use crate::subscription::module_subscription_actor::{commit_and_broadcast_event, CommitAndBroadcastEventSuccess}; use crate::subscription::module_subscription_manager::TransactionOffset; @@ -39,6 +38,7 @@ use spacetimedb_datastore::error::{DatastoreError, ViewError}; use spacetimedb_datastore::execution_context::{self, ReducerContext, Workload}; use spacetimedb_datastore::locking_tx_datastore::{FuncCallType, MutTxId, ViewCallInfo}; use spacetimedb_datastore::traits::{IsolationLevel, Program}; +use spacetimedb_engine::ast::SchemaViewer; use spacetimedb_execution::pipelined::PipelinedProject; use spacetimedb_lib::buffer::DecodeError; use spacetimedb_lib::db::raw_def::v9::{Lifecycle, ViewResultHeader}; diff --git a/crates/core/src/sql/ast.rs b/crates/core/src/sql/ast.rs deleted file mode 100644 index 892430ba1ea..00000000000 --- a/crates/core/src/sql/ast.rs +++ /dev/null @@ -1,77 +0,0 @@ -use anyhow::Context; -use spacetimedb_datastore::locking_tx_datastore::state_view::StateView; -use spacetimedb_datastore::system_tables::{StRowLevelSecurityFields, ST_ROW_LEVEL_SECURITY_ID}; -use spacetimedb_expr::check::SchemaView; -use spacetimedb_lib::identity::AuthCtx; -use spacetimedb_primitives::TableId; -use spacetimedb_sats::AlgebraicValue; -use spacetimedb_schema::schema::TableOrViewSchema; -use std::ops::Deref; -use std::sync::Arc; - -pub struct SchemaViewer<'a, T> { - tx: &'a T, - auth: &'a AuthCtx, -} - -impl Deref for SchemaViewer<'_, T> { - type Target = T; - - fn deref(&self) -> &Self::Target { - self.tx - } -} - -impl SchemaView for SchemaViewer<'_, T> { - fn table_id(&self, name: &str) -> Option { - self.tx - .table_id_from_name_or_alias(name) - .ok() - .flatten() - .and_then(|table_id| self.schema_for_table(table_id)) - .filter(|schema| self.auth.has_read_access(schema.table_access)) - .map(|schema| schema.table_id) - } - - fn schema_for_table(&self, table_id: TableId) -> Option> { - self.tx - .get_schema(table_id) - .filter(|schema| self.auth.has_read_access(schema.table_access)) - .map(Arc::clone) - .map(TableOrViewSchema::from) - .map(Arc::new) - } - - fn rls_rules_for_table(&self, table_id: TableId) -> anyhow::Result>> { - self.tx - .iter_by_col_eq( - ST_ROW_LEVEL_SECURITY_ID, - StRowLevelSecurityFields::TableId, - &AlgebraicValue::from(table_id), - )? - .map(|row| { - row.read_col::(StRowLevelSecurityFields::Sql) - .with_context(|| { - format!( - "Failed to read value from the `{}` column of `{}` for table_id `{}`", - "sql", "st_row_level_security", table_id - ) - }) - .and_then(|sql| { - sql.into_string().map_err(|_| { - anyhow::anyhow!(format!( - "Failed to read value from the `{}` column of `{}` for table_id `{}`", - "sql", "st_row_level_security", table_id - )) - }) - }) - }) - .collect::>() - } -} - -impl<'a, T> SchemaViewer<'a, T> { - pub fn new(tx: &'a T, auth: &'a AuthCtx) -> Self { - Self { tx, auth } - } -} diff --git a/crates/core/src/sql/execute.rs b/crates/core/src/sql/execute.rs index 255f57ff8b7..d5b471d3696 100644 --- a/crates/core/src/sql/execute.rs +++ b/crates/core/src/sql/execute.rs @@ -1,8 +1,6 @@ use std::sync::Arc; use std::time::Duration; -use super::ast::SchemaViewer; -use crate::db::relational_db::RelationalDB; use crate::energy::FunctionBudget; use crate::error::DBError; use crate::estimation::{check_row_limit, estimate_rows_scanned}; @@ -17,6 +15,8 @@ use crate::subscription::tx::DeltaTx; use anyhow::anyhow; use spacetimedb_datastore::execution_context::Workload; use spacetimedb_datastore::traits::IsolationLevel; +use spacetimedb_engine::ast::SchemaViewer; +use spacetimedb_engine::relational_db::RelationalDB; use spacetimedb_expr::statement::Statement; use spacetimedb_lib::identity::AuthCtx; use spacetimedb_lib::metrics::ExecutionMetrics; diff --git a/crates/core/src/sql/mod.rs b/crates/core/src/sql/mod.rs index 78c7f6bbfe7..2e8bdddf980 100644 --- a/crates/core/src/sql/mod.rs +++ b/crates/core/src/sql/mod.rs @@ -1,2 +1 @@ -pub mod ast; pub mod execute; diff --git a/crates/core/src/subscription/module_subscription_manager.rs b/crates/core/src/subscription/module_subscription_manager.rs index 0700846fc74..ccbee836c25 100644 --- a/crates/core/src/subscription/module_subscription_manager.rs +++ b/crates/core/src/subscription/module_subscription_manager.rs @@ -2204,7 +2204,6 @@ mod tests { use super::{Plan, SubscriptionManager}; use crate::db::relational_db::tests_utils::with_read_only; use crate::host::module_host::DatabaseTableUpdate; - use crate::sql::ast::SchemaViewer; use crate::subscription::module_subscription_manager::ClientQueryId; use crate::subscription::row_list_builder_pool::BsatnRowListBuilderPool; use crate::subscription::tx::DeltaTx; @@ -2219,6 +2218,7 @@ mod tests { subscription::execution_unit::QueryHash, }; use spacetimedb_datastore::execution_context::Workload; + use spacetimedb_engine::ast::SchemaViewer; fn create_table(db: &RelationalDB, name: &str) -> ResultTest { Ok(db.create_table_for_test(name, &[("a", AlgebraicType::U8)], &[])?) diff --git a/crates/core/src/subscription/query.rs b/crates/core/src/subscription/query.rs index bea30f96b7f..e09f0b87674 100644 --- a/crates/core/src/subscription/query.rs +++ b/crates/core/src/subscription/query.rs @@ -2,10 +2,10 @@ use super::execution_unit::QueryHash; use super::module_subscription_manager::Plan; use crate::db::relational_db::Tx; use crate::error::{DBError, SubscriptionError}; -use crate::sql::ast::SchemaViewer; use once_cell::sync::Lazy; use regex::Regex; use spacetimedb_datastore::locking_tx_datastore::state_view::StateView; +use spacetimedb_engine::ast::SchemaViewer; use spacetimedb_execution::Datastore; use spacetimedb_lib::identity::AuthCtx; use spacetimedb_subscription::SubscriptionPlan; diff --git a/crates/core/src/subscription/subscription.rs b/crates/core/src/subscription/subscription.rs index a9f3bd12f62..fd7c5fc3fcb 100644 --- a/crates/core/src/subscription/subscription.rs +++ b/crates/core/src/subscription/subscription.rs @@ -2,8 +2,8 @@ use super::execution_unit::QueryHash; use super::module_subscription_manager::Plan; use crate::db::relational_db::RelationalDB; use crate::error::DBError; -use crate::sql::ast::SchemaViewer; use spacetimedb_datastore::locking_tx_datastore::state_view::StateView; +use spacetimedb_engine::ast::SchemaViewer; use spacetimedb_lib::db::auth::StTableType; use spacetimedb_lib::identity::AuthCtx; use spacetimedb_schema::schema::TableSchema; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 42466557835..0a0ebb96a09 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1,4 +1,4 @@ -mod ast; +pub mod ast; pub(crate) mod durability; pub mod error; pub mod metrics; From 198967d58c63058fc67cc9e878ef187d5c509f3a Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Wed, 10 Jun 2026 20:39:22 +0530 Subject: [PATCH 07/25] clippy --- crates/bench/benches/subscription.rs | 2 +- crates/core/src/db/mod.rs | 2 ++ crates/core/src/estimation.rs | 2 +- crates/core/src/host/module_host.rs | 2 +- crates/core/src/host/wasm_common/module_host_actor.rs | 2 +- crates/core/src/sql/execute.rs | 2 +- crates/core/src/subscription/module_subscription_manager.rs | 2 +- crates/core/src/subscription/query.rs | 2 +- crates/core/src/subscription/subscription.rs | 2 +- 9 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/bench/benches/subscription.rs b/crates/bench/benches/subscription.rs index ebd8e83e35c..359ad86746b 100644 --- a/crates/bench/benches/subscription.rs +++ b/crates/bench/benches/subscription.rs @@ -1,9 +1,9 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion}; use spacetimedb::client::consume_each_list::ConsumeEachBuffer; use spacetimedb::db::relational_db::RelationalDB; +use spacetimedb::db::SchemaViewer; use spacetimedb::error::DBError; use spacetimedb::identity::AuthCtx; -use spacetimedb::sql::ast::SchemaViewer; use spacetimedb::subscription::row_list_builder_pool::BsatnRowListBuilderPool; use spacetimedb::subscription::tx::DeltaTx; use spacetimedb::subscription::{collect_table_update, TableUpdateType}; diff --git a/crates/core/src/db/mod.rs b/crates/core/src/db/mod.rs index 992076a7917..8ba71616db0 100644 --- a/crates/core/src/db/mod.rs +++ b/crates/core/src/db/mod.rs @@ -36,6 +36,8 @@ pub struct Config { pub type MetricsRecorderQueue = spacetimedb_engine::MetricsRecorderQueue; +pub type SchemaViewer<'a, T> = spacetimedb_engine::ast::SchemaViewer<'a, T>; + pub fn spawn_tx_metrics_recorder( handle: &spacetimedb_runtime::Handle, ) -> (MetricsRecorderQueue, spacetimedb_runtime::AbortHandle) { diff --git a/crates/core/src/estimation.rs b/crates/core/src/estimation.rs index 47f196d1c4d..ff704c92cd5 100644 --- a/crates/core/src/estimation.rs +++ b/crates/core/src/estimation.rs @@ -131,8 +131,8 @@ mod tests { use super::{estimate_rows_scanned, row_estimate}; use crate::db::relational_db::tests_utils::{begin_tx, insert, with_auto_commit}; use crate::db::relational_db::{tests_utils::TestDB, RelationalDB}; + use crate::db::SchemaViewer; use crate::error::DBError; - use spacetimedb_engine::ast::SchemaViewer; use spacetimedb_lib::{identity::AuthCtx, AlgebraicType}; use spacetimedb_query::compile_subscription; use spacetimedb_sats::product; diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index 579068f77d4..194eafb0334 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -6,6 +6,7 @@ use crate::client::messages::{OneOffQueryResponseMessage, ProcedureResultMessage use crate::client::{ClientActorId, ClientConnectionSender, WsVersion}; use crate::database_logger::{DatabaseLogger, LogLevel, Record}; use crate::db::relational_db::{RelationalDB, Tx}; +use crate::db::SchemaViewer; use crate::error::DBError; use crate::estimation::{check_row_limit, estimate_rows_scanned}; use crate::hash::Hash; @@ -49,7 +50,6 @@ use spacetimedb_datastore::execution_context::{Workload, WorkloadType}; use spacetimedb_datastore::locking_tx_datastore::{MutTxId, ViewCallInfo}; use spacetimedb_datastore::traits::{IsolationLevel, Program, TxData}; pub use spacetimedb_durability::{DurabilityExited, DurableOffset}; -use spacetimedb_engine::ast::SchemaViewer; use spacetimedb_engine::rls::RowLevelExpr; use spacetimedb_execution::pipelined::{PipelinedProject, ViewProject}; use spacetimedb_execution::RelValue; diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs index cdb185f8e4e..a3e9d618f09 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -2,6 +2,7 @@ use super::instrumentation::CallTimes; use super::*; use crate::client::ClientActorId; use crate::database_logger; +use crate::db::SchemaViewer; use crate::energy::{EnergyMonitor, FunctionBudget, FunctionFingerprint}; use crate::error::DBError; use crate::host::host_controller::CallProcedureReturn; @@ -38,7 +39,6 @@ use spacetimedb_datastore::error::{DatastoreError, ViewError}; use spacetimedb_datastore::execution_context::{self, ReducerContext, Workload}; use spacetimedb_datastore::locking_tx_datastore::{FuncCallType, MutTxId, ViewCallInfo}; use spacetimedb_datastore::traits::{IsolationLevel, Program}; -use spacetimedb_engine::ast::SchemaViewer; use spacetimedb_execution::pipelined::PipelinedProject; use spacetimedb_lib::buffer::DecodeError; use spacetimedb_lib::db::raw_def::v9::{Lifecycle, ViewResultHeader}; diff --git a/crates/core/src/sql/execute.rs b/crates/core/src/sql/execute.rs index d5b471d3696..1ad8ee7b770 100644 --- a/crates/core/src/sql/execute.rs +++ b/crates/core/src/sql/execute.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use std::time::Duration; +use crate::db::SchemaViewer; use crate::energy::FunctionBudget; use crate::error::DBError; use crate::estimation::{check_row_limit, estimate_rows_scanned}; @@ -15,7 +16,6 @@ use crate::subscription::tx::DeltaTx; use anyhow::anyhow; use spacetimedb_datastore::execution_context::Workload; use spacetimedb_datastore::traits::IsolationLevel; -use spacetimedb_engine::ast::SchemaViewer; use spacetimedb_engine::relational_db::RelationalDB; use spacetimedb_expr::statement::Statement; use spacetimedb_lib::identity::AuthCtx; diff --git a/crates/core/src/subscription/module_subscription_manager.rs b/crates/core/src/subscription/module_subscription_manager.rs index ccbee836c25..d3e95fb3044 100644 --- a/crates/core/src/subscription/module_subscription_manager.rs +++ b/crates/core/src/subscription/module_subscription_manager.rs @@ -2203,6 +2203,7 @@ mod tests { use super::{Plan, SubscriptionManager}; use crate::db::relational_db::tests_utils::with_read_only; + use crate::db::SchemaViewer; use crate::host::module_host::DatabaseTableUpdate; use crate::subscription::module_subscription_manager::ClientQueryId; use crate::subscription::row_list_builder_pool::BsatnRowListBuilderPool; @@ -2218,7 +2219,6 @@ mod tests { subscription::execution_unit::QueryHash, }; use spacetimedb_datastore::execution_context::Workload; - use spacetimedb_engine::ast::SchemaViewer; fn create_table(db: &RelationalDB, name: &str) -> ResultTest { Ok(db.create_table_for_test(name, &[("a", AlgebraicType::U8)], &[])?) diff --git a/crates/core/src/subscription/query.rs b/crates/core/src/subscription/query.rs index e09f0b87674..e2ef3676c10 100644 --- a/crates/core/src/subscription/query.rs +++ b/crates/core/src/subscription/query.rs @@ -1,11 +1,11 @@ use super::execution_unit::QueryHash; use super::module_subscription_manager::Plan; use crate::db::relational_db::Tx; +use crate::db::SchemaViewer; use crate::error::{DBError, SubscriptionError}; use once_cell::sync::Lazy; use regex::Regex; use spacetimedb_datastore::locking_tx_datastore::state_view::StateView; -use spacetimedb_engine::ast::SchemaViewer; use spacetimedb_execution::Datastore; use spacetimedb_lib::identity::AuthCtx; use spacetimedb_subscription::SubscriptionPlan; diff --git a/crates/core/src/subscription/subscription.rs b/crates/core/src/subscription/subscription.rs index fd7c5fc3fcb..82021e37e30 100644 --- a/crates/core/src/subscription/subscription.rs +++ b/crates/core/src/subscription/subscription.rs @@ -1,9 +1,9 @@ use super::execution_unit::QueryHash; use super::module_subscription_manager::Plan; use crate::db::relational_db::RelationalDB; +use crate::db::SchemaViewer; use crate::error::DBError; use spacetimedb_datastore::locking_tx_datastore::state_view::StateView; -use spacetimedb_engine::ast::SchemaViewer; use spacetimedb_lib::db::auth::StTableType; use spacetimedb_lib::identity::AuthCtx; use spacetimedb_schema::schema::TableSchema; From 6abd749826492aac91db047c4e9dbe995c3238c6 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Wed, 10 Jun 2026 20:52:09 +0530 Subject: [PATCH 08/25] sql module --- crates/core/src/db/mod.rs | 12 +++++++++++- crates/core/src/host/module_host.rs | 2 +- crates/engine/src/lib.rs | 3 +-- crates/engine/src/{ => sql}/ast.rs | 0 crates/engine/src/sql/mod.rs | 2 ++ crates/engine/src/{ => sql}/rls.rs | 2 +- crates/engine/src/update.rs | 2 +- 7 files changed, 17 insertions(+), 6 deletions(-) rename crates/engine/src/{ => sql}/ast.rs (100%) create mode 100644 crates/engine/src/sql/mod.rs rename crates/engine/src/{ => sql}/rls.rs (97%) diff --git a/crates/core/src/db/mod.rs b/crates/core/src/db/mod.rs index 8ba71616db0..81a250922a6 100644 --- a/crates/core/src/db/mod.rs +++ b/crates/core/src/db/mod.rs @@ -6,6 +6,16 @@ pub mod relational_db { pub use spacetimedb_engine::relational_db::*; } +pub mod sql { + pub mod ast { + pub use spacetimedb_engine::sql::ast::*; + } + + pub mod rls { + pub use spacetimedb_engine::sql::rls::*; + } +} + pub mod snapshot { pub use spacetimedb_engine::snapshot::*; } @@ -36,7 +46,7 @@ pub struct Config { pub type MetricsRecorderQueue = spacetimedb_engine::MetricsRecorderQueue; -pub type SchemaViewer<'a, T> = spacetimedb_engine::ast::SchemaViewer<'a, T>; +pub type SchemaViewer<'a, T> = spacetimedb_engine::sql::ast::SchemaViewer<'a, T>; pub fn spawn_tx_metrics_recorder( handle: &spacetimedb_runtime::Handle, diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index 194eafb0334..0ab921d3ec3 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -50,7 +50,7 @@ use spacetimedb_datastore::execution_context::{Workload, WorkloadType}; use spacetimedb_datastore::locking_tx_datastore::{MutTxId, ViewCallInfo}; use spacetimedb_datastore::traits::{IsolationLevel, Program, TxData}; pub use spacetimedb_durability::{DurabilityExited, DurableOffset}; -use spacetimedb_engine::rls::RowLevelExpr; +use spacetimedb_engine::sql::rls::RowLevelExpr; use spacetimedb_execution::pipelined::{PipelinedProject, ViewProject}; use spacetimedb_execution::RelValue; use spacetimedb_expr::expr::CollectViews; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0a0ebb96a09..bfcc2886d92 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1,10 +1,9 @@ -pub mod ast; pub(crate) mod durability; pub mod error; pub mod metrics; pub mod persistence; pub mod relational_db; -pub mod rls; +pub mod sql; pub mod snapshot; pub mod update; pub mod util; diff --git a/crates/engine/src/ast.rs b/crates/engine/src/sql/ast.rs similarity index 100% rename from crates/engine/src/ast.rs rename to crates/engine/src/sql/ast.rs diff --git a/crates/engine/src/sql/mod.rs b/crates/engine/src/sql/mod.rs new file mode 100644 index 00000000000..7ac171e4ed9 --- /dev/null +++ b/crates/engine/src/sql/mod.rs @@ -0,0 +1,2 @@ +pub mod ast; +pub mod rls; \ No newline at end of file diff --git a/crates/engine/src/rls.rs b/crates/engine/src/sql/rls.rs similarity index 97% rename from crates/engine/src/rls.rs rename to crates/engine/src/sql/rls.rs index 5fc606352ec..df063a6a63e 100644 --- a/crates/engine/src/rls.rs +++ b/crates/engine/src/sql/rls.rs @@ -1,4 +1,4 @@ -use crate::ast::SchemaViewer; +use super::ast::SchemaViewer; use spacetimedb_datastore::locking_tx_datastore::state_view::StateView; use spacetimedb_datastore::locking_tx_datastore::MutTxId; use spacetimedb_expr::check::parse_and_type_sub; diff --git a/crates/engine/src/update.rs b/crates/engine/src/update.rs index bfd6d7648bd..bc2464590bc 100644 --- a/crates/engine/src/update.rs +++ b/crates/engine/src/update.rs @@ -1,5 +1,5 @@ use super::relational_db::RelationalDB; -use crate::rls::RowLevelExpr; +use crate::sql::rls::RowLevelExpr; use anyhow::Context; use spacetimedb_datastore::locking_tx_datastore::MutTxId; use spacetimedb_lib::db::auth::StTableType; From 7148de25f208200b59e24eaf12aca075bc3b81db Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Wed, 10 Jun 2026 21:32:20 +0530 Subject: [PATCH 09/25] formatting --- crates/bench/benches/subscription.rs | 2 +- crates/core/src/db/mod.rs | 2 -- crates/core/src/estimation.rs | 2 +- crates/core/src/host/module_host.rs | 2 +- crates/core/src/host/wasm_common/module_host_actor.rs | 2 +- crates/core/src/sql/execute.rs | 2 +- crates/core/src/subscription/module_subscription_manager.rs | 2 +- crates/core/src/subscription/query.rs | 2 +- crates/core/src/subscription/subscription.rs | 2 +- crates/engine/src/lib.rs | 2 +- crates/engine/src/sql/mod.rs | 2 +- 11 files changed, 10 insertions(+), 12 deletions(-) diff --git a/crates/bench/benches/subscription.rs b/crates/bench/benches/subscription.rs index 359ad86746b..e54be943b4e 100644 --- a/crates/bench/benches/subscription.rs +++ b/crates/bench/benches/subscription.rs @@ -1,7 +1,7 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion}; use spacetimedb::client::consume_each_list::ConsumeEachBuffer; use spacetimedb::db::relational_db::RelationalDB; -use spacetimedb::db::SchemaViewer; +use spacetimedb::db::sql::ast::SchemaViewer; use spacetimedb::error::DBError; use spacetimedb::identity::AuthCtx; use spacetimedb::subscription::row_list_builder_pool::BsatnRowListBuilderPool; diff --git a/crates/core/src/db/mod.rs b/crates/core/src/db/mod.rs index 81a250922a6..6b1d2f6700b 100644 --- a/crates/core/src/db/mod.rs +++ b/crates/core/src/db/mod.rs @@ -46,8 +46,6 @@ pub struct Config { pub type MetricsRecorderQueue = spacetimedb_engine::MetricsRecorderQueue; -pub type SchemaViewer<'a, T> = spacetimedb_engine::sql::ast::SchemaViewer<'a, T>; - pub fn spawn_tx_metrics_recorder( handle: &spacetimedb_runtime::Handle, ) -> (MetricsRecorderQueue, spacetimedb_runtime::AbortHandle) { diff --git a/crates/core/src/estimation.rs b/crates/core/src/estimation.rs index ff704c92cd5..92aa9370d92 100644 --- a/crates/core/src/estimation.rs +++ b/crates/core/src/estimation.rs @@ -131,7 +131,7 @@ mod tests { use super::{estimate_rows_scanned, row_estimate}; use crate::db::relational_db::tests_utils::{begin_tx, insert, with_auto_commit}; use crate::db::relational_db::{tests_utils::TestDB, RelationalDB}; - use crate::db::SchemaViewer; + use crate::db::sql::ast::SchemaViewer; use crate::error::DBError; use spacetimedb_lib::{identity::AuthCtx, AlgebraicType}; use spacetimedb_query::compile_subscription; diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index 0ab921d3ec3..e9cda26ea01 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -6,7 +6,7 @@ use crate::client::messages::{OneOffQueryResponseMessage, ProcedureResultMessage use crate::client::{ClientActorId, ClientConnectionSender, WsVersion}; use crate::database_logger::{DatabaseLogger, LogLevel, Record}; use crate::db::relational_db::{RelationalDB, Tx}; -use crate::db::SchemaViewer; +use crate::db::sql::ast::SchemaViewer; use crate::error::DBError; use crate::estimation::{check_row_limit, estimate_rows_scanned}; use crate::hash::Hash; diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs index a3e9d618f09..a76d814ce7b 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -2,7 +2,7 @@ use super::instrumentation::CallTimes; use super::*; use crate::client::ClientActorId; use crate::database_logger; -use crate::db::SchemaViewer; +use crate::db::sql::ast::SchemaViewer; use crate::energy::{EnergyMonitor, FunctionBudget, FunctionFingerprint}; use crate::error::DBError; use crate::host::host_controller::CallProcedureReturn; diff --git a/crates/core/src/sql/execute.rs b/crates/core/src/sql/execute.rs index 1ad8ee7b770..7fe2fe229f8 100644 --- a/crates/core/src/sql/execute.rs +++ b/crates/core/src/sql/execute.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use std::time::Duration; -use crate::db::SchemaViewer; +use crate::db::sql::ast::SchemaViewer; use crate::energy::FunctionBudget; use crate::error::DBError; use crate::estimation::{check_row_limit, estimate_rows_scanned}; diff --git a/crates/core/src/subscription/module_subscription_manager.rs b/crates/core/src/subscription/module_subscription_manager.rs index d3e95fb3044..404a8230040 100644 --- a/crates/core/src/subscription/module_subscription_manager.rs +++ b/crates/core/src/subscription/module_subscription_manager.rs @@ -2203,7 +2203,7 @@ mod tests { use super::{Plan, SubscriptionManager}; use crate::db::relational_db::tests_utils::with_read_only; - use crate::db::SchemaViewer; + use crate::db::sql::ast::SchemaViewer; use crate::host::module_host::DatabaseTableUpdate; use crate::subscription::module_subscription_manager::ClientQueryId; use crate::subscription::row_list_builder_pool::BsatnRowListBuilderPool; diff --git a/crates/core/src/subscription/query.rs b/crates/core/src/subscription/query.rs index e2ef3676c10..1a82ac6c5a7 100644 --- a/crates/core/src/subscription/query.rs +++ b/crates/core/src/subscription/query.rs @@ -1,7 +1,7 @@ use super::execution_unit::QueryHash; use super::module_subscription_manager::Plan; use crate::db::relational_db::Tx; -use crate::db::SchemaViewer; +use crate::db::sql::ast::SchemaViewer; use crate::error::{DBError, SubscriptionError}; use once_cell::sync::Lazy; use regex::Regex; diff --git a/crates/core/src/subscription/subscription.rs b/crates/core/src/subscription/subscription.rs index 82021e37e30..54b25acbfd2 100644 --- a/crates/core/src/subscription/subscription.rs +++ b/crates/core/src/subscription/subscription.rs @@ -1,7 +1,7 @@ use super::execution_unit::QueryHash; use super::module_subscription_manager::Plan; use crate::db::relational_db::RelationalDB; -use crate::db::SchemaViewer; +use crate::db::sql::ast::SchemaViewer; use crate::error::DBError; use spacetimedb_datastore::locking_tx_datastore::state_view::StateView; use spacetimedb_lib::db::auth::StTableType; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bfcc2886d92..204f54f7187 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -3,8 +3,8 @@ pub mod error; pub mod metrics; pub mod persistence; pub mod relational_db; -pub mod sql; pub mod snapshot; +pub mod sql; pub mod update; pub mod util; diff --git a/crates/engine/src/sql/mod.rs b/crates/engine/src/sql/mod.rs index 7ac171e4ed9..70aae2831d7 100644 --- a/crates/engine/src/sql/mod.rs +++ b/crates/engine/src/sql/mod.rs @@ -1,2 +1,2 @@ pub mod ast; -pub mod rls; \ No newline at end of file +pub mod rls; From 545b1ffc17d63b482cc4a3340e8e4802df1c1422 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Wed, 10 Jun 2026 21:44:51 +0530 Subject: [PATCH 10/25] uncomment --- crates/runtime/src/lib.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 11a0e4990fb..8093ca61ca4 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -1,8 +1,8 @@ -//#[cfg(all(feature = "tokio", feature = "simulation"))] -//compile_error!( -// "spacetimedb-runtime requires exactly one runtime backend: enable either `tokio` or `simulation`, not both" -//); -// +#[cfg(all(feature = "tokio", feature = "simulation"))] +compile_error!( + "spacetimedb-runtime requires exactly one runtime backend: enable either `tokio` or `simulation`, not both" +); + #[cfg(not(any(feature = "tokio", feature = "simulation")))] compile_error!("spacetimedb-runtime requires exactly one runtime backend: enable either `tokio` or `simulation`"); From 6aedc521e6c83b31a8e0668a30a1cbf5169c3d6c Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Thu, 11 Jun 2026 18:00:51 +0530 Subject: [PATCH 11/25] add back commentary --- crates/engine/src/lib.rs | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 204f54f7187..09229362bd7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -76,13 +76,39 @@ fn record_metrics( }: MetricsMessage, ) { if let Some(tx_metrics) = metrics_for_writer { - tx_metrics.report(tx_data.as_deref(), reducer.as_ref(), |wl| &counters[wl]); + tx_metrics.report( + // If row updates are present, + // they will always belong to the writer transaction. + tx_data.as_deref(), + reducer.as_ref(), + |wl| &counters[wl], + ); } if let Some(tx_metrics) = metrics_for_reader { - tx_metrics.report(None, reducer.as_ref(), |wl| &counters[wl]); + tx_metrics.report( + // If row updates are present, + // they will never belong to the reader transaction. + // Passing row updates here will most likely panic. + None, + reducer.as_ref(), + |wl| &counters[wl], + ); } } +/// The metrics recorder is a side channel that the main database thread forwards metrics to. +/// While we want to avoid unnecessary compute on the critical path, communicating with other +/// threads is not free, and for this case in particular waking a parked task is not free. +/// +/// Previously, each tx would send its metrics to the recorder task. As soon as the recorder +/// task `recv`d a message, it would update the counters and gauges, and immediately wait for +/// the next tx's message. This meant that the tx would need to be more expensive than the +/// recording of its metrics in order for the recorder task not to be parked on `recv` when +/// the tx would `send` its metrics. This would obviously never be the case, and so each `send` +/// would incur the overhead of waking the task. +/// +/// To mitigate this, we now record metrics, for potentially many transactions, periodically +/// every 5ms. const TX_METRICS_RECORDING_INTERVAL: std::time::Duration = std::time::Duration::from_millis(5); /// Spawns a task for recording transaction metrics. From d7db10ce677cf95da461083dac5c532b1fe1d64e Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Fri, 12 Jun 2026 11:11:30 +0530 Subject: [PATCH 12/25] Add engine as dev-depency of core --- crates/core/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 83f44967d6e..b9c21843e59 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -156,6 +156,7 @@ spacetimedb-lib = { path = "../lib", features = ["proptest", "test"] } spacetimedb-sats = { path = "../sats", features = ["proptest"] } spacetimedb-commitlog = { path = "../commitlog", features = ["test"] } spacetimedb-datastore = { path = "../datastore/", features = ["test"] } +spacetimedb-engine = { workspace = true, features = ["test"] } criterion.workspace = true # Also as dev-dependencies for use in _this_ crate's tests. From fbae25eae3017d0066ed829dfb5319d628b9987e Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Mon, 15 Jun 2026 15:22:56 +0530 Subject: [PATCH 13/25] fmt --- crates/engine/src/update.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/update.rs b/crates/engine/src/update.rs index 646b5cd8782..6d420714a88 100644 --- a/crates/engine/src/update.rs +++ b/crates/engine/src/update.rs @@ -351,7 +351,10 @@ pub fn create_table_from_def( #[cfg(test)] mod test { use super::*; - use crate::relational_db::{open_snapshot_repo, tests_utils::{begin_mut_tx, insert, TestDB}}; + use crate::relational_db::{ + open_snapshot_repo, + tests_utils::{begin_mut_tx, insert, TestDB}, + }; use spacetimedb_datastore::locking_tx_datastore::PendingSchemaChange; use spacetimedb_datastore::system_tables::ST_EVENT_TABLE_ID; use spacetimedb_lib::{ From f44bb80b19ea1777ae15cb0d50e4ddd1fd145698 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Mon, 15 Jun 2026 15:49:15 +0530 Subject: [PATCH 14/25] merge ops --- crates/core/src/subscription/mod.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/crates/core/src/subscription/mod.rs b/crates/core/src/subscription/mod.rs index 856c9be3039..ec06dfc6f58 100644 --- a/crates/core/src/subscription/mod.rs +++ b/crates/core/src/subscription/mod.rs @@ -27,14 +27,7 @@ pub mod subscription; pub mod tx; pub mod websocket_building; -/// Execute a subscription query over a view. -/// -/// Specifically this utility is for queries that return rows from a view. -/// Unlike user tables, views have internal columns that should not be returned to clients. -/// The [`ViewProject`] operator implicitly drops these columns as part of its execution. -/// -/// NOTE: This method was largely copied from [`execute_plan`]. -/// TODO: Merge with [`execute_plan`]. +/// Execute subscription query fragments over a view. pub fn execute_plan_for_view<'p, F>( plan_fragments: impl IntoIterator, num_cols: usize, From b51456e5a5107787d7ac8bec3c1cc13cf2fe0ee0 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Tue, 16 Jun 2026 19:10:23 +0530 Subject: [PATCH 15/25] init --- Cargo.lock | 10 +++ Cargo.toml | 2 +- crates/dst/Cargo.toml | 14 ++++ crates/dst/README.md | 0 crates/dst/src/core.rs | 66 ++++++++++++++++ crates/dst/src/main.rs | 116 +++++++++++++++++++++++++++++ crates/dst/src/source/table_ops.rs | 7 ++ 7 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 crates/dst/Cargo.toml create mode 100644 crates/dst/README.md create mode 100644 crates/dst/src/core.rs create mode 100644 crates/dst/src/main.rs create mode 100644 crates/dst/src/source/table_ops.rs diff --git a/Cargo.lock b/Cargo.lock index 8768ccdba62..29d90b4db4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8205,6 +8205,16 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "spacetimedb-dst" +version = "2.5.0" +dependencies = [ + "anyhow", + "clap 4.5.50", + "spacetimedb-runtime", + "tracing", +] + [[package]] name = "spacetimedb-durability" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index e7929a9a010..ffc5854f1cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,7 +75,7 @@ members = [ "crates/bindings-typescript/test-app/server", "crates/bindings-typescript/test-react-router-app/server", "crates/bindings-typescript/test-solid-router/server", - "crates/query-builder", + "crates/query-builder", "crates/dst", ] default-members = ["crates/cli", "crates/standalone", "crates/update"] # cargo feature graph resolver. v3 is default in edition2024 but workspace diff --git a/crates/dst/Cargo.toml b/crates/dst/Cargo.toml new file mode 100644 index 00000000000..6fd37fc11f5 --- /dev/null +++ b/crates/dst/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "spacetimedb-dst" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +anyhow.workspace = true +clap.workspace = true +spacetimedb-runtime = { path = "../runtime/",default-features = false, features = ["simulation"]} +tracing.workspace = true + +[lints] +workspace = true diff --git a/crates/dst/README.md b/crates/dst/README.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/crates/dst/src/core.rs b/crates/dst/src/core.rs new file mode 100644 index 00000000000..e2ee220b225 --- /dev/null +++ b/crates/dst/src/core.rs @@ -0,0 +1,66 @@ +use anyhow::Error; + +pub trait TargetDriver { + type Observation; + type Outcome; + + fn execute(&mut self, interaction: &I) -> Result; + + fn finish(&mut self) -> Result; +} + +pub trait Source { + type Interaction; + + fn next_interaction(&mut self) -> Option; +} + +pub trait Properties { + fn observe(&mut self, interaction: &I, observation: &O) -> Result<(), Error>; + + fn finish(&mut self) -> Result<(), Error> { + Ok(()) + } +} + +pub trait TestSuite { + const NAME: &'static str; + + type Interaction; + type Source: Source; + type Target: TargetDriver; + type Properties: Properties>::Observation>; + + fn build(&self) -> Result, Error> + where + Self: Sized; +} + +pub struct TestRun +where + S: TestSuite, +{ + pub source: S::Source, + pub target: S::Target, + pub properties: S::Properties, +} + +pub fn run_test(suite: S) -> Result<>::Outcome, Error> +where + S: TestSuite, + S::Interaction: Clone, +{ + let TestRun { + mut source, + mut target, + mut properties, + } = suite.build()?; + + while let Some(interaction) = source.next_interaction() { + let observation = target.execute(&interaction)?; + properties.observe(&interaction, &observation)?; + } + + properties.finish()?; + target.finish() +} diff --git a/crates/dst/src/main.rs b/crates/dst/src/main.rs new file mode 100644 index 00000000000..e7293c2de7d --- /dev/null +++ b/crates/dst/src/main.rs @@ -0,0 +1,116 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use clap::{Args, Parser, Subcommand}; +use spacetimedb_runtime::sim::Runtime; + +mod core; +mod target; + +#[derive(Parser, Debug)] +#[command(name = "spacetimedb-dst")] +#[command(about = "Run deterministic simulation targets")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + Run(RunArgs), +} + +#[derive(Args, Debug)] +struct RunArgs { + #[arg(long, default_value = Engine::NAME, help = "Target to run.")] + target: String, + #[arg(long, help = "Seed for generated choices. Defaults to wall-clock time.")] + seed: Option, + #[arg(long, help = "Deterministic interaction budget. Preferred for replayable failures.")] + max_interactions: Option, +} + +fn main() -> anyhow::Result<()> { + init_tracing(); + match Cli::parse().command { + Command::Run(args) => run_command(args), + } +} + +fn init_tracing() {} + +fn run_command(args: RunArgs) -> anyhow::Result<()> { + //resolve_target(&args.target)?; + let seed = resolve_seed(args.seed); + let config = RunConfig { + max_interactions: args.max_interactions, + seed, + }; + + run_prepared_target::(config) +} + +fn run_prepared_target(config: RunConfig) -> anyhow::Result<()> +where + T: Target + 'static, +{ + T::prepare(&config)?; + std::thread::spawn(move || { + let mut runtime = Runtime::new(config.seed); + runtime.block_on(run_target::(config)) + }) + .join() + .unwrap_or_else(|payload| std::panic::resume_unwind(payload)) +} + +fn resolve_seed(seed: Option) -> u64 { + seed.unwrap_or_else(|| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") + .as_nanos() as u64 + }) +} + +//fn resolve_target(target: &str) -> anyhow::Result<()> { +// if target == RelationalDbCommitlogDescriptor::NAME { +// Ok(()) +// } else { +// anyhow::bail!( +// "unsupported target: {target}; expected: {}", +// RelationalDbCommitlogDescriptor::NAME +// ) +// } +//} +// +// +async fn run_target(config: RunConfig) -> anyhow::Result<()> { + let line = T::run_streaming(config).await?; + println!("{line}"); + Ok(()) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RunConfig { + /// Hard cap on generated interactions. `None` means no interaction budget. + /// + /// This is the preferred budget for exact seed replay: the same target, + /// scenario, seed, max-interactions value, and fault profile should produce + /// the same generated interaction stream. + pub max_interactions: Option, + + pub seed: u64, +} + +struct Engine; + +impl Target for Engine { + const NAME: &'static str = "engine"; + + fn prepare(config: &RunConfig) { + todo!() + } + + fn run_streaming(config: RunConfig) { + todo!() + } +} diff --git a/crates/dst/src/source/table_ops.rs b/crates/dst/src/source/table_ops.rs new file mode 100644 index 00000000000..ac5f76a708e --- /dev/null +++ b/crates/dst/src/source/table_ops.rs @@ -0,0 +1,7 @@ +type Row = AlgebraicValue; + +enum RowOps { + Insert(TableId, Row), + Delete(TableId, Row), + Update(TableId, Row, Row), +} From 10030d02ad2eedbdba8da92db81d74acdb0d7cb5 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Tue, 16 Jun 2026 22:15:44 +0530 Subject: [PATCH 16/25] schema generation --- crates/dst/Cargo.toml | 10 +- crates/dst/README.md | 21 ++ crates/dst/src/core.rs | 12 ++ crates/dst/src/main.rs | 131 ++++++----- crates/dst/src/source/mod.rs | 3 + crates/dst/src/source/schema.rs | 323 ++++++++++++++++++++++++++++ crates/dst/src/source/schema_gen.rs | 235 ++++++++++++++++++++ crates/dst/src/source/table_ops.rs | 194 ++++++++++++++++- 8 files changed, 865 insertions(+), 64 deletions(-) create mode 100644 crates/dst/src/source/mod.rs create mode 100644 crates/dst/src/source/schema.rs create mode 100644 crates/dst/src/source/schema_gen.rs diff --git a/crates/dst/Cargo.toml b/crates/dst/Cargo.toml index 6fd37fc11f5..45e239d3696 100644 --- a/crates/dst/Cargo.toml +++ b/crates/dst/Cargo.toml @@ -7,7 +7,15 @@ rust-version.workspace = true [dependencies] anyhow.workspace = true clap.workspace = true -spacetimedb-runtime = { path = "../runtime/",default-features = false, features = ["simulation"]} +spacetimedb-datastore = { path = "../datastore", default-features = false, features = ["simulation"] } +spacetimedb-durability = { path = "../durability", default-features = false, features = ["simulation"] } +spacetimedb-engine = { path = "../engine", default-features = false, features = ["simulation"] } +spacetimedb-lib.workspace = true +spacetimedb-primitives.workspace = true +spacetimedb-runtime = { path = "../runtime/", default-features = false, features = ["simulation"] } +spacetimedb-sats.workspace = true +spacetimedb-schema.workspace = true +spacetimedb-table = { path = "../table", default-features = false } tracing.workspace = true [lints] diff --git a/crates/dst/README.md b/crates/dst/README.md index e69de29bb2d..f24923d88f1 100644 --- a/crates/dst/README.md +++ b/crates/dst/README.md @@ -0,0 +1,21 @@ +# SpacetimeDB DST + +Deterministic Simulation Testing framework for SpacetimeDB. + +## Test + +```sh +cargo test -p spacetimedb-dst +``` + +## Run + +```sh +cargo run -p spacetimedb-dst -- run --seed 42 --tables 5 +``` + +Options: + +- `--seed ` — RNG seed (defaults to wall-clock nanos) +- `--tables ` — number of tables to generate (default 3) +- `--max-interactions ` — interaction budget diff --git a/crates/dst/src/core.rs b/crates/dst/src/core.rs index e2ee220b225..ef498b35d1c 100644 --- a/crates/dst/src/core.rs +++ b/crates/dst/src/core.rs @@ -1,5 +1,17 @@ use anyhow::Error; +pub trait Target { + const NAME: &'static str; + + fn prepare(config: &crate::RunConfig) -> Result<(), Error> + where + Self: Sized; + + fn run_streaming(config: crate::RunConfig) -> Result + where + Self: Sized; +} + pub trait TargetDriver { type Observation; type Outcome; diff --git a/crates/dst/src/main.rs b/crates/dst/src/main.rs index e7293c2de7d..4984aceb1a9 100644 --- a/crates/dst/src/main.rs +++ b/crates/dst/src/main.rs @@ -1,10 +1,15 @@ use std::time::{SystemTime, UNIX_EPOCH}; use clap::{Args, Parser, Subcommand}; -use spacetimedb_runtime::sim::Runtime; +use spacetimedb_runtime::sim::Rng; -mod core; -mod target; +pub mod core; +pub mod source; +pub mod target; + +use source::schema_gen::{SchemaGenerator, SchemaProfile}; +use source::table_ops::{Interaction, InteractionGen, Model}; +use target::engine::EngineTarget; #[derive(Parser, Debug)] #[command(name = "spacetimedb-dst")] @@ -21,11 +26,9 @@ enum Command { #[derive(Args, Debug)] struct RunArgs { - #[arg(long, default_value = Engine::NAME, help = "Target to run.")] - target: String, #[arg(long, help = "Seed for generated choices. Defaults to wall-clock time.")] seed: Option, - #[arg(long, help = "Deterministic interaction budget. Preferred for replayable failures.")] + #[arg(long, help = "Deterministic interaction budget.")] max_interactions: Option, } @@ -39,27 +42,77 @@ fn main() -> anyhow::Result<()> { fn init_tracing() {} fn run_command(args: RunArgs) -> anyhow::Result<()> { - //resolve_target(&args.target)?; let seed = resolve_seed(args.seed); let config = RunConfig { max_interactions: args.max_interactions, seed, }; - run_prepared_target::(config) -} + eprintln!("seed: {}", config.seed); -fn run_prepared_target(config: RunConfig) -> anyhow::Result<()> -where - T: Target + 'static, -{ - T::prepare(&config)?; - std::thread::spawn(move || { - let mut runtime = Runtime::new(config.seed); - runtime.block_on(run_target::(config)) - }) - .join() - .unwrap_or_else(|payload| std::panic::resume_unwind(payload)) + // Generate schema from seed. + let rng = Rng::new(config.seed); + let schema = SchemaGenerator::new(&rng, SchemaProfile::default()).gen_schema(); + + eprintln!("generated {} tables:", schema.tables.len()); + for table in &schema.tables { + eprintln!(" {} ({} columns)", table.name, table.columns.len()); + } + + // Open engine and create tables. + let engine = EngineTarget::prepare(&config, schema.clone())?; + eprintln!("engine ready"); + + // Generate and execute interactions. + let budget = config.max_interactions.unwrap_or(100); + let source = InteractionGen::new(&rng, &schema); + let mut model = Model::new(&schema); + + let mut inserts = 0u64; + let mut deletes = 0u64; + let mut counts = 0u64; + + for _ in 0..budget { + let ix = source.next_interaction(&model); + let expected = model.apply(&ix); + let got = engine.execute(&ix).unwrap(); + assert_eq!(expected, got, "model mismatch"); + match &ix { + Interaction::Insert { .. } => inserts += 1, + Interaction::Delete { .. } => deletes += 1, + Interaction::Count { .. } => counts += 1, + } + } + + eprintln!("done: {inserts} inserts, {deletes} deletes, {counts} counts, {budget} total"); + + // Final verification: model row counts match engine. + for (i, table) in schema.tables.iter().enumerate() { + let table_id = engine.db().with_auto_commit( + spacetimedb_datastore::execution_context::Workload::Internal, + |tx| { + engine + .db() + .table_id_from_name_mut(tx, &table.name) + .map(|t| t.unwrap()) + }, + )?; + let actual = engine.db().with_auto_commit( + spacetimedb_datastore::execution_context::Workload::Internal, + |tx| engine.db().iter_mut(tx, table_id).map(|it| it.count() as u64), + )?; + assert_eq!( + model.row_count(i), + actual, + "table '{}': model={} engine={}", + table.name, + model.row_count(i), + actual, + ); + } + eprintln!("model consistency verified"); + + Ok(()) } fn resolve_seed(seed: Option) -> u64 { @@ -71,46 +124,8 @@ fn resolve_seed(seed: Option) -> u64 { }) } -//fn resolve_target(target: &str) -> anyhow::Result<()> { -// if target == RelationalDbCommitlogDescriptor::NAME { -// Ok(()) -// } else { -// anyhow::bail!( -// "unsupported target: {target}; expected: {}", -// RelationalDbCommitlogDescriptor::NAME -// ) -// } -//} -// -// -async fn run_target(config: RunConfig) -> anyhow::Result<()> { - let line = T::run_streaming(config).await?; - println!("{line}"); - Ok(()) -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct RunConfig { - /// Hard cap on generated interactions. `None` means no interaction budget. - /// - /// This is the preferred budget for exact seed replay: the same target, - /// scenario, seed, max-interactions value, and fault profile should produce - /// the same generated interaction stream. pub max_interactions: Option, - pub seed: u64, } - -struct Engine; - -impl Target for Engine { - const NAME: &'static str = "engine"; - - fn prepare(config: &RunConfig) { - todo!() - } - - fn run_streaming(config: RunConfig) { - todo!() - } -} diff --git a/crates/dst/src/source/mod.rs b/crates/dst/src/source/mod.rs new file mode 100644 index 00000000000..c6370fb5049 --- /dev/null +++ b/crates/dst/src/source/mod.rs @@ -0,0 +1,3 @@ +pub mod schema; +pub mod schema_gen; +pub mod table_ops; diff --git a/crates/dst/src/source/schema.rs b/crates/dst/src/source/schema.rs new file mode 100644 index 00000000000..c01623a5f70 --- /dev/null +++ b/crates/dst/src/source/schema.rs @@ -0,0 +1,323 @@ +//! Custom schema types for DST table/index definitions. +//! +//! These types are the canonical source of truth for generated schemas. +//! They lower into [`RawModuleDefV10`] via [`lower_schema`]. + +use spacetimedb_lib::db::raw_def::v10::*; +use spacetimedb_lib::db::raw_def::v9::{RawIndexAlgorithm, TableAccess, TableType}; +use spacetimedb_primitives::{ColId, ColList}; +use spacetimedb_sats::{AlgebraicType, AlgebraicValue, ArrayType, ArrayValue, ProductType, ProductTypeElement}; + +// --------------------------------------------------------------------------- +// Column types — closed set, expand deliberately. +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Type { + Bool, + I64, + U64, + String, + Bytes, +} + +impl Type { + pub const ALL: &'static [Type] = &[ + Type::Bool, + Type::I64, + Type::U64, + Type::String, + Type::Bytes, + ]; + + pub fn to_algebraic(self) -> AlgebraicType { + match self { + Type::Bool => AlgebraicType::Bool, + Type::I64 => AlgebraicType::I64, + Type::U64 => AlgebraicType::U64, + Type::String => AlgebraicType::String, + Type::Bytes => AlgebraicType::Array(ArrayType { + elem_ty: Box::new(AlgebraicType::U8), + }), + } + } + + pub fn is_integral(self) -> bool { + matches!(self, Type::I64 | Type::U64) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Value { + Bool(bool), + I64(i64), + U64(u64), + String(String), + Bytes(Vec), +} + +impl Value { + pub fn type_of(&self) -> Type { + match self { + Value::Bool(_) => Type::Bool, + Value::I64(_) => Type::I64, + Value::U64(_) => Type::U64, + Value::String(_) => Type::String, + Value::Bytes(_) => Type::Bytes, + } + } + + fn to_algebraic(&self) -> AlgebraicValue { + match self { + Value::Bool(b) => AlgebraicValue::Bool(*b), + Value::I64(v) => AlgebraicValue::I64(*v), + Value::U64(v) => AlgebraicValue::U64(*v), + Value::String(s) => AlgebraicValue::String(s.clone().into()), + Value::Bytes(b) => AlgebraicValue::Array(ArrayValue::U8(b.clone().into())), + } + } +} + +// --------------------------------------------------------------------------- +// Schema plan — the canonical source of truth. +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct SchemaPlan { + pub tables: Vec, +} + +#[derive(Debug, Clone)] +pub struct TablePlan { + pub name: String, + pub columns: Vec, + pub primary_key: Option, + pub indexes: Vec, + pub unique_constraints: Vec, + pub sequences: Vec, + pub default_values: Vec, + pub is_event: bool, + pub is_public: bool, +} + +#[derive(Debug, Clone)] +pub struct ColumnPlan { + pub name: String, + pub ty: Type, +} + +#[derive(Debug, Clone)] +pub struct IndexPlan { + /// Indices into `TablePlan.columns`. + pub columns: Vec, + pub algorithm: IndexAlgorithm, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IndexAlgorithm { + BTree, + Hash, +} + +#[derive(Debug, Clone)] +pub struct UniqueConstraintPlan { + /// Indices into `TablePlan.columns`. Non-empty. + pub columns: Vec, +} + +/// A sequence on a specific column. The column's type is carried inline +/// so callers cannot create a sequence on a non-integral column — +/// the constructor requires `ty.is_integral()`. +#[derive(Debug, Clone)] +pub struct SequencePlan { + /// Index into `TablePlan.columns`. + pub column: usize, + /// The type of that column. Must be integral (I64 or U64). + pub ty: Type, + pub start: Option, + pub min_value: Option, + pub max_value: Option, + pub increment: i128, +} + +impl SequencePlan { + /// Create a sequence plan. Returns `None` if the type is not integral. + pub fn new(column: usize, ty: Type) -> Option { + if !ty.is_integral() { + return None; + } + Some(Self { + column, + ty, + start: None, + min_value: None, + max_value: None, + increment: 1, + }) + } +} + +#[derive(Debug, Clone)] +pub struct DefaultPlan { + /// Index into `TablePlan.columns`. + pub column: usize, + pub value: Value, +} + +// --------------------------------------------------------------------------- +// Lowering into RawModuleDefV10. +// --------------------------------------------------------------------------- + +pub fn lower_schema(schema: &SchemaPlan) -> RawModuleDefV10 { + let mut builder = RawModuleDefV10Builder::new(); + builder.set_case_conversion_policy(CaseConversionPolicy::None); + + for table in &schema.tables { + lower_table(&mut builder, table); + } + + builder.finish() +} + +fn lower_table(builder: &mut RawModuleDefV10Builder, table: &TablePlan) { + let product_type = ProductType { + elements: table + .columns + .iter() + .map(|col| ProductTypeElement { + name: Some(col.name.clone().into()), + algebraic_type: col.ty.to_algebraic(), + }) + .collect(), + }; + + let mut tbl = builder.build_table_with_new_type(table.name.clone(), product_type, false); + + tbl = tbl.with_type(TableType::User); + tbl = tbl.with_access(if table.is_public { + TableAccess::Public + } else { + TableAccess::Private + }); + tbl = tbl.with_event(table.is_event); + + // Primary key. + if let Some(pk) = table.primary_key { + tbl = tbl.with_primary_key(ColId(pk as u16)); + } + + // Unique constraints — all of them, including PK-matching. + for constraint in &table.unique_constraints { + let col_list: ColList = constraint + .columns + .iter() + .map(|&c| ColId(c as u16)) + .collect(); + tbl = tbl.with_unique_constraint(col_list); + } + + // Indexes. + for index in &table.indexes { + let col_list: ColList = index + .columns + .iter() + .map(|&c| ColId(c as u16)) + .collect(); + + let algorithm = match index.algorithm { + IndexAlgorithm::BTree => RawIndexAlgorithm::BTree { columns: col_list }, + IndexAlgorithm::Hash => RawIndexAlgorithm::Hash { columns: col_list }, + }; + + let source_name = format!( + "{}_{}_idx", + table.name, + index + .columns + .iter() + .map(|&c| table.columns[c].name.as_str()) + .collect::>() + .join("_") + ); + + tbl = tbl.with_index_no_accessor_name(algorithm, source_name); + } + + // Sequences — all of them. + for seq in &table.sequences { + tbl = tbl.with_column_sequence(ColId(seq.column as u16)); + } + + // Default values. + for default in &table.default_values { + let algebraic_val = default.value.to_algebraic(); + tbl = tbl.with_default_column_value(ColId(default.column as u16), algebraic_val); + } + + tbl.finish(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lower_single_table() { + let schema = SchemaPlan { + tables: vec![TablePlan { + name: "users".into(), + columns: vec![ + ColumnPlan { name: "id".into(), ty: Type::U64 }, + ColumnPlan { name: "name".into(), ty: Type::String }, + ColumnPlan { name: "score".into(), ty: Type::I64 }, + ], + primary_key: Some(0), + indexes: vec![IndexPlan { + columns: vec![2], + algorithm: IndexAlgorithm::BTree, + }], + unique_constraints: vec![UniqueConstraintPlan { + columns: vec![0], + }], + sequences: vec![SequencePlan::new(0, Type::U64).unwrap()], + default_values: vec![], + is_event: false, + is_public: true, + }], + }; + + let raw = lower_schema(&schema); + + // Should have Typespace, Types, and Tables sections. + assert!(raw.typespace().is_some()); + assert!(raw.types().is_some()); + let tables = raw.tables().unwrap(); + assert_eq!(tables.len(), 1); + + let t = &tables[0]; + assert_eq!(t.source_name.as_ref(), "users"); + assert_eq!(t.table_type, TableType::User); + assert_eq!(t.table_access, TableAccess::Public); + assert!(!t.is_event); + assert_eq!(t.primary_key.len(), 1); + assert_eq!(t.indexes.len(), 1); + assert_eq!(t.sequences.len(), 1); + } + + #[test] + fn sequence_rejects_non_integral() { + assert!(SequencePlan::new(0, Type::Bool).is_none()); + assert!(SequencePlan::new(0, Type::String).is_none()); + assert!(SequencePlan::new(0, Type::Bytes).is_none()); + assert!(SequencePlan::new(0, Type::I64).is_some()); + assert!(SequencePlan::new(0, Type::U64).is_some()); + } + + #[test] + fn type_roundtrip() { + for ty in Type::ALL { + // Every DST type should roundtrip through AlgebraicType. + let _ = ty.to_algebraic(); + } + } +} diff --git a/crates/dst/src/source/schema_gen.rs b/crates/dst/src/source/schema_gen.rs new file mode 100644 index 00000000000..3e8bdc966ed --- /dev/null +++ b/crates/dst/src/source/schema_gen.rs @@ -0,0 +1,235 @@ +//! Seed-based random schema generation. + +use spacetimedb_runtime::sim::Rng; + +use super::schema::*; + +/// Controls the shape of generated schemas. +#[derive(Debug, Clone)] +pub struct SchemaProfile { + pub table_count: (usize, usize), + pub columns: (usize, usize), + pub pk_prob: f64, + pub auto_inc_prob: f64, + pub indexes: (usize, usize), + pub unique_constraints: (usize, usize), + pub btree_prob: f64, + pub event_prob: f64, + pub private_prob: f64, +} + +impl Default for SchemaProfile { + fn default() -> Self { + Self { + table_count: (2, 5), + columns: (1, 8), + pk_prob: 0.7, + auto_inc_prob: 0.8, + indexes: (0, 3), + unique_constraints: (0, 2), + btree_prob: 0.7, + event_prob: 0.1, + private_prob: 0.1, + } + } +} + +pub struct SchemaGenerator<'a> { + rng: &'a Rng, + profile: SchemaProfile, +} + +impl<'a> SchemaGenerator<'a> { + pub fn new(rng: &'a Rng, profile: SchemaProfile) -> Self { + Self { rng, profile } + } + + fn range(&self, (lo, hi): (usize, usize)) -> usize { + if lo >= hi { + return lo; + } + lo + (self.rng.next_u64() as usize % (hi - lo + 1)) + } + + fn gen_type(&self) -> Type { + Type::ALL[self.rng.index(Type::ALL.len())] + } + + fn gen_columns(&self) -> Vec { + let n = self.range(self.profile.columns); + (0..n) + .map(|i| ColumnPlan { + name: format!("col_{i}"), + ty: self.gen_type(), + }) + .collect() + } + + fn gen_unique_constraints( + &self, + columns: &[ColumnPlan], + pk: &Option, + ) -> Vec { + let n = self.range(self.profile.unique_constraints); + let mut seen: Vec> = Vec::new(); + let mut result = Vec::new(); + for _ in 0..n { + let num_cols = 1 + self.rng.index(columns.len().min(3)); + let mut cols: Vec = (0..num_cols) + .map(|_| self.rng.index(columns.len())) + .collect(); + cols.sort(); + cols.dedup(); + if !cols.is_empty() && !seen.contains(&cols) { + seen.push(cols.clone()); + result.push(UniqueConstraintPlan { columns: cols }); + } + } + // Ensure PK has a matching unique constraint. + if let Some(pk) = pk { + if !seen.contains(&vec![*pk]) { + result.push(UniqueConstraintPlan { + columns: vec![*pk], + }); + } + } + result + } + + fn gen_indexes( + &self, + columns: &[ColumnPlan], + unique_constraints: &[UniqueConstraintPlan], + pk: &Option, + ) -> Vec { + // Every unique constraint and PK needs a matching index. + let mut seen_cols: Vec> = Vec::new(); + let mut indexes: Vec = Vec::new(); + + // Index for PK. + if let Some(pk) = pk { + seen_cols.push(vec![*pk]); + indexes.push(IndexPlan { + columns: vec![*pk], + algorithm: IndexAlgorithm::BTree, + }); + } + + // Indexes for unique constraints. + for constraint in unique_constraints { + if seen_cols.contains(&constraint.columns) { + continue; + } + seen_cols.push(constraint.columns.clone()); + indexes.push(IndexPlan { + columns: constraint.columns.clone(), + algorithm: IndexAlgorithm::BTree, + }); + } + + // Additional random indexes. + let n = self.range(self.profile.indexes); + for _ in 0..n { + let num_cols = 1 + self.rng.index(columns.len().min(3)); + let mut cols: Vec = (0..num_cols) + .map(|_| self.rng.index(columns.len())) + .collect(); + cols.sort(); + cols.dedup(); + if cols.is_empty() || seen_cols.contains(&cols) { + continue; + } + seen_cols.push(cols.clone()); + let algorithm = if self.rng.sample_probability(self.profile.btree_prob) { + IndexAlgorithm::BTree + } else { + IndexAlgorithm::Hash + }; + indexes.push(IndexPlan { + columns: cols, + algorithm, + }); + } + + indexes + } + + fn gen_table(&self, _table_index: usize) -> TablePlan { + let columns = self.gen_columns(); + + let primary_key = if self.rng.sample_probability(self.profile.pk_prob) && !columns.is_empty() + { + Some(self.rng.index(columns.len())) + } else { + None + }; + + let unique_constraints = self.gen_unique_constraints(&columns, &primary_key); + + let sequences = if let Some(pk) = primary_key { + if columns[pk].ty.is_integral() + && self.rng.sample_probability(self.profile.auto_inc_prob) + { + SequencePlan::new(pk, columns[pk].ty).into_iter().collect() + } else { + vec![] + } + } else { + vec![] + }; + + let indexes = self.gen_indexes(&columns, &unique_constraints, &primary_key); + + // Generate a name from the RNG so different seeds produce different names. + let name = format!("tbl_{}", self.rng.next_u64()); + + TablePlan { + name, + columns, + primary_key, + indexes, + unique_constraints, + sequences, + default_values: vec![], + is_event: self.rng.sample_probability(self.profile.event_prob), + is_public: !self.rng.sample_probability(self.profile.private_prob), + } + } + + pub fn gen_schema(&self) -> SchemaPlan { + let table_count = self.range(self.profile.table_count); + let tables = (0..table_count) + .map(|i| self.gen_table(i)) + .collect(); + SchemaPlan { tables } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use spacetimedb_runtime::sim::Rng; + + #[test] + fn deterministic_from_seed() { + let rng1 = Rng::new(42); + let rng2 = Rng::new(42); + let s1 = SchemaGenerator::new(&rng1, SchemaProfile::default()).gen_schema(); + let s2 = SchemaGenerator::new(&rng2, SchemaProfile::default()).gen_schema(); + assert_eq!(s1.tables.len(), s2.tables.len()); + for (a, b) in s1.tables.iter().zip(s2.tables.iter()) { + assert_eq!(a.name, b.name); + assert_eq!(a.columns.len(), b.columns.len()); + } + } + + #[test] + fn different_seeds_differ() { + let rng1 = Rng::new(1); + let rng2 = Rng::new(2); + let s1 = SchemaGenerator::new(&rng1, SchemaProfile::default()).gen_schema(); + let s2 = SchemaGenerator::new(&rng2, SchemaProfile::default()).gen_schema(); + // At least one table name should differ. + assert_ne!(s1.tables[0].name, s2.tables[0].name); + } +} diff --git a/crates/dst/src/source/table_ops.rs b/crates/dst/src/source/table_ops.rs index ac5f76a708e..5c45bf0050a 100644 --- a/crates/dst/src/source/table_ops.rs +++ b/crates/dst/src/source/table_ops.rs @@ -1,7 +1,191 @@ -type Row = AlgebraicValue; +use spacetimedb_lib::bsatn::to_vec; +use spacetimedb_lib::{AlgebraicValue, ProductValue}; +use spacetimedb_runtime::sim::Rng; -enum RowOps { - Insert(TableId, Row), - Delete(TableId, Row), - Update(TableId, Row, Row), +use super::schema::{SchemaPlan, TablePlan, Type}; + +/// A row is a product value aligned to a table's columns. +pub type Row = ProductValue; + +/// A single interaction against the database. +#[derive(Debug, Clone)] +pub enum Interaction { + Insert { table: usize, row: Row }, + Delete { table: usize, row_index: usize }, + Count { table: usize }, +} + +/// Observation returned by executing an interaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Observation { + Inserted { count_after: u64 }, + Deleted { count_after: u64 }, + Counted { count: u64 }, +} + +/// Model stores all rows per table — the ground truth. +#[derive(Debug)] +pub struct Model { + rows: Vec>, +} + +impl Model { + pub fn new(schema: &SchemaPlan) -> Self { + Self { + rows: schema.tables.iter().map(|_| Vec::new()).collect(), + } + } + + pub fn apply(&mut self, interaction: &Interaction) -> Observation { + match interaction { + Interaction::Insert { table, row } => { + self.rows[*table].push(row.clone()); + Observation::Inserted { + count_after: self.rows[*table].len() as u64, + } + } + Interaction::Delete { table, row_index } => { + if *row_index < self.rows[*table].len() { + self.rows[*table].remove(*row_index); + } + Observation::Deleted { + count_after: self.rows[*table].len() as u64, + } + } + Interaction::Count { table } => Observation::Counted { + count: self.rows[*table].len() as u64, + }, + } + } + + pub fn row_count(&self, table: usize) -> u64 { + self.rows[table].len() as u64 + } + + pub fn rows(&self, table: usize) -> &[Row] { + &self.rows[table] + } +} + +/// Generates random interactions from a schema plan. +pub struct InteractionGen<'a> { + rng: &'a Rng, + schema: &'a SchemaPlan, +} + +impl<'a> InteractionGen<'a> { + pub fn new(rng: &'a Rng, schema: &'a SchemaPlan) -> Self { + Self { rng, schema } + } + + fn gen_value(&self, ty: Type) -> AlgebraicValue { + match ty { + Type::Bool => AlgebraicValue::Bool(self.rng.next_u64() % 2 == 0), + Type::I64 => AlgebraicValue::I64(self.rng.next_u64() as i64), + Type::U64 => AlgebraicValue::U64(self.rng.next_u64()), + Type::String => { + AlgebraicValue::String(format!("v_{}", self.rng.next_u64()).into()) + } + Type::Bytes => { + let len = (self.rng.next_u64() % 16) as usize; + let bytes: Vec = (0..len).map(|_| self.rng.next_u64() as u8).collect(); + AlgebraicValue::Array(ArrayValue::U8(bytes.into())) + } + } + } + + fn gen_row(&self, table: &TablePlan) -> Row { + table + .columns + .iter() + .map(|c| self.gen_value(c.ty)) + .collect::() + } + + /// Generate an interaction given the current model state. + pub fn next_interaction(&self, model: &Model) -> Interaction { + let table_idx = self.rng.index(self.schema.tables.len()); + + // ~60% insert, ~20% delete (if rows exist), ~20% count + let coin = self.rng.next_u64() % 10; + if coin < 6 { + Interaction::Insert { + table: table_idx, + row: self.gen_row(&self.schema.tables[table_idx]), + } + } else if coin < 8 && !model.rows(table_idx).is_empty() { + let row_index = self.rng.index(model.rows(table_idx).len()); + Interaction::Delete { + table: table_idx, + row_index, + } + } else { + Interaction::Count { table: table_idx } + } + } +} + +use spacetimedb_sats::ArrayValue; + +/// Serialize a row to BSATN bytes for the engine insert API. +pub fn row_to_bytes(row: &Row) -> Vec { + to_vec(row).expect("row serialization must not fail") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn model_tracks_rows() { + let schema = SchemaPlan { + tables: vec![TablePlan { + name: "t".into(), + columns: vec![], + primary_key: None, + indexes: vec![], + unique_constraints: vec![], + sequences: vec![], + default_values: vec![], + is_event: false, + is_public: true, + }], + }; + let mut model = Model::new(&schema); + + let obs = model.apply(&Interaction::Insert { + table: 0, + row: ProductValue::default(), + }); + assert_eq!(obs, Observation::Inserted { count_after: 1 }); + assert_eq!(model.row_count(0), 1); + + let obs = model.apply(&Interaction::Delete { + table: 0, + row_index: 0, + }); + assert_eq!(obs, Observation::Deleted { count_after: 0 }); + assert_eq!(model.row_count(0), 0); + } + + #[test] + fn gen_produces_valid_interactions() { + use spacetimedb_runtime::sim::Rng; + let rng = Rng::new(42); + let schema = super::super::schema_gen::SchemaGenerator::new( + &rng, + super::super::schema_gen::SchemaProfile::default(), + ) + .gen_schema(); + let model = Model::new(&schema); + let source = InteractionGen::new(&rng, &schema); + for _ in 0..100 { + let ix = source.next_interaction(&model); + match ix { + Interaction::Insert { table, .. } => assert!(table < schema.tables.len()), + Interaction::Delete { table, .. } => assert!(table < schema.tables.len()), + Interaction::Count { table } => assert!(table < schema.tables.len()), + } + } + } } From 2cbd77df3065b49e8f7f2bffdda0ec1ed9863020 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Thu, 18 Jun 2026 14:24:54 +0530 Subject: [PATCH 17/25] restructure --- crates/dst/src/core.rs | 78 ----- crates/dst/src/engine.rs | 146 +++++++++ crates/dst/src/engine/properties.rs | 15 + crates/dst/src/engine/workload.rs | 170 ++++++++++ crates/dst/src/main.rs | 71 +--- crates/dst/src/schema.rs | 492 ++++++++++++++++++++++++++++ crates/dst/src/source/mod.rs | 3 - crates/dst/src/source/schema.rs | 323 ------------------ crates/dst/src/source/schema_gen.rs | 235 ------------- crates/dst/src/source/table_ops.rs | 191 ----------- crates/dst/src/traits.rs | 38 +++ 11 files changed, 867 insertions(+), 895 deletions(-) delete mode 100644 crates/dst/src/core.rs create mode 100644 crates/dst/src/engine.rs create mode 100644 crates/dst/src/engine/properties.rs create mode 100644 crates/dst/src/engine/workload.rs create mode 100644 crates/dst/src/schema.rs delete mode 100644 crates/dst/src/source/mod.rs delete mode 100644 crates/dst/src/source/schema.rs delete mode 100644 crates/dst/src/source/schema_gen.rs delete mode 100644 crates/dst/src/source/table_ops.rs create mode 100644 crates/dst/src/traits.rs diff --git a/crates/dst/src/core.rs b/crates/dst/src/core.rs deleted file mode 100644 index ef498b35d1c..00000000000 --- a/crates/dst/src/core.rs +++ /dev/null @@ -1,78 +0,0 @@ -use anyhow::Error; - -pub trait Target { - const NAME: &'static str; - - fn prepare(config: &crate::RunConfig) -> Result<(), Error> - where - Self: Sized; - - fn run_streaming(config: crate::RunConfig) -> Result - where - Self: Sized; -} - -pub trait TargetDriver { - type Observation; - type Outcome; - - fn execute(&mut self, interaction: &I) -> Result; - - fn finish(&mut self) -> Result; -} - -pub trait Source { - type Interaction; - - fn next_interaction(&mut self) -> Option; -} - -pub trait Properties { - fn observe(&mut self, interaction: &I, observation: &O) -> Result<(), Error>; - - fn finish(&mut self) -> Result<(), Error> { - Ok(()) - } -} - -pub trait TestSuite { - const NAME: &'static str; - - type Interaction; - type Source: Source; - type Target: TargetDriver; - type Properties: Properties>::Observation>; - - fn build(&self) -> Result, Error> - where - Self: Sized; -} - -pub struct TestRun -where - S: TestSuite, -{ - pub source: S::Source, - pub target: S::Target, - pub properties: S::Properties, -} - -pub fn run_test(suite: S) -> Result<>::Outcome, Error> -where - S: TestSuite, - S::Interaction: Clone, -{ - let TestRun { - mut source, - mut target, - mut properties, - } = suite.build()?; - - while let Some(interaction) = source.next_interaction() { - let observation = target.execute(&interaction)?; - properties.observe(&interaction, &observation)?; - } - - properties.finish()?; - target.finish() -} diff --git a/crates/dst/src/engine.rs b/crates/dst/src/engine.rs new file mode 100644 index 00000000000..cbe18c0b9aa --- /dev/null +++ b/crates/dst/src/engine.rs @@ -0,0 +1,146 @@ +use spacetimedb_datastore::execution_context::Workload; +use spacetimedb_durability::EmptyHistory; +use spacetimedb_engine::error::DBError; +use spacetimedb_engine::relational_db::RelationalDB; +use spacetimedb_lib::RawModuleDef; +use spacetimedb_primitives::TableId; +use spacetimedb_runtime::sim::Rng; +use spacetimedb_schema::def::ModuleDef; +use spacetimedb_schema::schema::{Schema, TableSchema}; +use spacetimedb_table::page_pool::PagePool; + +mod properties; +mod workload; + +use self::workload::{row_to_bytes, Interaction, Observation}; + +use crate::engine::properties::EngineProperties; +use crate::engine::workload::{Model, WorkloadGen}; +use crate::schema::{default_schema, lower_schema, SchemaPlan}; +use crate::traits::{TargetDriver, TestSuite}; +pub struct EngineTarget { + db: RelationalDB, + schema: SchemaPlan, + table_ids: Vec, +} + +impl EngineTarget { + pub fn init(schema: SchemaPlan) -> Result { + let history = EmptyHistory::new(); + let (db, _) = RelationalDB::open( + spacetimedb_lib::Identity::ZERO, + spacetimedb_lib::Identity::ZERO, + history, + None, + None, + PagePool::new_for_test(), + )?; + + let raw = lower_schema(&schema); + let raw_module_def = RawModuleDef::V10(raw); + let module_def = + ModuleDef::try_from(raw_module_def).map_err(|e| anyhow::anyhow!("schema validation failed: {e}"))?; + + db.with_auto_commit(Workload::Internal, |tx| -> Result<(), DBError> { + for table_def in module_def.tables() { + let tbl_schema = TableSchema::from_module_def(&module_def, table_def, (), TableId::SENTINEL); + db.create_table(tx, tbl_schema)?; + } + Ok(()) + })?; + + let mut table_ids = Vec::with_capacity(schema.tables.len()); + db.with_auto_commit(Workload::Internal, |tx| -> Result<(), DBError> { + for table_plan in &schema.tables { + let id = db + .table_id_from_name_mut(tx, &table_plan.name)? + .ok_or_else(|| anyhow::anyhow!("table '{}' not found after creation", table_plan.name))?; + table_ids.push(id); + } + Ok(()) + })?; + + Ok(Self { db, schema, table_ids }) + } + + pub fn execute(&self, interaction: &Interaction) -> Result { + match interaction { + Interaction::Insert { table, row } => { + let table_id = self.table_ids[*table]; + let bytes = row_to_bytes(row); + let count_after = self + .db + .with_auto_commit(Workload::Internal, |tx| -> Result { + match self.db.insert(tx, table_id, &bytes) { + Ok(_) => {} + Err(_) => { + let cnt = self.db.iter_mut(tx, table_id)?.collect::>().len() as u64; + return Ok(cnt); + } + } + let cnt = self.db.iter_mut(tx, table_id)?.collect::>().len() as u64; + Ok(cnt) + })?; + Ok(Observation::Inserted { count_after }) + } + Interaction::Delete { table, row } => { + let table_id = self.table_ids[*table]; + let count_after = self + .db + .with_auto_commit(Workload::Internal, |tx| -> Result { + self.db.delete_by_rel(tx, table_id, [row.clone()]); + let cnt = self.db.iter_mut(tx, table_id)?.count() as u64; + Ok(cnt) + })?; + Ok(Observation::Deleted { count_after }) + } + Interaction::Count { table } => { + let table_id = self.table_ids[*table]; + let count = self.db.with_auto_commit(Workload::Internal, |tx| { + self.db.iter_mut(tx, table_id).map(|it| it.count() as u64) + })?; + Ok(Observation::Counted { count }) + } + } + } + + pub fn db(&self) -> &RelationalDB { + &self.db + } + + pub fn schema(&self) -> &SchemaPlan { + &self.schema + } +} +pub struct Outcome; +impl TargetDriver for EngineTarget { + type Observation = Observation; + + type Outcome = Outcome; + + fn execute(&mut self, interaction: &Interaction) -> Result { + self.execute(interaction) + } +} +pub struct EngineTest; + +impl TestSuite for EngineTest { + type Interaction = Interaction; + + type Interactions = WorkloadGen; + + type Target = EngineTarget; + + type Properties = EngineProperties; + + fn build(&self, rng: Rng) -> Result<(Self::Interactions, Self::Target, Self::Properties), anyhow::Error> { + let schema = default_schema(rng.clone()); + let target = EngineTarget::init(schema.clone())?; + let properties = EngineProperties {}; + + let model = Model::new(schema); + let interactions = WorkloadGen::new(rng, model); + + Ok((interactions, target, properties)) + } +} diff --git a/crates/dst/src/engine/properties.rs b/crates/dst/src/engine/properties.rs new file mode 100644 index 00000000000..5596f75a4fb --- /dev/null +++ b/crates/dst/src/engine/properties.rs @@ -0,0 +1,15 @@ +use super::workload::{Interaction, Observation}; +use crate::traits::Properties; + +pub struct EngineProperties; + +impl Properties for EngineProperties { + fn observe(&mut self, interaction: &Interaction, observation: &Observation) -> Result<(), anyhow::Error> { + Ok(()) + // match interaction { + // Interaction::Insert { table, row } => todo!(), + // Interaction::Delete { table, row } => todo!(), + // Interaction::Count { table } => todo!(), + // } + } +} diff --git a/crates/dst/src/engine/workload.rs b/crates/dst/src/engine/workload.rs new file mode 100644 index 00000000000..a8ebd0fd0f2 --- /dev/null +++ b/crates/dst/src/engine/workload.rs @@ -0,0 +1,170 @@ +use spacetimedb_lib::bsatn::to_vec; +use spacetimedb_lib::{AlgebraicValue, ProductValue}; +use spacetimedb_runtime::sim::Rng; + +use crate::schema::{SchemaPlan, TablePlan, Type}; + +pub type Row = ProductValue; + +#[derive(Debug, Clone)] +pub enum Interaction { + Insert { table: usize, row: Row }, + Delete { table: usize, row: Row }, + Count { table: usize }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Observation { + Inserted { count_after: u64 }, + Deleted { count_after: u64 }, + Counted { count: u64 }, +} + +#[derive(Debug)] +pub struct Model { + schema: SchemaPlan, + tables: Vec, +} + +#[derive(Debug)] +struct TableState { + rows: Vec, +} + +impl Model { + pub fn new(schema: SchemaPlan) -> Self { + let tables = schema.tables.iter().map(|_| TableState { rows: vec![] }).collect(); + Self { schema, tables } + } + + fn violates_unique_constraint(&self, table: usize, row: &Row) -> bool { + let table_plan = &self.schema.tables[table]; + let rows = &self.tables[table].rows; + for constraint in &table_plan.unique_constraints { + if rows + .iter() + .any(|r| constraint.columns.iter().all(|&c| r.elements[c] == row.elements[c])) + { + return true; + } + } + false + } + + pub fn apply(&mut self, interaction: &Interaction) -> Observation { + match interaction { + Interaction::Insert { table, row } => { + let table_plan = &self.schema.tables[*table]; + + if self.violates_unique_constraint(*table, row) || self.tables[*table].rows.contains(row) { + return Observation::Inserted { + count_after: self.tables[*table].rows.len() as u64, + }; + } + + let rows = &mut self.tables[*table].rows; + if let Some(pk_col) = table_plan.primary_key { + if let Some(pos) = rows.iter().position(|r| r.elements[pk_col] == row.elements[pk_col]) { + rows[pos] = row.clone(); + return Observation::Inserted { + count_after: rows.len() as u64, + }; + } + } + rows.push(row.clone()); + Observation::Inserted { + count_after: rows.len() as u64, + } + } + Interaction::Delete { table, row } => { + let rows = &mut self.tables[*table].rows; + rows.retain(|r| r != row); + Observation::Deleted { + count_after: rows.len() as u64, + } + } + Interaction::Count { table } => Observation::Counted { + count: self.tables[*table].rows.len() as u64, + }, + } + } + + pub fn row_count(&self, table: usize) -> u64 { + self.tables[table].rows.len() as u64 + } + + pub fn rows(&self, table: usize) -> &[Row] { + &self.tables[table].rows + } +} + +pub struct WorkloadGen { + rng: Rng, + model: Model, +} + +impl WorkloadGen { + pub fn new(rng: Rng, model: Model) -> Self { + Self { rng, model } + } + + fn schema(&self) -> &SchemaPlan { + &self.model.schema + } + + fn gen_value(&self, ty: Type) -> AlgebraicValue { + match ty { + Type::Bool => AlgebraicValue::Bool(self.rng.next_u64() % 2 == 0), + Type::I64 => AlgebraicValue::I64(self.rng.next_u64() as i64), + Type::U64 => AlgebraicValue::U64(self.rng.next_u64()), + Type::String => AlgebraicValue::String(format!("v_{}", self.rng.next_u64()).into()), + Type::Bytes => { + let len = (self.rng.next_u64() % 16) as usize; + let bytes: Vec = (0..len).map(|_| self.rng.next_u64() as u8).collect(); + AlgebraicValue::Array(ArrayValue::U8(bytes.into())) + } + } + } + + fn gen_row(&self, table: &TablePlan) -> Row { + table + .columns + .iter() + .map(|c| self.gen_value(c.ty)) + .collect::() + } + + pub fn next_interaction(&mut self) -> Interaction { + let model = &self.model; + let table_idx = self.rng.index(self.schema().tables.len()); + + let coin = self.rng.next_u64() % 10; + if coin < 6 { + Interaction::Insert { + table: table_idx, + row: self.gen_row(&self.schema().tables[table_idx]), + } + } else if coin < 8 && !model.rows(table_idx).is_empty() { + let rows = model.rows(table_idx); + let row_index = self.rng.index(rows.len()); + Interaction::Delete { + table: table_idx, + row: rows[row_index].clone(), + } + } else { + Interaction::Count { table: table_idx } + } + } +} +impl Iterator for WorkloadGen { + type Item = Interaction; + fn next(&mut self) -> Option { + Some(self.next_interaction()) + } +} + +use spacetimedb_sats::ArrayValue; + +pub fn row_to_bytes(row: &Row) -> Vec { + to_vec(row).expect("row serialization must not fail") +} diff --git a/crates/dst/src/main.rs b/crates/dst/src/main.rs index 4984aceb1a9..75b67e6c098 100644 --- a/crates/dst/src/main.rs +++ b/crates/dst/src/main.rs @@ -3,13 +3,11 @@ use std::time::{SystemTime, UNIX_EPOCH}; use clap::{Args, Parser, Subcommand}; use spacetimedb_runtime::sim::Rng; -pub mod core; -pub mod source; -pub mod target; +mod engine; +mod schema; +mod traits; -use source::schema_gen::{SchemaGenerator, SchemaProfile}; -use source::table_ops::{Interaction, InteractionGen, Model}; -use target::engine::EngineTarget; +use crate::{engine::EngineTest, traits::TestSuite}; #[derive(Parser, Debug)] #[command(name = "spacetimedb-dst")] @@ -52,66 +50,9 @@ fn run_command(args: RunArgs) -> anyhow::Result<()> { // Generate schema from seed. let rng = Rng::new(config.seed); - let schema = SchemaGenerator::new(&rng, SchemaProfile::default()).gen_schema(); - - eprintln!("generated {} tables:", schema.tables.len()); - for table in &schema.tables { - eprintln!(" {} ({} columns)", table.name, table.columns.len()); - } - - // Open engine and create tables. - let engine = EngineTarget::prepare(&config, schema.clone())?; - eprintln!("engine ready"); - - // Generate and execute interactions. - let budget = config.max_interactions.unwrap_or(100); - let source = InteractionGen::new(&rng, &schema); - let mut model = Model::new(&schema); - - let mut inserts = 0u64; - let mut deletes = 0u64; - let mut counts = 0u64; - - for _ in 0..budget { - let ix = source.next_interaction(&model); - let expected = model.apply(&ix); - let got = engine.execute(&ix).unwrap(); - assert_eq!(expected, got, "model mismatch"); - match &ix { - Interaction::Insert { .. } => inserts += 1, - Interaction::Delete { .. } => deletes += 1, - Interaction::Count { .. } => counts += 1, - } - } - - eprintln!("done: {inserts} inserts, {deletes} deletes, {counts} counts, {budget} total"); - - // Final verification: model row counts match engine. - for (i, table) in schema.tables.iter().enumerate() { - let table_id = engine.db().with_auto_commit( - spacetimedb_datastore::execution_context::Workload::Internal, - |tx| { - engine - .db() - .table_id_from_name_mut(tx, &table.name) - .map(|t| t.unwrap()) - }, - )?; - let actual = engine.db().with_auto_commit( - spacetimedb_datastore::execution_context::Workload::Internal, - |tx| engine.db().iter_mut(tx, table_id).map(|it| it.count() as u64), - )?; - assert_eq!( - model.row_count(i), - actual, - "table '{}': model={} engine={}", - table.name, - model.row_count(i), - actual, - ); - } - eprintln!("model consistency verified"); + let test = EngineTest {}; + test.run(rng)?; Ok(()) } diff --git a/crates/dst/src/schema.rs b/crates/dst/src/schema.rs new file mode 100644 index 00000000000..27acaae1693 --- /dev/null +++ b/crates/dst/src/schema.rs @@ -0,0 +1,492 @@ +use spacetimedb_lib::db::raw_def::v10::*; +use spacetimedb_lib::db::raw_def::v9::{RawIndexAlgorithm, TableAccess, TableType}; +use spacetimedb_primitives::{ColId, ColList}; +use spacetimedb_runtime::sim::Rng; +use spacetimedb_sats::{AlgebraicType, AlgebraicValue, ArrayType, ArrayValue, ProductType, ProductTypeElement}; + +pub fn default_schema(rng: Rng) -> SchemaPlan { + let profile = SchemaProfile::default(); + let plan = SchemaGenerator::new(rng, profile).gen_schema(); + plan +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Type { + Bool, + I64, + U64, + String, + Bytes, +} + +impl Type { + pub const ALL: &'static [Type] = &[Type::Bool, Type::I64, Type::U64, Type::String, Type::Bytes]; + + pub fn to_algebraic(self) -> AlgebraicType { + match self { + Type::Bool => AlgebraicType::Bool, + Type::I64 => AlgebraicType::I64, + Type::U64 => AlgebraicType::U64, + Type::String => AlgebraicType::String, + Type::Bytes => AlgebraicType::Array(ArrayType { + elem_ty: Box::new(AlgebraicType::U8), + }), + } + } + + pub fn is_integral(self) -> bool { + matches!(self, Type::I64 | Type::U64) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Value { + Bool(bool), + I64(i64), + U64(u64), + String(String), + Bytes(Vec), +} + +impl Value { + fn to_algebraic(&self) -> AlgebraicValue { + match self { + Value::Bool(b) => AlgebraicValue::Bool(*b), + Value::I64(v) => AlgebraicValue::I64(*v), + Value::U64(v) => AlgebraicValue::U64(*v), + Value::String(s) => AlgebraicValue::String(s.clone().into()), + Value::Bytes(b) => AlgebraicValue::Array(ArrayValue::U8(b.clone().into())), + } + } +} + +// Schema plan — the canonical source of truth. +// This Schema should be able to translate to valid `RawModuleDefV10`. +#[derive(Debug, Clone)] +pub struct SchemaPlan { + pub tables: Vec, +} + +impl SchemaPlan { + fn new(rng: Rng) { + let profile = SchemaProfile::default(); + let schema = SchemaGenerator::new(rng, profile).gen_schema(); + } +} + +#[derive(Debug, Clone)] +pub struct TablePlan { + pub name: String, + pub columns: Vec, + pub primary_key: Option, + pub indexes: Vec, + pub unique_constraints: Vec, + pub sequences: Vec, + pub default_values: Vec, + pub is_public: bool, +} + +#[derive(Debug, Clone)] +pub struct ColumnPlan { + pub name: String, + pub ty: Type, +} + +#[derive(Debug, Clone)] +pub struct IndexPlan { + /// Indices into `TablePlan.columns`. + pub columns: Vec, + pub algorithm: IndexAlgorithm, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IndexAlgorithm { + BTree, + Hash, +} + +#[derive(Debug, Clone)] +pub struct UniqueConstraintPlan { + /// Indices into `TablePlan.columns`. Non-empty. + pub columns: Vec, +} + +/// A sequence on a specific column. The column's type is carried inline +/// so callers cannot create a sequence on a non-integral column — +/// the constructor requires `ty.is_integral()`. +#[derive(Debug, Clone)] +pub struct SequencePlan { + /// Index into `TablePlan.columns`. + pub column: usize, + /// The type of that column. Must be integral (I64 or U64). + pub ty: Type, + pub start: Option, + pub min_value: Option, + pub max_value: Option, + pub increment: i128, +} + +impl SequencePlan { + /// Create a sequence plan. Returns `None` if the type is not integral. + pub fn new(column: usize, ty: Type) -> Option { + if !ty.is_integral() { + return None; + } + Some(Self { + column, + ty, + start: None, + min_value: None, + max_value: None, + increment: 1, + }) + } +} + +#[derive(Debug, Clone)] +pub struct DefaultPlan { + /// Index into `TablePlan.columns`. + pub column: usize, + pub value: Value, +} + +// Lowering into RawModuleDefV10. +pub fn lower_schema(schema: &SchemaPlan) -> RawModuleDefV10 { + let mut builder = RawModuleDefV10Builder::new(); + builder.set_case_conversion_policy(CaseConversionPolicy::None); + + for table in &schema.tables { + lower_table(&mut builder, table); + } + + builder.finish() +} + +fn lower_table(builder: &mut RawModuleDefV10Builder, table: &TablePlan) { + let product_type = ProductType { + elements: table + .columns + .iter() + .map(|col| ProductTypeElement { + name: Some(col.name.clone().into()), + algebraic_type: col.ty.to_algebraic(), + }) + .collect(), + }; + + let mut tbl = builder.build_table_with_new_type(table.name.clone(), product_type, true); + + tbl = tbl.with_type(TableType::User); + tbl = tbl.with_access(if table.is_public { + TableAccess::Public + } else { + TableAccess::Private + }); + // Primary key. + if let Some(pk) = table.primary_key { + tbl = tbl.with_primary_key(ColId(pk as u16)); + } + + // Unique constraints — all of them, including PK-matching. + for constraint in &table.unique_constraints { + let col_list: ColList = constraint.columns.iter().map(|&c| ColId(c as u16)).collect(); + tbl = tbl.with_unique_constraint(col_list); + } + + // Indexes. + for index in &table.indexes { + let col_list: ColList = index.columns.iter().map(|&c| ColId(c as u16)).collect(); + + let algorithm = match index.algorithm { + IndexAlgorithm::BTree => RawIndexAlgorithm::BTree { columns: col_list }, + IndexAlgorithm::Hash => RawIndexAlgorithm::Hash { columns: col_list }, + }; + + let source_name = format!( + "{}_{}_idx", + table.name, + index + .columns + .iter() + .map(|&c| table.columns[c].name.as_str()) + .collect::>() + .join("_") + ); + + tbl = tbl.with_index_no_accessor_name(algorithm, source_name); + } + + // Sequences — all of them. + for seq in &table.sequences { + tbl = tbl.with_column_sequence(ColId(seq.column as u16)); + } + + // Default values. + for default in &table.default_values { + let algebraic_val = default.value.to_algebraic(); + tbl = tbl.with_default_column_value(ColId(default.column as u16), algebraic_val); + } + + tbl.finish(); +} + +/// Controls the shape of generated schemas. +#[derive(Debug, Clone)] +pub struct SchemaProfile { + pub table_count: (usize, usize), + pub columns: (usize, usize), + pub pk_prob: f64, + pub auto_inc_prob: f64, + pub indexes: (usize, usize), + pub unique_constraints: (usize, usize), + pub btree_prob: f64, + pub private_prob: f64, +} + +impl Default for SchemaProfile { + fn default() -> Self { + Self { + table_count: (1, 100), + columns: (1, 10), + pk_prob: 0.7, + auto_inc_prob: 0.3, + indexes: (0, 3), + unique_constraints: (0, 2), + btree_prob: 0.7, + private_prob: 0.1, + } + } +} + +pub struct SchemaGenerator { + rng: Rng, + profile: SchemaProfile, +} + +impl SchemaGenerator { + pub fn new(rng: Rng, profile: SchemaProfile) -> Self { + Self { rng, profile } + } + + fn range(&self, (lo, hi): (usize, usize)) -> usize { + if lo >= hi { + return lo; + } + lo + (self.rng.next_u64() as usize % (hi - lo + 1)) + } + + fn gen_type(&self) -> Type { + Type::ALL[self.rng.index(Type::ALL.len())] + } + + fn gen_columns(&self) -> Vec { + let n = self.range(self.profile.columns); + let mut names = Vec::with_capacity(n); + let mut seen = Vec::with_capacity(n); + for _ in 0..n { + let name = self.gen_column_name(&seen); + seen.push(name.clone()); + names.push(ColumnPlan { + name, + ty: self.gen_type(), + }); + } + names + } + + fn gen_ident(&self) -> String { + const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789_"; + const FIRST: &[u8] = b"abcdefghijklmnopqrstuvwxyz_"; + let len = 4 + (self.rng.next_u64() as usize % 12); + let mut s = String::with_capacity(len); + s.push(FIRST[self.rng.index(FIRST.len())] as char); + for _ in 1..len { + s.push(CHARS[self.rng.index(CHARS.len())] as char); + } + s + } + + fn gen_column_name(&self, seen: &[String]) -> String { + loop { + let name = self.gen_ident(); + if !seen.contains(&name) { + return name; + } + } + } + + fn gen_unique_constraints(&self, columns: &[ColumnPlan], pk: &Option) -> Vec { + let n = self.range(self.profile.unique_constraints); + let mut seen: Vec> = Vec::new(); + let mut result = Vec::new(); + for _ in 0..n { + let num_cols = 1 + self.rng.index(columns.len().min(3)); + let mut cols: Vec = (0..num_cols).map(|_| self.rng.index(columns.len())).collect(); + cols.sort(); + cols.dedup(); + if !cols.is_empty() && !seen.contains(&cols) { + seen.push(cols.clone()); + result.push(UniqueConstraintPlan { columns: cols }); + } + } + // Ensure PK has a matching unique constraint. + if let Some(pk) = pk { + if !seen.contains(&vec![*pk]) { + result.push(UniqueConstraintPlan { columns: vec![*pk] }); + } + } + result + } + + fn gen_indexes( + &self, + columns: &[ColumnPlan], + unique_constraints: &[UniqueConstraintPlan], + pk: &Option, + ) -> Vec { + // Every unique constraint and PK needs a matching index. + let mut seen_cols: Vec> = Vec::new(); + let mut indexes: Vec = Vec::new(); + + // Index for PK. + if let Some(pk) = pk { + seen_cols.push(vec![*pk]); + indexes.push(IndexPlan { + columns: vec![*pk], + algorithm: IndexAlgorithm::BTree, + }); + } + + // Indexes for unique constraints. + for constraint in unique_constraints { + if seen_cols.contains(&constraint.columns) { + continue; + } + seen_cols.push(constraint.columns.clone()); + indexes.push(IndexPlan { + columns: constraint.columns.clone(), + algorithm: IndexAlgorithm::BTree, + }); + } + + // Additional random indexes. + let n = self.range(self.profile.indexes); + for _ in 0..n { + let num_cols = 1 + self.rng.index(columns.len().min(3)); + let mut cols: Vec = (0..num_cols).map(|_| self.rng.index(columns.len())).collect(); + cols.sort(); + cols.dedup(); + if cols.is_empty() || seen_cols.contains(&cols) { + continue; + } + seen_cols.push(cols.clone()); + let algorithm = if self.rng.sample_probability(self.profile.btree_prob) { + IndexAlgorithm::BTree + } else { + IndexAlgorithm::Hash + }; + indexes.push(IndexPlan { + columns: cols, + algorithm, + }); + } + + indexes + } + + fn gen_table(&self, _table_index: usize) -> TablePlan { + let columns = self.gen_columns(); + + let primary_key = if self.rng.sample_probability(self.profile.pk_prob) && !columns.is_empty() { + Some(self.rng.index(columns.len())) + } else { + None + }; + + let unique_constraints = self.gen_unique_constraints(&columns, &primary_key); + + let sequences = if let Some(pk) = primary_key { + if columns[pk].ty.is_integral() && self.rng.sample_probability(self.profile.auto_inc_prob) { + SequencePlan::new(pk, columns[pk].ty).into_iter().collect() + } else { + vec![] + } + } else { + vec![] + }; + + let indexes = self.gen_indexes(&columns, &unique_constraints, &primary_key); + + let name = format!("tbl_{}", self.gen_ident()); + + TablePlan { + name, + columns, + primary_key, + indexes, + unique_constraints, + sequences, + default_values: vec![], + is_public: !self.rng.sample_probability(self.profile.private_prob), + } + } + + pub fn gen_schema(&self) -> SchemaPlan { + let table_count = self.range(self.profile.table_count); + let tables = (0..table_count).map(|i| self.gen_table(i)).collect(); + SchemaPlan { tables } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lower_single_table() { + let schema = SchemaPlan { + tables: vec![TablePlan { + name: "users".into(), + columns: vec![ + ColumnPlan { + name: "id".into(), + ty: Type::U64, + }, + ColumnPlan { + name: "name".into(), + ty: Type::String, + }, + ColumnPlan { + name: "score".into(), + ty: Type::I64, + }, + ], + primary_key: Some(0), + indexes: vec![IndexPlan { + columns: vec![2], + algorithm: IndexAlgorithm::BTree, + }], + unique_constraints: vec![UniqueConstraintPlan { columns: vec![0] }], + sequences: vec![SequencePlan::new(0, Type::U64).unwrap()], + default_values: vec![], + is_public: true, + }], + }; + + let raw = lower_schema(&schema); + + // Should have Typespace, Types, and Tables sections. + assert!(raw.typespace().is_some()); + assert!(raw.types().is_some()); + let tables = raw.tables().unwrap(); + assert_eq!(tables.len(), 1); + + let t = &tables[0]; + assert_eq!(t.source_name.as_ref(), "users"); + assert_eq!(t.table_type, TableType::User); + assert_eq!(t.table_access, TableAccess::Public); + assert_eq!(t.primary_key.len(), 1); + assert_eq!(t.indexes.len(), 1); + assert_eq!(t.sequences.len(), 1); + } +} diff --git a/crates/dst/src/source/mod.rs b/crates/dst/src/source/mod.rs deleted file mode 100644 index c6370fb5049..00000000000 --- a/crates/dst/src/source/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod schema; -pub mod schema_gen; -pub mod table_ops; diff --git a/crates/dst/src/source/schema.rs b/crates/dst/src/source/schema.rs deleted file mode 100644 index c01623a5f70..00000000000 --- a/crates/dst/src/source/schema.rs +++ /dev/null @@ -1,323 +0,0 @@ -//! Custom schema types for DST table/index definitions. -//! -//! These types are the canonical source of truth for generated schemas. -//! They lower into [`RawModuleDefV10`] via [`lower_schema`]. - -use spacetimedb_lib::db::raw_def::v10::*; -use spacetimedb_lib::db::raw_def::v9::{RawIndexAlgorithm, TableAccess, TableType}; -use spacetimedb_primitives::{ColId, ColList}; -use spacetimedb_sats::{AlgebraicType, AlgebraicValue, ArrayType, ArrayValue, ProductType, ProductTypeElement}; - -// --------------------------------------------------------------------------- -// Column types — closed set, expand deliberately. -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum Type { - Bool, - I64, - U64, - String, - Bytes, -} - -impl Type { - pub const ALL: &'static [Type] = &[ - Type::Bool, - Type::I64, - Type::U64, - Type::String, - Type::Bytes, - ]; - - pub fn to_algebraic(self) -> AlgebraicType { - match self { - Type::Bool => AlgebraicType::Bool, - Type::I64 => AlgebraicType::I64, - Type::U64 => AlgebraicType::U64, - Type::String => AlgebraicType::String, - Type::Bytes => AlgebraicType::Array(ArrayType { - elem_ty: Box::new(AlgebraicType::U8), - }), - } - } - - pub fn is_integral(self) -> bool { - matches!(self, Type::I64 | Type::U64) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum Value { - Bool(bool), - I64(i64), - U64(u64), - String(String), - Bytes(Vec), -} - -impl Value { - pub fn type_of(&self) -> Type { - match self { - Value::Bool(_) => Type::Bool, - Value::I64(_) => Type::I64, - Value::U64(_) => Type::U64, - Value::String(_) => Type::String, - Value::Bytes(_) => Type::Bytes, - } - } - - fn to_algebraic(&self) -> AlgebraicValue { - match self { - Value::Bool(b) => AlgebraicValue::Bool(*b), - Value::I64(v) => AlgebraicValue::I64(*v), - Value::U64(v) => AlgebraicValue::U64(*v), - Value::String(s) => AlgebraicValue::String(s.clone().into()), - Value::Bytes(b) => AlgebraicValue::Array(ArrayValue::U8(b.clone().into())), - } - } -} - -// --------------------------------------------------------------------------- -// Schema plan — the canonical source of truth. -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone)] -pub struct SchemaPlan { - pub tables: Vec, -} - -#[derive(Debug, Clone)] -pub struct TablePlan { - pub name: String, - pub columns: Vec, - pub primary_key: Option, - pub indexes: Vec, - pub unique_constraints: Vec, - pub sequences: Vec, - pub default_values: Vec, - pub is_event: bool, - pub is_public: bool, -} - -#[derive(Debug, Clone)] -pub struct ColumnPlan { - pub name: String, - pub ty: Type, -} - -#[derive(Debug, Clone)] -pub struct IndexPlan { - /// Indices into `TablePlan.columns`. - pub columns: Vec, - pub algorithm: IndexAlgorithm, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum IndexAlgorithm { - BTree, - Hash, -} - -#[derive(Debug, Clone)] -pub struct UniqueConstraintPlan { - /// Indices into `TablePlan.columns`. Non-empty. - pub columns: Vec, -} - -/// A sequence on a specific column. The column's type is carried inline -/// so callers cannot create a sequence on a non-integral column — -/// the constructor requires `ty.is_integral()`. -#[derive(Debug, Clone)] -pub struct SequencePlan { - /// Index into `TablePlan.columns`. - pub column: usize, - /// The type of that column. Must be integral (I64 or U64). - pub ty: Type, - pub start: Option, - pub min_value: Option, - pub max_value: Option, - pub increment: i128, -} - -impl SequencePlan { - /// Create a sequence plan. Returns `None` if the type is not integral. - pub fn new(column: usize, ty: Type) -> Option { - if !ty.is_integral() { - return None; - } - Some(Self { - column, - ty, - start: None, - min_value: None, - max_value: None, - increment: 1, - }) - } -} - -#[derive(Debug, Clone)] -pub struct DefaultPlan { - /// Index into `TablePlan.columns`. - pub column: usize, - pub value: Value, -} - -// --------------------------------------------------------------------------- -// Lowering into RawModuleDefV10. -// --------------------------------------------------------------------------- - -pub fn lower_schema(schema: &SchemaPlan) -> RawModuleDefV10 { - let mut builder = RawModuleDefV10Builder::new(); - builder.set_case_conversion_policy(CaseConversionPolicy::None); - - for table in &schema.tables { - lower_table(&mut builder, table); - } - - builder.finish() -} - -fn lower_table(builder: &mut RawModuleDefV10Builder, table: &TablePlan) { - let product_type = ProductType { - elements: table - .columns - .iter() - .map(|col| ProductTypeElement { - name: Some(col.name.clone().into()), - algebraic_type: col.ty.to_algebraic(), - }) - .collect(), - }; - - let mut tbl = builder.build_table_with_new_type(table.name.clone(), product_type, false); - - tbl = tbl.with_type(TableType::User); - tbl = tbl.with_access(if table.is_public { - TableAccess::Public - } else { - TableAccess::Private - }); - tbl = tbl.with_event(table.is_event); - - // Primary key. - if let Some(pk) = table.primary_key { - tbl = tbl.with_primary_key(ColId(pk as u16)); - } - - // Unique constraints — all of them, including PK-matching. - for constraint in &table.unique_constraints { - let col_list: ColList = constraint - .columns - .iter() - .map(|&c| ColId(c as u16)) - .collect(); - tbl = tbl.with_unique_constraint(col_list); - } - - // Indexes. - for index in &table.indexes { - let col_list: ColList = index - .columns - .iter() - .map(|&c| ColId(c as u16)) - .collect(); - - let algorithm = match index.algorithm { - IndexAlgorithm::BTree => RawIndexAlgorithm::BTree { columns: col_list }, - IndexAlgorithm::Hash => RawIndexAlgorithm::Hash { columns: col_list }, - }; - - let source_name = format!( - "{}_{}_idx", - table.name, - index - .columns - .iter() - .map(|&c| table.columns[c].name.as_str()) - .collect::>() - .join("_") - ); - - tbl = tbl.with_index_no_accessor_name(algorithm, source_name); - } - - // Sequences — all of them. - for seq in &table.sequences { - tbl = tbl.with_column_sequence(ColId(seq.column as u16)); - } - - // Default values. - for default in &table.default_values { - let algebraic_val = default.value.to_algebraic(); - tbl = tbl.with_default_column_value(ColId(default.column as u16), algebraic_val); - } - - tbl.finish(); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn lower_single_table() { - let schema = SchemaPlan { - tables: vec![TablePlan { - name: "users".into(), - columns: vec![ - ColumnPlan { name: "id".into(), ty: Type::U64 }, - ColumnPlan { name: "name".into(), ty: Type::String }, - ColumnPlan { name: "score".into(), ty: Type::I64 }, - ], - primary_key: Some(0), - indexes: vec![IndexPlan { - columns: vec![2], - algorithm: IndexAlgorithm::BTree, - }], - unique_constraints: vec![UniqueConstraintPlan { - columns: vec![0], - }], - sequences: vec![SequencePlan::new(0, Type::U64).unwrap()], - default_values: vec![], - is_event: false, - is_public: true, - }], - }; - - let raw = lower_schema(&schema); - - // Should have Typespace, Types, and Tables sections. - assert!(raw.typespace().is_some()); - assert!(raw.types().is_some()); - let tables = raw.tables().unwrap(); - assert_eq!(tables.len(), 1); - - let t = &tables[0]; - assert_eq!(t.source_name.as_ref(), "users"); - assert_eq!(t.table_type, TableType::User); - assert_eq!(t.table_access, TableAccess::Public); - assert!(!t.is_event); - assert_eq!(t.primary_key.len(), 1); - assert_eq!(t.indexes.len(), 1); - assert_eq!(t.sequences.len(), 1); - } - - #[test] - fn sequence_rejects_non_integral() { - assert!(SequencePlan::new(0, Type::Bool).is_none()); - assert!(SequencePlan::new(0, Type::String).is_none()); - assert!(SequencePlan::new(0, Type::Bytes).is_none()); - assert!(SequencePlan::new(0, Type::I64).is_some()); - assert!(SequencePlan::new(0, Type::U64).is_some()); - } - - #[test] - fn type_roundtrip() { - for ty in Type::ALL { - // Every DST type should roundtrip through AlgebraicType. - let _ = ty.to_algebraic(); - } - } -} diff --git a/crates/dst/src/source/schema_gen.rs b/crates/dst/src/source/schema_gen.rs deleted file mode 100644 index 3e8bdc966ed..00000000000 --- a/crates/dst/src/source/schema_gen.rs +++ /dev/null @@ -1,235 +0,0 @@ -//! Seed-based random schema generation. - -use spacetimedb_runtime::sim::Rng; - -use super::schema::*; - -/// Controls the shape of generated schemas. -#[derive(Debug, Clone)] -pub struct SchemaProfile { - pub table_count: (usize, usize), - pub columns: (usize, usize), - pub pk_prob: f64, - pub auto_inc_prob: f64, - pub indexes: (usize, usize), - pub unique_constraints: (usize, usize), - pub btree_prob: f64, - pub event_prob: f64, - pub private_prob: f64, -} - -impl Default for SchemaProfile { - fn default() -> Self { - Self { - table_count: (2, 5), - columns: (1, 8), - pk_prob: 0.7, - auto_inc_prob: 0.8, - indexes: (0, 3), - unique_constraints: (0, 2), - btree_prob: 0.7, - event_prob: 0.1, - private_prob: 0.1, - } - } -} - -pub struct SchemaGenerator<'a> { - rng: &'a Rng, - profile: SchemaProfile, -} - -impl<'a> SchemaGenerator<'a> { - pub fn new(rng: &'a Rng, profile: SchemaProfile) -> Self { - Self { rng, profile } - } - - fn range(&self, (lo, hi): (usize, usize)) -> usize { - if lo >= hi { - return lo; - } - lo + (self.rng.next_u64() as usize % (hi - lo + 1)) - } - - fn gen_type(&self) -> Type { - Type::ALL[self.rng.index(Type::ALL.len())] - } - - fn gen_columns(&self) -> Vec { - let n = self.range(self.profile.columns); - (0..n) - .map(|i| ColumnPlan { - name: format!("col_{i}"), - ty: self.gen_type(), - }) - .collect() - } - - fn gen_unique_constraints( - &self, - columns: &[ColumnPlan], - pk: &Option, - ) -> Vec { - let n = self.range(self.profile.unique_constraints); - let mut seen: Vec> = Vec::new(); - let mut result = Vec::new(); - for _ in 0..n { - let num_cols = 1 + self.rng.index(columns.len().min(3)); - let mut cols: Vec = (0..num_cols) - .map(|_| self.rng.index(columns.len())) - .collect(); - cols.sort(); - cols.dedup(); - if !cols.is_empty() && !seen.contains(&cols) { - seen.push(cols.clone()); - result.push(UniqueConstraintPlan { columns: cols }); - } - } - // Ensure PK has a matching unique constraint. - if let Some(pk) = pk { - if !seen.contains(&vec![*pk]) { - result.push(UniqueConstraintPlan { - columns: vec![*pk], - }); - } - } - result - } - - fn gen_indexes( - &self, - columns: &[ColumnPlan], - unique_constraints: &[UniqueConstraintPlan], - pk: &Option, - ) -> Vec { - // Every unique constraint and PK needs a matching index. - let mut seen_cols: Vec> = Vec::new(); - let mut indexes: Vec = Vec::new(); - - // Index for PK. - if let Some(pk) = pk { - seen_cols.push(vec![*pk]); - indexes.push(IndexPlan { - columns: vec![*pk], - algorithm: IndexAlgorithm::BTree, - }); - } - - // Indexes for unique constraints. - for constraint in unique_constraints { - if seen_cols.contains(&constraint.columns) { - continue; - } - seen_cols.push(constraint.columns.clone()); - indexes.push(IndexPlan { - columns: constraint.columns.clone(), - algorithm: IndexAlgorithm::BTree, - }); - } - - // Additional random indexes. - let n = self.range(self.profile.indexes); - for _ in 0..n { - let num_cols = 1 + self.rng.index(columns.len().min(3)); - let mut cols: Vec = (0..num_cols) - .map(|_| self.rng.index(columns.len())) - .collect(); - cols.sort(); - cols.dedup(); - if cols.is_empty() || seen_cols.contains(&cols) { - continue; - } - seen_cols.push(cols.clone()); - let algorithm = if self.rng.sample_probability(self.profile.btree_prob) { - IndexAlgorithm::BTree - } else { - IndexAlgorithm::Hash - }; - indexes.push(IndexPlan { - columns: cols, - algorithm, - }); - } - - indexes - } - - fn gen_table(&self, _table_index: usize) -> TablePlan { - let columns = self.gen_columns(); - - let primary_key = if self.rng.sample_probability(self.profile.pk_prob) && !columns.is_empty() - { - Some(self.rng.index(columns.len())) - } else { - None - }; - - let unique_constraints = self.gen_unique_constraints(&columns, &primary_key); - - let sequences = if let Some(pk) = primary_key { - if columns[pk].ty.is_integral() - && self.rng.sample_probability(self.profile.auto_inc_prob) - { - SequencePlan::new(pk, columns[pk].ty).into_iter().collect() - } else { - vec![] - } - } else { - vec![] - }; - - let indexes = self.gen_indexes(&columns, &unique_constraints, &primary_key); - - // Generate a name from the RNG so different seeds produce different names. - let name = format!("tbl_{}", self.rng.next_u64()); - - TablePlan { - name, - columns, - primary_key, - indexes, - unique_constraints, - sequences, - default_values: vec![], - is_event: self.rng.sample_probability(self.profile.event_prob), - is_public: !self.rng.sample_probability(self.profile.private_prob), - } - } - - pub fn gen_schema(&self) -> SchemaPlan { - let table_count = self.range(self.profile.table_count); - let tables = (0..table_count) - .map(|i| self.gen_table(i)) - .collect(); - SchemaPlan { tables } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use spacetimedb_runtime::sim::Rng; - - #[test] - fn deterministic_from_seed() { - let rng1 = Rng::new(42); - let rng2 = Rng::new(42); - let s1 = SchemaGenerator::new(&rng1, SchemaProfile::default()).gen_schema(); - let s2 = SchemaGenerator::new(&rng2, SchemaProfile::default()).gen_schema(); - assert_eq!(s1.tables.len(), s2.tables.len()); - for (a, b) in s1.tables.iter().zip(s2.tables.iter()) { - assert_eq!(a.name, b.name); - assert_eq!(a.columns.len(), b.columns.len()); - } - } - - #[test] - fn different_seeds_differ() { - let rng1 = Rng::new(1); - let rng2 = Rng::new(2); - let s1 = SchemaGenerator::new(&rng1, SchemaProfile::default()).gen_schema(); - let s2 = SchemaGenerator::new(&rng2, SchemaProfile::default()).gen_schema(); - // At least one table name should differ. - assert_ne!(s1.tables[0].name, s2.tables[0].name); - } -} diff --git a/crates/dst/src/source/table_ops.rs b/crates/dst/src/source/table_ops.rs deleted file mode 100644 index 5c45bf0050a..00000000000 --- a/crates/dst/src/source/table_ops.rs +++ /dev/null @@ -1,191 +0,0 @@ -use spacetimedb_lib::bsatn::to_vec; -use spacetimedb_lib::{AlgebraicValue, ProductValue}; -use spacetimedb_runtime::sim::Rng; - -use super::schema::{SchemaPlan, TablePlan, Type}; - -/// A row is a product value aligned to a table's columns. -pub type Row = ProductValue; - -/// A single interaction against the database. -#[derive(Debug, Clone)] -pub enum Interaction { - Insert { table: usize, row: Row }, - Delete { table: usize, row_index: usize }, - Count { table: usize }, -} - -/// Observation returned by executing an interaction. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Observation { - Inserted { count_after: u64 }, - Deleted { count_after: u64 }, - Counted { count: u64 }, -} - -/// Model stores all rows per table — the ground truth. -#[derive(Debug)] -pub struct Model { - rows: Vec>, -} - -impl Model { - pub fn new(schema: &SchemaPlan) -> Self { - Self { - rows: schema.tables.iter().map(|_| Vec::new()).collect(), - } - } - - pub fn apply(&mut self, interaction: &Interaction) -> Observation { - match interaction { - Interaction::Insert { table, row } => { - self.rows[*table].push(row.clone()); - Observation::Inserted { - count_after: self.rows[*table].len() as u64, - } - } - Interaction::Delete { table, row_index } => { - if *row_index < self.rows[*table].len() { - self.rows[*table].remove(*row_index); - } - Observation::Deleted { - count_after: self.rows[*table].len() as u64, - } - } - Interaction::Count { table } => Observation::Counted { - count: self.rows[*table].len() as u64, - }, - } - } - - pub fn row_count(&self, table: usize) -> u64 { - self.rows[table].len() as u64 - } - - pub fn rows(&self, table: usize) -> &[Row] { - &self.rows[table] - } -} - -/// Generates random interactions from a schema plan. -pub struct InteractionGen<'a> { - rng: &'a Rng, - schema: &'a SchemaPlan, -} - -impl<'a> InteractionGen<'a> { - pub fn new(rng: &'a Rng, schema: &'a SchemaPlan) -> Self { - Self { rng, schema } - } - - fn gen_value(&self, ty: Type) -> AlgebraicValue { - match ty { - Type::Bool => AlgebraicValue::Bool(self.rng.next_u64() % 2 == 0), - Type::I64 => AlgebraicValue::I64(self.rng.next_u64() as i64), - Type::U64 => AlgebraicValue::U64(self.rng.next_u64()), - Type::String => { - AlgebraicValue::String(format!("v_{}", self.rng.next_u64()).into()) - } - Type::Bytes => { - let len = (self.rng.next_u64() % 16) as usize; - let bytes: Vec = (0..len).map(|_| self.rng.next_u64() as u8).collect(); - AlgebraicValue::Array(ArrayValue::U8(bytes.into())) - } - } - } - - fn gen_row(&self, table: &TablePlan) -> Row { - table - .columns - .iter() - .map(|c| self.gen_value(c.ty)) - .collect::() - } - - /// Generate an interaction given the current model state. - pub fn next_interaction(&self, model: &Model) -> Interaction { - let table_idx = self.rng.index(self.schema.tables.len()); - - // ~60% insert, ~20% delete (if rows exist), ~20% count - let coin = self.rng.next_u64() % 10; - if coin < 6 { - Interaction::Insert { - table: table_idx, - row: self.gen_row(&self.schema.tables[table_idx]), - } - } else if coin < 8 && !model.rows(table_idx).is_empty() { - let row_index = self.rng.index(model.rows(table_idx).len()); - Interaction::Delete { - table: table_idx, - row_index, - } - } else { - Interaction::Count { table: table_idx } - } - } -} - -use spacetimedb_sats::ArrayValue; - -/// Serialize a row to BSATN bytes for the engine insert API. -pub fn row_to_bytes(row: &Row) -> Vec { - to_vec(row).expect("row serialization must not fail") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn model_tracks_rows() { - let schema = SchemaPlan { - tables: vec![TablePlan { - name: "t".into(), - columns: vec![], - primary_key: None, - indexes: vec![], - unique_constraints: vec![], - sequences: vec![], - default_values: vec![], - is_event: false, - is_public: true, - }], - }; - let mut model = Model::new(&schema); - - let obs = model.apply(&Interaction::Insert { - table: 0, - row: ProductValue::default(), - }); - assert_eq!(obs, Observation::Inserted { count_after: 1 }); - assert_eq!(model.row_count(0), 1); - - let obs = model.apply(&Interaction::Delete { - table: 0, - row_index: 0, - }); - assert_eq!(obs, Observation::Deleted { count_after: 0 }); - assert_eq!(model.row_count(0), 0); - } - - #[test] - fn gen_produces_valid_interactions() { - use spacetimedb_runtime::sim::Rng; - let rng = Rng::new(42); - let schema = super::super::schema_gen::SchemaGenerator::new( - &rng, - super::super::schema_gen::SchemaProfile::default(), - ) - .gen_schema(); - let model = Model::new(&schema); - let source = InteractionGen::new(&rng, &schema); - for _ in 0..100 { - let ix = source.next_interaction(&model); - match ix { - Interaction::Insert { table, .. } => assert!(table < schema.tables.len()), - Interaction::Delete { table, .. } => assert!(table < schema.tables.len()), - Interaction::Count { table } => assert!(table < schema.tables.len()), - } - } - } -} diff --git a/crates/dst/src/traits.rs b/crates/dst/src/traits.rs new file mode 100644 index 00000000000..ee96159b48a --- /dev/null +++ b/crates/dst/src/traits.rs @@ -0,0 +1,38 @@ +use anyhow::Error; +use spacetimedb_runtime::sim::Rng; + +/// This should be implemented by System under test. +pub trait TargetDriver { + type Observation; + type Outcome; + + fn execute(&mut self, interaction: &I) -> Result; +} + +/// Ensures if Output of `TargetDrive` is expected for the input +pub trait Properties { + fn observe(&mut self, interaction: &I, observation: &O) -> Result<(), Error>; +} + +pub trait TestSuite { + type Interaction; + type Interactions: Iterator; + type Target: TargetDriver; + type Properties: Properties>::Observation>; + + fn build(&self, rng: Rng) -> Result<(Self::Interactions, Self::Target, Self::Properties), Error>; + + fn run(&self, rng: Rng) -> Result<(), Error> + where + Self: Sized, + { + let (interactions, mut target, mut properties) = self.build(rng)?; + + for interaction in interactions { + let observation = target.execute(&interaction)?; + properties.observe(&interaction, &observation)?; + } + + Ok(()) + } +} From cf12d66aa5896a4c90b3f60c98511f546fb9199c Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Thu, 18 Jun 2026 19:09:57 +0530 Subject: [PATCH 18/25] begin tx --- crates/dst/src/engine.rs | 74 ++++++++++++++++++++----------- crates/dst/src/engine/workload.rs | 74 +++++++++++++++++++++++-------- 2 files changed, 102 insertions(+), 46 deletions(-) diff --git a/crates/dst/src/engine.rs b/crates/dst/src/engine.rs index cbe18c0b9aa..7da2b27017e 100644 --- a/crates/dst/src/engine.rs +++ b/crates/dst/src/engine.rs @@ -1,7 +1,8 @@ use spacetimedb_datastore::execution_context::Workload; +use spacetimedb_datastore::traits::IsolationLevel; use spacetimedb_durability::EmptyHistory; use spacetimedb_engine::error::DBError; -use spacetimedb_engine::relational_db::RelationalDB; +use spacetimedb_engine::relational_db::{MutTx, RelationalDB}; use spacetimedb_lib::RawModuleDef; use spacetimedb_primitives::TableId; use spacetimedb_runtime::sim::Rng; @@ -22,6 +23,7 @@ pub struct EngineTarget { db: RelationalDB, schema: SchemaPlan, table_ids: Vec, + active_mut_tx: Option, } impl EngineTarget { @@ -60,45 +62,63 @@ impl EngineTarget { Ok(()) })?; - Ok(Self { db, schema, table_ids }) + Ok(Self { + db, + schema, + table_ids, + active_mut_tx: None, + }) } - pub fn execute(&self, interaction: &Interaction) -> Result { + pub fn execute(&mut self, interaction: &Interaction) -> anyhow::Result { match interaction { + Interaction::BeginMutTx => { + anyhow::ensure!( + self.active_mut_tx.is_none(), + "begin mutable transaction while one is already active" + ); + self.active_mut_tx = Some(self.db.begin_mut_tx(IsolationLevel::Serializable, Workload::Internal)); + Ok(Observation::BeganMutTx) + } Interaction::Insert { table, row } => { let table_id = self.table_ids[*table]; let bytes = row_to_bytes(row); - let count_after = self - .db - .with_auto_commit(Workload::Internal, |tx| -> Result { - match self.db.insert(tx, table_id, &bytes) { - Ok(_) => {} - Err(_) => { - let cnt = self.db.iter_mut(tx, table_id)?.collect::>().len() as u64; - return Ok(cnt); - } - } - let cnt = self.db.iter_mut(tx, table_id)?.collect::>().len() as u64; - Ok(cnt) - })?; + let tx = self + .active_mut_tx + .as_mut() + .ok_or_else(|| anyhow::anyhow!("insert without active mutable transaction"))?; + match self.db.insert(tx, table_id, &bytes) { + Ok(_) => {} + Err(_) => {} + } + let count_after = self.db.iter_mut(tx, table_id)?.count() as u64; Ok(Observation::Inserted { count_after }) } Interaction::Delete { table, row } => { let table_id = self.table_ids[*table]; - let count_after = self - .db - .with_auto_commit(Workload::Internal, |tx| -> Result { - self.db.delete_by_rel(tx, table_id, [row.clone()]); - let cnt = self.db.iter_mut(tx, table_id)?.count() as u64; - Ok(cnt) - })?; + let tx = self + .active_mut_tx + .as_mut() + .ok_or_else(|| anyhow::anyhow!("delete without active mutable transaction"))?; + self.db.delete_by_rel(tx, table_id, [row.clone()]); + let count_after = self.db.iter_mut(tx, table_id)?.count() as u64; Ok(Observation::Deleted { count_after }) } + Interaction::CommitTx => { + let tx = self + .active_mut_tx + .take() + .ok_or_else(|| anyhow::anyhow!("commit without active mutable transaction"))?; + self.db.finish_tx(tx, Ok::<(), anyhow::Error>(()))?; + Ok(Observation::Committed) + } Interaction::Count { table } => { let table_id = self.table_ids[*table]; - let count = self.db.with_auto_commit(Workload::Internal, |tx| { - self.db.iter_mut(tx, table_id).map(|it| it.count() as u64) - })?; + let tx = self + .active_mut_tx + .as_mut() + .ok_or_else(|| anyhow::anyhow!("count without active mutable transaction"))?; + let count = self.db.iter_mut(tx, table_id)?.count() as u64; Ok(Observation::Counted { count }) } } @@ -119,7 +139,7 @@ impl TargetDriver for EngineTarget { type Outcome = Outcome; fn execute(&mut self, interaction: &Interaction) -> Result { - self.execute(interaction) + EngineTarget::execute(self, interaction) } } pub struct EngineTest; diff --git a/crates/dst/src/engine/workload.rs b/crates/dst/src/engine/workload.rs index a8ebd0fd0f2..87f8de135ee 100644 --- a/crates/dst/src/engine/workload.rs +++ b/crates/dst/src/engine/workload.rs @@ -8,15 +8,19 @@ pub type Row = ProductValue; #[derive(Debug, Clone)] pub enum Interaction { + BeginMutTx, Insert { table: usize, row: Row }, Delete { table: usize, row: Row }, + CommitTx, Count { table: usize }, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Observation { + BeganMutTx, Inserted { count_after: u64 }, Deleted { count_after: u64 }, + Committed, Counted { count: u64 }, } @@ -24,6 +28,7 @@ pub enum Observation { pub struct Model { schema: SchemaPlan, tables: Vec, + in_mut_tx: bool, } #[derive(Debug)] @@ -34,7 +39,11 @@ struct TableState { impl Model { pub fn new(schema: SchemaPlan) -> Self { let tables = schema.tables.iter().map(|_| TableState { rows: vec![] }).collect(); - Self { schema, tables } + Self { + schema, + tables, + in_mut_tx: false, + } } fn violates_unique_constraint(&self, table: usize, row: &Row) -> bool { @@ -53,7 +62,13 @@ impl Model { pub fn apply(&mut self, interaction: &Interaction) -> Observation { match interaction { + Interaction::BeginMutTx => { + debug_assert!(!self.in_mut_tx); + self.in_mut_tx = true; + Observation::BeganMutTx + } Interaction::Insert { table, row } => { + debug_assert!(self.in_mut_tx); let table_plan = &self.schema.tables[*table]; if self.violates_unique_constraint(*table, row) || self.tables[*table].rows.contains(row) { @@ -77,18 +92,31 @@ impl Model { } } Interaction::Delete { table, row } => { + debug_assert!(self.in_mut_tx); let rows = &mut self.tables[*table].rows; rows.retain(|r| r != row); Observation::Deleted { count_after: rows.len() as u64, } } - Interaction::Count { table } => Observation::Counted { - count: self.tables[*table].rows.len() as u64, - }, + Interaction::CommitTx => { + debug_assert!(self.in_mut_tx); + self.in_mut_tx = false; + Observation::Committed + } + Interaction::Count { table } => { + debug_assert!(self.in_mut_tx); + Observation::Counted { + count: self.tables[*table].rows.len() as u64, + } + } } } + pub fn in_mut_tx(&self) -> bool { + self.in_mut_tx + } + pub fn row_count(&self, table: usize) -> u64 { self.tables[table].rows.len() as u64 } @@ -135,25 +163,33 @@ impl WorkloadGen { } pub fn next_interaction(&mut self) -> Interaction { - let model = &self.model; let table_idx = self.rng.index(self.schema().tables.len()); - let coin = self.rng.next_u64() % 10; - if coin < 6 { - Interaction::Insert { - table: table_idx, - row: self.gen_row(&self.schema().tables[table_idx]), - } - } else if coin < 8 && !model.rows(table_idx).is_empty() { - let rows = model.rows(table_idx); - let row_index = self.rng.index(rows.len()); - Interaction::Delete { - table: table_idx, - row: rows[row_index].clone(), + let interaction = if self.model.in_mut_tx() { + let coin = self.rng.next_u64() % 10; + if coin < 5 { + Interaction::Insert { + table: table_idx, + row: self.gen_row(&self.schema().tables[table_idx]), + } + } else if coin < 7 && !self.model.rows(table_idx).is_empty() { + let rows = self.model.rows(table_idx); + let row_index = self.rng.index(rows.len()); + Interaction::Delete { + table: table_idx, + row: rows[row_index].clone(), + } + } else if coin < 9 { + Interaction::Count { table: table_idx } + } else { + Interaction::CommitTx } } else { - Interaction::Count { table: table_idx } - } + Interaction::BeginMutTx + }; + + self.model.apply(&interaction); + interaction } } impl Iterator for WorkloadGen { From 7647353a183340b13a09a3a36306e2223f1e9850 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Thu, 18 Jun 2026 21:11:13 +0530 Subject: [PATCH 19/25] commitlog replay --- crates/dst/Cargo.toml | 1 + crates/dst/src/engine.rs | 139 ++++++++-- crates/dst/src/engine/workload.rs | 80 +++--- crates/dst/src/main.rs | 1 + crates/dst/src/sim/commitlog.rs | 417 ++++++++++++++++++++++++++++++ crates/dst/src/sim/mod.rs | 1 + 6 files changed, 584 insertions(+), 55 deletions(-) create mode 100644 crates/dst/src/sim/commitlog.rs create mode 100644 crates/dst/src/sim/mod.rs diff --git a/crates/dst/Cargo.toml b/crates/dst/Cargo.toml index 45e239d3696..7926d27bdfc 100644 --- a/crates/dst/Cargo.toml +++ b/crates/dst/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true anyhow.workspace = true clap.workspace = true spacetimedb-datastore = { path = "../datastore", default-features = false, features = ["simulation"] } +spacetimedb-commitlog.workspace = true spacetimedb-durability = { path = "../durability", default-features = false, features = ["simulation"] } spacetimedb-engine = { path = "../engine", default-features = false, features = ["simulation"] } spacetimedb-lib.workspace = true diff --git a/crates/dst/src/engine.rs b/crates/dst/src/engine.rs index 7da2b27017e..953952b62fc 100644 --- a/crates/dst/src/engine.rs +++ b/crates/dst/src/engine.rs @@ -1,11 +1,15 @@ +use std::{io, sync::Arc}; + +use spacetimedb_commitlog::SizeOnDisk; use spacetimedb_datastore::execution_context::Workload; use spacetimedb_datastore::traits::IsolationLevel; -use spacetimedb_durability::EmptyHistory; use spacetimedb_engine::error::DBError; +use spacetimedb_engine::persistence::{DiskSizeFn, Durability as EngineDurability, Persistence}; use spacetimedb_engine::relational_db::{MutTx, RelationalDB}; -use spacetimedb_lib::RawModuleDef; +use spacetimedb_lib::{Identity, RawModuleDef}; use spacetimedb_primitives::TableId; -use spacetimedb_runtime::sim::Rng; +use spacetimedb_runtime::sim::{Rng, Runtime as SimRuntime}; +use spacetimedb_runtime::Handle; use spacetimedb_schema::def::ModuleDef; use spacetimedb_schema::schema::{Schema, TableSchema}; use spacetimedb_table::page_pool::PagePool; @@ -18,27 +22,73 @@ use self::workload::{row_to_bytes, Interaction, Observation}; use crate::engine::properties::EngineProperties; use crate::engine::workload::{Model, WorkloadGen}; use crate::schema::{default_schema, lower_schema, SchemaPlan}; +use crate::sim::commitlog::{InMemoryCommitlog, InMemoryCommitlogHandle}; use crate::traits::{TargetDriver, TestSuite}; + pub struct EngineTarget { - db: RelationalDB, + db: Option, schema: SchemaPlan, table_ids: Vec, active_mut_tx: Option, + commitlog: InMemoryCommitlog, + runtime_handle: Handle, + runtime: SimRuntime, } impl EngineTarget { - pub fn init(schema: SchemaPlan) -> Result { - let history = EmptyHistory::new(); - let (db, _) = RelationalDB::open( - spacetimedb_lib::Identity::ZERO, - spacetimedb_lib::Identity::ZERO, + pub fn init(schema: SchemaPlan, runtime_seed: u64) -> anyhow::Result { + let runtime = SimRuntime::new(runtime_seed); + let runtime_handle = Handle::simulation(runtime.handle()); + let commitlog = InMemoryCommitlog::new(); + let db = Self::open_db(&commitlog, runtime_handle.clone())?; + + Self::install_schema(&db, &schema)?; + let table_ids = Self::load_table_ids(&db, &schema)?; + + Ok(Self { + db: Some(db), + schema, + table_ids, + active_mut_tx: None, + commitlog, + runtime_handle, + runtime, + }) + } + + fn open_db(commitlog: &InMemoryCommitlog, runtime_handle: Handle) -> anyhow::Result { + let history = commitlog.open_handle()?; + let persistence = Self::persistence(history.clone(), runtime_handle); + let (db, connected_clients) = RelationalDB::open( + Identity::ZERO, + Identity::ZERO, history, - None, + Some(persistence), None, PagePool::new_for_test(), )?; + anyhow::ensure!(connected_clients.is_empty(), "replay produced connected clients"); + Ok(db) + } + + fn persistence(handle: InMemoryCommitlogHandle, runtime_handle: Handle) -> Persistence { + let durability: Arc = Arc::new(handle); + let disk_size: DiskSizeFn = Arc::new(|| { + io::Result::Ok(SizeOnDisk { + total_bytes: 0, + total_blocks: 0, + }) + }); + Persistence { + durability, + disk_size, + snapshots: None, + runtime: runtime_handle, + } + } - let raw = lower_schema(&schema); + fn install_schema(db: &RelationalDB, schema: &SchemaPlan) -> anyhow::Result<()> { + let raw = lower_schema(schema); let raw_module_def = RawModuleDef::V10(raw); let module_def = ModuleDef::try_from(raw_module_def).map_err(|e| anyhow::anyhow!("schema validation failed: {e}"))?; @@ -51,6 +101,10 @@ impl EngineTarget { Ok(()) })?; + Ok(()) + } + + fn load_table_ids(db: &RelationalDB, schema: &SchemaPlan) -> anyhow::Result> { let mut table_ids = Vec::with_capacity(schema.tables.len()); db.with_auto_commit(Workload::Internal, |tx| -> Result<(), DBError> { for table_plan in &schema.tables { @@ -61,13 +115,20 @@ impl EngineTarget { } Ok(()) })?; + Ok(table_ids) + } - Ok(Self { - db, - schema, - table_ids, - active_mut_tx: None, - }) + fn replay(&mut self) -> anyhow::Result<()> { + self.active_mut_tx.take(); + let db = self + .db + .take() + .ok_or_else(|| anyhow::anyhow!("replay without open database"))?; + + drop(db); + + self.db = Some(Self::open_db(&self.commitlog, self.runtime_handle.clone())?); + Ok(()) } pub fn execute(&mut self, interaction: &Interaction) -> anyhow::Result { @@ -77,31 +138,43 @@ impl EngineTarget { self.active_mut_tx.is_none(), "begin mutable transaction while one is already active" ); - self.active_mut_tx = Some(self.db.begin_mut_tx(IsolationLevel::Serializable, Workload::Internal)); + let db = self + .db + .as_ref() + .ok_or_else(|| anyhow::anyhow!("database is not open"))?; + self.active_mut_tx = Some(db.begin_mut_tx(IsolationLevel::Serializable, Workload::Internal)); Ok(Observation::BeganMutTx) } Interaction::Insert { table, row } => { let table_id = self.table_ids[*table]; let bytes = row_to_bytes(row); + let db = self + .db + .as_ref() + .ok_or_else(|| anyhow::anyhow!("database is not open"))?; let tx = self .active_mut_tx .as_mut() .ok_or_else(|| anyhow::anyhow!("insert without active mutable transaction"))?; - match self.db.insert(tx, table_id, &bytes) { + match db.insert(tx, table_id, &bytes) { Ok(_) => {} Err(_) => {} } - let count_after = self.db.iter_mut(tx, table_id)?.count() as u64; + let count_after = db.iter_mut(tx, table_id)?.count() as u64; Ok(Observation::Inserted { count_after }) } Interaction::Delete { table, row } => { let table_id = self.table_ids[*table]; + let db = self + .db + .as_ref() + .ok_or_else(|| anyhow::anyhow!("database is not open"))?; let tx = self .active_mut_tx .as_mut() .ok_or_else(|| anyhow::anyhow!("delete without active mutable transaction"))?; - self.db.delete_by_rel(tx, table_id, [row.clone()]); - let count_after = self.db.iter_mut(tx, table_id)?.count() as u64; + db.delete_by_rel(tx, table_id, [row.clone()]); + let count_after = db.iter_mut(tx, table_id)?.count() as u64; Ok(Observation::Deleted { count_after }) } Interaction::CommitTx => { @@ -109,29 +182,42 @@ impl EngineTarget { .active_mut_tx .take() .ok_or_else(|| anyhow::anyhow!("commit without active mutable transaction"))?; - self.db.finish_tx(tx, Ok::<(), anyhow::Error>(()))?; + let db = self + .db + .as_ref() + .ok_or_else(|| anyhow::anyhow!("database is not open"))?; + db.finish_tx(tx, Ok::<(), anyhow::Error>(()))?; Ok(Observation::Committed) } Interaction::Count { table } => { let table_id = self.table_ids[*table]; + let db = self + .db + .as_ref() + .ok_or_else(|| anyhow::anyhow!("database is not open"))?; let tx = self .active_mut_tx .as_mut() .ok_or_else(|| anyhow::anyhow!("count without active mutable transaction"))?; - let count = self.db.iter_mut(tx, table_id)?.count() as u64; + let count = db.iter_mut(tx, table_id)?.count() as u64; Ok(Observation::Counted { count }) } + Interaction::Replay => { + self.replay()?; + Ok(Observation::Replayed) + } } } pub fn db(&self) -> &RelationalDB { - &self.db + self.db.as_ref().expect("database is open") } pub fn schema(&self) -> &SchemaPlan { &self.schema } } + pub struct Outcome; impl TargetDriver for EngineTarget { type Observation = Observation; @@ -155,7 +241,8 @@ impl TestSuite for EngineTest { fn build(&self, rng: Rng) -> Result<(Self::Interactions, Self::Target, Self::Properties), anyhow::Error> { let schema = default_schema(rng.clone()); - let target = EngineTarget::init(schema.clone())?; + let runtime_seed = rng.next_u64(); + let target = EngineTarget::init(schema.clone(), runtime_seed)?; let properties = EngineProperties {}; let model = Model::new(schema); diff --git a/crates/dst/src/engine/workload.rs b/crates/dst/src/engine/workload.rs index 87f8de135ee..fbdeff0776f 100644 --- a/crates/dst/src/engine/workload.rs +++ b/crates/dst/src/engine/workload.rs @@ -13,6 +13,7 @@ pub enum Interaction { Delete { table: usize, row: Row }, CommitTx, Count { table: usize }, + Replay, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -22,33 +23,44 @@ pub enum Observation { Deleted { count_after: u64 }, Committed, Counted { count: u64 }, + Replayed, } #[derive(Debug)] pub struct Model { schema: SchemaPlan, - tables: Vec, - in_mut_tx: bool, + committed_tables: Vec, + pending_tables: Option>, } -#[derive(Debug)] +#[derive(Debug, Clone)] struct TableState { rows: Vec, } impl Model { pub fn new(schema: SchemaPlan) -> Self { - let tables = schema.tables.iter().map(|_| TableState { rows: vec![] }).collect(); + let committed_tables = schema.tables.iter().map(|_| TableState { rows: vec![] }).collect(); Self { schema, - tables, - in_mut_tx: false, + committed_tables, + pending_tables: None, } } - fn violates_unique_constraint(&self, table: usize, row: &Row) -> bool { + fn tables(&self) -> &[TableState] { + self.pending_tables.as_deref().unwrap_or(&self.committed_tables) + } + + fn pending_tables_mut(&mut self) -> &mut [TableState] { + self.pending_tables + .as_deref_mut() + .expect("mutable interaction without active transaction") + } + + fn violates_unique_constraint_in(&self, tables: &[TableState], table: usize, row: &Row) -> bool { let table_plan = &self.schema.tables[table]; - let rows = &self.tables[table].rows; + let rows = &tables[table].rows; for constraint in &table_plan.unique_constraints { if rows .iter() @@ -63,22 +75,24 @@ impl Model { pub fn apply(&mut self, interaction: &Interaction) -> Observation { match interaction { Interaction::BeginMutTx => { - debug_assert!(!self.in_mut_tx); - self.in_mut_tx = true; + debug_assert!(self.pending_tables.is_none()); + self.pending_tables = Some(self.committed_tables.clone()); Observation::BeganMutTx } Interaction::Insert { table, row } => { - debug_assert!(self.in_mut_tx); - let table_plan = &self.schema.tables[*table]; + debug_assert!(self.pending_tables.is_some()); + let primary_key = self.schema.tables[*table].primary_key; - if self.violates_unique_constraint(*table, row) || self.tables[*table].rows.contains(row) { + if self.violates_unique_constraint_in(self.tables(), *table, row) + || self.tables()[*table].rows.contains(row) + { return Observation::Inserted { - count_after: self.tables[*table].rows.len() as u64, + count_after: self.tables()[*table].rows.len() as u64, }; } - let rows = &mut self.tables[*table].rows; - if let Some(pk_col) = table_plan.primary_key { + let rows = &mut self.pending_tables_mut()[*table].rows; + if let Some(pk_col) = primary_key { if let Some(pos) = rows.iter().position(|r| r.elements[pk_col] == row.elements[pk_col]) { rows[pos] = row.clone(); return Observation::Inserted { @@ -92,37 +106,41 @@ impl Model { } } Interaction::Delete { table, row } => { - debug_assert!(self.in_mut_tx); - let rows = &mut self.tables[*table].rows; + debug_assert!(self.pending_tables.is_some()); + let rows = &mut self.pending_tables_mut()[*table].rows; rows.retain(|r| r != row); Observation::Deleted { count_after: rows.len() as u64, } } Interaction::CommitTx => { - debug_assert!(self.in_mut_tx); - self.in_mut_tx = false; + debug_assert!(self.pending_tables.is_some()); + self.committed_tables = self.pending_tables.take().expect("active transaction"); Observation::Committed } Interaction::Count { table } => { - debug_assert!(self.in_mut_tx); + debug_assert!(self.pending_tables.is_some()); Observation::Counted { - count: self.tables[*table].rows.len() as u64, + count: self.tables()[*table].rows.len() as u64, } } + Interaction::Replay => { + self.pending_tables = None; + Observation::Replayed + } } } pub fn in_mut_tx(&self) -> bool { - self.in_mut_tx + self.pending_tables.is_some() } pub fn row_count(&self, table: usize) -> u64 { - self.tables[table].rows.len() as u64 + self.tables()[table].rows.len() as u64 } pub fn rows(&self, table: usize) -> &[Row] { - &self.tables[table].rows + &self.tables()[table].rows } } @@ -166,24 +184,28 @@ impl WorkloadGen { let table_idx = self.rng.index(self.schema().tables.len()); let interaction = if self.model.in_mut_tx() { - let coin = self.rng.next_u64() % 10; - if coin < 5 { + let coin = self.rng.next_u64() % 11; + if coin == 0 { + Interaction::Replay + } else if coin < 6 { Interaction::Insert { table: table_idx, row: self.gen_row(&self.schema().tables[table_idx]), } - } else if coin < 7 && !self.model.rows(table_idx).is_empty() { + } else if coin < 8 && !self.model.rows(table_idx).is_empty() { let rows = self.model.rows(table_idx); let row_index = self.rng.index(rows.len()); Interaction::Delete { table: table_idx, row: rows[row_index].clone(), } - } else if coin < 9 { + } else if coin < 10 { Interaction::Count { table: table_idx } } else { Interaction::CommitTx } + } else if self.rng.next_u64() % 5 == 0 { + Interaction::Replay } else { Interaction::BeginMutTx }; diff --git a/crates/dst/src/main.rs b/crates/dst/src/main.rs index 75b67e6c098..7fc59ca99d6 100644 --- a/crates/dst/src/main.rs +++ b/crates/dst/src/main.rs @@ -5,6 +5,7 @@ use spacetimedb_runtime::sim::Rng; mod engine; mod schema; +mod sim; mod traits; use crate::{engine::EngineTest, traits::TestSuite}; diff --git a/crates/dst/src/sim/commitlog.rs b/crates/dst/src/sim/commitlog.rs new file mode 100644 index 00000000000..1e5b87e6e3d --- /dev/null +++ b/crates/dst/src/sim/commitlog.rs @@ -0,0 +1,417 @@ +use std::{ + collections::{btree_map, BTreeMap}, + fmt, io, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, RwLock, + }, +}; + +use spacetimedb_commitlog::{ + repo::{CompressOnce, CompressionStats, Repo, RepoWithoutLockFile, SegmentLen, SegmentReader, TxOffset}, + segment::{FileLike, Header}, + Commitlog, Decoder, Options, Transaction, +}; +use spacetimedb_durability::{Close, Durability, DurableOffset, History, PreparedTx}; +use spacetimedb_engine::relational_db::Txdata; +use spacetimedb_runtime::sync::watch; + +#[derive(Clone, Debug)] +pub struct InMemoryCommitlog { + repo: Memory, + options: Options, +} + +impl InMemoryCommitlog { + pub fn new() -> Self { + Self { + repo: Memory::unlimited(), + options: Options::default(), + } + } + + pub fn open_handle(&self) -> io::Result { + InMemoryCommitlogHandle::open(self.repo.clone(), self.options) + } +} + +#[derive(Clone)] +pub struct InMemoryCommitlogHandle { + inner: Arc, +} + +struct HandleInner { + log: Commitlog, + durable_tx: watch::Sender>, + closed: AtomicBool, +} + +impl InMemoryCommitlogHandle { + fn open(repo: Memory, options: Options) -> io::Result { + let log = Commitlog::open_with_repo(repo, options)?; + let (durable_tx, _) = watch::channel(log.max_committed_offset()); + Ok(Self { + inner: Arc::new(HandleInner { + log, + durable_tx, + closed: AtomicBool::new(false), + }), + }) + } +} + +impl Durability for InMemoryCommitlogHandle { + type TxData = Txdata; + + fn append_tx(&self, tx: PreparedTx) { + assert!( + !self.inner.closed.load(Ordering::Acquire), + "in-memory commitlog durability is closed" + ); + + let tx = tx.into_transaction(); + self.inner.log.commit([tx]).expect("in-memory commitlog append failed"); + let durable_offset = self + .inner + .log + .flush_and_sync() + .expect("in-memory commitlog flush failed"); + let _ = self.inner.durable_tx.send(durable_offset); + } + + fn durable_tx_offset(&self) -> DurableOffset { + self.inner.durable_tx.subscribe().into() + } + + fn close(&self) -> Close { + self.inner.closed.store(true, Ordering::Release); + let durable_offset = self.inner.log.max_committed_offset(); + let _ = self.inner.durable_tx.send(durable_offset); + Box::pin(async move { durable_offset }) + } +} + +impl History for InMemoryCommitlogHandle { + type TxData = Txdata; + + fn fold_transactions_from(&self, offset: TxOffset, decoder: D) -> Result<(), D::Error> + where + D: Decoder, + D::Error: From, + { + self.inner.log.fold_transactions_from(offset, decoder) + } + + fn transactions_from<'a, D>( + &self, + offset: TxOffset, + decoder: &'a D, + ) -> impl Iterator, D::Error>> + where + D: Decoder, + D::Error: From, + Self::TxData: 'a, + { + self.inner.log.transactions_from(offset, decoder) + } + + fn tx_range_hint(&self) -> (TxOffset, Option) { + let min = self.inner.log.min_committed_offset().unwrap_or_default(); + let max = self.inner.log.max_committed_offset(); + + (min, max) + } +} + +const PAGE_SIZE: usize = 4096; + +type SharedLock = Arc>; +type SpaceOnDevice = Arc>; + +#[derive(Clone, Debug)] +pub struct Memory { + space: SpaceOnDevice, + segments: SharedLock>>, +} + +impl Memory { + pub fn new(total_space: u64) -> Self { + Self { + space: Arc::new(Mutex::new(total_space)), + segments: <_>::default(), + } + } + + pub fn unlimited() -> Self { + Self::new(u64::MAX) + } +} + +impl fmt::Display for Memory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("") + } +} + +impl Repo for Memory { + type SegmentWriter = Segment; + type SegmentReader = ReadOnlySegment; + + fn create_segment(&self, offset: u64, header: Header) -> io::Result { + let mut inner = self.segments.write().unwrap(); + let mut segment = match inner.entry(offset) { + btree_map::Entry::Occupied(entry) => { + let entry = entry.get(); + if entry.read().unwrap().is_empty() { + Segment::from_shared(self.space.clone(), entry.clone()) + } else { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("segment {offset} already exists"), + )); + } + } + btree_map::Entry::Vacant(entry) => { + let storage = entry.insert(Arc::new(RwLock::new(Storage::new()))); + Segment::from_shared(self.space.clone(), storage.clone()) + } + }; + header.write(&mut segment)?; + + Ok(segment) + } + + fn open_segment_reader(&self, offset: u64) -> io::Result { + self.open_segment_writer(offset).map(Into::into) + } + + fn open_segment_writer(&self, offset: u64) -> io::Result { + let inner = self.segments.read().unwrap(); + let Some(buf) = inner.get(&offset) else { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("segment {offset} does not exist"), + )); + }; + Ok(Segment::from_shared(self.space.clone(), buf.clone())) + } + + fn remove_segment(&self, offset: u64) -> io::Result<()> { + let mut inner = self.segments.write().unwrap(); + if inner.remove(&offset).is_none() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("segment {offset} does not exist"), + )); + } + + Ok(()) + } + + fn compress_segment_with(&self, _: u64, _: impl CompressOnce) -> io::Result { + Ok(<_>::default()) + } + + fn existing_offsets(&self) -> io::Result> { + Ok(self.segments.read().unwrap().keys().copied().collect()) + } +} + +impl RepoWithoutLockFile for Memory {} + +#[derive(Debug)] +struct Storage { + alloc: u64, + buf: Vec, +} + +impl Storage { + fn new() -> Self { + Self { + alloc: 0, + buf: Vec::with_capacity(PAGE_SIZE), + } + } + + const fn len(&self) -> usize { + self.buf.len() + } + + const fn is_empty(&self) -> bool { + self.buf.is_empty() + } +} + +#[derive(Clone, Debug)] +pub struct Segment { + pos: u64, + storage: SharedLock, + space: SpaceOnDevice, +} + +impl Segment { + fn from_shared(space: SpaceOnDevice, storage: SharedLock) -> Self { + Self { pos: 0, storage, space } + } + + fn len(&self) -> usize { + self.storage.read().unwrap().len() + } +} + +impl io::Write for Segment { + fn write(&mut self, buf: &[u8]) -> io::Result { + let mut storage = self.storage.write().unwrap(); + + let mut remaining = (storage.alloc - self.pos) as usize; + if remaining == 0 { + let mut avail = self.space.lock().unwrap(); + if *avail == 0 { + return Err(enospc()); + } + + let want = buf.len().next_multiple_of(PAGE_SIZE); + let have = want.min(*avail as usize); + + storage.alloc += have as u64; + *avail -= have as u64; + remaining = (storage.alloc - self.pos) as usize; + } + + let read = buf.len().min(remaining); + storage.buf.extend(&buf[..read]); + self.pos += read as u64; + + Ok(read) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl io::Read for Segment { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let storage = self.storage.read().unwrap(); + + let Some(remaining) = storage.len().checked_sub(self.pos as usize) else { + return Ok(0); + }; + let want = remaining.min(buf.len()); + let pos = self.pos as usize; + buf[..want].copy_from_slice(&storage.buf[pos..pos + want]); + self.pos += want as u64; + + Ok(want) + } +} + +impl io::Seek for Segment { + fn seek(&mut self, pos: io::SeekFrom) -> io::Result { + let (base_pos, offset) = match pos { + io::SeekFrom::Start(n) => { + self.pos = n; + return Ok(n); + } + io::SeekFrom::End(n) => (self.len() as u64, n), + io::SeekFrom::Current(n) => (self.pos, n), + }; + match base_pos.checked_add_signed(offset) { + Some(n) => { + self.pos = n; + Ok(n) + } + None => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid seek to a negative or overflowing position", + )), + } + } +} + +impl SegmentLen for Segment { + fn segment_len(&mut self) -> io::Result { + Ok(self.len() as u64) + } +} + +impl FileLike for Segment { + fn fsync(&mut self) -> io::Result<()> { + Ok(()) + } + + fn ftruncate(&mut self, _tx_offset: u64, size: u64) -> io::Result<()> { + let mut storage = self.storage.write().unwrap(); + let mut avail = self.space.lock().unwrap(); + + if size > storage.alloc { + if *avail == 0 { + return Err(enospc()); + } + + let want = size.next_multiple_of(PAGE_SIZE as u64) - storage.alloc; + let have = want.min(*avail); + + storage.alloc += have; + *avail -= have; + storage.buf.resize(size as usize, 0); + + if want > have { + return Err(enospc()); + } + } else { + let alloc = size.next_multiple_of(PAGE_SIZE as u64); + *avail += storage.alloc - alloc; + storage.alloc = alloc; + storage.buf.resize(size as usize, 0); + } + + Ok(()) + } +} + +pub struct ReadOnlySegment { + inner: io::BufReader, +} + +impl From for ReadOnlySegment { + fn from(inner: Segment) -> Self { + Self { + inner: io::BufReader::new(inner), + } + } +} + +impl SegmentReader for ReadOnlySegment { + fn sealed(&self) -> bool { + false + } +} + +impl io::Read for ReadOnlySegment { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.inner.read(buf) + } +} + +impl io::BufRead for ReadOnlySegment { + fn fill_buf(&mut self) -> io::Result<&[u8]> { + self.inner.fill_buf() + } + + fn consume(&mut self, amount: usize) { + self.inner.consume(amount); + } +} + +impl io::Seek for ReadOnlySegment { + fn seek(&mut self, pos: io::SeekFrom) -> io::Result { + self.inner.seek(pos) + } +} + +impl SegmentLen for ReadOnlySegment {} + +fn enospc() -> io::Error { + io::Error::new(io::ErrorKind::StorageFull, "no space left on device") +} diff --git a/crates/dst/src/sim/mod.rs b/crates/dst/src/sim/mod.rs new file mode 100644 index 00000000000..3a448644d48 --- /dev/null +++ b/crates/dst/src/sim/mod.rs @@ -0,0 +1 @@ +pub mod commitlog; From 5413a56c8e9613cfa9702d6735878b1f56827cae Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Fri, 19 Jun 2026 14:38:39 +0530 Subject: [PATCH 20/25] properties --- crates/dst/src/engine.rs | 35 ++++++++- crates/dst/src/engine/properties.rs | 111 ++++++++++++++++++++++++++-- crates/dst/src/engine/workload.rs | 43 ++++++++++- 3 files changed, 174 insertions(+), 15 deletions(-) diff --git a/crates/dst/src/engine.rs b/crates/dst/src/engine.rs index 953952b62fc..1ad73a01ba8 100644 --- a/crates/dst/src/engine.rs +++ b/crates/dst/src/engine.rs @@ -17,7 +17,7 @@ use spacetimedb_table::page_pool::PagePool; mod properties; mod workload; -use self::workload::{row_to_bytes, Interaction, Observation}; +use self::workload::{row_to_bytes, summarize_rows, Interaction, Observation, TableSummary}; use crate::engine::properties::EngineProperties; use crate::engine::workload::{Model, WorkloadGen}; @@ -131,6 +131,29 @@ impl EngineTarget { Ok(()) } + fn table_summaries(&self) -> anyhow::Result> { + let db = self + .db + .as_ref() + .ok_or_else(|| anyhow::anyhow!("database is not open"))?; + let tx = db.begin_tx(Workload::Internal); + let mut summaries = Vec::with_capacity(self.table_ids.len()); + + for table_id in &self.table_ids { + let rows = match db.iter(&tx, *table_id) { + Ok(iter) => iter.map(|row| row.to_product_value()).collect::>(), + Err(err) => { + let _ = db.release_tx(tx); + return Err(err.into()); + } + }; + summaries.push(summarize_rows(&rows)); + } + + let _ = db.release_tx(tx); + Ok(summaries) + } + pub fn execute(&mut self, interaction: &Interaction) -> anyhow::Result { match interaction { Interaction::BeginMutTx => { @@ -187,7 +210,9 @@ impl EngineTarget { .as_ref() .ok_or_else(|| anyhow::anyhow!("database is not open"))?; db.finish_tx(tx, Ok::<(), anyhow::Error>(()))?; - Ok(Observation::Committed) + Ok(Observation::Committed { + summaries: self.table_summaries()?, + }) } Interaction::Count { table } => { let table_id = self.table_ids[*table]; @@ -204,7 +229,9 @@ impl EngineTarget { } Interaction::Replay => { self.replay()?; - Ok(Observation::Replayed) + Ok(Observation::Replayed { + summaries: self.table_summaries()?, + }) } } } @@ -243,7 +270,7 @@ impl TestSuite for EngineTest { let schema = default_schema(rng.clone()); let runtime_seed = rng.next_u64(); let target = EngineTarget::init(schema.clone(), runtime_seed)?; - let properties = EngineProperties {}; + let properties = EngineProperties::new(schema.clone()); let model = Model::new(schema); let interactions = WorkloadGen::new(rng, model); diff --git a/crates/dst/src/engine/properties.rs b/crates/dst/src/engine/properties.rs index 5596f75a4fb..e5e4cd78860 100644 --- a/crates/dst/src/engine/properties.rs +++ b/crates/dst/src/engine/properties.rs @@ -1,15 +1,112 @@ -use super::workload::{Interaction, Observation}; +use super::workload::{Interaction, Model, Observation, TableSummary}; +use crate::schema::SchemaPlan; use crate::traits::Properties; -pub struct EngineProperties; +pub struct EngineProperties { + oracle: EngineOracle, + count_visible: CountVisible, + commit_matches: CommitMatches, + replay_matches: ReplayMatches, +} + +impl EngineProperties { + pub fn new(schema: SchemaPlan) -> Self { + Self { + oracle: EngineOracle::new(schema), + count_visible: CountVisible, + commit_matches: CommitMatches, + replay_matches: ReplayMatches, + } + } +} impl Properties for EngineProperties { fn observe(&mut self, interaction: &Interaction, observation: &Observation) -> Result<(), anyhow::Error> { + self.oracle.apply(interaction); + self.count_visible.check(interaction, observation, &self.oracle)?; + self.commit_matches.check(interaction, observation, &self.oracle)?; + self.replay_matches.check(interaction, observation, &self.oracle)?; + Ok(()) + } +} + +struct EngineOracle { + model: Model, +} + +impl EngineOracle { + fn new(schema: SchemaPlan) -> Self { + Self { + model: Model::new(schema), + } + } + + fn apply(&mut self, interaction: &Interaction) { + self.model.apply(interaction); + } + + fn row_count(&self, table: usize) -> u64 { + self.model.row_count(table) + } + + fn summaries(&self) -> Vec { + self.model.summaries() + } +} + +struct CountVisible; + +impl CountVisible { + fn check(&self, interaction: &Interaction, observation: &Observation, oracle: &EngineOracle) -> anyhow::Result<()> { + let Interaction::Count { table } = interaction else { + return Ok(()); + }; + let Observation::Counted { count } = observation else { + anyhow::bail!("count_visible: count produced unexpected observation"); + }; + + anyhow::ensure!( + *count == oracle.row_count(*table), + "count_visible: count did not reflect visible transaction state for table {table}" + ); + Ok(()) + } +} + +struct CommitMatches; + +impl CommitMatches { + fn check(&self, interaction: &Interaction, observation: &Observation, oracle: &EngineOracle) -> anyhow::Result<()> { + if !matches!(interaction, Interaction::CommitTx) { + return Ok(()); + } + let Observation::Committed { summaries } = observation else { + anyhow::bail!("commit_matches: commit produced unexpected observation"); + }; + + anyhow::ensure!( + summaries == &oracle.summaries(), + "commit_matches: committed target summary diverged from model" + ); + Ok(()) + } +} + +struct ReplayMatches; + +impl ReplayMatches { + fn check(&self, interaction: &Interaction, observation: &Observation, oracle: &EngineOracle) -> anyhow::Result<()> { + if !matches!(interaction, Interaction::Replay) { + return Ok(()); + } + let Observation::Replayed { summaries } = observation else { + anyhow::bail!("replay_matches: replay produced unexpected observation"); + }; + + anyhow::ensure!( + summaries == &oracle.summaries(), + "replay_matches: replayed target summary diverged from committed model" + ); Ok(()) - // match interaction { - // Interaction::Insert { table, row } => todo!(), - // Interaction::Delete { table, row } => todo!(), - // Interaction::Count { table } => todo!(), - // } } } diff --git a/crates/dst/src/engine/workload.rs b/crates/dst/src/engine/workload.rs index fbdeff0776f..66c79392075 100644 --- a/crates/dst/src/engine/workload.rs +++ b/crates/dst/src/engine/workload.rs @@ -21,9 +21,15 @@ pub enum Observation { BeganMutTx, Inserted { count_after: u64 }, Deleted { count_after: u64 }, - Committed, + Committed { summaries: Vec }, Counted { count: u64 }, - Replayed, + Replayed { summaries: Vec }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TableSummary { + pub count: u64, + pub hash: u64, } #[derive(Debug)] @@ -116,7 +122,9 @@ impl Model { Interaction::CommitTx => { debug_assert!(self.pending_tables.is_some()); self.committed_tables = self.pending_tables.take().expect("active transaction"); - Observation::Committed + Observation::Committed { + summaries: self.summaries(), + } } Interaction::Count { table } => { debug_assert!(self.pending_tables.is_some()); @@ -126,7 +134,9 @@ impl Model { } Interaction::Replay => { self.pending_tables = None; - Observation::Replayed + Observation::Replayed { + summaries: self.summaries(), + } } } } @@ -139,6 +149,10 @@ impl Model { self.tables()[table].rows.len() as u64 } + pub fn summaries(&self) -> Vec { + self.tables().iter().map(|table| summarize_rows(&table.rows)).collect() + } + pub fn rows(&self, table: usize) -> &[Row] { &self.tables()[table].rows } @@ -226,3 +240,24 @@ use spacetimedb_sats::ArrayValue; pub fn row_to_bytes(row: &Row) -> Vec { to_vec(row).expect("row serialization must not fail") } + +pub fn summarize_rows(rows: &[Row]) -> TableSummary { + let mut hash = 0u64; + for row in rows { + let row_hash = stable_hash(&row_to_bytes(row)); + hash = hash.wrapping_add(row_hash.rotate_left((row_hash & 31) as u32)); + } + TableSummary { + count: rows.len() as u64, + hash, + } +} + +fn stable_hash(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for byte in bytes { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100_0000_01b3); + } + hash +} From e4e4eb49815ea3870852880a0d1a6bb7e97d3307 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Fri, 19 Jun 2026 16:07:06 +0530 Subject: [PATCH 21/25] separate mode; --- Cargo.lock | 9 ++ crates/dst/src/engine.rs | 40 ++++++- crates/dst/src/engine/model.rs | 155 ++++++++++++++++++++++++++++ crates/dst/src/engine/properties.rs | 112 ++++++++++++-------- crates/dst/src/engine/workload.rs | 136 +++--------------------- 5 files changed, 279 insertions(+), 173 deletions(-) create mode 100644 crates/dst/src/engine/model.rs diff --git a/Cargo.lock b/Cargo.lock index 29d90b4db4f..ab8d897008d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8211,7 +8211,16 @@ version = "2.5.0" dependencies = [ "anyhow", "clap 4.5.50", + "spacetimedb-commitlog", + "spacetimedb-datastore", + "spacetimedb-durability", + "spacetimedb-engine", + "spacetimedb-lib", + "spacetimedb-primitives", "spacetimedb-runtime", + "spacetimedb-sats", + "spacetimedb-schema", + "spacetimedb-table", "tracing", ] diff --git a/crates/dst/src/engine.rs b/crates/dst/src/engine.rs index 1ad73a01ba8..03c5099dc32 100644 --- a/crates/dst/src/engine.rs +++ b/crates/dst/src/engine.rs @@ -2,7 +2,7 @@ use std::{io, sync::Arc}; use spacetimedb_commitlog::SizeOnDisk; use spacetimedb_datastore::execution_context::Workload; -use spacetimedb_datastore::traits::IsolationLevel; +use spacetimedb_datastore::traits::{IsolationLevel, TxData}; use spacetimedb_engine::error::DBError; use spacetimedb_engine::persistence::{DiskSizeFn, Durability as EngineDurability, Persistence}; use spacetimedb_engine::relational_db::{MutTx, RelationalDB}; @@ -14,13 +14,15 @@ use spacetimedb_schema::def::ModuleDef; use spacetimedb_schema::schema::{Schema, TableSchema}; use spacetimedb_table::page_pool::PagePool; +mod model; mod properties; mod workload; -use self::workload::{row_to_bytes, summarize_rows, Interaction, Observation, TableSummary}; +use self::workload::{row_to_bytes, summarize_rows, CommitDelta, Interaction, Observation, TableDelta, TableSummary}; +use crate::engine::model::Model; use crate::engine::properties::EngineProperties; -use crate::engine::workload::{Model, WorkloadGen}; +use crate::engine::workload::WorkloadGen; use crate::schema::{default_schema, lower_schema, SchemaPlan}; use crate::sim::commitlog::{InMemoryCommitlog, InMemoryCommitlogHandle}; use crate::traits::{TargetDriver, TestSuite}; @@ -154,6 +156,32 @@ impl EngineTarget { Ok(summaries) } + fn commit_delta_from_tx_data(&self, tx_data: &TxData) -> CommitDelta { + let mut tables = Vec::new(); + + for (table_id, entry) in tx_data.iter_table_entries() { + let Some(table) = self.table_ids.iter().position(|id| *id == table_id) else { + continue; + }; + + let inserts = summarize_rows(entry.inserts.as_ref()); + let deletes = summarize_rows(entry.deletes.as_ref()); + if inserts.count == 0 && deletes.count == 0 && !entry.truncated { + continue; + } + + tables.push(TableDelta { + table, + inserts, + deletes, + truncated: entry.truncated, + }); + } + + tables.sort_by_key(|delta| delta.table); + CommitDelta { tables } + } + pub fn execute(&mut self, interaction: &Interaction) -> anyhow::Result { match interaction { Interaction::BeginMutTx => { @@ -209,9 +237,11 @@ impl EngineTarget { .db .as_ref() .ok_or_else(|| anyhow::anyhow!("database is not open"))?; - db.finish_tx(tx, Ok::<(), anyhow::Error>(()))?; + let Some((_tx_offset, tx_data, _tx_metrics, _reducer)) = db.commit_tx(tx)? else { + anyhow::bail!("commit produced no transaction data"); + }; Ok(Observation::Committed { - summaries: self.table_summaries()?, + delta: self.commit_delta_from_tx_data(&tx_data), }) } Interaction::Count { table } => { diff --git a/crates/dst/src/engine/model.rs b/crates/dst/src/engine/model.rs new file mode 100644 index 00000000000..4f2dff86d1c --- /dev/null +++ b/crates/dst/src/engine/model.rs @@ -0,0 +1,155 @@ +use super::workload::{summarize_rows, CommitDelta, Interaction, Observation, Row, TableDelta, TableSummary}; +use crate::schema::SchemaPlan; + +#[derive(Debug)] +pub struct Model { + schema: SchemaPlan, + committed_tables: Vec, + pending_tables: Option>, +} + +#[derive(Debug, Clone)] +struct TableState { + rows: Vec, +} + +impl Model { + pub fn new(schema: SchemaPlan) -> Self { + let committed_tables = schema.tables.iter().map(|_| TableState { rows: vec![] }).collect(); + Self { + schema, + committed_tables, + pending_tables: None, + } + } + + pub fn schema(&self) -> &SchemaPlan { + &self.schema + } + + fn tables(&self) -> &[TableState] { + self.pending_tables.as_deref().unwrap_or(&self.committed_tables) + } + + fn pending_tables_mut(&mut self) -> &mut [TableState] { + self.pending_tables + .as_deref_mut() + .expect("mutable interaction without active transaction") + } + + fn violates_unique_constraint_in(&self, tables: &[TableState], table: usize, row: &Row) -> bool { + let table_plan = &self.schema.tables[table]; + let rows = &tables[table].rows; + for constraint in &table_plan.unique_constraints { + if rows + .iter() + .any(|r| constraint.columns.iter().all(|&c| r.elements[c] == row.elements[c])) + { + return true; + } + } + false + } + + pub fn apply(&mut self, interaction: &Interaction) -> Observation { + match interaction { + Interaction::BeginMutTx => { + debug_assert!(self.pending_tables.is_none()); + self.pending_tables = Some(self.committed_tables.clone()); + Observation::BeganMutTx + } + Interaction::Insert { table, row } => { + debug_assert!(self.pending_tables.is_some()); + let primary_key = self.schema.tables[*table].primary_key; + + if self.violates_unique_constraint_in(self.tables(), *table, row) + || self.tables()[*table].rows.contains(row) + { + return Observation::Inserted { + count_after: self.tables()[*table].rows.len() as u64, + }; + } + + let rows = &mut self.pending_tables_mut()[*table].rows; + if let Some(pk_col) = primary_key { + if let Some(pos) = rows.iter().position(|r| r.elements[pk_col] == row.elements[pk_col]) { + rows[pos] = row.clone(); + return Observation::Inserted { + count_after: rows.len() as u64, + }; + } + } + rows.push(row.clone()); + Observation::Inserted { + count_after: rows.len() as u64, + } + } + Interaction::Delete { table, row } => { + debug_assert!(self.pending_tables.is_some()); + let rows = &mut self.pending_tables_mut()[*table].rows; + rows.retain(|r| r != row); + Observation::Deleted { + count_after: rows.len() as u64, + } + } + Interaction::CommitTx => { + debug_assert!(self.pending_tables.is_some()); + let pending_tables = self.pending_tables.take().expect("active transaction"); + let delta = commit_delta_from_tables(&self.committed_tables, &pending_tables); + self.committed_tables = pending_tables; + Observation::Committed { delta } + } + Interaction::Count { table } => { + debug_assert!(self.pending_tables.is_some()); + Observation::Counted { + count: self.tables()[*table].rows.len() as u64, + } + } + Interaction::Replay => { + self.pending_tables = None; + Observation::Replayed { + summaries: self.summaries(), + } + } + } + } + + pub fn in_mut_tx(&self) -> bool { + self.pending_tables.is_some() + } + + pub fn summaries(&self) -> Vec { + self.tables().iter().map(|table| summarize_rows(&table.rows)).collect() + } + + pub fn rows(&self, table: usize) -> &[Row] { + &self.tables()[table].rows + } +} + +fn commit_delta_from_tables(before: &[TableState], after: &[TableState]) -> CommitDelta { + let mut tables = Vec::new(); + + for (table, (before, after)) in before.iter().zip(after).enumerate() { + let inserts = rows_absent_from(&after.rows, &before.rows); + let deletes = rows_absent_from(&before.rows, &after.rows); + let truncated = !before.rows.is_empty() && after.rows.is_empty() && !deletes.is_empty(); + + if inserts.is_empty() && deletes.is_empty() && !truncated { + continue; + } + + tables.push(TableDelta { + table, + inserts: summarize_rows(&inserts), + deletes: summarize_rows(&deletes), + truncated, + }); + } + + CommitDelta { tables } +} + +fn rows_absent_from(rows: &[Row], other: &[Row]) -> Vec { + rows.iter().filter(|row| !other.contains(row)).cloned().collect() +} diff --git a/crates/dst/src/engine/properties.rs b/crates/dst/src/engine/properties.rs index e5e4cd78860..6548cb4a866 100644 --- a/crates/dst/src/engine/properties.rs +++ b/crates/dst/src/engine/properties.rs @@ -1,35 +1,43 @@ -use super::workload::{Interaction, Model, Observation, TableSummary}; +use super::model::Model; +use super::workload::{Interaction, Observation}; use crate::schema::SchemaPlan; use crate::traits::Properties; pub struct EngineProperties { oracle: EngineOracle, - count_visible: CountVisible, - commit_matches: CommitMatches, - replay_matches: ReplayMatches, + properties: Vec>, } impl EngineProperties { pub fn new(schema: SchemaPlan) -> Self { Self { oracle: EngineOracle::new(schema), - count_visible: CountVisible, - commit_matches: CommitMatches, - replay_matches: ReplayMatches, + properties: vec![Box::new(CountVisible), Box::new(CommitMatches), Box::new(ReplayMatches)], } } } impl Properties for EngineProperties { fn observe(&mut self, interaction: &Interaction, observation: &Observation) -> Result<(), anyhow::Error> { - self.oracle.apply(interaction); - self.count_visible.check(interaction, observation, &self.oracle)?; - self.commit_matches.check(interaction, observation, &self.oracle)?; - self.replay_matches.check(interaction, observation, &self.oracle)?; + let expected = self.oracle.apply(interaction); + + for property in &self.properties { + if property.observes(interaction) { + property.check(interaction, observation, &expected)?; + } + } + Ok(()) } } +trait EngineProperty { + fn observes(&self, interaction: &Interaction) -> bool; + + fn check(&self, interaction: &Interaction, observation: &Observation, expected: &Observation) + -> anyhow::Result<()>; +} + struct EngineOracle { model: Model, } @@ -41,33 +49,34 @@ impl EngineOracle { } } - fn apply(&mut self, interaction: &Interaction) { - self.model.apply(interaction); - } - - fn row_count(&self, table: usize) -> u64 { - self.model.row_count(table) - } - - fn summaries(&self) -> Vec { - self.model.summaries() + fn apply(&mut self, interaction: &Interaction) -> Observation { + self.model.apply(interaction) } } struct CountVisible; -impl CountVisible { - fn check(&self, interaction: &Interaction, observation: &Observation, oracle: &EngineOracle) -> anyhow::Result<()> { - let Interaction::Count { table } = interaction else { - return Ok(()); - }; +impl EngineProperty for CountVisible { + fn observes(&self, interaction: &Interaction) -> bool { + matches!(interaction, Interaction::Count { .. }) + } + + fn check( + &self, + _interaction: &Interaction, + observation: &Observation, + expected: &Observation, + ) -> anyhow::Result<()> { let Observation::Counted { count } = observation else { anyhow::bail!("count_visible: count produced unexpected observation"); }; + let Observation::Counted { count: expected } = expected else { + unreachable!("CountVisible only subscribes to count interactions"); + }; anyhow::ensure!( - *count == oracle.row_count(*table), - "count_visible: count did not reflect visible transaction state for table {table}" + count == expected, + "count_visible: count did not reflect visible transaction state" ); Ok(()) } @@ -75,36 +84,51 @@ impl CountVisible { struct CommitMatches; -impl CommitMatches { - fn check(&self, interaction: &Interaction, observation: &Observation, oracle: &EngineOracle) -> anyhow::Result<()> { - if !matches!(interaction, Interaction::CommitTx) { - return Ok(()); - } - let Observation::Committed { summaries } = observation else { +impl EngineProperty for CommitMatches { + fn observes(&self, interaction: &Interaction) -> bool { + matches!(interaction, Interaction::CommitTx) + } + + fn check( + &self, + _interaction: &Interaction, + observation: &Observation, + expected: &Observation, + ) -> anyhow::Result<()> { + let Observation::Committed { delta } = observation else { anyhow::bail!("commit_matches: commit produced unexpected observation"); }; + let Observation::Committed { delta: expected } = expected else { + unreachable!("CommitMatches only subscribes to commit interactions"); + }; - anyhow::ensure!( - summaries == &oracle.summaries(), - "commit_matches: committed target summary diverged from model" - ); + anyhow::ensure!(delta == expected, "commit_matches: committed delta diverged from model"); Ok(()) } } struct ReplayMatches; -impl ReplayMatches { - fn check(&self, interaction: &Interaction, observation: &Observation, oracle: &EngineOracle) -> anyhow::Result<()> { - if !matches!(interaction, Interaction::Replay) { - return Ok(()); - } +impl EngineProperty for ReplayMatches { + fn observes(&self, interaction: &Interaction) -> bool { + matches!(interaction, Interaction::Replay) + } + + fn check( + &self, + _interaction: &Interaction, + observation: &Observation, + expected: &Observation, + ) -> anyhow::Result<()> { let Observation::Replayed { summaries } = observation else { anyhow::bail!("replay_matches: replay produced unexpected observation"); }; + let Observation::Replayed { summaries: expected } = expected else { + unreachable!("ReplayMatches only subscribes to replay interactions"); + }; anyhow::ensure!( - summaries == &oracle.summaries(), + summaries == expected, "replay_matches: replayed target summary diverged from committed model" ); Ok(()) diff --git a/crates/dst/src/engine/workload.rs b/crates/dst/src/engine/workload.rs index 66c79392075..972c7d1f5f9 100644 --- a/crates/dst/src/engine/workload.rs +++ b/crates/dst/src/engine/workload.rs @@ -2,6 +2,7 @@ use spacetimedb_lib::bsatn::to_vec; use spacetimedb_lib::{AlgebraicValue, ProductValue}; use spacetimedb_runtime::sim::Rng; +use super::model::Model; use crate::schema::{SchemaPlan, TablePlan, Type}; pub type Row = ProductValue; @@ -21,7 +22,7 @@ pub enum Observation { BeganMutTx, Inserted { count_after: u64 }, Deleted { count_after: u64 }, - Committed { summaries: Vec }, + Committed { delta: CommitDelta }, Counted { count: u64 }, Replayed { summaries: Vec }, } @@ -32,130 +33,17 @@ pub struct TableSummary { pub hash: u64, } -#[derive(Debug)] -pub struct Model { - schema: SchemaPlan, - committed_tables: Vec, - pending_tables: Option>, -} - -#[derive(Debug, Clone)] -struct TableState { - rows: Vec, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitDelta { + pub tables: Vec, } -impl Model { - pub fn new(schema: SchemaPlan) -> Self { - let committed_tables = schema.tables.iter().map(|_| TableState { rows: vec![] }).collect(); - Self { - schema, - committed_tables, - pending_tables: None, - } - } - - fn tables(&self) -> &[TableState] { - self.pending_tables.as_deref().unwrap_or(&self.committed_tables) - } - - fn pending_tables_mut(&mut self) -> &mut [TableState] { - self.pending_tables - .as_deref_mut() - .expect("mutable interaction without active transaction") - } - - fn violates_unique_constraint_in(&self, tables: &[TableState], table: usize, row: &Row) -> bool { - let table_plan = &self.schema.tables[table]; - let rows = &tables[table].rows; - for constraint in &table_plan.unique_constraints { - if rows - .iter() - .any(|r| constraint.columns.iter().all(|&c| r.elements[c] == row.elements[c])) - { - return true; - } - } - false - } - - pub fn apply(&mut self, interaction: &Interaction) -> Observation { - match interaction { - Interaction::BeginMutTx => { - debug_assert!(self.pending_tables.is_none()); - self.pending_tables = Some(self.committed_tables.clone()); - Observation::BeganMutTx - } - Interaction::Insert { table, row } => { - debug_assert!(self.pending_tables.is_some()); - let primary_key = self.schema.tables[*table].primary_key; - - if self.violates_unique_constraint_in(self.tables(), *table, row) - || self.tables()[*table].rows.contains(row) - { - return Observation::Inserted { - count_after: self.tables()[*table].rows.len() as u64, - }; - } - - let rows = &mut self.pending_tables_mut()[*table].rows; - if let Some(pk_col) = primary_key { - if let Some(pos) = rows.iter().position(|r| r.elements[pk_col] == row.elements[pk_col]) { - rows[pos] = row.clone(); - return Observation::Inserted { - count_after: rows.len() as u64, - }; - } - } - rows.push(row.clone()); - Observation::Inserted { - count_after: rows.len() as u64, - } - } - Interaction::Delete { table, row } => { - debug_assert!(self.pending_tables.is_some()); - let rows = &mut self.pending_tables_mut()[*table].rows; - rows.retain(|r| r != row); - Observation::Deleted { - count_after: rows.len() as u64, - } - } - Interaction::CommitTx => { - debug_assert!(self.pending_tables.is_some()); - self.committed_tables = self.pending_tables.take().expect("active transaction"); - Observation::Committed { - summaries: self.summaries(), - } - } - Interaction::Count { table } => { - debug_assert!(self.pending_tables.is_some()); - Observation::Counted { - count: self.tables()[*table].rows.len() as u64, - } - } - Interaction::Replay => { - self.pending_tables = None; - Observation::Replayed { - summaries: self.summaries(), - } - } - } - } - - pub fn in_mut_tx(&self) -> bool { - self.pending_tables.is_some() - } - - pub fn row_count(&self, table: usize) -> u64 { - self.tables()[table].rows.len() as u64 - } - - pub fn summaries(&self) -> Vec { - self.tables().iter().map(|table| summarize_rows(&table.rows)).collect() - } - - pub fn rows(&self, table: usize) -> &[Row] { - &self.tables()[table].rows - } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TableDelta { + pub table: usize, + pub inserts: TableSummary, + pub deletes: TableSummary, + pub truncated: bool, } pub struct WorkloadGen { @@ -169,7 +57,7 @@ impl WorkloadGen { } fn schema(&self) -> &SchemaPlan { - &self.model.schema + self.model.schema() } fn gen_value(&self, ty: Type) -> AlgebraicValue { From 3e342cb3d39d3d287908a9e0f8572691ac4c2b1d Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sat, 20 Jun 2026 15:50:32 +0530 Subject: [PATCH 22/25] autoinc --- crates/dst/src/engine.rs | 17 +++++ crates/dst/src/engine/model.rs | 12 ++-- crates/dst/src/engine/properties.rs | 104 ++++++++++++++++++++++++++-- crates/dst/src/engine/workload.rs | 61 +++++++++++++--- crates/dst/src/schema.rs | 42 ++++++++++- 5 files changed, 214 insertions(+), 22 deletions(-) diff --git a/crates/dst/src/engine.rs b/crates/dst/src/engine.rs index 03c5099dc32..d283e7dda36 100644 --- a/crates/dst/src/engine.rs +++ b/crates/dst/src/engine.rs @@ -13,6 +13,7 @@ use spacetimedb_runtime::Handle; use spacetimedb_schema::def::ModuleDef; use spacetimedb_schema::schema::{Schema, TableSchema}; use spacetimedb_table::page_pool::PagePool; +use spacetimedb_table::read_column::ReadColumn; mod model; mod properties; @@ -182,6 +183,21 @@ impl EngineTarget { CommitDelta { tables } } + fn auto_inc_values_from_tx_data(&self, tx_data: &TxData) -> Vec { + let Some((table_idx, col_idx)) = self.schema.auto_inc_table_and_column() else { + return vec![]; + }; + let table_id = self.table_ids[table_idx]; + let mut values = tx_data + .iter_table_entries() + .filter(|(id, _)| *id == table_id) + .flat_map(|(_, entry)| entry.inserts.iter()) + .filter_map(|row| row.read_col::(col_idx).ok()) + .collect::>(); + values.sort_unstable(); + values + } + pub fn execute(&mut self, interaction: &Interaction) -> anyhow::Result { match interaction { Interaction::BeginMutTx => { @@ -242,6 +258,7 @@ impl EngineTarget { }; Ok(Observation::Committed { delta: self.commit_delta_from_tx_data(&tx_data), + auto_inc_values: self.auto_inc_values_from_tx_data(&tx_data), }) } Interaction::Count { table } => { diff --git a/crates/dst/src/engine/model.rs b/crates/dst/src/engine/model.rs index 4f2dff86d1c..7dc1c43f25d 100644 --- a/crates/dst/src/engine/model.rs +++ b/crates/dst/src/engine/model.rs @@ -61,9 +61,10 @@ impl Model { Interaction::Insert { table, row } => { debug_assert!(self.pending_tables.is_some()); let primary_key = self.schema.tables[*table].primary_key; + let row = row.clone(); - if self.violates_unique_constraint_in(self.tables(), *table, row) - || self.tables()[*table].rows.contains(row) + if self.violates_unique_constraint_in(self.tables(), *table, &row) + || self.tables()[*table].rows.contains(&row) { return Observation::Inserted { count_after: self.tables()[*table].rows.len() as u64, @@ -79,7 +80,7 @@ impl Model { }; } } - rows.push(row.clone()); + rows.push(row); Observation::Inserted { count_after: rows.len() as u64, } @@ -97,7 +98,10 @@ impl Model { let pending_tables = self.pending_tables.take().expect("active transaction"); let delta = commit_delta_from_tables(&self.committed_tables, &pending_tables); self.committed_tables = pending_tables; - Observation::Committed { delta } + Observation::Committed { + delta, + auto_inc_values: vec![], + } } Interaction::Count { table } => { debug_assert!(self.pending_tables.is_some()); diff --git a/crates/dst/src/engine/properties.rs b/crates/dst/src/engine/properties.rs index 6548cb4a866..b9db96f2df0 100644 --- a/crates/dst/src/engine/properties.rs +++ b/crates/dst/src/engine/properties.rs @@ -1,3 +1,5 @@ +use std::cell::Cell; + use super::model::Model; use super::workload::{Interaction, Observation}; use crate::schema::SchemaPlan; @@ -10,9 +12,19 @@ pub struct EngineProperties { impl EngineProperties { pub fn new(schema: SchemaPlan) -> Self { + let auto_inc_table = schema.auto_inc_table_and_column().map(|(table, _)| table); Self { oracle: EngineOracle::new(schema), - properties: vec![Box::new(CountVisible), Box::new(CommitMatches), Box::new(ReplayMatches)], + properties: vec![ + Box::new(CountVisible), + Box::new(CommitMatches { + ignored_table: auto_inc_table, + }), + Box::new(AutoIncIncreasing::default()), + Box::new(ReplayMatches { + ignored_table: auto_inc_table, + }), + ], } } } @@ -82,7 +94,9 @@ impl EngineProperty for CountVisible { } } -struct CommitMatches; +struct CommitMatches { + ignored_table: Option, +} impl EngineProperty for CommitMatches { fn observes(&self, interaction: &Interaction) -> bool { @@ -95,19 +109,65 @@ impl EngineProperty for CommitMatches { observation: &Observation, expected: &Observation, ) -> anyhow::Result<()> { - let Observation::Committed { delta } = observation else { + let Observation::Committed { delta, .. } = observation else { anyhow::bail!("commit_matches: commit produced unexpected observation"); }; - let Observation::Committed { delta: expected } = expected else { + let Observation::Committed { delta: expected, .. } = expected else { unreachable!("CommitMatches only subscribes to commit interactions"); }; - anyhow::ensure!(delta == expected, "commit_matches: committed delta diverged from model"); + anyhow::ensure!( + filter_commit_delta(delta, self.ignored_table) == filter_commit_delta(expected, self.ignored_table), + "commit_matches: committed delta diverged from model" + ); + Ok(()) + } +} + +struct AutoIncIncreasing { + max_seen: Cell>, +} + +impl Default for AutoIncIncreasing { + fn default() -> Self { + Self { + max_seen: Cell::new(None), + } + } +} + +impl EngineProperty for AutoIncIncreasing { + fn observes(&self, interaction: &Interaction) -> bool { + matches!(interaction, Interaction::CommitTx) + } + + fn check( + &self, + _interaction: &Interaction, + observation: &Observation, + _expected: &Observation, + ) -> anyhow::Result<()> { + let Observation::Committed { auto_inc_values, .. } = observation else { + anyhow::bail!("auto_inc_increasing: commit produced unexpected observation"); + }; + + let mut previous = self.max_seen.get().unwrap_or(0); + for &value in auto_inc_values { + anyhow::ensure!( + value > previous, + "auto_inc_increasing: observed value {value} after {previous}" + ); + previous = value; + } + self.max_seen.set(Some(previous)); + Ok(()) } } -struct ReplayMatches; +struct ReplayMatches { + ignored_table: Option, +} impl EngineProperty for ReplayMatches { fn observes(&self, interaction: &Interaction) -> bool { @@ -128,9 +188,39 @@ impl EngineProperty for ReplayMatches { }; anyhow::ensure!( - summaries == expected, + filter_summaries(summaries, self.ignored_table) == filter_summaries(expected, self.ignored_table), "replay_matches: replayed target summary diverged from committed model" ); Ok(()) } } + +fn filter_commit_delta( + delta: &super::workload::CommitDelta, + ignored_table: Option, +) -> super::workload::CommitDelta { + let Some(ignored_table) = ignored_table else { + return delta.clone(); + }; + + super::workload::CommitDelta { + tables: delta + .tables + .iter() + .filter(|table| table.table != ignored_table) + .cloned() + .collect(), + } +} + +fn filter_summaries( + summaries: &[super::workload::TableSummary], + ignored_table: Option, +) -> Vec { + summaries + .iter() + .enumerate() + .filter(|(table, _)| Some(*table) != ignored_table) + .map(|(_, summary)| *summary) + .collect() +} diff --git a/crates/dst/src/engine/workload.rs b/crates/dst/src/engine/workload.rs index 972c7d1f5f9..41d74e1d020 100644 --- a/crates/dst/src/engine/workload.rs +++ b/crates/dst/src/engine/workload.rs @@ -20,11 +20,22 @@ pub enum Interaction { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Observation { BeganMutTx, - Inserted { count_after: u64 }, - Deleted { count_after: u64 }, - Committed { delta: CommitDelta }, - Counted { count: u64 }, - Replayed { summaries: Vec }, + Inserted { + count_after: u64, + }, + Deleted { + count_after: u64, + }, + Committed { + delta: CommitDelta, + auto_inc_values: Vec, + }, + Counted { + count: u64, + }, + Replayed { + summaries: Vec, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -82,8 +93,35 @@ impl WorkloadGen { .collect::() } + fn gen_insert_row(&self, table_idx: usize) -> Row { + let table = &self.schema().tables[table_idx]; + let mut row = self.gen_row(table); + if let Some(sequence) = table.sequences.first() { + row.elements[sequence.column] = match sequence.ty { + Type::I64 => AlgebraicValue::I64(0), + Type::U64 => AlgebraicValue::U64(0), + _ => unreachable!("sequence columns are integral"), + }; + } + row + } + + fn non_auto_inc_table_idx(&self) -> Option { + let auto_inc_table = self + .schema() + .auto_inc_table_and_column() + .map(|(table_idx, _)| table_idx); + (0..self.schema().tables.len()).find(|&table_idx| Some(table_idx) != auto_inc_table) + } + pub fn next_interaction(&mut self) -> Interaction { - let table_idx = self.rng.index(self.schema().tables.len()); + let insert_table_idx = self + .schema() + .auto_inc_table_and_column() + .map(|(table_idx, _)| table_idx) + .filter(|_| self.rng.next_u64() % 3 != 0) + .unwrap_or_else(|| self.rng.index(self.schema().tables.len())); + let read_write_table_idx = self.non_auto_inc_table_idx(); let interaction = if self.model.in_mut_tx() { let coin = self.rng.next_u64() % 11; @@ -91,17 +129,20 @@ impl WorkloadGen { Interaction::Replay } else if coin < 6 { Interaction::Insert { - table: table_idx, - row: self.gen_row(&self.schema().tables[table_idx]), + table: insert_table_idx, + row: self.gen_insert_row(insert_table_idx), } - } else if coin < 8 && !self.model.rows(table_idx).is_empty() { + } else if coin < 8 && read_write_table_idx.is_some_and(|table_idx| !self.model.rows(table_idx).is_empty()) { + let table_idx = read_write_table_idx.expect("checked above"); let rows = self.model.rows(table_idx); let row_index = self.rng.index(rows.len()); Interaction::Delete { table: table_idx, row: rows[row_index].clone(), } - } else if coin < 10 { + } else if coin < 10 + && let Some(table_idx) = read_write_table_idx + { Interaction::Count { table: table_idx } } else { Interaction::CommitTx diff --git a/crates/dst/src/schema.rs b/crates/dst/src/schema.rs index 27acaae1693..21aa4374cd3 100644 --- a/crates/dst/src/schema.rs +++ b/crates/dst/src/schema.rs @@ -6,7 +6,8 @@ use spacetimedb_sats::{AlgebraicType, AlgebraicValue, ArrayType, ArrayValue, Pro pub fn default_schema(rng: Rng) -> SchemaPlan { let profile = SchemaProfile::default(); - let plan = SchemaGenerator::new(rng, profile).gen_schema(); + let mut plan = SchemaGenerator::new(rng, profile).gen_schema(); + plan.ensure_auto_inc_table(); plan } @@ -72,6 +73,45 @@ impl SchemaPlan { let profile = SchemaProfile::default(); let schema = SchemaGenerator::new(rng, profile).gen_schema(); } + + pub fn auto_inc_table_and_column(&self) -> Option<(usize, usize)> { + self.tables + .iter() + .enumerate() + .find_map(|(table_idx, table)| table.sequences.first().map(|sequence| (table_idx, sequence.column))) + } + + pub fn ensure_auto_inc_table(&mut self) { + if self.auto_inc_table_and_column().is_some() { + return; + } + + let table = self.tables.first_mut().expect("schema must contain at least one table"); + if table.columns.is_empty() { + table.columns.push(ColumnPlan { + name: "id".into(), + ty: Type::U64, + }); + } else { + table.columns[0].ty = Type::U64; + } + + table.primary_key = Some(0); + if !table + .unique_constraints + .iter() + .any(|constraint| constraint.columns == [0]) + { + table.unique_constraints.push(UniqueConstraintPlan { columns: vec![0] }); + } + if !table.indexes.iter().any(|index| index.columns == [0]) { + table.indexes.push(IndexPlan { + columns: vec![0], + algorithm: IndexAlgorithm::BTree, + }); + } + table.sequences = vec![SequencePlan::new(0, Type::U64).expect("u64 is integral")]; + } } #[derive(Debug, Clone)] From e19181c0e426bccd9f448e13dd8196376d0cb381 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Mon, 22 Jun 2026 17:52:49 +0530 Subject: [PATCH 23/25] polish --- Cargo.lock | 1 + crates/dst/Cargo.toml | 1 + crates/dst/src/engine.rs | 123 ++++---- crates/dst/src/engine/model.rs | 425 +++++++++++++++++++++++----- crates/dst/src/engine/properties.rs | 138 ++------- crates/dst/src/engine/workload.rs | 280 ++++++++++++------ crates/dst/src/main.rs | 24 +- crates/dst/src/schema.rs | 71 +---- crates/dst/src/traits.rs | 22 +- 9 files changed, 675 insertions(+), 410 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7c127b2a78..d3c2f04d231 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8222,6 +8222,7 @@ dependencies = [ "spacetimedb-schema", "spacetimedb-table", "tracing", + "tracing-subscriber", ] [[package]] diff --git a/crates/dst/Cargo.toml b/crates/dst/Cargo.toml index 7926d27bdfc..2eadb7d99df 100644 --- a/crates/dst/Cargo.toml +++ b/crates/dst/Cargo.toml @@ -18,6 +18,7 @@ spacetimedb-sats.workspace = true spacetimedb-schema.workspace = true spacetimedb-table = { path = "../table", default-features = false } tracing.workspace = true +tracing-subscriber.workspace = true [lints] workspace = true diff --git a/crates/dst/src/engine.rs b/crates/dst/src/engine.rs index d283e7dda36..261c46644bf 100644 --- a/crates/dst/src/engine.rs +++ b/crates/dst/src/engine.rs @@ -3,7 +3,7 @@ use std::{io, sync::Arc}; use spacetimedb_commitlog::SizeOnDisk; use spacetimedb_datastore::execution_context::Workload; use spacetimedb_datastore::traits::{IsolationLevel, TxData}; -use spacetimedb_engine::error::DBError; +use spacetimedb_engine::error::{DBError, DatastoreError, IndexError}; use spacetimedb_engine::persistence::{DiskSizeFn, Durability as EngineDurability, Persistence}; use spacetimedb_engine::relational_db::{MutTx, RelationalDB}; use spacetimedb_lib::{Identity, RawModuleDef}; @@ -13,29 +13,29 @@ use spacetimedb_runtime::Handle; use spacetimedb_schema::def::ModuleDef; use spacetimedb_schema::schema::{Schema, TableSchema}; use spacetimedb_table::page_pool::PagePool; -use spacetimedb_table::read_column::ReadColumn; mod model; mod properties; mod workload; -use self::workload::{row_to_bytes, summarize_rows, CommitDelta, Interaction, Observation, TableDelta, TableSummary}; +use self::workload::{ + normalize_rows, row_to_bytes, CommitDelta, CountState, Interaction, Observation, TableDelta, TableRowCount, +}; use crate::engine::model::Model; use crate::engine::properties::EngineProperties; use crate::engine::workload::WorkloadGen; -use crate::schema::{default_schema, lower_schema, SchemaPlan}; +use crate::schema::{default_schema, to_raw_def, SchemaPlan}; use crate::sim::commitlog::{InMemoryCommitlog, InMemoryCommitlogHandle}; use crate::traits::{TargetDriver, TestSuite}; pub struct EngineTarget { db: Option, - schema: SchemaPlan, table_ids: Vec, + row_counts: Vec, active_mut_tx: Option, commitlog: InMemoryCommitlog, runtime_handle: Handle, - runtime: SimRuntime, } impl EngineTarget { @@ -50,12 +50,11 @@ impl EngineTarget { Ok(Self { db: Some(db), - schema, + row_counts: vec![0; table_ids.len()], table_ids, active_mut_tx: None, commitlog, runtime_handle, - runtime, }) } @@ -91,7 +90,7 @@ impl EngineTarget { } fn install_schema(db: &RelationalDB, schema: &SchemaPlan) -> anyhow::Result<()> { - let raw = lower_schema(schema); + let raw = to_raw_def(schema); let raw_module_def = RawModuleDef::V10(raw); let module_def = ModuleDef::try_from(raw_module_def).map_err(|e| anyhow::anyhow!("schema validation failed: {e}"))?; @@ -121,8 +120,7 @@ impl EngineTarget { Ok(table_ids) } - fn replay(&mut self) -> anyhow::Result<()> { - self.active_mut_tx.take(); + fn reopen_from_commitlog(&mut self) -> anyhow::Result<()> { let db = self .db .take() @@ -134,27 +132,34 @@ impl EngineTarget { Ok(()) } - fn table_summaries(&self) -> anyhow::Result> { + fn count_state(&self) -> anyhow::Result { let db = self .db .as_ref() .ok_or_else(|| anyhow::anyhow!("database is not open"))?; let tx = db.begin_tx(Workload::Internal); - let mut summaries = Vec::with_capacity(self.table_ids.len()); + let mut row_counts = Vec::with_capacity(self.table_ids.len()); - for table_id in &self.table_ids { - let rows = match db.iter(&tx, *table_id) { - Ok(iter) => iter.map(|row| row.to_product_value()).collect::>(), + for (table, table_id) in self.table_ids.iter().enumerate() { + let count = match db.iter(&tx, *table_id) { + Ok(iter) => iter.count() as u64, Err(err) => { let _ = db.release_tx(tx); return Err(err.into()); } }; - summaries.push(summarize_rows(&rows)); + row_counts.push(TableRowCount { table, count }); } let _ = db.release_tx(tx); - Ok(summaries) + Ok(CountState { row_counts }) + } + + fn is_unique_constraint_violation(error: &DBError) -> bool { + matches!( + error, + DBError::Datastore(DatastoreError::Index(IndexError::UniqueConstraintViolation(_))) + ) } fn commit_delta_from_tx_data(&self, tx_data: &TxData) -> CommitDelta { @@ -165,9 +170,9 @@ impl EngineTarget { continue; }; - let inserts = summarize_rows(entry.inserts.as_ref()); - let deletes = summarize_rows(entry.deletes.as_ref()); - if inserts.count == 0 && deletes.count == 0 && !entry.truncated { + let inserts = normalize_rows(entry.inserts.iter().cloned().collect()); + let deletes = normalize_rows(entry.deletes.iter().cloned().collect()); + if inserts.is_empty() && deletes.is_empty() && !entry.truncated { continue; } @@ -183,23 +188,10 @@ impl EngineTarget { CommitDelta { tables } } - fn auto_inc_values_from_tx_data(&self, tx_data: &TxData) -> Vec { - let Some((table_idx, col_idx)) = self.schema.auto_inc_table_and_column() else { - return vec![]; - }; - let table_id = self.table_ids[table_idx]; - let mut values = tx_data - .iter_table_entries() - .filter(|(id, _)| *id == table_id) - .flat_map(|(_, entry)| entry.inserts.iter()) - .filter_map(|row| row.read_col::(col_idx).ok()) - .collect::>(); - values.sort_unstable(); - values - } - pub fn execute(&mut self, interaction: &Interaction) -> anyhow::Result { - match interaction { + tracing::debug!(?interaction, "executing interaction"); + + let observation = match interaction { Interaction::BeginMutTx => { anyhow::ensure!( self.active_mut_tx.is_none(), @@ -224,11 +216,14 @@ impl EngineTarget { .as_mut() .ok_or_else(|| anyhow::anyhow!("insert without active mutable transaction"))?; match db.insert(tx, table_id, &bytes) { - Ok(_) => {} - Err(_) => {} + Ok(_) => self.row_counts[*table] += 1, + // Generated rows can intentionally hit unique constraints; the model treats those inserts as no-ops. + Err(error) if Self::is_unique_constraint_violation(&error) => {} + Err(error) => return Err(error.into()), } - let count_after = db.iter_mut(tx, table_id)?.count() as u64; - Ok(Observation::Inserted { count_after }) + Ok(Observation::Inserted { + rows_count: self.row_counts[*table], + }) } Interaction::Delete { table, row } => { let table_id = self.table_ids[*table]; @@ -240,9 +235,13 @@ impl EngineTarget { .active_mut_tx .as_mut() .ok_or_else(|| anyhow::anyhow!("delete without active mutable transaction"))?; - db.delete_by_rel(tx, table_id, [row.clone()]); - let count_after = db.iter_mut(tx, table_id)?.count() as u64; - Ok(Observation::Deleted { count_after }) + let deleted = db.delete_by_rel(tx, table_id, [row.clone()]) as u64; + self.row_counts[*table] = self.row_counts[*table] + .checked_sub(deleted) + .ok_or_else(|| anyhow::anyhow!("delete removed more rows than were tracked"))?; + Ok(Observation::Deleted { + rows_count: self.row_counts[*table], + }) } Interaction::CommitTx => { let tx = self @@ -258,37 +257,23 @@ impl EngineTarget { }; Ok(Observation::Committed { delta: self.commit_delta_from_tx_data(&tx_data), - auto_inc_values: self.auto_inc_values_from_tx_data(&tx_data), }) } - Interaction::Count { table } => { - let table_id = self.table_ids[*table]; - let db = self - .db - .as_ref() - .ok_or_else(|| anyhow::anyhow!("database is not open"))?; - let tx = self - .active_mut_tx - .as_mut() - .ok_or_else(|| anyhow::anyhow!("count without active mutable transaction"))?; - let count = db.iter_mut(tx, table_id)?.count() as u64; - Ok(Observation::Counted { count }) - } Interaction::Replay => { - self.replay()?; - Ok(Observation::Replayed { - summaries: self.table_summaries()?, - }) + let _ = self.active_mut_tx.take(); + self.reopen_from_commitlog()?; + let state = self.count_state()?; + self.row_counts = state.row_counts.iter().map(|row_count| row_count.count).collect(); + Ok(Observation::Replayed { state }) } - } - } + }; - pub fn db(&self) -> &RelationalDB { - self.db.as_ref().expect("database is open") - } + match &observation { + Ok(observation) => tracing::debug!(?observation, "observed interaction"), + Err(error) => tracing::error!(?interaction, %error, "interaction failed"), + } - pub fn schema(&self) -> &SchemaPlan { - &self.schema + observation } } diff --git a/crates/dst/src/engine/model.rs b/crates/dst/src/engine/model.rs index 7dc1c43f25d..1f1cdaf8a58 100644 --- a/crates/dst/src/engine/model.rs +++ b/crates/dst/src/engine/model.rs @@ -1,25 +1,63 @@ -use super::workload::{summarize_rows, CommitDelta, Interaction, Observation, Row, TableDelta, TableSummary}; +use super::workload::{ + normalize_rows, CommitDelta, CountState, Interaction, Observation, Row, TableDelta, TableRowCount, +}; use crate::schema::SchemaPlan; #[derive(Debug)] pub struct Model { schema: SchemaPlan, committed_tables: Vec, - pending_tables: Option>, + pending_tx: Option, } -#[derive(Debug, Clone)] +#[derive(Debug)] struct TableState { rows: Vec, } +#[derive(Debug)] +struct PendingTx { + tables: Vec, +} + +#[derive(Debug, Default)] +struct PendingTable { + inserts: Vec, + deletes: Vec, +} + +impl PendingTable { + fn is_touched(&self) -> bool { + !self.inserts.is_empty() || !self.deletes.is_empty() + } + + fn is_deleted(&self, row: &Row) -> bool { + self.deletes.iter().any(|deleted| deleted == row) + } + + fn after_contains(&self, before_rows: &[Row], row: &Row) -> bool { + self.inserts.iter().any(|inserted| inserted == row) + || before_rows + .iter() + .any(|before| !self.is_deleted(before) && before == row) + } +} + +impl PendingTx { + fn new(table_count: usize) -> Self { + Self { + tables: (0..table_count).map(|_| PendingTable::default()).collect(), + } + } +} + impl Model { pub fn new(schema: SchemaPlan) -> Self { let committed_tables = schema.tables.iter().map(|_| TableState { rows: vec![] }).collect(); Self { schema, committed_tables, - pending_tables: None, + pending_tx: None, } } @@ -27,24 +65,81 @@ impl Model { &self.schema } - fn tables(&self) -> &[TableState] { - self.pending_tables.as_deref().unwrap_or(&self.committed_tables) + fn pending_table(&self, table: usize) -> Option<&PendingTable> { + self.pending_tx.as_ref().map(|pending_tx| &pending_tx.tables[table]) } - fn pending_tables_mut(&mut self) -> &mut [TableState] { - self.pending_tables - .as_deref_mut() - .expect("mutable interaction without active transaction") + fn pending_table_mut(&mut self, table: usize) -> &mut PendingTable { + debug_assert!(self.pending_tx.is_some()); + &mut self.pending_tx.as_mut().expect("active transaction").tables[table] } - fn violates_unique_constraint_in(&self, tables: &[TableState], table: usize, row: &Row) -> bool { + fn committed_row_is_visible(&self, table: usize, row: &Row) -> bool { + self.pending_table(table) + .is_none_or(|pending_table| !pending_table.is_deleted(row)) + } + + fn visible_count(&self, table: usize) -> u64 { + let committed_count = self.committed_tables[table] + .rows + .iter() + .filter(|row| self.committed_row_is_visible(table, row)) + .count(); + let pending_insert_count = self + .pending_table(table) + .map_or(0, |pending_table| pending_table.inserts.len()); + (committed_count + pending_insert_count) as u64 + } + + fn any_visible_row(&self, table: usize, mut matches: impl FnMut(&Row) -> bool) -> bool { + for row in &self.committed_tables[table].rows { + if self.committed_row_is_visible(table, row) && matches(row) { + return true; + } + } + + if let Some(pending_table) = self.pending_table(table) { + for row in &pending_table.inserts { + if matches(row) { + return true; + } + } + } + + false + } + + fn visible_contains(&self, table: usize, row: &Row) -> bool { + self.any_visible_row(table, |visible_row| visible_row == row) + } + + fn committed_visible_contains(&self, table: usize, row: &Row) -> bool { + self.committed_tables[table] + .rows + .iter() + .any(|committed_row| self.committed_row_is_visible(table, committed_row) && committed_row == row) + } + + fn committed_visible_pk_match(&self, table: usize, pk_col: usize, row: &Row) -> Option { + self.committed_tables[table] + .rows + .iter() + .find(|committed_row| { + self.committed_row_is_visible(table, committed_row) + && committed_row.elements[pk_col] == row.elements[pk_col] + }) + .cloned() + } + + fn violates_unique_constraint(&self, table: usize, row: &Row) -> bool { let table_plan = &self.schema.tables[table]; - let rows = &tables[table].rows; for constraint in &table_plan.unique_constraints { - if rows - .iter() - .any(|r| constraint.columns.iter().all(|&c| r.elements[c] == row.elements[c])) - { + if self.any_visible_row(table, |visible_row| { + constraint + .columns + .iter() + .all(|&col| visible_row.elements[col] == row.elements[col]) + }) { return true; } } @@ -54,106 +149,290 @@ impl Model { pub fn apply(&mut self, interaction: &Interaction) -> Observation { match interaction { Interaction::BeginMutTx => { - debug_assert!(self.pending_tables.is_none()); - self.pending_tables = Some(self.committed_tables.clone()); + debug_assert!(self.pending_tx.is_none()); + self.pending_tx = Some(PendingTx::new(self.committed_tables.len())); Observation::BeganMutTx } Interaction::Insert { table, row } => { - debug_assert!(self.pending_tables.is_some()); + debug_assert!(self.pending_tx.is_some()); let primary_key = self.schema.tables[*table].primary_key; - let row = row.clone(); + let count_before = self.visible_count(*table); - if self.violates_unique_constraint_in(self.tables(), *table, &row) - || self.tables()[*table].rows.contains(&row) - { + if self.violates_unique_constraint(*table, row) || self.visible_contains(*table, row) { return Observation::Inserted { - count_after: self.tables()[*table].rows.len() as u64, + rows_count: count_before, }; } - let rows = &mut self.pending_tables_mut()[*table].rows; if let Some(pk_col) = primary_key { - if let Some(pos) = rows.iter().position(|r| r.elements[pk_col] == row.elements[pk_col]) { - rows[pos] = row.clone(); + if let Some(replaced_row) = self.committed_visible_pk_match(*table, pk_col, row) { + let pending_table = self.pending_table_mut(*table); + if !pending_table.is_deleted(&replaced_row) { + pending_table.deletes.push(replaced_row); + } + pending_table.inserts.push(row.clone()); + return Observation::Inserted { + rows_count: count_before, + }; + } + + let pending_table = self.pending_table_mut(*table); + if let Some(pos) = pending_table + .inserts + .iter() + .position(|inserted| inserted.elements[pk_col] == row.elements[pk_col]) + { + pending_table.inserts[pos] = row.clone(); return Observation::Inserted { - count_after: rows.len() as u64, + rows_count: count_before, }; } } - rows.push(row); + + self.pending_table_mut(*table).inserts.push(row.clone()); Observation::Inserted { - count_after: rows.len() as u64, + rows_count: count_before + 1, } } Interaction::Delete { table, row } => { - debug_assert!(self.pending_tables.is_some()); - let rows = &mut self.pending_tables_mut()[*table].rows; - rows.retain(|r| r != row); + debug_assert!(self.pending_tx.is_some()); + if self.visible_contains(*table, row) { + let committed_has_row = self.committed_visible_contains(*table, row); + let pending_table = self.pending_table_mut(*table); + pending_table.inserts.retain(|inserted| inserted != row); + if committed_has_row && !pending_table.is_deleted(row) { + pending_table.deletes.push(row.clone()); + } + } Observation::Deleted { - count_after: rows.len() as u64, + rows_count: self.visible_count(*table), } } Interaction::CommitTx => { - debug_assert!(self.pending_tables.is_some()); - let pending_tables = self.pending_tables.take().expect("active transaction"); - let delta = commit_delta_from_tables(&self.committed_tables, &pending_tables); - self.committed_tables = pending_tables; - Observation::Committed { - delta, - auto_inc_values: vec![], - } - } - Interaction::Count { table } => { - debug_assert!(self.pending_tables.is_some()); - Observation::Counted { - count: self.tables()[*table].rows.len() as u64, - } + debug_assert!(self.pending_tx.is_some()); + let pending_tx = self.pending_tx.take().expect("active transaction"); + let delta = self.commit_pending(pending_tx); + Observation::Committed { delta } } Interaction::Replay => { - self.pending_tables = None; + self.pending_tx = None; Observation::Replayed { - summaries: self.summaries(), + state: self.light_snapshot(), } } } } + fn commit_pending(&mut self, pending_tx: PendingTx) -> CommitDelta { + let mut tables = Vec::new(); + + for (table, pending_table) in pending_tx.tables.into_iter().enumerate() { + if !pending_table.is_touched() { + continue; + } + + let before_rows = &self.committed_tables[table].rows; + let inserts = normalize_rows( + pending_table + .inserts + .iter() + .filter(|inserted| !before_rows.contains(inserted)) + .cloned() + .collect(), + ); + let deletes = normalize_rows( + before_rows + .iter() + .filter(|before| !pending_table.after_contains(before_rows, before)) + .cloned() + .collect(), + ); + let after_count = before_rows + .iter() + .filter(|before| !pending_table.is_deleted(before)) + .count() + + pending_table.inserts.len(); + let truncated = !before_rows.is_empty() && after_count == 0 && !deletes.is_empty(); + + if !inserts.is_empty() || !deletes.is_empty() || truncated { + tables.push(TableDelta { + table, + inserts, + deletes, + truncated, + }); + } + + let committed_rows = &mut self.committed_tables[table].rows; + committed_rows.retain(|row| !pending_table.is_deleted(row)); + committed_rows.extend(pending_table.inserts); + } + + CommitDelta { tables } + } + pub fn in_mut_tx(&self) -> bool { - self.pending_tables.is_some() + self.pending_tx.is_some() } - pub fn summaries(&self) -> Vec { - self.tables().iter().map(|table| summarize_rows(&table.rows)).collect() + pub fn row_count(&self, table: usize) -> usize { + self.visible_count(table) as usize } - pub fn rows(&self, table: usize) -> &[Row] { - &self.tables()[table].rows + pub fn row(&self, table: usize, row: usize) -> Option<&Row> { + let mut remaining = row; + for committed_row in &self.committed_tables[table].rows { + if !self.committed_row_is_visible(table, committed_row) { + continue; + } + if remaining == 0 { + return Some(committed_row); + } + remaining -= 1; + } + + self.pending_table(table) + .and_then(|pending_table| pending_table.inserts.get(remaining)) + } + + #[cfg(test)] + pub fn rows(&self, table: usize) -> Vec { + let mut rows = Vec::with_capacity(self.row_count(table)); + for committed_row in &self.committed_tables[table].rows { + if self.committed_row_is_visible(table, committed_row) { + rows.push(committed_row.clone()); + } + } + if let Some(pending_table) = self.pending_table(table) { + rows.extend(pending_table.inserts.iter().cloned()); + } + rows + } + + fn light_snapshot(&self) -> CountState { + let row_counts = (0..self.schema.tables.len()) + .map(|table| TableRowCount { + table, + count: self.visible_count(table), + }) + .collect(); + CountState { row_counts } } } -fn commit_delta_from_tables(before: &[TableState], after: &[TableState]) -> CommitDelta { - let mut tables = Vec::new(); +#[cfg(test)] +mod tests { + use spacetimedb_lib::AlgebraicValue; - for (table, (before, after)) in before.iter().zip(after).enumerate() { - let inserts = rows_absent_from(&after.rows, &before.rows); - let deletes = rows_absent_from(&before.rows, &after.rows); - let truncated = !before.rows.is_empty() && after.rows.is_empty() && !deletes.is_empty(); + use super::*; + use crate::schema::{ColumnPlan, IndexAlgorithm, IndexPlan, TablePlan, Type, UniqueConstraintPlan}; - if inserts.is_empty() && deletes.is_empty() && !truncated { - continue; + fn schema() -> SchemaPlan { + SchemaPlan { + tables: vec![TablePlan { + name: "items".into(), + columns: vec![ColumnPlan { + name: "id".into(), + ty: Type::U64, + }], + primary_key: Some(0), + indexes: vec![IndexPlan { + columns: vec![0], + algorithm: IndexAlgorithm::BTree, + }], + unique_constraints: vec![UniqueConstraintPlan { columns: vec![0] }], + sequences: vec![], + is_public: true, + }], } + } - tables.push(TableDelta { - table, - inserts: summarize_rows(&inserts), - deletes: summarize_rows(&deletes), - truncated, - }); + fn row(id: u64) -> Row { + Row { + elements: vec![AlgebraicValue::U64(id)].into(), + } } - CommitDelta { tables } -} + #[test] + fn begin_mut_tx_does_not_clone_committed_tables() { + let mut model = Model::new(schema()); + model.committed_tables[0].rows.push(row(1)); + + model.apply(&Interaction::BeginMutTx); + + let pending_tx = model.pending_tx.as_ref().expect("active transaction"); + assert!(pending_tx.tables.iter().all(|table| !table.is_touched())); + assert_eq!(model.rows(0), vec![row(1)]); + } + + #[test] + fn insert_records_delta_without_cloning_committed_rows() { + let mut model = Model::new(schema()); + model.committed_tables[0].rows.push(row(1)); + + model.apply(&Interaction::BeginMutTx); + model.apply(&Interaction::Insert { table: 0, row: row(2) }); + + let pending_table = &model.pending_tx.as_ref().expect("active transaction").tables[0]; + assert_eq!(pending_table.inserts, vec![row(2)]); + assert!(pending_table.deletes.is_empty()); + assert_eq!(model.committed_tables[0].rows, vec![row(1)]); + assert_eq!(model.rows(0), vec![row(1), row(2)]); + } + + #[test] + fn delete_records_marker_without_cloning_committed_rows() { + let mut model = Model::new(schema()); + model.committed_tables[0].rows.push(row(1)); + model.committed_tables[0].rows.push(row(2)); + + model.apply(&Interaction::BeginMutTx); + model.apply(&Interaction::Delete { table: 0, row: row(1) }); + + let pending_table = &model.pending_tx.as_ref().expect("active transaction").tables[0]; + assert!(pending_table.inserts.is_empty()); + assert_eq!(pending_table.deletes, vec![row(1)]); + assert_eq!(model.committed_tables[0].rows, vec![row(1), row(2)]); + assert_eq!(model.rows(0), vec![row(2)]); + } + + #[test] + fn insert_is_visible_before_commit_and_replay_rolls_back() { + let mut model = Model::new(schema()); + + model.apply(&Interaction::BeginMutTx); + model.apply(&Interaction::Insert { table: 0, row: row(1) }); + assert_eq!(model.row_count(0), 1); + + model.apply(&Interaction::Replay); + model.apply(&Interaction::BeginMutTx); + assert_eq!(model.row_count(0), 0); + } + + #[test] + fn commit_applies_only_pending_overlay() { + let mut model = Model::new(schema()); -fn rows_absent_from(rows: &[Row], other: &[Row]) -> Vec { - rows.iter().filter(|row| !other.contains(row)).cloned().collect() + model.apply(&Interaction::BeginMutTx); + model.apply(&Interaction::Insert { table: 0, row: row(1) }); + let observation = model.apply(&Interaction::CommitTx); + + let Observation::Committed { delta, .. } = observation else { + panic!("expected commit observation"); + }; + assert_eq!(delta.tables.len(), 1); + assert_eq!(delta.tables[0].inserts, vec![row(1)]); + assert_eq!(model.committed_tables[0].rows, vec![row(1)]); + } + + #[test] + fn delete_is_visible_before_commit() { + let mut model = Model::new(schema()); + model.committed_tables[0].rows.push(row(1)); + + model.apply(&Interaction::BeginMutTx); + model.apply(&Interaction::Delete { table: 0, row: row(1) }); + + assert_eq!(model.row_count(0), 0); + } } diff --git a/crates/dst/src/engine/properties.rs b/crates/dst/src/engine/properties.rs index b9db96f2df0..5db551c1e9c 100644 --- a/crates/dst/src/engine/properties.rs +++ b/crates/dst/src/engine/properties.rs @@ -1,5 +1,3 @@ -use std::cell::Cell; - use super::model::Model; use super::workload::{Interaction, Observation}; use crate::schema::SchemaPlan; @@ -12,18 +10,19 @@ pub struct EngineProperties { impl EngineProperties { pub fn new(schema: SchemaPlan) -> Self { - let auto_inc_table = schema.auto_inc_table_and_column().map(|(table, _)| table); + let ignored_tables: Vec = schema + .tables + .iter() + .enumerate() + .filter_map(|(table, plan)| (!plan.sequences.is_empty()).then_some(table)) + .collect(); Self { oracle: EngineOracle::new(schema), properties: vec![ - Box::new(CountVisible), Box::new(CommitMatches { - ignored_table: auto_inc_table, - }), - Box::new(AutoIncIncreasing::default()), - Box::new(ReplayMatches { - ignored_table: auto_inc_table, + ignored_tables: ignored_tables.clone(), }), + Box::new(ReplayMatchesModel { ignored_tables }), ], } } @@ -66,36 +65,8 @@ impl EngineOracle { } } -struct CountVisible; - -impl EngineProperty for CountVisible { - fn observes(&self, interaction: &Interaction) -> bool { - matches!(interaction, Interaction::Count { .. }) - } - - fn check( - &self, - _interaction: &Interaction, - observation: &Observation, - expected: &Observation, - ) -> anyhow::Result<()> { - let Observation::Counted { count } = observation else { - anyhow::bail!("count_visible: count produced unexpected observation"); - }; - let Observation::Counted { count: expected } = expected else { - unreachable!("CountVisible only subscribes to count interactions"); - }; - - anyhow::ensure!( - count == expected, - "count_visible: count did not reflect visible transaction state" - ); - Ok(()) - } -} - struct CommitMatches { - ignored_table: Option, + ignored_tables: Vec, } impl EngineProperty for CommitMatches { @@ -117,59 +88,18 @@ impl EngineProperty for CommitMatches { }; anyhow::ensure!( - filter_commit_delta(delta, self.ignored_table) == filter_commit_delta(expected, self.ignored_table), + filter_commit_delta(delta, &self.ignored_tables) == filter_commit_delta(expected, &self.ignored_tables), "commit_matches: committed delta diverged from model" ); Ok(()) } } -struct AutoIncIncreasing { - max_seen: Cell>, +struct ReplayMatchesModel { + ignored_tables: Vec, } -impl Default for AutoIncIncreasing { - fn default() -> Self { - Self { - max_seen: Cell::new(None), - } - } -} - -impl EngineProperty for AutoIncIncreasing { - fn observes(&self, interaction: &Interaction) -> bool { - matches!(interaction, Interaction::CommitTx) - } - - fn check( - &self, - _interaction: &Interaction, - observation: &Observation, - _expected: &Observation, - ) -> anyhow::Result<()> { - let Observation::Committed { auto_inc_values, .. } = observation else { - anyhow::bail!("auto_inc_increasing: commit produced unexpected observation"); - }; - - let mut previous = self.max_seen.get().unwrap_or(0); - for &value in auto_inc_values { - anyhow::ensure!( - value > previous, - "auto_inc_increasing: observed value {value} after {previous}" - ); - previous = value; - } - self.max_seen.set(Some(previous)); - - Ok(()) - } -} - -struct ReplayMatches { - ignored_table: Option, -} - -impl EngineProperty for ReplayMatches { +impl EngineProperty for ReplayMatchesModel { fn observes(&self, interaction: &Interaction) -> bool { matches!(interaction, Interaction::Replay) } @@ -180,47 +110,39 @@ impl EngineProperty for ReplayMatches { observation: &Observation, expected: &Observation, ) -> anyhow::Result<()> { - let Observation::Replayed { summaries } = observation else { - anyhow::bail!("replay_matches: replay produced unexpected observation"); + let Observation::Replayed { state } = observation else { + anyhow::bail!("replay_matches_model: replay produced unexpected observation"); }; - let Observation::Replayed { summaries: expected } = expected else { - unreachable!("ReplayMatches only subscribes to replay interactions"); + let Observation::Replayed { state: expected } = expected else { + unreachable!("ReplayMatchesModel only subscribes to replay interactions"); }; anyhow::ensure!( - filter_summaries(summaries, self.ignored_table) == filter_summaries(expected, self.ignored_table), - "replay_matches: replayed target summary diverged from committed model" + filter_count_state(state, &self.ignored_tables) == filter_count_state(expected, &self.ignored_tables), + "replay_matches_model: replayed state diverged from model" ); Ok(()) } } -fn filter_commit_delta( - delta: &super::workload::CommitDelta, - ignored_table: Option, -) -> super::workload::CommitDelta { - let Some(ignored_table) = ignored_table else { - return delta.clone(); - }; - +fn filter_commit_delta(delta: &super::workload::CommitDelta, ignored_tables: &[usize]) -> super::workload::CommitDelta { super::workload::CommitDelta { tables: delta .tables .iter() - .filter(|table| table.table != ignored_table) + .filter(|table| !ignored_tables.contains(&table.table)) .cloned() .collect(), } } -fn filter_summaries( - summaries: &[super::workload::TableSummary], - ignored_table: Option, -) -> Vec { - summaries - .iter() - .enumerate() - .filter(|(table, _)| Some(*table) != ignored_table) - .map(|(_, summary)| *summary) - .collect() +fn filter_count_state(state: &super::workload::CountState, ignored_tables: &[usize]) -> super::workload::CountState { + super::workload::CountState { + row_counts: state + .row_counts + .iter() + .filter(|table| !ignored_tables.contains(&table.table)) + .copied() + .collect(), + } } diff --git a/crates/dst/src/engine/workload.rs b/crates/dst/src/engine/workload.rs index 41d74e1d020..7abfad27a88 100644 --- a/crates/dst/src/engine/workload.rs +++ b/crates/dst/src/engine/workload.rs @@ -1,6 +1,9 @@ +use std::fmt::{Debug, Error, Formatter}; + use spacetimedb_lib::bsatn::to_vec; use spacetimedb_lib::{AlgebraicValue, ProductValue}; use spacetimedb_runtime::sim::Rng; +use spacetimedb_sats::ArrayValue; use super::model::Model; use crate::schema::{SchemaPlan, TablePlan, Type}; @@ -13,58 +16,88 @@ pub enum Interaction { Insert { table: usize, row: Row }, Delete { table: usize, row: Row }, CommitTx, - Count { table: usize }, Replay, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct InteractionCounts { + pub total: usize, + pub begin_mut_tx: usize, + pub insert: usize, + pub delete: usize, + pub commit_tx: usize, + pub replay: usize, +} + +impl InteractionCounts { + pub fn record(&mut self, interaction: &Interaction) { + self.total += 1; + + match interaction { + Interaction::BeginMutTx => self.begin_mut_tx += 1, + Interaction::Insert { .. } => self.insert += 1, + Interaction::Delete { .. } => self.delete += 1, + Interaction::CommitTx => self.commit_tx += 1, + Interaction::Replay => self.replay += 1, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Observation { BeganMutTx, - Inserted { - count_after: u64, - }, - Deleted { - count_after: u64, - }, - Committed { - delta: CommitDelta, - auto_inc_values: Vec, - }, - Counted { - count: u64, - }, - Replayed { - summaries: Vec, - }, + Inserted { rows_count: u64 }, + Deleted { rows_count: u64 }, + Committed { delta: CommitDelta }, + Replayed { state: CountState }, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct TableSummary { - pub count: u64, - pub hash: u64, +#[derive(Debug, Clone, Copy)] +pub struct InteractionWeights { + pub insert: u64, + pub delete: u64, + pub commit_tx: u64, + pub replay: u64, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommitDelta { - pub tables: Vec, +impl Default for InteractionWeights { + fn default() -> Self { + Self { + insert: 50, + delete: 20, + commit_tx: 29, + replay: 1, + } + } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TableDelta { - pub table: usize, - pub inserts: TableSummary, - pub deletes: TableSummary, - pub truncated: bool, +#[derive(Debug, Clone, Copy)] +enum InteractionChoice { + Insert, + Delete, + CommitTx, + Replay, } pub struct WorkloadGen { rng: Rng, model: Model, + stats: InteractionCounts, + weights: InteractionWeights, } impl WorkloadGen { pub fn new(rng: Rng, model: Model) -> Self { - Self { rng, model } + Self { + rng, + model, + stats: InteractionCounts::default(), + weights: InteractionWeights::default(), + } + } + + pub fn stats(&self) -> InteractionCounts { + self.stats } fn schema(&self) -> &SchemaPlan { @@ -96,13 +129,15 @@ impl WorkloadGen { fn gen_insert_row(&self, table_idx: usize) -> Row { let table = &self.schema().tables[table_idx]; let mut row = self.gen_row(table); + if let Some(sequence) = table.sequences.first() { - row.elements[sequence.column] = match sequence.ty { + row.elements[sequence.column] = match table.columns[sequence.column].ty { Type::I64 => AlgebraicValue::I64(0), Type::U64 => AlgebraicValue::U64(0), _ => unreachable!("sequence columns are integral"), }; } + row } @@ -111,82 +146,157 @@ impl WorkloadGen { .schema() .auto_inc_table_and_column() .map(|(table_idx, _)| table_idx); + (0..self.schema().tables.len()).find(|&table_idx| Some(table_idx) != auto_inc_table) } pub fn next_interaction(&mut self) -> Interaction { - let insert_table_idx = self - .schema() - .auto_inc_table_and_column() - .map(|(table_idx, _)| table_idx) - .filter(|_| self.rng.next_u64() % 3 != 0) - .unwrap_or_else(|| self.rng.index(self.schema().tables.len())); - let read_write_table_idx = self.non_auto_inc_table_idx(); - - let interaction = if self.model.in_mut_tx() { - let coin = self.rng.next_u64() % 11; - if coin == 0 { - Interaction::Replay - } else if coin < 6 { + let choice = self.pick_interaction_choice(); + let interaction = self.interaction_from_choice(choice); + + self.model.apply(&interaction); + self.stats.record(&interaction); + + interaction + } + + fn interaction_from_choice(&mut self, choice: InteractionChoice) -> Interaction { + if !self.model.in_mut_tx() { + return match choice { + InteractionChoice::Replay => Interaction::Replay, + + // Insert/Delete/CommitTx are not legal outside a mutable tx. + // Treat those weighted choices as pressure to start one. + InteractionChoice::Insert | InteractionChoice::Delete | InteractionChoice::CommitTx => { + Interaction::BeginMutTx + } + }; + } + + match choice { + InteractionChoice::Replay => Interaction::Replay, + + InteractionChoice::Insert => { + let table = self.insert_table_idx(); + Interaction::Insert { - table: insert_table_idx, - row: self.gen_insert_row(insert_table_idx), + table, + row: self.gen_insert_row(table), } - } else if coin < 8 && read_write_table_idx.is_some_and(|table_idx| !self.model.rows(table_idx).is_empty()) { - let table_idx = read_write_table_idx.expect("checked above"); - let rows = self.model.rows(table_idx); - let row_index = self.rng.index(rows.len()); + } + + InteractionChoice::Delete => { + let Some(table) = self.deletable_table_idx() else { + return Interaction::CommitTx; + }; + + let row_index = self.rng.index(self.model.row_count(table)); + Interaction::Delete { - table: table_idx, - row: rows[row_index].clone(), + table, + row: self + .model + .row(table, row_index) + .expect("row index is in bounds") + .clone(), } - } else if coin < 10 - && let Some(table_idx) = read_write_table_idx - { - Interaction::Count { table: table_idx } - } else { - Interaction::CommitTx } - } else if self.rng.next_u64() % 5 == 0 { - Interaction::Replay - } else { - Interaction::BeginMutTx - }; - self.model.apply(&interaction); - interaction + InteractionChoice::CommitTx => Interaction::CommitTx, + } + } + + fn pick_interaction_choice(&mut self) -> InteractionChoice { + let weights = self.weights; + + match self.pick_weighted(&[weights.insert, weights.delete, weights.commit_tx, weights.replay]) { + 0 => InteractionChoice::Insert, + 1 => InteractionChoice::Delete, + 2 => InteractionChoice::CommitTx, + 3 => InteractionChoice::Replay, + _ => unreachable!(), + } + } + + fn pick_weighted(&mut self, weights: &[u64]) -> usize { + let total: u64 = weights.iter().sum(); + + assert!(total > 0, "at least one interaction weight must be non-zero"); + + let mut selected = self.rng.next_u64() % total; + + for (idx, weight) in weights.iter().copied().enumerate() { + if selected < weight { + return idx; + } + + selected -= weight; + } + + unreachable!("selected value is always inside total weight") + } + + fn insert_table_idx(&self) -> usize { + let auto_inc_table_idx = self + .schema() + .auto_inc_table_and_column() + .map(|(table_idx, _)| table_idx); + + match auto_inc_table_idx { + Some(table_idx) if self.rng.next_u64() % 3 != 0 => table_idx, + _ => self.rng.index(self.schema().tables.len()), + } + } + + fn deletable_table_idx(&self) -> Option { + self.non_auto_inc_table_idx() + .filter(|&table_idx| self.model.row_count(table_idx) > 0) + } +} + +impl Debug for WorkloadGen { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + write!(f, "{:?}", self.stats()) } } + impl Iterator for WorkloadGen { type Item = Interaction; + fn next(&mut self) -> Option { Some(self.next_interaction()) } } -use spacetimedb_sats::ArrayValue; - pub fn row_to_bytes(row: &Row) -> Vec { to_vec(row).expect("row serialization must not fail") } -pub fn summarize_rows(rows: &[Row]) -> TableSummary { - let mut hash = 0u64; - for row in rows { - let row_hash = stable_hash(&row_to_bytes(row)); - hash = hash.wrapping_add(row_hash.rotate_left((row_hash & 31) as u32)); - } - TableSummary { - count: rows.len() as u64, - hash, - } +pub fn normalize_rows(mut rows: Vec) -> Vec { + rows.sort_by_key(row_to_bytes); + rows } -fn stable_hash(bytes: &[u8]) -> u64 { - let mut hash = 0xcbf2_9ce4_8422_2325u64; - for byte in bytes { - hash ^= *byte as u64; - hash = hash.wrapping_mul(0x100_0000_01b3); - } - hash +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CountState { + pub row_counts: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TableRowCount { + pub table: usize, + pub count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitDelta { + pub tables: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TableDelta { + pub table: usize, + pub inserts: Vec, + pub deletes: Vec, + pub truncated: bool, } diff --git a/crates/dst/src/main.rs b/crates/dst/src/main.rs index 7fc59ca99d6..e208f921a83 100644 --- a/crates/dst/src/main.rs +++ b/crates/dst/src/main.rs @@ -2,6 +2,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use clap::{Args, Parser, Subcommand}; use spacetimedb_runtime::sim::Rng; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod engine; mod schema; @@ -38,7 +39,24 @@ fn main() -> anyhow::Result<()> { } } -fn init_tracing() {} +fn init_tracing() { + let timer = tracing_subscriber::fmt::time(); + let format = tracing_subscriber::fmt::format::Format::default() + .with_timer(timer) + .with_line_number(true) + .with_file(true) + .with_target(false) + .compact(); + let fmt_layer = tracing_subscriber::fmt::Layer::default() + .event_format(format) + .with_writer(std::io::stderr); + let env_filter_layer = tracing_subscriber::EnvFilter::from_default_env(); + + let _ = tracing_subscriber::Registry::default() + .with(fmt_layer) + .with(env_filter_layer) + .try_init(); +} fn run_command(args: RunArgs) -> anyhow::Result<()> { let seed = resolve_seed(args.seed); @@ -47,13 +65,13 @@ fn run_command(args: RunArgs) -> anyhow::Result<()> { seed, }; - eprintln!("seed: {}", config.seed); + tracing::info!(?config, "initial run config"); // Generate schema from seed. let rng = Rng::new(config.seed); let test = EngineTest {}; - test.run(rng)?; + test.run(rng, config.max_interactions)?; Ok(()) } diff --git a/crates/dst/src/schema.rs b/crates/dst/src/schema.rs index 21aa4374cd3..82c1e8bac87 100644 --- a/crates/dst/src/schema.rs +++ b/crates/dst/src/schema.rs @@ -2,7 +2,7 @@ use spacetimedb_lib::db::raw_def::v10::*; use spacetimedb_lib::db::raw_def::v9::{RawIndexAlgorithm, TableAccess, TableType}; use spacetimedb_primitives::{ColId, ColList}; use spacetimedb_runtime::sim::Rng; -use spacetimedb_sats::{AlgebraicType, AlgebraicValue, ArrayType, ArrayValue, ProductType, ProductTypeElement}; +use spacetimedb_sats::{AlgebraicType, ArrayType, ProductType, ProductTypeElement}; pub fn default_schema(rng: Rng) -> SchemaPlan { let profile = SchemaProfile::default(); @@ -40,27 +40,6 @@ impl Type { } } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum Value { - Bool(bool), - I64(i64), - U64(u64), - String(String), - Bytes(Vec), -} - -impl Value { - fn to_algebraic(&self) -> AlgebraicValue { - match self { - Value::Bool(b) => AlgebraicValue::Bool(*b), - Value::I64(v) => AlgebraicValue::I64(*v), - Value::U64(v) => AlgebraicValue::U64(*v), - Value::String(s) => AlgebraicValue::String(s.clone().into()), - Value::Bytes(b) => AlgebraicValue::Array(ArrayValue::U8(b.clone().into())), - } - } -} - // Schema plan — the canonical source of truth. // This Schema should be able to translate to valid `RawModuleDefV10`. #[derive(Debug, Clone)] @@ -69,11 +48,6 @@ pub struct SchemaPlan { } impl SchemaPlan { - fn new(rng: Rng) { - let profile = SchemaProfile::default(); - let schema = SchemaGenerator::new(rng, profile).gen_schema(); - } - pub fn auto_inc_table_and_column(&self) -> Option<(usize, usize)> { self.tables .iter() @@ -122,7 +96,6 @@ pub struct TablePlan { pub indexes: Vec, pub unique_constraints: Vec, pub sequences: Vec, - pub default_values: Vec, pub is_public: bool, } @@ -151,19 +124,11 @@ pub struct UniqueConstraintPlan { pub columns: Vec, } -/// A sequence on a specific column. The column's type is carried inline -/// so callers cannot create a sequence on a non-integral column — -/// the constructor requires `ty.is_integral()`. +/// A sequence on a specific integral column. #[derive(Debug, Clone)] pub struct SequencePlan { /// Index into `TablePlan.columns`. pub column: usize, - /// The type of that column. Must be integral (I64 or U64). - pub ty: Type, - pub start: Option, - pub min_value: Option, - pub max_value: Option, - pub increment: i128, } impl SequencePlan { @@ -172,37 +137,23 @@ impl SequencePlan { if !ty.is_integral() { return None; } - Some(Self { - column, - ty, - start: None, - min_value: None, - max_value: None, - increment: 1, - }) + Some(Self { column }) } } -#[derive(Debug, Clone)] -pub struct DefaultPlan { - /// Index into `TablePlan.columns`. - pub column: usize, - pub value: Value, -} - // Lowering into RawModuleDefV10. -pub fn lower_schema(schema: &SchemaPlan) -> RawModuleDefV10 { +pub fn to_raw_def(schema: &SchemaPlan) -> RawModuleDefV10 { let mut builder = RawModuleDefV10Builder::new(); builder.set_case_conversion_policy(CaseConversionPolicy::None); for table in &schema.tables { - lower_table(&mut builder, table); + to_raw_def_table(&mut builder, table); } builder.finish() } -fn lower_table(builder: &mut RawModuleDefV10Builder, table: &TablePlan) { +fn to_raw_def_table(builder: &mut RawModuleDefV10Builder, table: &TablePlan) { let product_type = ProductType { elements: table .columns @@ -261,12 +212,6 @@ fn lower_table(builder: &mut RawModuleDefV10Builder, table: &TablePlan) { tbl = tbl.with_column_sequence(ColId(seq.column as u16)); } - // Default values. - for default in &table.default_values { - let algebraic_val = default.value.to_algebraic(); - tbl = tbl.with_default_column_value(ColId(default.column as u16), algebraic_val); - } - tbl.finish(); } @@ -466,7 +411,6 @@ impl SchemaGenerator { indexes, unique_constraints, sequences, - default_values: vec![], is_public: !self.rng.sample_probability(self.profile.private_prob), } } @@ -508,12 +452,11 @@ mod tests { }], unique_constraints: vec![UniqueConstraintPlan { columns: vec![0] }], sequences: vec![SequencePlan::new(0, Type::U64).unwrap()], - default_values: vec![], is_public: true, }], }; - let raw = lower_schema(&schema); + let raw = to_raw_def(&schema); // Should have Typespace, Types, and Tables sections. assert!(raw.typespace().is_some()); diff --git a/crates/dst/src/traits.rs b/crates/dst/src/traits.rs index ee96159b48a..aff7e706e29 100644 --- a/crates/dst/src/traits.rs +++ b/crates/dst/src/traits.rs @@ -16,23 +16,29 @@ pub trait Properties { pub trait TestSuite { type Interaction; - type Interactions: Iterator; + type Interactions: Iterator + std::fmt::Debug; type Target: TargetDriver; type Properties: Properties>::Observation>; fn build(&self, rng: Rng) -> Result<(Self::Interactions, Self::Target, Self::Properties), Error>; - fn run(&self, rng: Rng) -> Result<(), Error> + fn run(&self, rng: Rng, max_interactions: Option) -> Result<(), Error> where Self: Sized, { - let (interactions, mut target, mut properties) = self.build(rng)?; + let (mut interactions, mut target, mut properties) = self.build(rng)?; - for interaction in interactions { - let observation = target.execute(&interaction)?; - properties.observe(&interaction, &observation)?; - } + let result = (|| { + for interaction in interactions.by_ref().take(max_interactions.unwrap_or(usize::MAX)) { + let observation = target.execute(&interaction)?; + properties.observe(&interaction, &observation)?; + } - Ok(()) + Ok(()) + })(); + + tracing::info!(interaction_counts = ?interactions, "final interaction counts"); + + result } } From 07a6c3ddf6d182855a6932e883aa4d77e07456d2 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Mon, 22 Jun 2026 18:17:35 +0530 Subject: [PATCH 24/25] fixes --- crates/dst/README.md | 3 +- crates/dst/src/engine.rs | 36 +++---- crates/dst/src/engine/model.rs | 158 +++++++--------------------- crates/dst/src/engine/properties.rs | 117 ++++++++++++-------- crates/dst/src/engine/workload.rs | 10 +- crates/dst/src/sim/commitlog.rs | 103 +++++++++++++++--- 6 files changed, 224 insertions(+), 203 deletions(-) diff --git a/crates/dst/README.md b/crates/dst/README.md index f24923d88f1..1a4dfcb2396 100644 --- a/crates/dst/README.md +++ b/crates/dst/README.md @@ -11,11 +11,10 @@ cargo test -p spacetimedb-dst ## Run ```sh -cargo run -p spacetimedb-dst -- run --seed 42 --tables 5 +cargo run -p spacetimedb-dst -- run --seed 42 --max-interactions 1000 ``` Options: - `--seed ` — RNG seed (defaults to wall-clock nanos) -- `--tables ` — number of tables to generate (default 3) - `--max-interactions ` — interaction budget diff --git a/crates/dst/src/engine.rs b/crates/dst/src/engine.rs index 261c46644bf..ce607befd23 100644 --- a/crates/dst/src/engine.rs +++ b/crates/dst/src/engine.rs @@ -19,7 +19,8 @@ mod properties; mod workload; use self::workload::{ - normalize_rows, row_to_bytes, CommitDelta, CountState, Interaction, Observation, TableDelta, TableRowCount, + normalize_rows, row_to_bytes, CommitDelta, CountState, InsertOutcome, Interaction, Observation, TableDelta, + TableRowCount, }; use crate::engine::model::Model; @@ -32,7 +33,6 @@ use crate::traits::{TargetDriver, TestSuite}; pub struct EngineTarget { db: Option, table_ids: Vec, - row_counts: Vec, active_mut_tx: Option, commitlog: InMemoryCommitlog, runtime_handle: Handle, @@ -50,7 +50,6 @@ impl EngineTarget { Ok(Self { db: Some(db), - row_counts: vec![0; table_ids.len()], table_ids, active_mut_tx: None, commitlog, @@ -215,15 +214,15 @@ impl EngineTarget { .active_mut_tx .as_mut() .ok_or_else(|| anyhow::anyhow!("insert without active mutable transaction"))?; - match db.insert(tx, table_id, &bytes) { - Ok(_) => self.row_counts[*table] += 1, - // Generated rows can intentionally hit unique constraints; the model treats those inserts as no-ops. - Err(error) if Self::is_unique_constraint_violation(&error) => {} + let outcome = match db.insert(tx, table_id, &bytes) { + Ok((_generated_columns, row, _flags)) => InsertOutcome::Accepted(row.to_product_value()), + // Generated rows can intentionally hit unique constraints; the oracle validates that rejection. + Err(error) if Self::is_unique_constraint_violation(&error) => { + InsertOutcome::UniqueConstraintViolation + } Err(error) => return Err(error.into()), - } - Ok(Observation::Inserted { - rows_count: self.row_counts[*table], - }) + }; + Ok(Observation::Inserted { outcome }) } Interaction::Delete { table, row } => { let table_id = self.table_ids[*table]; @@ -235,13 +234,8 @@ impl EngineTarget { .active_mut_tx .as_mut() .ok_or_else(|| anyhow::anyhow!("delete without active mutable transaction"))?; - let deleted = db.delete_by_rel(tx, table_id, [row.clone()]) as u64; - self.row_counts[*table] = self.row_counts[*table] - .checked_sub(deleted) - .ok_or_else(|| anyhow::anyhow!("delete removed more rows than were tracked"))?; - Ok(Observation::Deleted { - rows_count: self.row_counts[*table], - }) + db.delete_by_rel(tx, table_id, [row.clone()]); + Ok(Observation::Deleted) } Interaction::CommitTx => { let tx = self @@ -262,9 +256,9 @@ impl EngineTarget { Interaction::Replay => { let _ = self.active_mut_tx.take(); self.reopen_from_commitlog()?; - let state = self.count_state()?; - self.row_counts = state.row_counts.iter().map(|row_count| row_count.count).collect(); - Ok(Observation::Replayed { state }) + Ok(Observation::Replayed { + state: self.count_state()?, + }) } }; diff --git a/crates/dst/src/engine/model.rs b/crates/dst/src/engine/model.rs index 1f1cdaf8a58..4d1ecb0aeda 100644 --- a/crates/dst/src/engine/model.rs +++ b/crates/dst/src/engine/model.rs @@ -1,5 +1,5 @@ use super::workload::{ - normalize_rows, CommitDelta, CountState, Interaction, Observation, Row, TableDelta, TableRowCount, + normalize_rows, CommitDelta, CountState, InsertOutcome, Interaction, Observation, Row, TableDelta, TableRowCount, }; use crate::schema::SchemaPlan; @@ -20,6 +20,8 @@ struct PendingTx { tables: Vec, } +// Keep mutable transactions as an overlay: committed rows stay shared, while +// pending tables record only new rows and delete markers. #[derive(Debug, Default)] struct PendingTable { inserts: Vec, @@ -27,20 +29,9 @@ struct PendingTable { } impl PendingTable { - fn is_touched(&self) -> bool { - !self.inserts.is_empty() || !self.deletes.is_empty() - } - fn is_deleted(&self, row: &Row) -> bool { self.deletes.iter().any(|deleted| deleted == row) } - - fn after_contains(&self, before_rows: &[Row], row: &Row) -> bool { - self.inserts.iter().any(|inserted| inserted == row) - || before_rows - .iter() - .any(|before| !self.is_deleted(before) && before == row) - } } impl PendingTx { @@ -74,61 +65,29 @@ impl Model { &mut self.pending_tx.as_mut().expect("active transaction").tables[table] } - fn committed_row_is_visible(&self, table: usize, row: &Row) -> bool { - self.pending_table(table) - .is_none_or(|pending_table| !pending_table.is_deleted(row)) - } - - fn visible_count(&self, table: usize) -> u64 { - let committed_count = self.committed_tables[table] + fn visible_committed_rows(&self, table: usize) -> impl Iterator + '_ { + let pending_table = self.pending_table(table); + self.committed_tables[table] .rows .iter() - .filter(|row| self.committed_row_is_visible(table, row)) - .count(); - let pending_insert_count = self - .pending_table(table) - .map_or(0, |pending_table| pending_table.inserts.len()); - (committed_count + pending_insert_count) as u64 + .filter(move |row| pending_table.is_none_or(|pending_table| !pending_table.is_deleted(row))) } - fn any_visible_row(&self, table: usize, mut matches: impl FnMut(&Row) -> bool) -> bool { - for row in &self.committed_tables[table].rows { - if self.committed_row_is_visible(table, row) && matches(row) { - return true; - } - } - - if let Some(pending_table) = self.pending_table(table) { - for row in &pending_table.inserts { - if matches(row) { - return true; - } - } - } - - false + // Visibility is committed rows minus delete markers, followed by pending inserts. + fn visible_rows(&self, table: usize) -> impl Iterator + '_ { + self.visible_committed_rows(table).chain( + self.pending_table(table) + .into_iter() + .flat_map(|pending_table| pending_table.inserts.iter()), + ) } - fn visible_contains(&self, table: usize, row: &Row) -> bool { - self.any_visible_row(table, |visible_row| visible_row == row) - } - - fn committed_visible_contains(&self, table: usize, row: &Row) -> bool { - self.committed_tables[table] - .rows - .iter() - .any(|committed_row| self.committed_row_is_visible(table, committed_row) && committed_row == row) + fn visible_count(&self, table: usize) -> u64 { + self.visible_rows(table).count() as u64 } - fn committed_visible_pk_match(&self, table: usize, pk_col: usize, row: &Row) -> Option { - self.committed_tables[table] - .rows - .iter() - .find(|committed_row| { - self.committed_row_is_visible(table, committed_row) - && committed_row.elements[pk_col] == row.elements[pk_col] - }) - .cloned() + fn any_visible_row(&self, table: usize, matches: impl FnMut(&Row) -> bool) -> bool { + self.visible_rows(table).any(matches) } fn violates_unique_constraint(&self, table: usize, row: &Row) -> bool { @@ -155,58 +114,36 @@ impl Model { } Interaction::Insert { table, row } => { debug_assert!(self.pending_tx.is_some()); - let primary_key = self.schema.tables[*table].primary_key; - let count_before = self.visible_count(*table); - - if self.violates_unique_constraint(*table, row) || self.visible_contains(*table, row) { + // Properties feed the target-returned row here, so sequence-generated + // values become part of the oracle before commit/replay checks run. + if self.any_visible_row(*table, |visible_row| visible_row == row) { return Observation::Inserted { - rows_count: count_before, + outcome: InsertOutcome::Accepted(row.clone()), }; } - if let Some(pk_col) = primary_key { - if let Some(replaced_row) = self.committed_visible_pk_match(*table, pk_col, row) { - let pending_table = self.pending_table_mut(*table); - if !pending_table.is_deleted(&replaced_row) { - pending_table.deletes.push(replaced_row); - } - pending_table.inserts.push(row.clone()); - return Observation::Inserted { - rows_count: count_before, - }; - } - - let pending_table = self.pending_table_mut(*table); - if let Some(pos) = pending_table - .inserts - .iter() - .position(|inserted| inserted.elements[pk_col] == row.elements[pk_col]) - { - pending_table.inserts[pos] = row.clone(); - return Observation::Inserted { - rows_count: count_before, - }; - } + if self.violates_unique_constraint(*table, row) { + return Observation::Inserted { + outcome: InsertOutcome::UniqueConstraintViolation, + }; } self.pending_table_mut(*table).inserts.push(row.clone()); Observation::Inserted { - rows_count: count_before + 1, + outcome: InsertOutcome::Accepted(row.clone()), } } Interaction::Delete { table, row } => { debug_assert!(self.pending_tx.is_some()); - if self.visible_contains(*table, row) { - let committed_has_row = self.committed_visible_contains(*table, row); + if self.any_visible_row(*table, |visible_row| visible_row == row) { + let committed_has_row = self.visible_committed_rows(*table).any(|committed| committed == row); let pending_table = self.pending_table_mut(*table); pending_table.inserts.retain(|inserted| inserted != row); if committed_has_row && !pending_table.is_deleted(row) { pending_table.deletes.push(row.clone()); } } - Observation::Deleted { - rows_count: self.visible_count(*table), - } + Observation::Deleted } Interaction::CommitTx => { debug_assert!(self.pending_tx.is_some()); @@ -227,7 +164,7 @@ impl Model { let mut tables = Vec::new(); for (table, pending_table) in pending_tx.tables.into_iter().enumerate() { - if !pending_table.is_touched() { + if pending_table.inserts.is_empty() && pending_table.deletes.is_empty() { continue; } @@ -240,10 +177,11 @@ impl Model { .cloned() .collect(), ); + // A delete followed by the same insert leaves the committed set unchanged. let deletes = normalize_rows( before_rows .iter() - .filter(|before| !pending_table.after_contains(before_rows, before)) + .filter(|before| pending_table.is_deleted(before) && !pending_table.inserts.contains(before)) .cloned() .collect(), ); @@ -280,33 +218,12 @@ impl Model { } pub fn row(&self, table: usize, row: usize) -> Option<&Row> { - let mut remaining = row; - for committed_row in &self.committed_tables[table].rows { - if !self.committed_row_is_visible(table, committed_row) { - continue; - } - if remaining == 0 { - return Some(committed_row); - } - remaining -= 1; - } - - self.pending_table(table) - .and_then(|pending_table| pending_table.inserts.get(remaining)) + self.visible_rows(table).nth(row) } #[cfg(test)] pub fn rows(&self, table: usize) -> Vec { - let mut rows = Vec::with_capacity(self.row_count(table)); - for committed_row in &self.committed_tables[table].rows { - if self.committed_row_is_visible(table, committed_row) { - rows.push(committed_row.clone()); - } - } - if let Some(pending_table) = self.pending_table(table) { - rows.extend(pending_table.inserts.iter().cloned()); - } - rows + self.visible_rows(table).cloned().collect() } fn light_snapshot(&self) -> CountState { @@ -361,7 +278,10 @@ mod tests { model.apply(&Interaction::BeginMutTx); let pending_tx = model.pending_tx.as_ref().expect("active transaction"); - assert!(pending_tx.tables.iter().all(|table| !table.is_touched())); + assert!(pending_tx + .tables + .iter() + .all(|table| table.inserts.is_empty() && table.deletes.is_empty())); assert_eq!(model.rows(0), vec![row(1)]); } diff --git a/crates/dst/src/engine/properties.rs b/crates/dst/src/engine/properties.rs index 5db551c1e9c..667eec09510 100644 --- a/crates/dst/src/engine/properties.rs +++ b/crates/dst/src/engine/properties.rs @@ -1,5 +1,5 @@ use super::model::Model; -use super::workload::{Interaction, Observation}; +use super::workload::{InsertOutcome, Interaction, Observation, Row}; use crate::schema::SchemaPlan; use crate::traits::Properties; @@ -10,19 +10,12 @@ pub struct EngineProperties { impl EngineProperties { pub fn new(schema: SchemaPlan) -> Self { - let ignored_tables: Vec = schema - .tables - .iter() - .enumerate() - .filter_map(|(table, plan)| (!plan.sequences.is_empty()).then_some(table)) - .collect(); Self { oracle: EngineOracle::new(schema), properties: vec![ - Box::new(CommitMatches { - ignored_tables: ignored_tables.clone(), - }), - Box::new(ReplayMatchesModel { ignored_tables }), + Box::new(InsertMatches), + Box::new(CommitMatches), + Box::new(ReplayMatchesModel), ], } } @@ -30,7 +23,7 @@ impl EngineProperties { impl Properties for EngineProperties { fn observe(&mut self, interaction: &Interaction, observation: &Observation) -> Result<(), anyhow::Error> { - let expected = self.oracle.apply(interaction); + let expected = self.oracle.apply(interaction, observation)?; for property in &self.properties { if property.observes(interaction) { @@ -60,15 +53,74 @@ impl EngineOracle { } } - fn apply(&mut self, interaction: &Interaction) -> Observation { - self.model.apply(interaction) + fn apply(&mut self, interaction: &Interaction, observation: &Observation) -> anyhow::Result { + let observation = match (interaction, observation) { + ( + Interaction::Insert { table, .. }, + Observation::Inserted { + outcome: InsertOutcome::Accepted(row), + }, + ) => self.apply_insert(*table, row), + ( + Interaction::Insert { .. }, + Observation::Inserted { + outcome: InsertOutcome::UniqueConstraintViolation, + }, + ) => self.model.apply(interaction), + (Interaction::Insert { .. }, _) => anyhow::bail!("insert produced unexpected observation"), + _ => self.model.apply(interaction), + }; + + Ok(observation) + } + + fn apply_insert(&mut self, table: usize, row: &Row) -> Observation { + self.model.apply(&Interaction::Insert { + table, + row: row.clone(), + }) } } -struct CommitMatches { - ignored_tables: Vec, +struct InsertMatches; + +impl EngineProperty for InsertMatches { + fn observes(&self, interaction: &Interaction) -> bool { + matches!(interaction, Interaction::Insert { .. }) + } + + fn check( + &self, + _interaction: &Interaction, + observation: &Observation, + expected: &Observation, + ) -> anyhow::Result<()> { + let Observation::Inserted { outcome } = observation else { + anyhow::bail!("insert_matches: insert produced unexpected observation"); + }; + let Observation::Inserted { outcome: expected } = expected else { + unreachable!("InsertMatches only subscribes to insert interactions"); + }; + + match (outcome, expected) { + (InsertOutcome::Accepted(row), InsertOutcome::Accepted(expected)) => { + anyhow::ensure!(row == expected, "insert_matches: accepted row diverged from model"); + } + (InsertOutcome::UniqueConstraintViolation, InsertOutcome::UniqueConstraintViolation) => {} + (InsertOutcome::Accepted(_), InsertOutcome::UniqueConstraintViolation) => { + anyhow::bail!("insert_matches: target accepted row rejected by model"); + } + (InsertOutcome::UniqueConstraintViolation, InsertOutcome::Accepted(_)) => { + anyhow::bail!("insert_matches: target rejected row accepted by model"); + } + } + + Ok(()) + } } +struct CommitMatches; + impl EngineProperty for CommitMatches { fn observes(&self, interaction: &Interaction) -> bool { matches!(interaction, Interaction::CommitTx) @@ -87,17 +139,12 @@ impl EngineProperty for CommitMatches { unreachable!("CommitMatches only subscribes to commit interactions"); }; - anyhow::ensure!( - filter_commit_delta(delta, &self.ignored_tables) == filter_commit_delta(expected, &self.ignored_tables), - "commit_matches: committed delta diverged from model" - ); + anyhow::ensure!(delta == expected, "commit_matches: committed delta diverged from model"); Ok(()) } } -struct ReplayMatchesModel { - ignored_tables: Vec, -} +struct ReplayMatchesModel; impl EngineProperty for ReplayMatchesModel { fn observes(&self, interaction: &Interaction) -> bool { @@ -118,31 +165,9 @@ impl EngineProperty for ReplayMatchesModel { }; anyhow::ensure!( - filter_count_state(state, &self.ignored_tables) == filter_count_state(expected, &self.ignored_tables), + state == expected, "replay_matches_model: replayed state diverged from model" ); Ok(()) } } - -fn filter_commit_delta(delta: &super::workload::CommitDelta, ignored_tables: &[usize]) -> super::workload::CommitDelta { - super::workload::CommitDelta { - tables: delta - .tables - .iter() - .filter(|table| !ignored_tables.contains(&table.table)) - .cloned() - .collect(), - } -} - -fn filter_count_state(state: &super::workload::CountState, ignored_tables: &[usize]) -> super::workload::CountState { - super::workload::CountState { - row_counts: state - .row_counts - .iter() - .filter(|table| !ignored_tables.contains(&table.table)) - .copied() - .collect(), - } -} diff --git a/crates/dst/src/engine/workload.rs b/crates/dst/src/engine/workload.rs index 7abfad27a88..a0b754d5f21 100644 --- a/crates/dst/src/engine/workload.rs +++ b/crates/dst/src/engine/workload.rs @@ -46,12 +46,18 @@ impl InteractionCounts { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Observation { BeganMutTx, - Inserted { rows_count: u64 }, - Deleted { rows_count: u64 }, + Inserted { outcome: InsertOutcome }, + Deleted, Committed { delta: CommitDelta }, Replayed { state: CountState }, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InsertOutcome { + Accepted(Row), + UniqueConstraintViolation, +} + #[derive(Debug, Clone, Copy)] pub struct InteractionWeights { pub insert: u64, diff --git a/crates/dst/src/sim/commitlog.rs b/crates/dst/src/sim/commitlog.rs index 1e5b87e6e3d..dbf6d004a4c 100644 --- a/crates/dst/src/sim/commitlog.rs +++ b/crates/dst/src/sim/commitlog.rs @@ -261,28 +261,54 @@ impl Segment { impl io::Write for Segment { fn write(&mut self, buf: &[u8]) -> io::Result { + if buf.is_empty() { + return Ok(0); + } + let mut storage = self.storage.write().unwrap(); + let requested_end = self + .pos + .checked_add(buf.len() as u64) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "write position overflow"))?; - let mut remaining = (storage.alloc - self.pos) as usize; - if remaining == 0 { + if requested_end > storage.alloc { let mut avail = self.space.lock().unwrap(); - if *avail == 0 { - return Err(enospc()); + + if self.pos >= storage.alloc { + let minimum_alloc = next_page_multiple( + self.pos + .checked_add(1) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "write position overflow"))?, + )?; + let needed = minimum_alloc - storage.alloc; + if *avail < needed { + return Err(enospc()); + } } - let want = buf.len().next_multiple_of(PAGE_SIZE); - let have = want.min(*avail as usize); + let target_alloc = next_page_multiple(requested_end)?; + let wanted = target_alloc - storage.alloc; + let available = wanted.min(*avail); - storage.alloc += have as u64; - *avail -= have as u64; - remaining = (storage.alloc - self.pos) as usize; + storage.alloc += available; + *avail -= available; } - let read = buf.len().min(remaining); - storage.buf.extend(&buf[..read]); - self.pos += read as u64; + debug_assert!(self.pos < storage.alloc); + let writable = buf.len().min((storage.alloc - self.pos) as usize); + let start = self.pos as usize; + let end = start + writable; - Ok(read) + if storage.buf.len() < start { + storage.buf.resize(start, 0); + } + if storage.buf.len() < end { + storage.buf.resize(end, 0); + } + storage.buf[start..end].copy_from_slice(&buf[..writable]); + self.pos += writable as u64; + + Ok(writable) } fn flush(&mut self) -> io::Result<()> { @@ -412,6 +438,57 @@ impl io::Seek for ReadOnlySegment { impl SegmentLen for ReadOnlySegment {} +fn next_page_multiple(size: u64) -> io::Result { + let page = PAGE_SIZE as u64; + let remainder = size % page; + if remainder == 0 { + return Ok(size); + } + + size.checked_add(page - remainder) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "allocation size overflow")) +} + fn enospc() -> io::Error { io::Error::new(io::ErrorKind::StorageFull, "no space left on device") } + +#[cfg(test)] +mod tests { + use std::io::{Read, Seek, Write}; + + use super::*; + + fn segment() -> Segment { + Segment::from_shared(Arc::new(Mutex::new(u64::MAX)), Arc::new(RwLock::new(Storage::new()))) + } + + #[test] + fn write_overwrites_at_seek_position() { + let mut segment = segment(); + + segment.write_all(b"abcdef").unwrap(); + segment.seek(io::SeekFrom::Start(2)).unwrap(); + segment.write_all(b"XY").unwrap(); + + let mut bytes = Vec::new(); + segment.seek(io::SeekFrom::Start(0)).unwrap(); + segment.read_to_end(&mut bytes).unwrap(); + + assert_eq!(bytes, b"abXYef"); + } + + #[test] + fn write_after_end_fills_gap_with_zeroes() { + let mut segment = segment(); + + segment.seek(io::SeekFrom::Start(4)).unwrap(); + segment.write_all(&[1, 2]).unwrap(); + + let mut bytes = Vec::new(); + segment.seek(io::SeekFrom::Start(0)).unwrap(); + segment.read_to_end(&mut bytes).unwrap(); + + assert_eq!(bytes, &[0, 0, 0, 0, 1, 2]); + } +} From a598d1d95cc5c21da188d7de8ae5ad83283baa1c Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Mon, 22 Jun 2026 19:24:59 +0530 Subject: [PATCH 25/25] lints --- crates/dst/src/engine.rs | 3 --- crates/dst/src/engine/model.rs | 4 ++-- crates/dst/src/engine/workload.rs | 4 ++-- crates/dst/src/schema.rs | 8 ++++---- crates/dst/src/traits.rs | 11 +++++++++-- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/crates/dst/src/engine.rs b/crates/dst/src/engine.rs index ce607befd23..8dcfaab46a7 100644 --- a/crates/dst/src/engine.rs +++ b/crates/dst/src/engine.rs @@ -271,12 +271,9 @@ impl EngineTarget { } } -pub struct Outcome; impl TargetDriver for EngineTarget { type Observation = Observation; - type Outcome = Outcome; - fn execute(&mut self, interaction: &Interaction) -> Result { EngineTarget::execute(self, interaction) } diff --git a/crates/dst/src/engine/model.rs b/crates/dst/src/engine/model.rs index 4d1ecb0aeda..5d6913212e0 100644 --- a/crates/dst/src/engine/model.rs +++ b/crates/dst/src/engine/model.rs @@ -154,7 +154,7 @@ impl Model { Interaction::Replay => { self.pending_tx = None; Observation::Replayed { - state: self.light_snapshot(), + state: self.count_state(), } } } @@ -226,7 +226,7 @@ impl Model { self.visible_rows(table).cloned().collect() } - fn light_snapshot(&self) -> CountState { + fn count_state(&self) -> CountState { let row_counts = (0..self.schema.tables.len()) .map(|table| TableRowCount { table, diff --git a/crates/dst/src/engine/workload.rs b/crates/dst/src/engine/workload.rs index a0b754d5f21..bc9e4fcc36e 100644 --- a/crates/dst/src/engine/workload.rs +++ b/crates/dst/src/engine/workload.rs @@ -112,7 +112,7 @@ impl WorkloadGen { fn gen_value(&self, ty: Type) -> AlgebraicValue { match ty { - Type::Bool => AlgebraicValue::Bool(self.rng.next_u64() % 2 == 0), + Type::Bool => AlgebraicValue::Bool(self.rng.next_u64().is_multiple_of(2)), Type::I64 => AlgebraicValue::I64(self.rng.next_u64() as i64), Type::U64 => AlgebraicValue::U64(self.rng.next_u64()), Type::String => AlgebraicValue::String(format!("v_{}", self.rng.next_u64()).into()), @@ -249,7 +249,7 @@ impl WorkloadGen { .map(|(table_idx, _)| table_idx); match auto_inc_table_idx { - Some(table_idx) if self.rng.next_u64() % 3 != 0 => table_idx, + Some(table_idx) if !self.rng.next_u64().is_multiple_of(3) => table_idx, _ => self.rng.index(self.schema().tables.len()), } } diff --git a/crates/dst/src/schema.rs b/crates/dst/src/schema.rs index 82c1e8bac87..641281db3c3 100644 --- a/crates/dst/src/schema.rs +++ b/crates/dst/src/schema.rs @@ -315,10 +315,10 @@ impl SchemaGenerator { } } // Ensure PK has a matching unique constraint. - if let Some(pk) = pk { - if !seen.contains(&vec![*pk]) { - result.push(UniqueConstraintPlan { columns: vec![*pk] }); - } + if let Some(pk) = pk + && !seen.iter().any(|cols| cols.len() == 1 && cols[0] == *pk) + { + result.push(UniqueConstraintPlan { columns: vec![*pk] }); } result } diff --git a/crates/dst/src/traits.rs b/crates/dst/src/traits.rs index aff7e706e29..d84aafa97db 100644 --- a/crates/dst/src/traits.rs +++ b/crates/dst/src/traits.rs @@ -4,7 +4,6 @@ use spacetimedb_runtime::sim::Rng; /// This should be implemented by System under test. pub trait TargetDriver { type Observation; - type Outcome; fn execute(&mut self, interaction: &I) -> Result; } @@ -14,13 +13,21 @@ pub trait Properties { fn observe(&mut self, interaction: &I, observation: &O) -> Result<(), Error>; } +pub type TestSuiteParts = ( + ::Interactions, + ::Target, + ::Properties, +); + pub trait TestSuite { type Interaction; type Interactions: Iterator + std::fmt::Debug; type Target: TargetDriver; type Properties: Properties>::Observation>; - fn build(&self, rng: Rng) -> Result<(Self::Interactions, Self::Target, Self::Properties), Error>; + fn build(&self, rng: Rng) -> Result, Error> + where + Self: Sized; fn run(&self, rng: Rng, max_interactions: Option) -> Result<(), Error> where