From 2993df0e36300567617c9483e55cfdb0cb1b7b13 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Mon, 12 Oct 2026 04:50:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(store):=20the=20sqlite=20constructor=20wir?= =?UTF-8?q?ing=20=E2=80=94=20the=20default-build=20store=20shapes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KvEngine::Sqlite slots in; all six kv ops dispatch; the kv cursor body dispatches through a new KvCursor sub-enum behind AnyCursor's one kv arm (the kv-arm-over-multiple-kv-engines design point, recorded) - dual_tier_sqlite_local (kv = sqlite + large = local, the default-build production shape) and kv_only_sqlite constructors — both feature-gated on sqlite (default-on), both taking the threshold and the delete-window delay, fallible where I/O is fallible (Err(Error::Io) per the dual_tier_mem_local pattern) - ensure_gc_schema runs at construction via the engine-neutral KvEngine call (sqlite's body creates gc_window only); the window-delay shipped default is the pinned DEFAULT_DELETE_WINDOW_DELAY (5 min, ADR-008 §2), constructor- tunable, Duration::ZERO legal — the store carries the value for the sweep's durable form to consume - the one-window-host posture recorded in the module doc (the durable window hosts on the kv engine when it is SQL-backed; wave 5's pg-lo dormancy slots in without reshaping) - stale empty-set scaffolding updated to the landed state: the #[allow(unused_variables)] attrs off the KvEngine methods; the scope notes in store.rs, backend.rs, sqlite.rs, gc.rs - constructor unit tests: mode/threshold/window-delay introspection, the GC schema observed in the fresh database (window table present, gc_pins never), kv-only rejection wiring unchanged, and the dual-tier shape round-tripping end-to-end through both tiers Verification: cargo test 62 passed (default; 5 new tests), cargo test --all-features 117, cargo test --features mem 117, --no-default- features --features mem 97; cargo clippy --all-targets -- -D warnings clean (default + all-features); cargo fmt --check clean; cargo doc --no-deps at the pre-existing warning baseline. --- src/backend.rs | 10 +- src/gc.rs | 7 +- src/lib.rs | 2 +- src/sqlite.rs | 51 ++- src/store.rs | 587 +++++++++++++++++++++++++++-- tasks/store-sqlite-construction.md | 80 +++- 6 files changed, 673 insertions(+), 64 deletions(-) diff --git a/src/backend.rs b/src/backend.rs index 66b2ee0..98cdbf5 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -31,9 +31,13 @@ //! the crate-internal engine-state companion seam lives in [`crate::gc`]. //! //! The `#![allow(dead_code)]` below still has real scope: `Backend` -//! trait items with no in-tree caller yet (reporting names, the kv -//! engines' whole-value `put` before wave 3) plus the wave-1 seam -//! tests' doubles. It shrinks as the waves land. +//! trait items with no in-tree caller yet (the engines' reporting +//! names — the dispatch/conformance identity the reporting and fleet +//! waves consume) plus the wave-1 seam tests' doubles. The kv engines' +//! whole-value `put` gained its in-tree callers in wave 3 (sqlite is +//! the default build's kv engine and the facade's put seam drives the +//! kv tier through it); the allow shrinks as the remaining callers +//! land. #![allow(dead_code)] diff --git a/src/gc.rs b/src/gc.rs index 2a8b88a..cd434de 100644 --- a/src/gc.rs +++ b/src/gc.rs @@ -65,8 +65,11 @@ //! report data (`Ok(SweepReport)` — ADR-012 §1), nothing deleted. //! //! The `#![allow(dead_code)]` below covers the companion-seam scope: -//! the engine-state clauses are implemented by the SQL engines of -//! waves 3–5 and are not called yet. It shrinks as those waves land. +//! the window family has its first in-tree caller in wave 3 (store +//! construction runs `ensure_gc_schema` on the SQL-backed kv engine), +//! while `window_stage`/`window_take` wait for the sweep's durable +//! form and the fleet family waits for postgres's wave. It shrinks as +//! those land. #![allow(dead_code)] diff --git a/src/lib.rs b/src/lib.rs index 92eb10e..8f40ad9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,7 +16,7 @@ pub use facade::{ }; pub use hashing::{Algorithm, Digest}; pub use keys::Key; -pub use store::{Store, StoreMode}; +pub use store::{Store, StoreMode, DEFAULT_DELETE_WINDOW_DELAY}; #[cfg(feature = "mem")] pub mod mem; diff --git a/src/sqlite.rs b/src/sqlite.rs index 5a23df2..9295e55 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -44,8 +44,15 @@ //! databases included): the durability promise is verified at open, not //! assumed. //! -//! The `#![allow(dead_code)]` below has real scope: nothing constructs -//! the engine until wave 3's constructor wiring (`store-sqlite-construction`). +//! The engine is constructed by the store facade — +//! `dual_tier_sqlite_local` / `kv_only_sqlite` (the wave-3 constructor +//! wiring, landed) — which also runs the GC schema's engine-neutral +//! call (`ensure_gc_schema`, creating `gc_window`). The +//! `#![allow(dead_code)]` below has real scope only through that +//! wiring's cfg-gates: a build with the feature off never compiles this +//! module, and the test-only instruments (the tableless constructor, +//! the commit-failure injection, the probe helpers) sit behind +//! `#[cfg(test)]`. #![allow(dead_code)] @@ -182,6 +189,27 @@ impl SqliteBackend { }) } + /// The sqlite_master table-existence probe — test-only: the store + /// construction test's instrument (covers every schema object, + /// not just probed reads) and the local tests' helper body. + #[cfg(test)] + pub(crate) async fn table_exists(&self, name: &str) -> Result { + let conn = Arc::clone(&self.conn); + let name = name.to_owned(); + blocking(move || { + let guard = conn.lock(); + let probed: rusqlite::Result = guard + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", + params![name], + |row| row.get::<_, i64>(0), + ) + .map(|count| count != 0); + probed.map_err(db_err) + }) + .await + } + /// The connection's live pragma value — the posture test's probe /// (`synchronous` is per-connection, so the backend's own connection /// must be asked). @@ -1054,23 +1082,10 @@ mod tests { } /// Table existence probed through sqlite_master — the schema test's - /// instrument (covers every schema object, not just probed reads). + /// instrument (covers every schema object, not just probed reads); a + /// thin unwrap-wrap over the backend's helper. async fn table_exists(backend: &SqliteBackend, name: &str) -> bool { - let conn = Arc::clone(&backend.conn); - let name = name.to_owned(); - blocking(move || { - let guard = conn.lock(); - Ok(guard - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", - params![name], - |row| row.get::<_, i64>(0), - ) - .unwrap() - != 0) - }) - .await - .unwrap() + backend.table_exists(name).await.unwrap() } fn stages(key: &[u8], visible_after: SystemTime) -> WindowCandidate { diff --git a/src/store.rs b/src/store.rs index 9e585b2..e26860b 100644 --- a/src/store.rs +++ b/src/store.rs @@ -36,13 +36,17 @@ //! - **dual-tier** (the default): kv + large, size-threshold dispatch //! via [`crate::dispatch::route`]. The wave-2 constructible shape is //! `kv = mem` + `large = local` — the dispatch-coverage test shape -//! ADR-012 §6.3 names (both tiers load-bearing). Over-threshold -//! content routes to the large tier; at-or-below to kv. +//! ADR-012 §6.3 names (both tiers load-bearing); the wave-3 +//! constructible shape is `kv = sqlite` + `large = local` — the +//! **default-build production shape** (feature `sqlite` is +//! default-on, ADR-013 §4). Over-threshold content routes to the +//! large tier; at-or-below to kv. //! - **kv-only**: no large tier; over-threshold puts reject **at put //! time** (ADR-012 §6.2 — known-length immediately; unknown-length at //! mid-stream threshold overflow). The rejection behavior itself //! lands with the put seam; the threshold this mode carries is the -//! rejection point's value. +//! rejection point's value. The wave-2 engine is mem (testing); +//! the wave-3 engine is sqlite (the durable single-node medium). //! - **mem-only**: the mem engine alone, **no dispatch threshold in //! effect** (everything is local and small) — a testing/embedder- //! ephemeral posture, never production. Structurally distinct from @@ -193,16 +197,38 @@ //! [`Store::large`]), and the GC state home ([`crate::gc::GcHome`]) //! the gc-protocol task fills. //! +//! **The durable delete window hosts on the kv engine when it is +//! SQL-backed (ADR-012 §5's one-window-host rule).** Sqlite — the +//! default build's kv engine — hosts: its constructors run the GC +//! schema's engine-neutral call (`ensure_gc_schema` on the +//! `EngineWindowState` seam) at construction, dispatched on the +//! kv-engine enum — a mem arm is structurally nothing, the in-process +//! engines execute the ADR-005 window protocol inline, so there is no +//! schema to ensure — and the SQL-backed sweep form (wave 3's +//! sweep-durable-window task) stages its candidates on the kv engine +//! the constructor built. The host branch keys off the engine +//! representation — one more kv-engine-enum match arm — so wave 5's +//! pg-lo dormancy (the dual-SQL instance's large-engine window clauses +//! are dormant when the kv engine already hosts) slots in without +//! reshaping. Both sqlite constructors carry the delete-window delay +//! (ADR-008 §2: default 5 min, constructor-tunable per deployment — +//! [`DEFAULT_DELETE_WINDOW_DELAY`]; `0` is a legal degenerate tuning, +//! execute-on-next-sweep) as the cadence knob the durable sweep form +//! consumes; the mem/local constructors' inline form has no delay in +//! effect. +//! //! The `#![allow(dead_code)]` below marks the work still ahead, not //! the put path (which is driven below): the GC home's fleet clauses -//! and the kv engine set are empty in default builds (mem is -//! feature-gated; sqlite is wave 3). The allow shrinks as those land. +//! and the durable window's cadence knob (until the sweep's durable +//! form consumes it) hold the real scope. The kv engine set is no +//! longer empty in default builds — sqlite landed in wave 3 — and the +//! empty-set `allow`s came off with it. #![allow(dead_code)] use std::collections::BTreeSet; use std::sync::Arc; -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; use tokio::io::{AsyncRead, AsyncReadExt}; @@ -212,6 +238,8 @@ use crate::facade::{ Batch, Blob, EntryMeta, GcAbortCause, LivenessSource, Pin, ProtectCallback, Registration, SweepReport, }; +#[cfg(feature = "sqlite")] +use crate::gc::EngineWindowState; use crate::gc::{Arbitration, GcHome}; use crate::hashing::{Algorithm, BlobHasher}; use crate::keys::Key; @@ -219,14 +247,26 @@ use crate::local::{LocalBackend, LocalCursor, LocalStaging}; #[cfg(feature = "mem")] use crate::mem::{MemBackend, MemCursor}; +#[cfg(feature = "sqlite")] +use crate::sqlite::{SqliteBackend, SqliteCursor}; use crate::Error; -#[cfg(feature = "mem")] +#[cfg(any(feature = "mem", feature = "sqlite"))] use std::path::PathBuf; use self::StoreMode::{DualTier, KvOnly, MemOnly}; +/// The shipped default **delete-window delay** (ADR-008 §2): the +/// staged candidates in the durable window become visible for +/// execution this long after the sweep staged them — 5 minutes, +/// constructor-tunable per deployment. `Duration::ZERO` is a legal +/// degenerate tuning (execute-on-next-sweep; the test tuning). The +/// sqlite constructors take the delay explicitly and their docs point +/// here as the shipped default; the mem/local inline form has no +/// delay in effect and the constant is irrelevant to it. +pub const DEFAULT_DELETE_WINDOW_DELAY: Duration = Duration::from_secs(5 * 60); + /// The store instance — the public facade above the tier engines /// (ADR-011 §1's vocabulary: *one running instantiation of the store /// facade — one engine set per the constructor mode, plus dispatch and @@ -248,6 +288,13 @@ pub struct Store { /// threshold in effect" is the mode's ruling (ADR-011 §4), not a /// value absence. threshold: u64, + /// The delete-window delay the constructor took (ADR-008 §2) — the + /// durable window's cadence knob the sweep's SQL-backed form + /// consumes (`window_stage`'s `visible_after = mark_end + window` + /// on the `EngineWindowState` seam). The mem/local inline form + /// has no delay in effect: those constructors carry + /// [`DEFAULT_DELETE_WINDOW_DELAY`], never consulted. + window_delay: Duration, /// The constructor mode; the dispatch/put/read/gc paths branch on it. mode: StoreMode, /// The GC state home (ADR-010 §2: the store core owns GC state) @@ -271,11 +318,18 @@ pub enum StoreMode { /// The kv-tier engine set — the closed per-tier engine enum of the /// engine-representation design point. Feature-gated variants slot in -/// as their engine waves land: sqlite (wave 3), postgres (wave 4). +/// as their engine waves land: sqlite (wave 3 — landed, the default), +/// postgres (wave 4). pub(crate) enum KvEngine { /// The contract-reference ephemeral engine (ADR-011 §2). #[cfg(feature = "mem")] Mem(MemBackend), + /// The default build's kv engine — the first SQL-backed engine, + /// the durable delete window's host (ADR-012 §5's one-window-host + /// rule: the window is the kv engine's when the kv engine is + /// SQL-backed). + #[cfg(feature = "sqlite")] + Sqlite(SqliteBackend), } /// The large-tier engine set. `local` is ungated (dependency-free, @@ -285,18 +339,56 @@ pub(crate) enum LargeEngine { Local(LocalBackend), } +/// **The kv-arm shape over multiple kv engines (named design point): +/// a `KvCursor` sub-enum behind [`AnyCursor`]'s one kv arm.** The +/// alternative — one `AnyCursor` variant per kv engine — would copy +/// the kv materializing arm (and its consumers: the verification-on- +/// get body, `Blob`'s drive) per engine without adding a behavior; +/// the sub-enum keeps [`AnyCursor`] a two-arm tier dispatch (kv / +/// large) while kv engines multiply inside this one. Recorded for the +/// review gate: postgres (wave 4) slots one arm in here. +pub(crate) enum KvCursor { + /// The contract-reference ephemeral body (ADR-011 §2). + #[cfg(feature = "mem")] + Mem(MemCursor), + /// The default build's materializing body: the row's value loaded + /// once, reads in-memory slices (the sqlite engine's record). + #[cfg(feature = "sqlite")] + Sqlite(SqliteCursor), +} + +impl ReadCursor for KvCursor { + fn len(&self) -> u64 { + match self { + #[cfg(feature = "mem")] + Self::Mem(cursor) => cursor.len(), + #[cfg(feature = "sqlite")] + Self::Sqlite(cursor) => cursor.len(), + } + } + + async fn read_at(&self, offset: u64, len: u64) -> Result, Error> { + match self { + #[cfg(feature = "mem")] + Self::Mem(cursor) => cursor.read_at(offset, len).await, + #[cfg(feature = "sqlite")] + Self::Sqlite(cursor) => cursor.read_at(offset, len).await, + } + } +} + /// The read path's cursor-dispatch enum (ADR-010 §1's "store core /// programs the abstraction"): tier dispatch over the engine cursor /// bodies — the kv tier's materializing body and the large tier's /// streaming body — behind one crate-internal value that [`Blob`] holds /// and drives. Crate-internal: engine cursors never appear on the /// public surface; the facade's stream shape is this enum's uniform -/// [`ReadCursor`] view (`Blob`). +/// [`ReadCursor`] view (`Blob`). The kv bodies live behind +/// [`KvCursor`] (the recorded design point above). pub(crate) enum AnyCursor { /// The kv body family: the whole value materialized in memory /// (bounded by tier policy). - #[cfg(feature = "mem")] - Kv(MemCursor), + Kv(KvCursor), /// The large body family: a pread cursor over the committed file. Large(LocalCursor), } @@ -304,7 +396,6 @@ pub(crate) enum AnyCursor { impl ReadCursor for AnyCursor { fn len(&self) -> u64 { match self { - #[cfg(feature = "mem")] Self::Kv(cursor) => cursor.len(), Self::Large(cursor) => cursor.len(), } @@ -312,7 +403,6 @@ impl ReadCursor for AnyCursor { async fn read_at(&self, offset: u64, len: u64) -> Result, Error> { match self { - #[cfg(feature = "mem")] Self::Kv(cursor) => cursor.read_at(offset, len).await, Self::Large(cursor) => cursor.read_at(offset, len).await, } @@ -367,50 +457,79 @@ impl StagingCursor for AnyStaging { } impl KvEngine { - #[allow(unused_variables)] // kv engine set empty until wave 3 (sqlite): no-mem builds never reference `key`/`value` async fn put(&self, key: &[u8], value: &[u8]) -> Result<(), Error> { - match *self { + match self { #[cfg(feature = "mem")] - KvEngine::Mem(ref engine) => engine.put(key, value).await, + KvEngine::Mem(engine) => engine.put(key, value).await, + #[cfg(feature = "sqlite")] + KvEngine::Sqlite(engine) => engine.put(key, value).await, } } - #[allow(unused_variables)] // kv engine set empty until wave 3 (sqlite): no-mem builds never reference `key` async fn get(&self, key: &[u8]) -> Result, Error> { - match *self { + match self { #[cfg(feature = "mem")] - KvEngine::Mem(ref engine) => Ok(engine.get(key).await?.map(AnyCursor::Kv)), + KvEngine::Mem(engine) => Ok(engine + .get(key) + .await? + .map(|c| AnyCursor::Kv(KvCursor::Mem(c)))), + #[cfg(feature = "sqlite")] + KvEngine::Sqlite(engine) => Ok(engine + .get(key) + .await? + .map(|c| AnyCursor::Kv(KvCursor::Sqlite(c)))), + } + } + + /// The GC schema's engine-neutral store-core call (ADR-010 §2's + /// companion seam; ADR-012 §5's applicability table), run at + /// construction: a SQL-backed kv engine creates its window + /// table(s) — sqlite's body creates `gc_window` only — and an + /// in-process kv engine is structurally nothing (the + /// local/mem row of the table: those engines execute the window + /// protocol inline, ADR-005, and carry no GC schema). + async fn ensure_gc_schema(&self) -> Result<(), Error> { + match self { + #[cfg(feature = "mem")] + KvEngine::Mem(_) => Ok(()), + #[cfg(feature = "sqlite")] + KvEngine::Sqlite(engine) => engine.ensure_gc_schema().await, } } - #[allow(unused_variables)] // same empty-set scope as `get` async fn has(&self, key: &[u8]) -> Result { - match *self { + match self { #[cfg(feature = "mem")] - KvEngine::Mem(ref engine) => engine.has(key).await, + KvEngine::Mem(engine) => engine.has(key).await, + #[cfg(feature = "sqlite")] + KvEngine::Sqlite(engine) => engine.has(key).await, } } - #[allow(unused_variables)] // same empty-set scope as `get` async fn size(&self, key: &[u8]) -> Result, Error> { - match *self { + match self { #[cfg(feature = "mem")] - KvEngine::Mem(ref engine) => engine.size(key).await, + KvEngine::Mem(engine) => engine.size(key).await, + #[cfg(feature = "sqlite")] + KvEngine::Sqlite(engine) => engine.size(key).await, } } - #[allow(unused_variables)] // same empty-set scope as `get` async fn delete(&self, key: &[u8]) -> Result<(), Error> { - match *self { + match self { #[cfg(feature = "mem")] - KvEngine::Mem(ref engine) => engine.delete(key).await, + KvEngine::Mem(engine) => engine.delete(key).await, + #[cfg(feature = "sqlite")] + KvEngine::Sqlite(engine) => engine.delete(key).await, } } async fn list(&self) -> Result>, Error> { - match *self { + match self { #[cfg(feature = "mem")] - KvEngine::Mem(ref engine) => engine.list().await, + KvEngine::Mem(engine) => engine.list().await, + #[cfg(feature = "sqlite")] + KvEngine::Sqlite(engine) => engine.list().await, } } } @@ -483,6 +602,7 @@ impl Store { kv: KvEngine::Mem(MemBackend::default()), large: None, threshold: crate::dispatch::DEFAULT_DISPATCH_THRESHOLD, + window_delay: DEFAULT_DELETE_WINDOW_DELAY, mode: MemOnly, gc: GcHome::default(), } @@ -500,6 +620,7 @@ impl Store { kv: KvEngine::Mem(MemBackend::default()), large: None, threshold, + window_delay: DEFAULT_DELETE_WINDOW_DELAY, mode: KvOnly, gc: GcHome::default(), } @@ -526,6 +647,82 @@ impl Store { kv: KvEngine::Mem(MemBackend::default()), large: Some(LargeEngine::Local(LocalBackend::new(root).await?)), threshold, + window_delay: DEFAULT_DELETE_WINDOW_DELAY, + mode: DualTier, + gc: GcHome::default(), + }) + } + + /// **kv-only mode** (ADR-011 §4; ADR-012 §6.2) with the sqlite kv + /// engine — the durable single-node kv-only shape: no large tier; + /// `threshold` is the put-time rejection point (known-length puts + /// over it reject immediately, unknown-length puts at mid-stream + /// threshold overflow — the put seam's behavior, identical to the + /// mem kv-only wiring; zero residue on either rejection). Routing + /// in this mode is kv for every length. + /// + /// Opening (or creating) the database file is fallible I/O; the + /// constructor also runs the GC schema's engine-neutral call + /// (`ensure_gc_schema` — sqlite's body creates `gc_window` only, + /// ADR-012 §5), so a construction failure is `Err(Error::Io)` per + /// the fallible-constructor pattern `dual_tier_mem_local` set. + /// + /// `window_delay` (ADR-008 §2) is the durable window's cadence + /// knob — [`DEFAULT_DELETE_WINDOW_DELAY`] is the shipped default, + /// `Duration::ZERO` the legal degenerate tuning. Not fleet- + /// anything: a sqlite fleet is a recorded non-option (ADR-010 §3). + #[cfg(feature = "sqlite")] + pub async fn kv_only_sqlite( + database: impl Into, + threshold: u64, + window_delay: Duration, + ) -> Result { + let kv = KvEngine::Sqlite(SqliteBackend::new(database).await?); + kv.ensure_gc_schema().await?; + Ok(Store { + kv, + large: None, + threshold, + window_delay, + mode: KvOnly, + gc: GcHome::default(), + }) + } + + /// **dual-tier mode** (the default, ADR-011 §4) in the default-build + /// production shape — `kv = sqlite` + `large = local` (feature + /// `sqlite` is default-on, ADR-013 §4): size-threshold dispatch at + /// `threshold` (constructor-tunable; + /// [`crate::dispatch::DEFAULT_DISPATCH_THRESHOLD`] is the shipped + /// default), at-or-below on the kv tier, over-threshold staged into + /// the large tier. + /// + /// Fallible on two accounts (file I/O): opening/creating the pool + /// database — which also runs the GC schema's engine-neutral call + /// (`ensure_gc_schema`; sqlite's body creates `gc_window` only, + /// ADR-012 §5's one-window-host rule: the durable window is the kv + /// engine's when the kv engine is SQL-backed) — and creating the + /// local root. A construction failure is `Err(Error::Io)` per the + /// fallible-constructor pattern `dual_tier_mem_local` set. + /// + /// `window_delay` (ADR-008 §2) is the durable window's cadence + /// knob — [`DEFAULT_DELETE_WINDOW_DELAY`] is the shipped default, + /// `Duration::ZERO` the legal degenerate tuning. Not fleet- + /// anything: a sqlite fleet is a recorded non-option (ADR-010 §3). + #[cfg(feature = "sqlite")] + pub async fn dual_tier_sqlite_local( + database: impl Into, + root: impl Into, + threshold: u64, + window_delay: Duration, + ) -> Result { + let kv = KvEngine::Sqlite(SqliteBackend::new(database).await?); + kv.ensure_gc_schema().await?; + Ok(Store { + kv, + large: Some(LargeEngine::Local(LocalBackend::new(root).await?)), + threshold, + window_delay, mode: DualTier, gc: GcHome::default(), }) @@ -548,6 +745,16 @@ impl Store { } } + /// The delete-window delay the constructor took (ADR-008 §2) — + /// crate-internal: the sweep's SQL-backed form consumes it when + /// staging candidates (`visible_after = mark_end + window`); + /// introspection for the construction tests. `Duration::ZERO` + /// (a constructor-passed value) is the legal degenerate tuning — + /// execute-on-next-sweep. + pub(crate) fn window_delay(&self) -> Duration { + self.window_delay + } + /// **Write-side tier selection for a given content length** — the /// dispatch seam's routing half (the put seam programs through /// this; read paths do not consult it). Dual-tier: @@ -621,7 +828,6 @@ impl Store { }; match cursor { - #[cfg(feature = "mem")] AnyCursor::Kv(kv) => { let value = kv.read_at(0, kv.len()).await?; let derived = key.algorithm().derive(&value, value.len() as u64); @@ -1519,7 +1725,10 @@ mod tests { /// verification postures are observed. async fn plant_kv(store: &Store, key: &[u8], value: &[u8]) { match store.kv() { + #[cfg(feature = "mem")] KvEngine::Mem(engine) => engine.put(key, value).await.expect("kv plant"), + #[cfg(feature = "sqlite")] + KvEngine::Sqlite(engine) => engine.put(key, value).await.expect("kv plant"), } } @@ -3486,3 +3695,319 @@ mod tests { assert_eq!(blob.read_at(0, 8).await.unwrap(), Vec::::new()); } } + +/// The wave-3 constructor wiring's tests (feature `sqlite`): the +/// sqlite modes construct and introspect correctly, the GC schema ran +/// at construction, the window-delay default is the pinned constant +/// and is constructor-tunable, the kv-only rejection wiring is the +/// mode's put seam unchanged, and the default-build production shape +/// (kv = sqlite + large = local) round-trips end-to-end through both +/// tiers. All-features builds share this module (mem on alongside): +/// the engine accessors carry cfg arms for that. +#[cfg(all(test, feature = "sqlite"))] +mod sqlite_construction_tests { + use super::*; + use std::fs; + use std::io::Cursor; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + /// A fresh temp database file (and sibling stage/root dir) per + /// test; Drop removes the file tree and the WAL sidecars + /// idempotently (the sqlite engine's test helper's shape). + struct TempSite { + database: PathBuf, + root: PathBuf, + } + + impl TempSite { + fn path(&self) -> &Path { + &self.root + } + + fn drop_sidecar(&self, suffix: &str) { + let base = self.database.to_string_lossy().into_owned(); + let _ = fs::remove_file(format!("{base}{suffix}")); + } + } + + impl Drop for TempSite { + fn drop(&mut self) { + self.drop_sidecar("-wal"); + self.drop_sidecar("-shm"); + let _ = fs::remove_file(&self.database); + let _ = fs::remove_dir_all(&self.root); + } + } + + fn temp_site(tag: &str) -> TempSite { + static SEQ: AtomicU64 = AtomicU64::new(0); + let seq = SEQ.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let base = std::env::temp_dir().join(format!( + "alkblobs-store-sqlite-{tag}-{}-{nanos:x}-{seq}", + std::process::id() + )); + fs::create_dir_all(&base).expect("temp site created"); + TempSite { + database: base.join("pool.db"), + root: base.join("local-root"), + } + } + + fn key_for(content: &[u8]) -> Key { + Key::derive(content, content.len() as u64) + } + + /// The sqlite kv engine behind the constructed store — the schema + /// observations' instrument. + fn sqlite_engine(store: &Store) -> &SqliteBackend { + match store.kv() { + #[cfg(feature = "sqlite")] + KvEngine::Sqlite(engine) => engine, + #[cfg(feature = "mem")] + KvEngine::Mem(_) => panic!("these tests construct sqlite stores"), + } + } + + /// The engine-neutral GC-schema call ran at construction: the pool + /// table exists (the engine's own open), `gc_window` exists (the + /// store-core call), `gc_pins` never (sqlite's applicability row, + /// ADR-012 §5's one-window-host rule — the kv engine hosts the + /// window and nothing else). + async fn assert_gc_schema_ran(store: &Store) { + let engine = sqlite_engine(store); + assert!( + engine.table_exists("blobs").await.unwrap(), + "the pool table exists at construction" + ); + assert!( + engine.table_exists("gc_window").await.unwrap(), + "ensure_gc_schema ran at construction: the window table exists in the fresh database" + ); + assert!( + !engine.table_exists("gc_pins").await.unwrap(), + "gc_pins is never created on sqlite (ADR-012 §5)" + ); + } + + /// The dual-tier construction: both tiers present, mode/threshold + /// introspection exact, boundary routing deterministic, and the + /// GC schema ran. + #[tokio::test] + async fn dual_tier_sqlite_local_constructs_and_routes() { + let site = temp_site("dual-tier"); + let store = Store::dual_tier_sqlite_local( + &site.database, + site.path(), + 1024, + DEFAULT_DELETE_WINDOW_DELAY, + ) + .await + .expect("dual-tier sqlite+local constructs"); + assert_eq!(store.mode(), DualTier); + assert_eq!(store.threshold(), Some(1024)); + assert_eq!(store.window_delay(), DEFAULT_DELETE_WINDOW_DELAY); + assert!(store.large().is_some(), "both tiers present"); + + for (len, tier) in [ + (0u64, Tier::Kv), + (1023, Tier::Kv), + (1024, Tier::Kv), + (1025, Tier::Large), + (u64::MAX, Tier::Large), + ] { + assert_eq!( + store.tier_for(len), + tier, + "length {len} routes deterministically" + ); + } + + assert_gc_schema_ran(&store).await; + } + + /// The kv-only construction: no large tier, the threshold is the + /// rejection point's carrier, and the rejection wiring is the put + /// seam's unchanged — known-length over-threshold rejects + /// immediately (zero residue), unknown-length at mid-stream + /// overflow, and exactly-at-threshold puts land. + #[tokio::test] + async fn kv_only_sqlite_constructs_and_rejects_over_threshold_puts() { + let site = temp_site("kv-only"); + let store = Store::kv_only_sqlite(&site.database, 64, DEFAULT_DELETE_WINDOW_DELAY) + .await + .expect("kv-only sqlite constructs"); + assert_eq!(store.mode(), KvOnly); + assert_eq!(store.threshold(), Some(64)); + assert_eq!(store.window_delay(), DEFAULT_DELETE_WINDOW_DELAY); + assert!(store.large().is_none(), "kv-only has no large tier"); + assert_gc_schema_ran(&store).await; + + for len in [0u64, 63, 64, 65, u64::MAX] { + assert_eq!( + store.tier_for(len), + Tier::Kv, + "kv-only routes every length kv" + ); + } + + let err = store + .put(Some(65), Cursor::new(&b"never read"[..])) + .await + .unwrap_err(); + assert!( + err.to_string().contains("kv-only mode rejects"), + "the immediate rejection is the mode's: {err}" + ); + assert_eq!( + store.list().await.unwrap(), + Vec::::new(), + "zero residue" + ); + + let streamed = [3u8; 100]; + let err = store + .put(None, Cursor::new(&streamed[..])) + .await + .unwrap_err(); + assert!( + err.to_string() + .contains("kv-only mode rejects an over-threshold put"), + "the overflow rejection is the mode's, not the stream's: {err}" + ); + assert_eq!( + store.list().await.unwrap(), + Vec::::new(), + "zero residue" + ); + + let at_threshold = vec![9u8; 64]; + let (key, pin) = store + .put(Some(64), Cursor::new(&at_threshold[..])) + .await + .expect("exactly-at-threshold known put lands"); + assert_eq!(key, key_for(&at_threshold)); + assert!(store.has(&key.as_bytes()).await.unwrap()); + drop(pin); + } + + /// The window-delay parameter: the shipped default is the pinned + /// 5-minute constant, the constructor's value passes through the + /// store's introspection, and `Duration::ZERO` is a legal + /// degenerate tuning (execute-on-next-sweep). + #[tokio::test] + async fn window_delay_default_is_pinned_and_constructor_tunable() { + assert_eq!( + DEFAULT_DELETE_WINDOW_DELAY, + Duration::from_secs(5 * 60), + "the shipped default is 5 minutes (ADR-008 §2)" + ); + + let site = temp_site("delay-default"); + let store = Store::dual_tier_sqlite_local( + &site.database, + site.path(), + 64, + DEFAULT_DELETE_WINDOW_DELAY, + ) + .await + .expect("constructs at the shipped default"); + assert_eq!(store.window_delay(), Duration::from_secs(300)); + + let site = temp_site("delay-tuned"); + let store = Store::kv_only_sqlite(&site.database, 64, Duration::from_secs(90)) + .await + .expect("constructs at a custom tuning"); + assert_eq!(store.window_delay(), Duration::from_secs(90)); + + let site = temp_site("delay-zero"); + let store = Store::dual_tier_sqlite_local(&site.database, site.path(), 64, Duration::ZERO) + .await + .expect("the degenerate zero tuning is legal"); + assert_eq!(store.window_delay(), Duration::ZERO); + + let site = temp_site("delay-long"); + Store::kv_only_sqlite(&site.database, 64, Duration::from_secs(6000)) + .await + .expect("a long delay is a legal tuning too"); + } + + /// The default-build production shape round-trips end-to-end: a + /// sub-threshold put lands on the kv tier (sqlite), an + /// over-threshold put stages into the large tier (local), both + /// get back byte-identical, and the pool list is the sorted union + /// of both tiers. + #[tokio::test] + async fn dual_tier_sqlite_shape_round_trips_end_to_end_through_both_tiers() { + let site = temp_site("round-trip"); + let store = Store::dual_tier_sqlite_local(&site.database, site.path(), 64, Duration::ZERO) + .await + .expect("dual-tier constructs"); + + let small = b"a sub-threshold blob"; + let (key, pin) = store + .put(Some(small.len() as u64), Cursor::new(&small[..])) + .await + .expect("kv put publishes"); + assert_eq!(key, key_for(small)); + assert!( + store.kv().has(&key.as_bytes()).await.unwrap(), + "landed on the kv tier (sqlite)" + ); + assert!( + !store.large().unwrap().has(&key.as_bytes()).await.unwrap(), + "no large-tier copy on the kv route" + ); + let (len, blob) = store + .get(&key.as_bytes()) + .await + .unwrap() + .expect("entry present"); + assert_eq!(len, small.len() as u64); + assert_eq!(blob.read_at(0, len).await.unwrap(), &small[..]); + drop(pin); + + let big = vec![42u8; 200]; + let (key, pin) = store + .put(Some(200), Cursor::new(&big[..])) + .await + .expect("large staged put publishes"); + assert_eq!(key, key_for(&big)); + assert!( + store.large().unwrap().has(&key.as_bytes()).await.unwrap(), + "landed on the large tier (commit-rename)" + ); + assert!( + !store.kv().has(&key.as_bytes()).await.unwrap(), + "no kv copy on the large route" + ); + let (len, blob) = store + .get(&key.as_bytes()) + .await + .unwrap() + .expect("entry present"); + assert_eq!(len, 200); + assert_eq!(blob.read_at(0, len).await.unwrap(), big); + drop(pin); + + let listed = store.list().await.expect("list succeeds"); + assert_eq!(listed.len(), 2, "one entry per tier, both enumerated"); + for window in listed.windows(2) { + assert!(window[0] < window[1], "list is sorted"); + } + + // A re-put dedups into the existing entry (CAS first-writer- + // wins, through the facade's put). + let (_, pin) = store + .put(Some(small.len() as u64), Cursor::new(&small[..])) + .await + .expect("dedup re-put"); + assert_eq!(store.list().await.unwrap().len(), 2, "still two entries"); + drop(pin); + } +} diff --git a/tasks/store-sqlite-construction.md b/tasks/store-sqlite-construction.md index 45458c6..9ea2b0f 100644 --- a/tasks/store-sqlite-construction.md +++ b/tasks/store-sqlite-construction.md @@ -1,7 +1,7 @@ --- id: store-sqlite-construction name: sqlite constructor wiring — the default-build store shapes -status: pending +status: completed depends_on: [engine-sqlite, engine-sqlite-window] scope: narrow risk: low @@ -61,19 +61,19 @@ a named constructor is slotting in"): ## Acceptance Criteria -- [ ] `KvEngine::Sqlite` slots in; all six kv ops dispatch; the kv +- [x] `KvEngine::Sqlite` slots in; all six kv ops dispatch; the kv cursor body dispatches (design point recorded) -- [ ] `dual_tier_sqlite_local` + `kv_only_sqlite` constructors exist +- [x] `dual_tier_sqlite_local` + `kv_only_sqlite` constructors exist behind feature `sqlite`, take threshold + window delay, fallible where I/O is fallible -- [ ] `ensure_gc_schema` runs at construction; the window-delay +- [x] `ensure_gc_schema` runs at construction; the window-delay shipped default is 5 min (pinned constant) and constructor-tunable; `0` legal -- [ ] The one-window-host posture recorded (sqlite hosts when the kv +- [x] The one-window-host posture recorded (sqlite hosts when the kv engine is SQL-backed) -- [ ] The stale empty-set scaffolding/allow notes updated to the +- [x] The stale empty-set scaffolding/allow notes updated to the landed state -- [ ] `cargo test` (default) green — the default build constructs and +- [x] `cargo test` (default) green — the default build constructs and exercises sqlite stores; `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` green @@ -88,8 +88,70 @@ a named constructor is slotting in"): ## Notes -> To be filled by implementation agent +- **KvCursor sub-enum (the recorded design point).** The kv cursor + dispatch went with a `KvCursor` sub-enum behind `AnyCursor`'s one + `Kv` arm (rather than a per-engine `AnyCursor::Kv*` variant pair): + per-engine variants would copy the kv materializing arm and its + consumers (the verification-on-get body, `Blob`'s drive) per engine + without adding a behavior; the sub-enum keeps `AnyCursor` a two-arm + tier dispatch. Postgres (wave 4) slots one arm into `KvCursor`. +- **`KvEngine::ensure_gc_schema` — the engine-neutral store-core + call** lives on the kv-tier enum itself: the sqlite arm forwards to + the `EngineWindowState` clause; the mem arm is structurally nothing + (the in-process engines execute the ADR-005 window protocol inline + and carry no GC schema). Both sqlite constructors dispatch through + it — the store core calls the kv engine, never a typed sqlite path, + which is the recording shape the one-window-host posture wanted. +- **The window delay** is a named shipped constant + (`DEFAULT_DELETE_WINDOW_DELAY`, 5 min, ADR-008 §2) re-exported from + `lib.rs`; both sqlite constructors take it explicitly + (`Duration`, `Duration::ZERO` the legal degenerate tuning) and the + store carries it as a `window_delay` field with a crate-internal + accessor (`Store::window_delay`) for the durable sweep form + (`store-sweep-durable-window`) to consume. The mem/local + constructors are signature-unchanged; they carry the constant, + never consulted (the inline form has no delay in effect — same + posture as the mem-only threshold field). +- **Fallibility split**: `dual_tier_sqlite_local(database, root, + threshold, window_delay)` is fallible on the database file I/O, + `ensure_gc_schema`, *and* the local root creation (all + `Err(Error::Io)` via `?`); `kv_only_sqlite(database, threshold, + window_delay)` on the first two. WAL engagement failure (e.g. + `:memory:`) is a construction error, per the engine's recorded + posture. +- `SqliteBackend` gained a `#[cfg(test)] table_exists` probe (the + sqlite_master instrument) so the store tests can observe the + schema from the facade side; sqlite.rs's own test helper now + delegates to it. +- The stale empty-set scaffolding is updated to the landed state: + the `#[allow(unused_variables)]` attrs came off the `KvEngine` + methods; the scope notes in store.rs, backend.rs + (whole-value `put` now has its in-tree callers), sqlite.rs + (nothing-constructs-the-engine), and gc.rs (the window family's + first caller) reflect the landed state. `--no-default-features` + builds (kv set genuinely empty again) still compile under their + own cfg arithmetic — verified along with the default, mem-only, + and all-features builds. ## Summary -> To be filled on completion \ No newline at end of file +Sqlite slotted into the facade's engine representation (wave 3 +construction; commit pending at write time): `KvEngine::Sqlite` with +all six kv-op dispatch arms, the `KvCursor` sub-enum carrying the +sqlite materializing body behind `AnyCursor`'s kv arm (design point +recorded), the two named constructors (`dual_tier_sqlite_local` — the +default-build production shape, and `kv_only_sqlite`) taking +threshold + the constructor-tunable delete-window delay +(`DEFAULT_DELETE_WINDOW_DELAY` pinned at 5 min, `0` legal), +`ensure_gc_schema` running at construction through the engine-neutral +`KvEngine` call, the one-window-host posture recorded in the module +doc (the durable window is the kv engine's when the kv engine is +SQL-backed — wave 5's pg-lo dormancy slots in without reshaping), +and all stale empty-set allow/scope notes updated to the landed +state. Verification: `cargo test` 62 passing (default build — the +5 new sqlite-construction tests construct, introspect, schema-probe, +rejection-check, and end-to-end round-trip both tiers), +`cargo test --all-features` 117, `cargo test --features mem` 117, +`--no-default-features --features mem` 97, clippy `-D warnings` +(default + all-features) clean, `cargo fmt --check` clean, and +`cargo doc --no-deps` at the pre-existing warning baseline. \ No newline at end of file