diff --git a/src/dispatch.rs b/src/dispatch.rs index 5048e46..c648c32 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -1,2 +1,120 @@ //! Size-threshold dispatch between the kv and large tiers (ADR-003): //! routing as a pure function of content length. +//! +//! **The routing seam the facade programs through.** [`route`] is the +//! pure function — `route(len, threshold) → Tier`, **at-or-below the +//! threshold stays on the kv tier** (the boundary's pinned reading: +//! store-put-paths' "≤ threshold → kv; > threshold → large") — and +//! [`DEFAULT_DISPATCH_THRESHOLD`] is the shipped default, 128 KiB, the +//! conservative edge of the measured ~128–256 KiB crossover zone (POC #3 +//! A4). Purity is the ADR-003 invariant: same content ⇒ same length ⇒ +//! same tier, re-puts are deterministic, no content ever migrates. +//! +//! The threshold is **constructor-tunable** (a per-deployment media +//! re-tuning, never an API change): the facade's mode constructors take +//! it explicitly and default their docs to this constant. Read-side +//! consult order — kv first, then large — is the facade's fall-through +//! general rule (ADR-012 §6.1), carried on the `Store` facade type +//! ([`crate::store`]); mode-specific behavior (kv-only's put-time +//! rejection, mem-only's no-threshold posture) lives on the facade's +//! mode machinery, not here: this module routes lengths, it does not +//! own modes. +//! +//! The `#![allow(dead_code)]` below marks the seam's scope: `route` is +//! driven by the facade's write path (store-put-paths) and consulted by +//! the facade's read paths; the allow shrinks as those land. + +#![allow(dead_code)] + +/// The default dispatch threshold in bytes: 128 KiB — the conservative +/// edge of the measured ~128–256 KiB crossover zone (ADR-003; POC #3 +/// finding A4's first-party benchmark). Constructor-tunable per +/// deployment media; retuning is a constructor parameter, never an API +/// change. +pub const DEFAULT_DISPATCH_THRESHOLD: u64 = 128 * 1024; + +/// The two tiers a content length routes between. `pub(crate)`: the +/// enum is the routing *answer*, not the engine — tier names the +/// routing class (ADR-011 §1), and which engine backs the tier is the +/// constructor's choice, never this type's business. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Tier { + /// The kv tier — at-or-below-threshold content. + Kv, + /// The large tier — over-threshold content. + Large, +} + +/// The pure routing function: over the threshold goes to the large +/// tier; **at or below** (exactly-at-threshold included) stays on the +/// kv tier. The boundary reading is pinned here and boundary-tested +/// below — `store-put-paths` and the read paths consume this exact +/// reading through the facade's seam. +/// +/// Total purity discipline (ADR-003): a pure function of `len` — no +/// engine input, no namespace, no state. Same length is the same tier +/// forever, across mixed put paths, re-puts, and restarts. +pub(crate) fn route(len: u64, threshold: u64) -> Tier { + if len > threshold { + Tier::Large + } else { + Tier::Kv + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The shipped default is 128 KiB — pinned numerically (131072). + #[test] + fn default_threshold_is_128_kib() { + assert_eq!(DEFAULT_DISPATCH_THRESHOLD, 131_072); + assert_eq!(DEFAULT_DISPATCH_THRESHOLD, 128 * 1024); + } + + /// The pinned boundary reading: exactly-at-threshold routes to the + /// kv tier (≤ threshold → kv); one over routes large. + #[test] + fn exactly_at_threshold_routes_kv_one_over_routes_large() { + assert_eq!(route(4096, 4096), Tier::Kv); + assert_eq!(route(4095, 4096), Tier::Kv); + assert_eq!(route(4097, 4096), Tier::Large); + } + + /// Boundary values across the domain: zero length, the default + /// threshold, and the degenerate tunings a constructor may pass — + /// all route deterministically. + #[test] + fn boundary_values_route_deterministically() { + assert_eq!(route(0, DEFAULT_DISPATCH_THRESHOLD), Tier::Kv); + assert_eq!(route(131_071, DEFAULT_DISPATCH_THRESHOLD), Tier::Kv); + assert_eq!(route(131_072, DEFAULT_DISPATCH_THRESHOLD), Tier::Kv); + assert_eq!(route(131_073, DEFAULT_DISPATCH_THRESHOLD), Tier::Large); + + assert_eq!(route(0, 0), Tier::Kv, "zero-length content always kv"); + assert_eq!( + route(1, 0), + Tier::Large, + "threshold 0: everything else large" + ); + + assert_eq!(route(0, u64::MAX), Tier::Kv, "max threshold: nothing large"); + assert_eq!( + route(u64::MAX, u64::MAX), + Tier::Kv, + "at-threshold is kv even at MAX" + ); + } + + /// Purity (the ADR-003 invariant): the same length is the same tier + /// on every call — routing derives from inputs alone. + #[test] + fn routing_is_a_pure_function_of_length() { + let len = 103_487; + let first = route(len, DEFAULT_DISPATCH_THRESHOLD); + for _ in 0..32 { + assert_eq!(route(len, DEFAULT_DISPATCH_THRESHOLD), first); + } + } +} diff --git a/src/gc.rs b/src/gc.rs index dc4684b..8ffe0d2 100644 --- a/src/gc.rs +++ b/src/gc.rs @@ -36,6 +36,19 @@ use crate::backend::Backend; use crate::facade::PinState; use crate::Error; +/// The store-instance GC state home — held as a field of the public +/// facade type ([`Store`](crate::store::Store)). ADR-011 §1's vocabulary +/// and ADR-010 §2's ownership rule together: the store instance owns +/// all GC state, so in-process GC state carries on the facade +/// instance itself. The wave-2 gc-protocol task fills it — the pin +/// registry (per-key refcounts under the arbitration lock), registered +/// liveness sources, the protect callback, and the in-process delete +/// window (ADR-005's protocol inline, ADR-012 §5's non-SQL staging) — +/// and drives sweep/direct-delete through the facade. Fleet state never +/// lands here: it rides the engine-state companion seam below +/// (ADR-010 §2), hosted on the fleet's kv engine (ADR-012 §4). +pub(crate) struct GcHome; + /// A delete-window candidate staged into the durable window: the key and /// the time it becomes visible for deletion — exactly the /// `gc_window (key, visible_after)` row pair (ADR-005's delete-window diff --git a/src/lib.rs b/src/lib.rs index 714d152..fb1f01a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,11 +6,14 @@ pub mod gc; pub mod hashing; pub mod keys; pub mod local; +pub mod store; +pub use dispatch::DEFAULT_DISPATCH_THRESHOLD; pub use error::Error; pub use facade::{Batch, EntryMeta, GcAbortCause, Pin, PinState, SweepReport}; pub use hashing::{Algorithm, Digest}; pub use keys::Key; +pub use store::{Store, StoreMode}; #[cfg(feature = "mem")] pub mod mem; diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..3149c22 --- /dev/null +++ b/src/store.rs @@ -0,0 +1,419 @@ +//! The `Store` facade type (store-api.md §What this is): the public +//! surface consumers program against — constructor modes and the +//! size-threshold dispatch seam. **Skeleton and routing only**: the ops +//! methods land against this type in `store-read-paths` (get/stat/ +//! read_range/has/list with the fall-through general rule), +//! `gc-protocol` (sweep, direct delete, the pin/liveness machinery), +//! and `store-put-paths` (put, the kv-only rejection behavior). This +//! module owns the engine representation and the constructor-mode +//! machinery; the pure routing function lives in [`crate::dispatch`]. +//! +//! **Named design point — engine representation: concrete `Store`, +//! crate-internal enum dispatch.** A `Store` +//! generic was the alternative: rejected. The engines are statically +//! known and in-crate (no `dyn` anywhere — wave-1 design point 3), and +//! they are `pub(crate)` behind the crate-internal [`Backend`][crate::backend::Backend] +//! trait — a generic facade parameterized over `Backend` implementers +//! would leak engine types through the public surface, force consumers +//! to name engines they must never see (the facade is **engine-blind**, +//! ADR-007/008/010), or push `dyn` onto the surface. The concrete type +//! holds two crate-internal engine-holding enums ([`KvEngine`], +//! [`LargeEngine`]) dispatching over the closed five-engine set; the +//! conformance factory's binding note anticipated exactly this collapse +//! (tests/conformance/factory.rs — "most plausibly the associated type +//! collapsing to the concrete facade type across all factories"). The +//! public surface is engine-blind: consumers name modes and engines +//! only through named constructors, feature-gated with their engine +//! (`mem_only` needs feature `mem`; the sqlite/postgres/pg-lo +//! constructors arrive with waves 3–5 and slot into the same enums and +//! constructor pattern without reshaping — adding an enum variant and +//! a named constructor is slotting in). +//! +//! **Constructor modes** (ADR-011 §4; ADR-012 §6.3): +//! +//! - **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. +//! - **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. +//! - **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 +//! kv-only precisely by that threshold absence. +//! +//! Modes are validated structurally — unrepresentable states are +//! unrepresentable by construction: dual-tier's signature constructs +//! the large engine itself, mem-only's constructor exists only with +//! feature `mem` and constructs the mem engine — and every instance +//! carries its mode for introspection ([`Store::mode`]). +//! +//! **The fleet declaration defers to the engine waves.** ADR-012 §3 +//! rules fleet mode a constructor declaration (`fleet: true`), never +//! inferred — but it is a constructor *error* for every non-fleet-valid +//! configuration, and no fleet-valid configuration is constructible yet +//! (mem is never fleet-valid; `local` needs the shared-media +//! declaration of ADR-008 §3; the fleet-valid engines — postgres, pg-lo +//! — are waves 4–5). Adding the declaration then is additive; carrying +//! a flag that could only ever be rejected today would be dead +//! machinery. +//! +//! **The dispatch seam the ops tasks program through:** tier selection +//! for a given length ([`Store::tier_for`] composing +//! [`crate::dispatch::route`] per mode), the fall-through consult +//! order — **kv first, then large** — that every key-addressed op +//! consumes (ADR-012 §6.1's general rule; [`Store::kv`] then +//! [`Store::large`]), and the GC state home ([`crate::gc::GcHome`]) +//! the gc-protocol task fills. +//! +//! The `#![allow(dead_code)]` below marks the skeleton's scope: the +//! accessors and engine enums exist for the ops tasks' drivers, and the +//! kv engine set is empty in default builds (sqlite is wave 3). The +//! allow shrinks as the ops tasks land. + +#![allow(dead_code)] + +use crate::dispatch::{route, Tier}; +use crate::gc::GcHome; +use crate::local::LocalBackend; + +#[cfg(feature = "mem")] +use crate::mem::MemBackend; + +#[cfg(feature = "mem")] +use crate::Error; + +#[cfg(feature = "mem")] +use std::path::PathBuf; + +use self::StoreMode::{DualTier, KvOnly, MemOnly}; + +/// 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 +/// GC state*; **the GC arbitration domain** for single-instance +/// topologies). +/// +/// Engine-blind by construction: the engines are held in crate-internal +/// enums dispatched statically (see the module's engine-representation +/// design point); consumers name modes only. The GC state home +/// ([`crate::gc::GcHome`]) rides as a field — the store instance owns +/// all GC state (ADR-010 §2), which the gc-protocol task fills. +pub struct Store { + /// The kv-tier engine — the first consult in every fall-through. + kv: KvEngine, + /// The large-tier engine — `None` in single-tier modes. + large: Option, + /// The dispatch threshold in effect where the mode has one; mem-only + /// carries the shipped default but never consults it — "no dispatch + /// threshold in effect" is the mode's ruling (ADR-011 §4), not a + /// value absence. + threshold: u64, + /// 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) + /// — the gc-protocol task's machinery lands here. + gc: GcHome, +} + +/// The constructor modes (ADR-011 §4's pinned set; ADR-012 §6.3's +/// dual-tier ruling). Three variants, ADR-pinned — a new mode is a new +/// ADR. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StoreMode { + /// kv + large, size-threshold dispatch between them (the default). + DualTier, + /// kv alone; over-threshold puts reject at put time (ADR-012 §6.2). + KvOnly, + /// The mem engine alone, no dispatch threshold in effect — never + /// production (ADR-011 §4). + MemOnly, +} + +/// 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). +pub(crate) enum KvEngine { + /// The contract-reference ephemeral engine (ADR-011 §2). + #[cfg(feature = "mem")] + Mem(MemBackend), +} + +/// The large-tier engine set. `local` is ungated (dependency-free, +/// always present, ADR-013 §4); pg-lo slots in at wave 5. +pub(crate) enum LargeEngine { + /// The sharded-filesystem engine (ADR-003/008). + Local(LocalBackend), +} + +impl Store { + /// **mem-only mode** (ADR-011 §4): the mem engine alone, no + /// dispatch threshold in effect — a testing/embedder-ephemeral + /// posture, never production. Every length routes to the (sole) + /// mem engine; there is no large tier and nothing to reject. + #[cfg(feature = "mem")] + pub fn mem_only() -> Store { + Store { + kv: KvEngine::Mem(MemBackend::default()), + large: None, + threshold: crate::dispatch::DEFAULT_DISPATCH_THRESHOLD, + mode: MemOnly, + gc: GcHome, + } + } + + /// **kv-only mode** (ADR-011 §4; ADR-012 §6.2) with the mem kv + /// engine: 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; zero residue on either rejection). Routing in + /// this mode is kv for every length. + #[cfg(feature = "mem")] + pub fn kv_only_mem(threshold: u64) -> Store { + Store { + kv: KvEngine::Mem(MemBackend::default()), + large: None, + threshold, + mode: KvOnly, + gc: GcHome, + } + } + + /// **dual-tier mode** (the default, ADR-011 §4) in the wave-2 + /// constructible shape — `kv = mem` + `large = local`, the + /// dispatch-coverage test shape ADR-012 §6.3 names: both tiers + /// load-bearing, routed by size-threshold dispatch at `threshold` + /// (constructor-tunable; [`crate::dispatch::DEFAULT_DISPATCH_THRESHOLD`] + /// is the shipped default). + /// + /// Constructing the large engine creates the root directory — + /// fallible I/O, hence a fallible constructor. The mem kv engine + /// cannot fail. + /// + /// Not fleet-anything: mem is never fleet-valid (ADR-011 §4). + #[cfg(feature = "mem")] + pub async fn dual_tier_mem_local( + root: impl Into, + threshold: u64, + ) -> Result { + Ok(Store { + kv: KvEngine::Mem(MemBackend::default()), + large: Some(LargeEngine::Local(LocalBackend::new(root).await?)), + threshold, + mode: DualTier, + gc: GcHome, + }) + } + + /// The constructor mode (ADR-011 §4's vocabulary) — introspection + /// for embedders and tests; the dispatch/put/read paths branch on + /// it crate-internally. + pub fn mode(&self) -> StoreMode { + self.mode + } + + /// The active dispatch threshold — `Some` in dual-tier (the routing + /// boundary) and kv-only (the put-time rejection point); `None` in + /// mem-only mode ("no dispatch threshold in effect", ADR-011 §4). + pub(crate) fn threshold(&self) -> Option { + match self.mode { + DualTier | KvOnly => Some(self.threshold), + MemOnly => None, + } + } + + /// **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: + /// [`crate::dispatch::route`]'s pure function at the instance's + /// threshold (exactly-at-threshold stays kv — the pinned boundary + /// reading). kv-only: kv for every length — the over-threshold + /// *rejection* is the put seam's behavior keyed off + /// [`Store::threshold`] (ADR-012 §6.2), not a routing answer. + /// Mem-only: kv — there is no other tier and no threshold in + /// effect. + pub(crate) fn tier_for(&self, len: u64) -> Tier { + match self.mode { + DualTier => route(len, self.threshold), + KvOnly | MemOnly => Tier::Kv, + } + } + + /// **The fall-through consult order — kv first** (ADR-012 §6.1's + /// general rule): every key-addressed op consults the kv tier + /// first; large comes after ([`Store::large`]). A miss on both + /// tiers resolves per op's shape (`None` on get/stat, + /// `Err(Missing)` on read_range/delete). + pub(crate) fn kv(&self) -> &KvEngine { + &self.kv + } + + /// **…then large** — the second consult; `None` in single-tier + /// modes (mem-only, kv-only), where large-tier content cannot + /// exist. + pub(crate) fn large(&self) -> Option<&LargeEngine> { + self.large.as_ref() + } + + /// The GC state home (ADR-010 §2: the store core owns all GC + /// state) — the gc-protocol task's pin registry, liveness sources, + /// protect callback, and in-process delete window land here and + /// drive the sweep and direct delete from this facade. + pub(crate) fn gc(&self) -> &GcHome { + &self.gc + } +} + +#[cfg(all(test, feature = "mem"))] +mod tests { + use super::*; + use crate::dispatch::DEFAULT_DISPATCH_THRESHOLD; + use std::fs; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + /// A fresh temp root per test (mirrors `local.rs`'s helper); Drop + /// removes the whole tree idempotently. + struct TempRoot(PathBuf); + + fn temp_root(tag: &'static str) -> TempRoot { + 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 dir = std::env::temp_dir().join(format!( + "alkblobs-store-{tag}-{}-{:x}-{seq}", + std::process::id(), + nanos + )); + fs::create_dir_all(&dir).expect("temp root created"); + TempRoot(dir) + } + + impl TempRoot { + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + /// mem-only constructs and validates: the ephemeral mode's shape — + /// no large tier, no threshold in effect, kv routing for every + /// length (ADR-011 §4). + #[cfg(feature = "mem")] + #[test] + fn mem_only_constructs_and_validates() { + let store = Store::mem_only(); + assert_eq!(store.mode(), MemOnly); + assert_eq!(store.threshold(), None, "no dispatch threshold in effect"); + assert!(store.large().is_none(), "mem-only has no large tier"); + + for len in [0u64, 1, DEFAULT_DISPATCH_THRESHOLD + 1, u64::MAX] { + assert_eq!( + store.tier_for(len), + Tier::Kv, + "mem-only routes every length to its sole tier" + ); + } + } + + /// kv-only constructs and validates: no large tier; the threshold + /// is the *rejection point's* carrier (ADR-012 §6.2), not a routing + /// answer — every length routes kv there, and the put seam rejects + /// known-length over-threshold puts immediately (its behavior). + #[cfg(feature = "mem")] + #[test] + fn kv_only_mem_constructs_and_validates() { + let store = Store::kv_only_mem(1024); + assert_eq!(store.mode(), KvOnly); + assert_eq!(store.threshold(), Some(1024)); + assert!(store.large().is_none(), "kv-only has no large tier"); + + for len in [0u64, 1023, 1024, 1025, u64::MAX] { + assert_eq!( + store.tier_for(len), + Tier::Kv, + "kv-only routes every length to its sole tier" + ); + } + } + + /// The ADR-012 §6.3 dispatch-coverage shape: `kv = mem` + + /// `large = local` dual-tier construction works, both tiers + /// load-bearing, dispatch threshold active — boundary values route + /// deterministically (at/at-below threshold kv, above large), and + /// tunability is proven against a non-default threshold. + #[cfg(feature = "mem")] + #[tokio::test] + async fn dual_tier_mem_local_constructs_and_routes() { + let root = temp_root("dual-tier"); + let store = Store::dual_tier_mem_local(root.path(), 1024) + .await + .expect("dual-tier mem+local constructs"); + assert_eq!(store.mode(), DualTier); + assert_eq!(store.threshold(), Some(1024)); + 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_eq!( + store.tier_for(len), + tier, + "same length ⇒ same tier (the purity invariant, re-checked)" + ); + } + } + + /// The shipped default threshold is the constructor-tunable + /// baseline: dual-tier at `DEFAULT_DISPATCH_THRESHOLD` routes at + /// the 128 KiB boundary exactly as the pure function does. + #[cfg(feature = "mem")] + #[tokio::test] + async fn dual_tier_routes_at_the_default_threshold() { + let root = temp_root("dual-tier-default"); + let store = Store::dual_tier_mem_local(root.path(), DEFAULT_DISPATCH_THRESHOLD) + .await + .expect("dual-tier constructs at the default threshold"); + + for (len, tier) in [ + (131_071, Tier::Kv), + (131_072, Tier::Kv), + (131_073, Tier::Large), + ] { + assert_eq!(store.tier_for(len), tier, "length {len} at 128 KiB"); + } + } + + /// The GC state home rides as a field of the store instance + /// (ADR-010 §2 — the store instance owns GC state) and is + /// reachable crate-internally: the gc-protocol task's machinery + /// lands against this handle. + #[cfg(feature = "mem")] + #[test] + fn gc_state_home_rides_on_the_foundation() { + let store = Store::mem_only(); + let _ = store.gc(); + } +} diff --git a/tasks/store-construction.md b/tasks/store-construction.md index c5a4b9c..4aa90f2 100644 --- a/tasks/store-construction.md +++ b/tasks/store-construction.md @@ -1,7 +1,7 @@ --- id: store-construction name: Store facade type — constructor modes and size-threshold dispatch -status: pending +status: completed depends_on: [engine-mem, engine-local] scope: moderate risk: medium @@ -87,8 +87,94 @@ and `store-put-paths` against this type. ## Notes -> To be filled by implementation agent +- **Engine representation decided**: concrete `Store` holding two + crate-internal engine enums (`KvEngine`: mem [feature-gated]; + `LargeEngine`: local [ungated]), not the generic + `Store` — the generic would leak `pub(crate)` engine + types through the public surface or force `dyn` (wave-1 design + point 3); the facade stays engine-blind (ADR-007/008/010) and the + conformance factory's binding note anticipated exactly this + collapse. Waves 3–5 slot in by adding an enum variant + a named + (feature-gated) constructor — no reshaping. Recorded in + `src/store.rs` module docs. +- **Constructor modes** land as named constructors, engines named by + the constructor (public surface can't take `pub(crate)` engine + handles): `mem_only()` / `kv_only_mem(threshold)` / + `dual_tier_mem_local(root, threshold)` (the ADR-012 §6.3 + dispatch-coverage shape; fallible because the local root's creation + is I/O). Modes validate structurally (unrepresentable states + unrepresentable — dual-tier constructs the large engine itself) and + every instance carries its mode (`Store::mode() → StoreMode`, + public). The **fleet declaration (`fleet: true`, ADR-012 §3) + defers to the engine waves**: it is a constructor *error* for every + non-fleet-valid configuration, and none is constructible yet — the + ruling is recorded in store.rs docs rather than carried as a + dead-machinery flag. +- **Threshold shape**: `None`-vs-value is the mode's ruling (mem-only + = "no dispatch threshold in effect", ADR-011 §4), so the field is + always a value and mem-only's `threshold()` returns `None` by mode + branch — no unrepresentable `Option` state to `expect` away + (AGENTS convention 2). The pure function is `dispatch::route(len, + threshold)` with the pinned boundary reading **at-or-below → kv**; + `DEFAULT_DISPATCH_THRESHOLD` (128 KiB) is exported at the crate + root and constructors take the threshold explicitly (tunability + proven by tests at a non-default threshold). +- **Dispatch seam handed to the ops tasks**: write-side + `Store::tier_for(len)` (dual-tier pure routing; kv-only routes kv + for every length — the over-threshold *rejection* is the put seam's + behavior keyed off `threshold()`, ADR-012 §6.2; mem-only kv), and + read-side fall-through order as `kv()`-then-`large()` accessors + documented as ADR-012 §6.1's general rule. The engine enum's + `Backend` dispatch methods and the cursor/staging enum wrappers are + deliberately not built here — they are store-read-paths and + store-put-paths named design points (the public stream shape, the + declared-len mismatch case) and adding methods to the enums is + slotting in. +- **GC state home**: `pub(crate) struct GcHome` in `src/gc.rs`, held + as a `Store` field with a crate-internal `gc()` accessor — the + gc-protocol task fills it (pin registry, liveness sources, protect + callback, in-process window); fleet state explicitly never lands + there (ADR-010 §2 / ADR-012 §4 host rule). +- **Factory binding made and recorded**: the skeleton's `Store` + associated type is gone — `StoreFactory::open` vends the concrete + `alkblobs::Store` directly (`tests/conformance/factory.rs` + README + §The factory contract record the ruling); the wave-1 mock probe + retired, replaced by mem-gated binding demo factories + (`tests/conformance/binding.rs`) covering mem-only and the + dispatch-coverage dual-tier shape with isolation + idempotent + teardown. +- **Default-build note**: the kv engine set is empty until wave 3 + (sqlite), so `Store` exists in default builds but is + unconstructible — constructors and construction tests are + `#[cfg(feature = "mem")]`; the `#![allow(dead_code)]` scope notes on + `dispatch.rs`/`store.rs` follow the house pattern and shrink as the + ops tasks land. ## Summary -> To be filled on completion \ No newline at end of file +Implemented the `Store` facade skeleton in `src/store.rs`: the public +engine-blind facade type holding the two crate-internal tier enums +(`KvEngine` {mem}, `LargeEngine` {local}) per the recorded +engine-representation ruling (concrete + enum dispatch, generic +rejected), the three constructor modes as named constructors — +`mem_only()`, `kv_only_mem(threshold)`, `dual_tier_mem_local(root, +threshold)` (the ADR-012 §6.3 shape) — with `StoreMode` introspection, +the write-side routing seam (`tier_for`) composing the pure +`dispatch::route` (default 128 KiB, constructor-tunable, at-threshold → +kv), the read-side fall-through order (`kv()` then `large()`, ADR-012 +§6.1), and the `GcHome` GC-state field the gc-protocol task fills +(added to `src/gc.rs`). `dispatch.rs` grew the pure routing function +plus `DEFAULT_DISPATCH_THRESHOLD` with boundary-value unit tests +(exactly-at-threshold, degenerate tunings, purity). The conformance +factory binding collapsed the `Store` associated type into the concrete +facade (recorded in `factory.rs` + the suite README); the wave-1 probe +became mem-gated binding demo factories. Tests: mem-only/kv-only/ +dual-tier construction + validation, threshold tunability at a +non-default value, dispatch boundary values, factory open/teardown +lifecycle in both constructible shapes. + +Verification: `cargo test` (default), `cargo test --features mem`, +`cargo test --all-features` all green; `cargo clippy --all-targets +-- -D warnings` green in all three feature shapes; `cargo fmt --check` +green; `cargo doc --no-deps` clean of new warnings beyond the +pre-existing private-intra-doc-links class. \ No newline at end of file diff --git a/tests/conformance/README.md b/tests/conformance/README.md index a04f7b8..02ac8d2 100644 --- a/tests/conformance/README.md +++ b/tests/conformance/README.md @@ -95,11 +95,16 @@ row with its own owner stamp, still one per property. the temp dir, dropping the schema, closing the pool — and owns the isolated-ness guarantee above. - Factories are `Send + Sync`: shareable across row runs. -- The wave-2 binding note: the trait's `Store` associated type is the - stub's placeholder for the `Store` facade type (wave 2). Wave 2's - first column binds it; if binding reveals a shape correction (e.g. - the associated type collapses to the concrete facade type), that is - wave 2's to make and record in this README and `factory.rs`. +- **The wave-2 binding — made and recorded.** The `Store` facade type + landed with wave 2's store-construction task as a concrete type + (`alkblobs::Store`, engine-blind; src/store.rs holds the engine- + representation design point). The binding made the anticipated + shape correction: the skeleton's `Store` associated type collapsed + into the concrete facade — `open()` vends `alkblobs::Store` + directly (factory.rs records the ruling; the wave-1 mock probe + retired with it, replaced by the mem-gated binding demo factories + in `main.rs`). A new engine column binds by implementing the trait; + there is no longer a vendible type to choose. ## The SKIP posture @@ -146,7 +151,9 @@ and first run in wave 6. ## Current state -The harness skeleton is compile-able with **zero rows** (this task's -scoped deliverable): the trait shape, the stamp convention, and the -backlog registry are in place; the rows arrive with wave 2's mem -column. \ No newline at end of file +The harness skeleton is compile-able with **zero rows**: the trait +shape, the stamp convention, and the backlog registry are in place; +the wave-2 factory binding is **made** — `open()` vends the concrete +`alkblobs::Store` facade (see §The factory contract), demonstrated by +the mem-gated binding demo factories. The rows arrive with wave 2's +mem column. \ No newline at end of file diff --git a/tests/conformance/binding.rs b/tests/conformance/binding.rs new file mode 100644 index 0000000..2084262 --- /dev/null +++ b/tests/conformance/binding.rs @@ -0,0 +1,140 @@ +//! The wave-2 factory binding demonstrator (mem-gated — mem is the +//! skip-free reference shape): the concrete `Store` facade vended by +//! [`StoreFactory::open`] in both constructible wave-2 shapes, the +//! factory contract's isolation + idempotent-teardown requirements +//! exercised, the wave-1 mock probe retired by the binding. The real +//! columns carry these factories forward with rows (suite-column-mem, +//! suite-column-local). + +#![allow(dead_code)] + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::factory::StoreFactory; +use alkblobs::{Error, Store, StoreMode, DEFAULT_DISPATCH_THRESHOLD}; + +/// The binding demo factory, mem-only mode: a fresh `BTreeMap`-backed +/// store per `open` (isolated by construction — the ephemeral engine +/// shares nothing across instances), teardown a no-op. Mem is the +/// skip-free reference shape, so this column never skips. +struct MemOnlyBinding; + +impl StoreFactory for MemOnlyBinding { + fn name(&self) -> &'static str { + "mem-only (binding demo)" + } + + async fn open(&self) -> Result { + Ok(Store::mem_only()) + } + + async fn teardown(&self) -> Result<(), Error> { + Ok(()) + } +} + +/// A fresh temp root per factory `open` — the isolated backing for the +/// dual-tier binding demo. No Drop guard: teardown owns removal (the +/// factory contract's ownership; a factory dropped without teardown +/// leaks its temp roots — harmless temp debris, and teardown makes +/// sense over the abandoned instance per the contract). +fn fresh_temp_root() -> PathBuf { + 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 dir = std::env::temp_dir().join(format!( + "alkblobs-conformance-{}-{:x}-{seq}", + std::process::id(), + nanos + )); + std::fs::create_dir_all(&dir).expect("temp root created"); + dir +} + +/// The binding demo factory, dual-tier mode (the ADR-012 §6.3 +/// dispatch-coverage shape, `kv = mem` + `large = local` at the default +/// threshold): a fresh temp directory per `open` so two opens never +/// share state, teardown removing every root it has vended (idempotent +/// — a repeated teardown is a no-op) and safe against abandoned +/// instances (each root is private to its open, so a half-open +/// instance's root is removable the same way). +struct DualTierBinding { + roots: Mutex>, +} + +impl Default for DualTierBinding { + fn default() -> Self { + DualTierBinding { + roots: Mutex::new(Vec::new()), + } + } +} + +impl StoreFactory for DualTierBinding { + fn name(&self) -> &'static str { + "dual-tier mem+local (binding demo)" + } + + async fn open(&self) -> Result { + let path = fresh_temp_root(); + let store = Store::dual_tier_mem_local(path.clone(), DEFAULT_DISPATCH_THRESHOLD).await?; + self.roots + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(path); + Ok(store) + } + + async fn teardown(&self) -> Result<(), Error> { + for root in self + .roots + .lock() + .unwrap_or_else(|e| e.into_inner()) + .drain(..) + { + match std::fs::remove_dir_all(&root) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(Error::Io(e.to_string())), + } + } + Ok(()) + } +} + +/// The binding holds: the demo factory vends the concrete facade in +/// the mem-only mode, reports its identity, and tears down idempotently. +#[tokio::test] +async fn binding_factory_vends_the_concrete_store_facade() { + let factory = MemOnlyBinding; + assert_eq!(factory.name(), "mem-only (binding demo)"); + let store = factory.open().await.expect("open vends the store"); + assert_eq!(store.mode(), StoreMode::MemOnly); + factory.teardown().await.expect("teardown is idempotent"); + factory.teardown().await.expect("teardown repeats cleanly"); +} + +/// The binding holds in dual-tier mode too: every `open` is isolated +/// (fresh backing), the vend store reports the dispatch-coverage +/// shape, and teardown across open-then-teardown cycles is idempotent. +#[tokio::test] +async fn dual_tier_binding_factory_vends_isolated_stores() { + let factory = DualTierBinding::default(); + assert_eq!(factory.name(), "dual-tier mem+local (binding demo)"); + + for _ in 0..2 { + let store = factory.open().await.expect("open vends the store"); + assert_eq!(store.mode(), StoreMode::DualTier, "dispatch-coverage shape"); + } + factory + .teardown() + .await + .expect("teardown removes the roots"); + factory.teardown().await.expect("teardown repeats cleanly"); +} diff --git a/tests/conformance/factory.rs b/tests/conformance/factory.rs index 0913371..aa2fca5 100644 --- a/tests/conformance/factory.rs +++ b/tests/conformance/factory.rs @@ -8,53 +8,56 @@ //! the suite README §The factory contract. Engines implement this in //! wave 2+'s column wiring; rows take factories, never stores. //! -//! **The wave-2 binding.** The `Store` facade type arrives in wave 2 -//! (store-api.md §What this is). This trait's `Store` associated type -//! is the skeleton's binding point for it: wave 2's first column (mem) -//! binds it to the real facade, and if binding reveals a shape -//! correction — most plausibly the associated type collapsing to the -//! concrete facade type across all factories — that is wave 2's to -//! make and record here and in the README. +//! **The wave-2 binding — made, recorded.** The `Store` facade type +//! landed with wave 2's store-construction task as a concrete type +//! (`alkblobs::Store`) whose engine representation is crate-internal +//! enum dispatch over the closed five-engine set — the collapse this +//! skeleton's note anticipated ("most plausibly the associated type +//! collapsing to the concrete facade type across all factories"). The +//! binding made that correction: there **is no `Store` associated +//! type** — [`StoreFactory::open`] vends the concrete facade type +//! directly, so every impl binds by construction and no factory can +//! vend anything else. The original associated-type shape was +//! plausible when the facade was unwritten and possibly generic; with +//! the concrete engine-blind type, a second vendible type would be the +//! surprise, so the placeholder collapsed into the contract. //! //! The SKIP posture is a factory-owner responsibility (README §SKIP //! posture): SQL-attached factories probe their server before rows -//! run; a unreachable server skips the column cleanly, while the +//! run; an unreachable server skips the column cleanly, while the //! engine wave's acceptance requires green against the server. #![allow(dead_code)] use std::future::Future; -use alkblobs::Error; +use alkblobs::{Error, Store}; /// Vends fresh, isolated store instances to the suite's rows. /// /// Implementers — the engine columns' wiring — yield an instance of -/// the `Store` facade over an isolated backing store (fresh temp -/// directory, pool, file, or schema per call, so a property's rows -/// never outlive their run into another's) plus a teardown disposing -/// of the backing store. Implementations must be `Send + Sync` so one -/// factory instance can be shared across row runs. +/// the concrete `Store` facade over an isolated backing store (fresh +/// temp directory, pool, file, or schema per call, so a property's +/// rows never outlive their run into another's) plus a teardown +/// disposing of the backing store. Implementations must be `Send + +/// Sync` so one factory instance can be shared across row runs. /// /// Rows call [`StoreFactory::open`], drive their assertions, and call /// [`StoreFactory::teardown`] before returning; a failing assertion /// panics the row (no teardown on that path), which is why teardown is /// required to be idempotent and safe against an abandoned instance. pub trait StoreFactory: Send + Sync { - /// The store instance type this factory vends — the wave-2 `Store` - /// facade's binding point (see the module docs). - type Store: Send; - /// The column's identity for reporting — which configuration rows - /// ran against (the constructor-mode vocabulary of ADR-011 §4, - /// once wave 2 names it). Rows never branch on it; the suite's - /// reporting and the SKIP posture's per-column bookkeeping do. + /// ran against (the constructor-mode vocabulary of ADR-011 §4). + /// Rows never branch on it; the suite's reporting and the SKIP + /// posture's per-column bookkeeping do. fn name(&self) -> &'static str; - /// Vend a fresh, isolated store instance. A failure here is an - /// environment failure (e.g. the SKIP posture's unreachable - /// server), not a row failure. - fn open(&self) -> impl Future> + Send; + /// Vend a fresh, isolated store instance — the concrete `alkblobs::Store` + /// facade (the wave-2 binding, made and recorded above). A failure + /// here is an environment failure (e.g. the SKIP posture's + /// unreachable server), not a row failure. + fn open(&self) -> impl Future> + Send; /// Tear down the backing store behind this factory instance. /// diff --git a/tests/conformance/main.rs b/tests/conformance/main.rs index 7fa002e..c6034b0 100644 --- a/tests/conformance/main.rs +++ b/tests/conformance/main.rs @@ -3,12 +3,18 @@ //! The suite is in-crate by decided point (docs/plans/implementation.md): //! engines are features of one crate, so the harness is factory- //! parameterized over engine constructors rather than a sibling suite -//! crate. This target currently carries the **skeleton only** — the -//! factory trait shape, the stamp convention, and the invariant backlog -//! registry (see [`README`](./README.md) for the conventions' normative -//! statement). Rows arrive with wave 2's first column (mem, the -//! contract-reference engine); if binding the `Store` facade type -//! reveals a shape correction, that is wave 2's to make and record. +//! crate. This target carries the skeleton (factory trait shape, the +//! stamp convention, the invariant backlog registry — see +//! [`README`](./README.md)) plus the **wave-2 factory binding**: the +//! `Store` facade type landed as the concrete `alkblobs::Store` +//! (engine-blind; src/store.rs holds the engine-representation design +//! point), and the skeleton's `Store` associated type collapsed into +//! it — `open` vends the concrete facade type directly, recorded in +//! `factory.rs` and the README. The wave-1 mock probe retired with it; +//! the binding demonstrator ([`binding`], mem-gated — mem is the +//! skip-free reference shape) proves the contract implementable +//! against the real type. Rows arrive with wave 2's columns (mem +//! first, the contract-reference engine). //! //! Items beyond the tests below are the wave-2 drivers' surface, hence //! the scoped `#![allow(dead_code)]` — it shrinks as rows land. @@ -16,50 +22,11 @@ #![allow(dead_code)] mod backlog; +#[cfg(feature = "mem")] +mod binding; mod factory; mod stamp; -use factory::StoreFactory; - -/// The harness probe factory: not an engine — a minimal mock proving -/// the [`StoreFactory`] contract is implementable with async methods -/// and reports the identity `name()` carries (the alkstore harness's -/// "exemplar against a trivial in-crate mock" posture, sized down to -/// the skeleton's no-rows state). -struct Probe { - label: &'static str, -} - -impl StoreFactory for Probe { - type Store = (); - - fn name(&self) -> &'static str { - self.label - } - - async fn open(&self) -> Result { - Ok(()) - } - - async fn teardown(&self) -> Result<(), alkblobs::Error> { - Ok(()) - } -} - -#[test] -fn probe_factory_implements_the_contract() { - let factory = Probe { label: "probe" }; - assert_eq!(factory.name(), "probe", "factory identity is reportable"); -} - -#[tokio::test] -async fn probe_factory_open_teardown_lifecycle() { - let factory = Probe { label: "probe" }; - factory.open().await.expect("open vends the instance"); - factory.teardown().await.expect("teardown is idempotent"); - factory.teardown().await.expect("teardown repeats cleanly"); -} - #[test] fn stamp_marker_matches_convention() { assert_eq!(stamp::STAMP_MARKER, "Contract stamp:");