- PG-1: sweep_lock_acquire reaps a lease whose held session died (in-instance session-loss reaping, ADR-013 §2) — previously the mutex answered Ok(false) until an explicit release; new test terminates the lease's own backend via pg_terminate_backend and re-acquires without a release - PG-3: the engine-level pg tests now drop their per-test schemas (test_support::SchemaJanitor, the Harness deref-guard wrapper in src/postgres.rs's tests, fresh_opts carrying a janitor in the construction tests) — 380 leaked abt_* schemas on the shared harness db are 0 after every run now - PG-2: PgJointTx's doc records the mid-flight-drop hazard (deadpool Fast recycle never resets transaction state; the clause bodies' failure paths all ROLLBACK — the store core's joint-tx driver owes pin_commit on every path from begin_joint_tx; wave 6 amendment) Verification: cargo test (69/19/3), --features mem (124/54/4), --features postgres vs server (103/36/3, 51 clean skips server-less), --all-features vs server (158/71/4), pg-pure (76/19/3); clippy -D warnings green (default/postgres/all-features/pg-pure); fmt --check green; shared-db schema residue 0; cargo doc clean
5306 lines
212 KiB
Rust
5306 lines
212 KiB
Rust
//! The `Store` facade type (store-api.md §What this is): the public
|
||
//! surface consumers program against — constructor modes, the
|
||
//! size-threshold dispatch seam, the write path (`put`, the two internal
|
||
//! put paths behind one signature, the `Batch` batching seam, kv-only
|
||
//! rejection — implemented below), the read-path ops (`get`/`stat`/
|
||
//! `read_range`/`has`/`list` with the fall-through general rule),
|
||
//! and the GC protocol's ops (`sweep`, direct `delete`, the liveness
|
||
//! registration — arbitration-then-delete per ADR-005/012, implemented
|
||
//! below). 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<Kv: Backend, Large: Backend>`
|
||
//! 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]
|
||
//! 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 constructors are the
|
||
//! default build's — wave 3; the postgres constructors arrived with
|
||
//! wave 4 behind feature `postgres`; pg-lo slots in at wave 5 — and
|
||
//! each slotting is exactly adding an enum variant and a named
|
||
//! constructor, without reshaping).
|
||
//!
|
||
//! **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); the wave-3
|
||
//! constructible shape is `kv = sqlite` + `large = local` — the
|
||
//! **default-build production shape** (feature `sqlite` is
|
||
//! default-on, ADR-013 §4); the wave-4 shape is `kv = postgres` +
|
||
//! `large = local` — the **fleet-capable-kv production shape** (the
|
||
//! engine is the fleet host, ADR-012 §4, but the constructor is the
|
||
//! non-fleet declaration until wave 6). 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. The wave-2 engine is mem (testing);
|
||
//! the wave-3 engine is sqlite (the durable single-node medium); the
|
||
//! wave-4 engine is postgres (the fleet host's kv-only shape).
|
||
//! - **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`]).
|
||
//!
|
||
//! **Read-path design points (recorded for the review gate):**
|
||
//!
|
||
//! - **The fall-through general rule (ADR-012 §6.1; POC #1 finding
|
||
//! 6).** Every key-addressed op consults `KvEngine` first, then
|
||
//! `LargeEngine` — there is no length routing on the read side and
|
||
//! `Store::tier_for` is never consulted by it (the dispatch seam's
|
||
//! write-half routing is the put path's; `route` feeds nothing on
|
||
//! reads). A miss on both tiers resolves per op's shape: `Ok(None)`
|
||
//! on get/stat, `false` on has, `Err(Missing)` on read_range.
|
||
//! - **The public stream type (named design point): `Blob`.** `get`
|
||
//! returns the pinned `(len, stream)` shape as
|
||
//! `Option<(u64, Blob)>` (store-api.md §Read path) — [`Blob`] is a
|
||
//! bounded, re-entrant windowed reader over the crate-internal
|
||
//! `AnyCursor`, which dispatches the engine cursor bodies by tier
|
||
//! (mem's materializing body for kv, `local`'s pread body for
|
||
//! large). Windowed reads are the uniform stream shape across
|
||
//! tiers; no chunk-iterator machinery was adopted — the cursor
|
||
//! bodies *are* the streaming abstraction, and per-chunk `Stream`-
|
||
//! wrapping would re-shape them without changing the underlying
|
||
//! reads. Engine cursors never leak onto the public surface:
|
||
//! `AnyCursor` is `pub(crate)`, `Blob` is opaque but drivable
|
||
//! (constructed only by the store core, ADR-010 §1).
|
||
//! - **Verification-on-get (named design point, pinned against
|
||
//! ADR-006's text).** The whole-blob hash-check fires where the
|
||
//! whole content is already in hand — the kv tier's materializing
|
||
//! reads. A kv-tier `get` materializes the stored value and
|
||
//! re-derives the canonical digest over it, comparing against the
|
||
//! key (ADR-006 decision 1: "put and get paths hash-check");
|
||
//! mismatch is `Err(Error::Verification)` and **never falls
|
||
//! through further** — a verified mismatch is a pool-integrity
|
||
//! fact, and consulting another tier past it would be a guess.
|
||
//! The large tier's streaming `get` does *not* re-derive at open:
|
||
//! opening is metadata + handle only, and the tier's integrity
|
||
//! story is the put path's whole-blob verification pass (the
|
||
//! complete staged artifact was checked before commit) plus the
|
||
//! per-range slice digests `read_range` carries out-of-band
|
||
//! (ADR-006 decisions 1–2; the §Context line "git objects
|
||
//! self-verify under git's own model when read whole"). `read_range`
|
||
//! carries no canonical check on either tier — the slice digest is
|
||
//! the convention's whole answer (ADR-006 decision 2). stat/has/
|
||
//! list carry no verification (metadata and enumeration only).
|
||
//! - **`list` returns `Vec<Key>` (named design point: Vec vs
|
||
//! stream).** Whole-pool enumeration, complete by contract
|
||
//! (ADR-003 — load-bearing for GC), returned **materialized and
|
||
//! sorted in the keys' total order** (deterministic output). The
|
||
//! sweep consumes the same crate-internal enumeration directly from
|
||
//! the engines (its live-set diffs sort independently), so the
|
||
//! facade shape serves consumers, not internal needs. A lazy stream
|
||
//! was rejected: every shipped engine enumerates fully and eagerly
|
||
//! (the completeness contract *is* the GC-safety property), so a
|
||
//! pseudo-lazy wrapper adds machinery without changing the
|
||
//! underlying walk. Listed bytes failing [`Key::from_bytes`]
|
||
//! reject with `Error::KeyInvalid` — a malformed pool list is
|
||
//! fatal, never a silent skip (ADR-003's malformed-list ruling
|
||
//! applied at the facade boundary).
|
||
//! - **`KeyInvalid` at the facade boundary (ADR-002/003).** Read ops
|
||
//! take opaque key *bytes* and convert to typed `Key` at the
|
||
//! boundary via [`Key::from_bytes`]: malformed bytes reject with
|
||
//! `Error::KeyInvalid` on every key-addressed op, never a guess,
|
||
//! and the engines receive only the physical byte encoding — they
|
||
//! never see typed keys.
|
||
//!
|
||
//! **Write-path design points (recorded for the review gate):**
|
||
//!
|
||
//! - **The stream parameter shape: `put(len, stream: impl AsyncRead +
|
||
//! Unpin + Send)`** — generic over the concrete reader, no boxing.
|
||
//! The family's no-`dyn` posture (wave-1 design point 3: no `dyn`
|
||
//! anywhere) carries to the write seam: the reader is monomorphized,
|
||
//! and a caller holding a boxed reader passes the box (a
|
||
//! `Box<dyn AsyncRead + Unpin + Send>` is itself `AsyncRead + Unpin +
|
||
//! Send` under tokio's blanket impl). `Send` (and no `'static`:
|
||
//! nothing is spawned) keeps spawn-site bounds resolvable for the
|
||
//! ops layer (wave 7), matching the ADR-005/ops posture. A boxed
|
||
//! trait-object parameter was rejected as the *default* shape — it
|
||
//! would force an allocation on every call (including the known-
|
||
//! length path's) to carry a posture the family has already ruled
|
||
//! against. Callers who need erasure box at their call site.
|
||
//! - **The declared-len mismatch case (`Verification`).** A known-
|
||
//! length stream that yields fewer or more bytes than `len` rejects
|
||
//! with `Error::Verification` on either side: the derivation's
|
||
//! preamble carries the *declared* length, so a mismatched artifact's
|
||
//! canonical address can never equal the stored key (a later get
|
||
//! re-derives over the actual length and hash-checks — storing under
|
||
//! the declared-length derivation would break pool integrity by
|
||
//! construction). Zero residue on either side: the kv path buffers
|
||
//! and publishes nothing; the staged path drives
|
||
//! `crate::backend::backend`'s `StagingCursor::discard`. The error family is
|
||
//! ADR-pinned to five members (ADR-012 §6.5); `Verification` is the
|
||
//! put-path hash-check family member and this is a content-≠-key
|
||
//! fact (the ADR-006 whole-blob posture applied at put time).
|
||
//! - **The kv-only over-threshold rejection's error carrier (named
|
||
//! design point).** ADR-012 §6.2's put-time rejection rides
|
||
//! `Error::Io(String)` with a descriptive message: the pinned family
|
||
//! (ADR-012 §6.5) holds no dedicated variant, and `Missing`/
|
||
//! `Verification`/`KeyInvalid`/`GcRefuse` are strictly narrower
|
||
//! families — the rejection is an operational/capability fact about
|
||
//! the store's medium posture, the bucket `Io(String)` covers
|
||
//! (stringly, so the message names mode, threshold, and the
|
||
//! rejected length). Recorded for the review gate: a dedicated
|
||
//! variant for embedders that need typed matchability would be a
|
||
//! new failure family — a new ADR.
|
||
//! - **The staged-put seam resolves the key at commit (the wave-2
|
||
//! amendment of the wave-1 handout shape).** The canonical key is
|
||
//! the derivation over preamble + content and completes at stream
|
||
//! end — after the staging cursor exists (both large-tier put paths
|
||
//! stream content once into the cursor while hashing; ADR-003). The
|
||
//! `crate::backend::backend`'s `put_staged` handout is therefore
|
||
//! keyless and the staging cursor's `commit` carries the
|
||
//! store-core-resolved key (CAS first-writer-wins as the whole-value
|
||
//! put). The wave-1 notes' `put_staged(key)` handout predates this
|
||
//! task's flow realization; no ADR text pins the key's arrival
|
||
//! point, and the amendment is recorded for review-wave-2.
|
||
//! - **`Batch` surface: `batch()` + `put_in_batch(&mut Batch, ...)`.**
|
||
//! The batch-scoped pin group is minted by the store core
|
||
//! ([`crate::gc`]); a member put runs the lone-put flow and
|
||
//! contributes its pin to the group (`Batch::add` — the minted guard
|
||
//! is defused, the group's one-step release owns the protection).
|
||
//! Members publish pin-before-publish exactly as lone puts do; the
|
||
//! group's pins drop together on [`Batch::commit`] or drop (the
|
||
//! `gc-protocol` machinery drives).
|
||
//!
|
||
//! **The fleet declaration defers to wave 6.** 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; pg-lo's wave-5 constructor completes the
|
||
//! fleet-valid engine set). The fleet-capable engines' constructors
|
||
//! — postgres's pair, landed wave 4 — are **implicitly `fleet: false`**
|
||
//! (ADR-012 §3): the ADR-005 in-process protocol applies verbatim
|
||
//! regardless of engine — pins are in-process guards, the sweep lease
|
||
//! is the in-process lock, and none of the `EngineFleetState` clauses
|
||
//! run — with the posture recorded in each constructor's doc so wave
|
||
//! 6's `fleet: true` declaration (the fleet-validity predicate plus
|
||
//! the constructor declarations) slots in without reshaping: no fleet
|
||
//! parameter is carried that could only ever be rejected today.
|
||
//!
|
||
//! **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 (`GcHome`,
|
||
//! crate-internal)
|
||
//! 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 and
|
||
//! postgres — the SQL-backed kv engines — host: their 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 sweep's SQL-backed form (**the durable
|
||
//! form**, ADR-008 §2 staged re-arbitrated delete; ADR-012 §6.7 the
|
||
//! executor is the next sweep) stages its candidates on the kv engine
|
||
//! the constructor built. The form branch keys off the engine
|
||
//! representation (`sweep_window`'s kv-engine-enum match — each
|
||
//! SQL-backed arm runs the durable form on its host, never a flag):
|
||
//! the inline form verbatim on mem/local, 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. Every
|
||
//! SQL-backed constructor carries 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 form's
|
||
//! staging consumes (`visible_after = mark_end + window`); the
|
||
//! mem/local constructors' inline form has no delay in effect.
|
||
//!
|
||
//! The `#![allow(dead_code)]` below marks the work still ahead, not
|
||
//! the GC protocol (which is driven below — the durable window's
|
||
//! cadence knob is consumed by the durable form's staging). The fleet
|
||
//! clauses hold the remaining scope until the fleet engine's wave.
|
||
|
||
#![allow(dead_code)]
|
||
|
||
use std::collections::BTreeSet;
|
||
use std::sync::Arc;
|
||
use std::time::{Duration, SystemTime};
|
||
|
||
use tokio::io::{AsyncRead, AsyncReadExt};
|
||
|
||
use crate::backend::{Backend, ReadCursor, StagingCursor};
|
||
use crate::dispatch::{route, Tier};
|
||
use crate::facade::{
|
||
Batch, Blob, EntryMeta, GcAbortCause, LivenessSource, Pin, ProtectCallback, Registration,
|
||
SweepReport,
|
||
};
|
||
use crate::gc::{Arbitration, EngineWindowState, GcHome, WindowCandidate};
|
||
use crate::hashing::{Algorithm, BlobHasher};
|
||
use crate::keys::Key;
|
||
use crate::local::{LocalBackend, LocalCursor, LocalStaging};
|
||
|
||
#[cfg(feature = "mem")]
|
||
use crate::mem::{MemBackend, MemCursor};
|
||
#[cfg(feature = "postgres")]
|
||
use crate::postgres::{PgOpts, PostgresBackend, PostgresCursor};
|
||
#[cfg(feature = "sqlite")]
|
||
use crate::sqlite::{SqliteBackend, SqliteCursor};
|
||
|
||
use crate::Error;
|
||
|
||
#[cfg(any(feature = "mem", feature = "postgres", 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
|
||
/// 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
|
||
/// (`GcHome`, crate-internal) 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<LargeEngine>,
|
||
/// 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 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)
|
||
/// — 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 — landed, the default),
|
||
/// postgres (wave 4 — landed, feature-gated default-off).
|
||
///
|
||
/// The variant payloads are structurally dissimilar by design — the
|
||
/// in-process engine holds one `Arc`, the SQL-backed engines hold
|
||
/// their connection postures inline — and every dispatch rides the
|
||
/// enum by reference (`&self` matches only), so the size difference
|
||
/// costs nothing.
|
||
#[allow(clippy::large_enum_variant)]
|
||
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 second SQL-backed engine — the fleet host (ADR-012 §4) and
|
||
/// a one-window-host alongside sqlite: its constructors run the
|
||
/// GC schema's engine-neutral call (the postgres row's body
|
||
/// creates `gc_pins` + `gc_window`) and its sweeps run the durable
|
||
/// window form on it. Feature `postgres`, default-off.
|
||
#[cfg(feature = "postgres")]
|
||
Postgres(PostgresBackend),
|
||
}
|
||
|
||
/// 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),
|
||
}
|
||
|
||
/// **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 and landed with wave 4: postgres slotted one arm in
|
||
/// here (exactly the predicted shape, no reshaping).
|
||
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),
|
||
/// The postgres body — the same materializing shape, the value
|
||
/// loaded once from the engine's pooled row (the postgres
|
||
/// engine's record).
|
||
#[cfg(feature = "postgres")]
|
||
Postgres(PostgresCursor),
|
||
}
|
||
|
||
impl ReadCursor for KvCursor {
|
||
fn len(&self) -> u64 {
|
||
match self {
|
||
#[cfg(feature = "mem")]
|
||
Self::Mem(cursor) => cursor.len(),
|
||
#[cfg(feature = "postgres")]
|
||
Self::Postgres(cursor) => cursor.len(),
|
||
#[cfg(feature = "sqlite")]
|
||
Self::Sqlite(cursor) => cursor.len(),
|
||
}
|
||
}
|
||
|
||
async fn read_at(&self, offset: u64, len: u64) -> Result<Vec<u8>, Error> {
|
||
match self {
|
||
#[cfg(feature = "mem")]
|
||
Self::Mem(cursor) => cursor.read_at(offset, len).await,
|
||
#[cfg(feature = "postgres")]
|
||
Self::Postgres(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`). 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).
|
||
Kv(KvCursor),
|
||
/// The large body family: a pread cursor over the committed file.
|
||
Large(LocalCursor),
|
||
}
|
||
|
||
impl ReadCursor for AnyCursor {
|
||
fn len(&self) -> u64 {
|
||
match self {
|
||
Self::Kv(cursor) => cursor.len(),
|
||
Self::Large(cursor) => cursor.len(),
|
||
}
|
||
}
|
||
|
||
async fn read_at(&self, offset: u64, len: u64) -> Result<Vec<u8>, Error> {
|
||
match self {
|
||
Self::Kv(cursor) => cursor.read_at(offset, len).await,
|
||
Self::Large(cursor) => cursor.read_at(offset, len).await,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The write path's staging-cursor dispatch (ADR-010 §1: the store core
|
||
/// programs the abstraction): the large tier's stage bodies behind one
|
||
/// crate-internal value, the put seam's twin of [`AnyCursor`]. The kv
|
||
/// tier never stages on the facade path (the whole-value put is its
|
||
/// settled form); a kv-degenerate staged body variant arrives only if
|
||
/// the conformance suite demands the staged form through the facade.
|
||
/// Crate-internal — engine cursors never leak onto the public surface.
|
||
pub(crate) enum AnyStaging {
|
||
/// The large body family: a stage file under the stage-hygiene
|
||
/// protocol, published by commit-rename.
|
||
Large(LocalStaging),
|
||
}
|
||
|
||
impl ReadCursor for AnyStaging {
|
||
fn len(&self) -> u64 {
|
||
match self {
|
||
Self::Large(cursor) => cursor.len(),
|
||
}
|
||
}
|
||
|
||
async fn read_at(&self, offset: u64, len: u64) -> Result<Vec<u8>, Error> {
|
||
match self {
|
||
Self::Large(cursor) => cursor.read_at(offset, len).await,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl StagingCursor for AnyStaging {
|
||
async fn append(&mut self, chunk: &[u8]) -> Result<(), Error> {
|
||
match self {
|
||
Self::Large(cursor) => cursor.append(chunk).await,
|
||
}
|
||
}
|
||
|
||
async fn commit(self, key: &[u8]) -> Result<(), Error> {
|
||
match self {
|
||
Self::Large(cursor) => cursor.commit(key).await,
|
||
}
|
||
}
|
||
|
||
async fn discard(self) {
|
||
match self {
|
||
Self::Large(cursor) => cursor.discard().await,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl KvEngine {
|
||
async fn put(&self, key: &[u8], value: &[u8]) -> Result<(), Error> {
|
||
match self {
|
||
#[cfg(feature = "mem")]
|
||
KvEngine::Mem(engine) => engine.put(key, value).await,
|
||
#[cfg(feature = "postgres")]
|
||
KvEngine::Postgres(engine) => engine.put(key, value).await,
|
||
#[cfg(feature = "sqlite")]
|
||
KvEngine::Sqlite(engine) => engine.put(key, value).await,
|
||
}
|
||
}
|
||
|
||
async fn get(&self, key: &[u8]) -> Result<Option<AnyCursor>, Error> {
|
||
match self {
|
||
#[cfg(feature = "mem")]
|
||
KvEngine::Mem(engine) => Ok(engine
|
||
.get(key)
|
||
.await?
|
||
.map(|c| AnyCursor::Kv(KvCursor::Mem(c)))),
|
||
#[cfg(feature = "postgres")]
|
||
KvEngine::Postgres(engine) => Ok(engine
|
||
.get(key)
|
||
.await?
|
||
.map(|c| AnyCursor::Kv(KvCursor::Postgres(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 the tables its
|
||
/// applicability row names — sqlite's body creates `gc_window`
|
||
/// only, postgres's creates `gc_pins` + `gc_window` (the fleet
|
||
/// host's both-tables row) — 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 = "postgres")]
|
||
KvEngine::Postgres(engine) => engine.ensure_gc_schema().await,
|
||
#[cfg(feature = "sqlite")]
|
||
KvEngine::Sqlite(engine) => engine.ensure_gc_schema().await,
|
||
}
|
||
}
|
||
|
||
async fn has(&self, key: &[u8]) -> Result<bool, Error> {
|
||
match self {
|
||
#[cfg(feature = "mem")]
|
||
KvEngine::Mem(engine) => engine.has(key).await,
|
||
#[cfg(feature = "postgres")]
|
||
KvEngine::Postgres(engine) => engine.has(key).await,
|
||
#[cfg(feature = "sqlite")]
|
||
KvEngine::Sqlite(engine) => engine.has(key).await,
|
||
}
|
||
}
|
||
|
||
async fn size(&self, key: &[u8]) -> Result<Option<u64>, Error> {
|
||
match self {
|
||
#[cfg(feature = "mem")]
|
||
KvEngine::Mem(engine) => engine.size(key).await,
|
||
#[cfg(feature = "postgres")]
|
||
KvEngine::Postgres(engine) => engine.size(key).await,
|
||
#[cfg(feature = "sqlite")]
|
||
KvEngine::Sqlite(engine) => engine.size(key).await,
|
||
}
|
||
}
|
||
|
||
async fn delete(&self, key: &[u8]) -> Result<(), Error> {
|
||
match self {
|
||
#[cfg(feature = "mem")]
|
||
KvEngine::Mem(engine) => engine.delete(key).await,
|
||
#[cfg(feature = "postgres")]
|
||
KvEngine::Postgres(engine) => engine.delete(key).await,
|
||
#[cfg(feature = "sqlite")]
|
||
KvEngine::Sqlite(engine) => engine.delete(key).await,
|
||
}
|
||
}
|
||
|
||
async fn list(&self) -> Result<Vec<Vec<u8>>, Error> {
|
||
match self {
|
||
#[cfg(feature = "mem")]
|
||
KvEngine::Mem(engine) => engine.list().await,
|
||
#[cfg(feature = "postgres")]
|
||
KvEngine::Postgres(engine) => engine.list().await,
|
||
#[cfg(feature = "sqlite")]
|
||
KvEngine::Sqlite(engine) => engine.list().await,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl LargeEngine {
|
||
/// The staged form's handout (ADR-010 §1): the put seam's large path
|
||
/// streams into this cursor while hashing; the key resolves at the
|
||
/// staging cursor's commit.
|
||
async fn put_staged(&self) -> Result<AnyStaging, Error> {
|
||
match self {
|
||
LargeEngine::Local(engine) => Ok(engine.put_staged().await.map(AnyStaging::Large)?),
|
||
}
|
||
}
|
||
|
||
async fn get(&self, key: &[u8]) -> Result<Option<AnyCursor>, Error> {
|
||
match self {
|
||
LargeEngine::Local(engine) => Ok(engine.get(key).await?.map(AnyCursor::Large)),
|
||
}
|
||
}
|
||
|
||
async fn has(&self, key: &[u8]) -> Result<bool, Error> {
|
||
match self {
|
||
LargeEngine::Local(engine) => engine.has(key).await,
|
||
}
|
||
}
|
||
|
||
async fn size(&self, key: &[u8]) -> Result<Option<u64>, Error> {
|
||
match self {
|
||
LargeEngine::Local(engine) => engine.size(key).await,
|
||
}
|
||
}
|
||
|
||
async fn delete(&self, key: &[u8]) -> Result<(), Error> {
|
||
match self {
|
||
LargeEngine::Local(engine) => engine.delete(key).await,
|
||
}
|
||
}
|
||
|
||
async fn list(&self) -> Result<Vec<Vec<u8>>, Error> {
|
||
match self {
|
||
LargeEngine::Local(engine) => engine.list().await,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The facade's key-bytes decode: typed `Key` conversion at the store
|
||
/// boundary (ADR-002/003) — the one place on the read path where key
|
||
/// bytes are interpreted; anything malformed is `Error::KeyInvalid`,
|
||
/// never a guess, and never reaches an engine typed.
|
||
fn boundary_key(key: &[u8]) -> Result<Key, Error> {
|
||
Key::from_bytes(key)
|
||
}
|
||
|
||
/// Constitute the facade's typed keys from an engine's opaque list
|
||
/// rows: a failed decode is fatal (`Error::KeyInvalid`) — a malformed
|
||
/// pool list is as deadly as an incomplete one and is never silently
|
||
/// skipped (ADR-003).
|
||
fn typed_keys(raws: Vec<Vec<u8>>) -> Result<Vec<Key>, Error> {
|
||
raws.into_iter().map(|raw| boundary_key(&raw)).collect()
|
||
}
|
||
|
||
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,
|
||
window_delay: DEFAULT_DELETE_WINDOW_DELAY,
|
||
mode: MemOnly,
|
||
gc: GcHome::default(),
|
||
}
|
||
}
|
||
|
||
/// **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,
|
||
window_delay: DEFAULT_DELETE_WINDOW_DELAY,
|
||
mode: KvOnly,
|
||
gc: GcHome::default(),
|
||
}
|
||
}
|
||
|
||
/// **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<PathBuf>,
|
||
threshold: u64,
|
||
) -> Result<Store, Error> {
|
||
Ok(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<PathBuf>,
|
||
threshold: u64,
|
||
window_delay: Duration,
|
||
) -> Result<Store, Error> {
|
||
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<PathBuf>,
|
||
root: impl Into<PathBuf>,
|
||
threshold: u64,
|
||
window_delay: Duration,
|
||
) -> Result<Store, Error> {
|
||
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(),
|
||
})
|
||
}
|
||
|
||
/// **kv-only mode** (ADR-011 §4; ADR-012 §6.2) with the postgres kv
|
||
/// engine — the fleet host's 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 and sqlite kv-only wiring; zero residue on either
|
||
/// rejection). Routing in this mode is kv for every length.
|
||
///
|
||
/// `config` is the connection settings — the tokio-postgres
|
||
/// [`tokio_postgres::Config`]-parseable DSN form; `opts` carries
|
||
/// the engine-configuration knobs (schema name, pool size,
|
||
/// `synchronous_commit`), riding the constructor parameters, never
|
||
/// invented defaults. Opening the engine (config parse, pool
|
||
/// build, connect, the bootstrap DDL) is fallible I/O; the
|
||
/// constructor also runs the GC schema's engine-neutral call
|
||
/// (`ensure_gc_schema` — the postgres row's body creates
|
||
/// `gc_pins` + `gc_window`, the fleet host's both-tables row,
|
||
/// 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.
|
||
///
|
||
/// **Non-fleet (ADR-012 §3):** this constructor is implicitly
|
||
/// `fleet: false` — the ADR-005 in-process protocol applies
|
||
/// verbatim regardless of engine (pins are in-process guards, the
|
||
/// sweep lease is the in-process lock, none of the
|
||
/// `EngineFleetState` clauses run). The `fleet: true` declaration
|
||
/// is wave 6 (the fleet-validity predicate plus the constructor
|
||
/// declarations) and slots in without reshaping — no fleet
|
||
/// parameter is carried here.
|
||
#[cfg(feature = "postgres")]
|
||
pub async fn kv_only_postgres(
|
||
config: &str,
|
||
opts: PgOpts,
|
||
threshold: u64,
|
||
window_delay: Duration,
|
||
) -> Result<Store, Error> {
|
||
let kv = KvEngine::Postgres(PostgresBackend::new(config, opts).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
|
||
/// fleet-capable-kv production shape — `kv = postgres` +
|
||
/// `large = local`: 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.
|
||
///
|
||
/// `config` is the connection settings — the tokio-postgres
|
||
/// [`tokio_postgres::Config`]-parseable DSN form; `opts` carries
|
||
/// the engine-configuration knobs (schema name, pool size,
|
||
/// `synchronous_commit`), riding the constructor parameters, never
|
||
/// invented defaults.
|
||
///
|
||
/// Fallible on two accounts (I/O): opening the engine (config
|
||
/// parse, pool build, connect, the bootstrap DDL) — which also
|
||
/// runs the GC schema's engine-neutral call (`ensure_gc_schema`;
|
||
/// the postgres row's body creates `gc_pins` + `gc_window`,
|
||
/// ADR-012 §5's one-window-host rule: the durable window is the
|
||
/// kv engine's when the kv engine is SQL-backed, so the postgres
|
||
/// host creates both GC tables per its applicability row) — 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.
|
||
///
|
||
/// **Non-fleet (ADR-012 §3):** this constructor is implicitly
|
||
/// `fleet: false` — the ADR-005 in-process protocol applies
|
||
/// verbatim regardless of engine (pins are in-process guards, the
|
||
/// sweep lease is the in-process lock, none of the
|
||
/// `EngineFleetState` clauses run). The `fleet: true` declaration
|
||
/// is wave 6 (the fleet-validity predicate plus the constructor
|
||
/// declarations) and slots in without reshaping — no fleet
|
||
/// parameter is carried here.
|
||
#[cfg(feature = "postgres")]
|
||
pub async fn dual_tier_postgres_local(
|
||
config: &str,
|
||
opts: PgOpts,
|
||
root: impl Into<PathBuf>,
|
||
threshold: u64,
|
||
window_delay: Duration,
|
||
) -> Result<Store, Error> {
|
||
let kv = KvEngine::Postgres(PostgresBackend::new(config, opts).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(),
|
||
})
|
||
}
|
||
|
||
/// 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<u64> {
|
||
match self.mode {
|
||
DualTier | KvOnly => Some(self.threshold),
|
||
MemOnly => None,
|
||
}
|
||
}
|
||
|
||
/// 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:
|
||
/// [`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
|
||
}
|
||
}
|
||
|
||
/// The read path (store-api.md §Read path): `get`, `stat`,
|
||
/// `read_range`, `has`, `list` — every key-addressed op falling through
|
||
/// kv first, then large (ADR-012 §6.1's general rule), typed key
|
||
/// conversion at the facade boundary, and the module-doc design points
|
||
/// (the `Blob` stream shape, verification-on-get, the `Vec<Key>` list
|
||
/// shape) carried in their implementations.
|
||
impl Store {
|
||
/// `get(key) → Option<(len, stream)>`. Falls through kv → large;
|
||
/// a both-tier miss is `Ok(None)` — never `Error::Missing`
|
||
/// (ADR-012 §6.5 ruling 5). On a kv hit the materialized read is
|
||
/// whole-blob-verified against the key at get time (mismatch:
|
||
/// `Err(Error::Verification)`, never a further fall-through) and
|
||
/// the returned length is the verified materialized byte length;
|
||
/// the large tier's hit opens streaming (metadata + handle, no
|
||
/// canonical re-derivation — see the module doc), its length the
|
||
/// cursor's metadata-only probe. Both hits return the same
|
||
/// `(len, stream)` shape: the stream is [`Blob`] over the tier's
|
||
/// cursor body.
|
||
pub async fn get(&self, key: &[u8]) -> Result<Option<(u64, Blob)>, Error> {
|
||
let key = boundary_key(key)?;
|
||
let bytes = key.as_bytes();
|
||
|
||
let cursor = if let Some(cursor) = self.kv().get(&bytes).await? {
|
||
cursor
|
||
} else if let Some(cursor) = self.large_get(&bytes).await? {
|
||
cursor
|
||
} else {
|
||
return Ok(None);
|
||
};
|
||
|
||
match cursor {
|
||
AnyCursor::Kv(kv) => {
|
||
let value = kv.read_at(0, kv.len()).await?;
|
||
let derived = key.algorithm().derive(&value, value.len() as u64);
|
||
if &derived != key.digest() {
|
||
return Err(Error::Verification);
|
||
}
|
||
Ok(Some((value.len() as u64, Blob::new(AnyCursor::Kv(kv)))))
|
||
}
|
||
AnyCursor::Large(large) => Ok(Some((large.len(), Blob::new(AnyCursor::Large(large))))),
|
||
}
|
||
}
|
||
|
||
/// The large tier's consult — `None` where the mode has no large
|
||
/// tier (single-tier modes cannot hold large-tier content).
|
||
async fn large_get(&self, bytes: &[u8]) -> Result<Option<AnyCursor>, Error> {
|
||
match self.large() {
|
||
Some(large) => large.get(bytes).await,
|
||
None => Ok(None),
|
||
}
|
||
}
|
||
|
||
/// `stat(key) → Option<EntryMeta>` — the cheap probe riding the
|
||
/// trait's `size` method (ADR-008): length plus the key's hash
|
||
/// algorithm, never a content load, falling through kv → large.
|
||
/// A both-tier miss is `Ok(None)`.
|
||
pub async fn stat(&self, key: &[u8]) -> Result<Option<EntryMeta>, Error> {
|
||
let key = boundary_key(key)?;
|
||
let bytes = key.as_bytes();
|
||
|
||
let len = match self.kv().size(&bytes).await? {
|
||
Some(len) => Some(len),
|
||
None => match self.large() {
|
||
Some(large) => large.size(&bytes).await?,
|
||
None => None,
|
||
},
|
||
};
|
||
Ok(len.map(|len| EntryMeta {
|
||
len,
|
||
algorithm: key.algorithm(),
|
||
}))
|
||
}
|
||
|
||
/// `read_range(key, offset, len) → (slice, slice_digest)` — the
|
||
/// slice plus the out-of-band SHA-256 slice digest (ADR-006, fixed
|
||
/// by convention), composed through the cursor's `read_range` seam.
|
||
/// Falls through kv → large exactly as [`Store::get`] does; no
|
||
/// canonical verification rides a range (the slice digest is the
|
||
/// whole answer, ADR-006 decision 2). A both-tier miss is
|
||
/// `Err(Error::Missing)` — the pinned shape difference vs `get`'s
|
||
/// `None` (ADR-012 §6.5 ruling 5).
|
||
pub async fn read_range(
|
||
&self,
|
||
key: &[u8],
|
||
offset: u64,
|
||
len: u64,
|
||
) -> Result<(Vec<u8>, [u8; 32]), Error> {
|
||
let key = boundary_key(key)?;
|
||
let bytes = key.as_bytes();
|
||
|
||
if let Some(cursor) = self.kv().get(&bytes).await? {
|
||
return cursor.read_range(offset, len).await;
|
||
}
|
||
match self.large_get(&bytes).await? {
|
||
Some(cursor) => cursor.read_range(offset, len).await,
|
||
None => Err(Error::Missing),
|
||
}
|
||
}
|
||
|
||
/// `has(key) → bool` — the pure existence probe (ADR-008's `has`
|
||
/// discipline: an existence probe per tier, `local`'s body being
|
||
/// the literal size-probe), falling through kv → large. Absent on
|
||
/// both tiers: `false`. Virgin-store reads are no-ops.
|
||
pub async fn has(&self, key: &[u8]) -> Result<bool, Error> {
|
||
let key = boundary_key(key)?;
|
||
let bytes = key.as_bytes();
|
||
|
||
if self.kv().has(&bytes).await? {
|
||
return Ok(true);
|
||
}
|
||
match self.large() {
|
||
Some(large) => large.has(&bytes).await,
|
||
None => Ok(false),
|
||
}
|
||
}
|
||
|
||
/// `list() → Vec<Key>` — whole-pool enumeration of every tier,
|
||
/// complete by contract (ADR-003), returned materialized and
|
||
/// sorted in the keys' total order (the recorded Vec-vs-stream
|
||
/// design point; see the module doc). Malformed pool bytes are
|
||
/// fatal: `Err(Error::KeyInvalid)`, never a silent skip.
|
||
pub async fn list(&self) -> Result<Vec<Key>, Error> {
|
||
let mut keys = typed_keys(self.kv().list().await?)?;
|
||
if let Some(large) = self.large() {
|
||
keys.extend(typed_keys(large.list().await?)?);
|
||
}
|
||
keys.sort();
|
||
Ok(keys)
|
||
}
|
||
}
|
||
|
||
/// The write path's streaming chunk granularity — every put loop reads
|
||
/// the stream in windows of this size. Sized inside the kv tier's
|
||
/// economics regime (bounded memory per iteration; engine bodies
|
||
/// aggregate upstream — pg-lo's measured 512 KiB `lowrite` knee is that
|
||
/// engine wave's concern).
|
||
const PUT_CHUNK: usize = 64 * 1024;
|
||
|
||
/// The write path (store-api.md §Write path): `put` — the two internal
|
||
/// paths behind one signature, the `Batch` batching seam, and the
|
||
/// kv-only rejection point (ADR-012 §6.2), on the pin-before-publish
|
||
/// ordering (ADR-005). The named design points (stream shape, len
|
||
/// mismatch, kv-only carrier, key-at-commit amendment) are recorded in
|
||
/// the module doc; their behaviors are pinned by the tests below.
|
||
impl Store {
|
||
/// **`put(len, stream) → (Key, Pin)`** — the streaming put seam
|
||
/// (store-api.md §Write path; POC #3 finding A1): two internal
|
||
/// paths behind one signature, routing a pure function of content
|
||
/// length (ADR-003) — no content ever migrates tiers.
|
||
///
|
||
/// **Known-length (`Some(len)`) — the encouraged path** (covers
|
||
/// git objects, `stat`-derived files, `Content-Length`-bearing
|
||
/// uploads, network offers): the preamble (`"blob <len>\0"`)
|
||
/// initializes the derivation before content flows, so one pass
|
||
/// suffices — no staging needed for hashing. ≤ the threshold → the
|
||
/// kv tier's whole-value put; > the threshold → the large tier's
|
||
/// staged put (content streams into the staging cursor while
|
||
/// hashing; the derivation completes at stream end; commit-rename
|
||
/// publishes). A stream that yields fewer or more bytes than the
|
||
/// declared `len` rejects with `Error::Verification` — zero residue
|
||
/// either side (the module doc's named design point).
|
||
///
|
||
/// **Unknown-length (`None`):** content buffers in memory up to the
|
||
/// dispatch threshold, ADR-003's option (d) semantics pinned in the
|
||
/// three numbered rules:
|
||
///
|
||
/// 1. **Mid-stream threshold overflow** (non-restartable sources
|
||
/// are normal): the buffered prefix flushes *into a fresh stage
|
||
/// file* and the stream continues appending into it — there is
|
||
/// no "restart the stream" requirement anywhere.
|
||
/// 2. **Verification runs over the eventual stored artifact as a
|
||
/// whole**: the memory buffer for the sub-threshold kv commit;
|
||
/// the complete staged bytes for the large commit — the
|
||
/// derivation pass restarts over preamble + staged bytes (the
|
||
/// preamble is inside the hashed input, ADR-002), reading the
|
||
/// staged artifact back through the staging cursor's `ReadCursor`
|
||
/// seam.
|
||
/// 3. **Stream error before commit**: no entry lands and no stage
|
||
/// file remains — buffer discard on the failure path, exactly
|
||
/// like stage discard (the stage-hygiene invariant extended to
|
||
/// the buffered case).
|
||
///
|
||
/// **Pin-before-publish (ADR-005):** the pin refcount is minted
|
||
/// under the arbitration lock *before* the entry becomes visible —
|
||
/// an entry cannot appear in a sweep's `list()` without already
|
||
/// carrying its pin. A dedup re-put acquires no new pool entry; the
|
||
/// returned `Pin` refcounts the existing one under the same lock.
|
||
///
|
||
/// **kv-only mode (ADR-012 §6.2):** a known-length put over the
|
||
/// threshold rejects immediately; an unknown-length put rejects at
|
||
/// mid-stream threshold overflow — lengths are not known at
|
||
/// construction, so put time is the only possible rejection point.
|
||
/// Zero residue on either rejection.
|
||
pub async fn put(
|
||
&self,
|
||
len: Option<u64>,
|
||
stream: impl AsyncRead + Unpin + Send,
|
||
) -> Result<(Key, Pin), Error> {
|
||
match len {
|
||
Some(len) => {
|
||
if self.mode == KvOnly && len > self.threshold {
|
||
return Err(Error::Io(format!(
|
||
"kv-only mode rejects an over-threshold put: declared len {len} > threshold {}",
|
||
self.threshold
|
||
)));
|
||
}
|
||
self.put_known(len, stream).await
|
||
}
|
||
None => self.put_unknown(stream).await,
|
||
}
|
||
}
|
||
|
||
/// **The batch-scoped pin group** (store-api.md §Write path; ADR-005
|
||
/// §Marks 2). Multi-put writes — manifest writes are exactly this —
|
||
/// group under one batch-scoped pin: [`Store::put_in_batch`] runs
|
||
/// each member's lone-put flow and contributes its pin to the
|
||
/// group; the group's pins drop together on [`Batch::commit`] or
|
||
/// drop (no early single-pin release; member liveness never
|
||
/// outlives the batch — batch drop is the failure path).
|
||
pub fn batch(&self) -> Batch {
|
||
self.gc().new_batch()
|
||
}
|
||
|
||
/// A member put inside a [`Batch`] — the batching put seam
|
||
/// (store-api.md §Write path). Member puts publish pin-before-
|
||
/// publish exactly as lone puts do: the two paths behind
|
||
/// [`Store::put`]'s one signature run, and the minted guard is
|
||
/// contributed to the group instead of returned. The batch's pins
|
||
/// drop together on [`Batch::commit`] or batch drop.
|
||
pub async fn put_in_batch(
|
||
&self,
|
||
batch: &mut Batch,
|
||
len: Option<u64>,
|
||
stream: impl AsyncRead + Unpin + Send,
|
||
) -> Result<Key, Error> {
|
||
let (key, pin) = self.put(len, stream).await?;
|
||
batch.add(pin);
|
||
Ok(key)
|
||
}
|
||
|
||
/// The known-length internal path: one streaming pass, the preamble
|
||
/// initialized into the derivation up front. Routing by the declared
|
||
/// length (the pure function); a stream that yields fewer or more
|
||
/// bytes than `len` rejects `Verification` with zero residue on
|
||
/// either tier.
|
||
async fn put_known<R: AsyncRead + Unpin + Send>(
|
||
&self,
|
||
len: u64,
|
||
mut stream: R,
|
||
) -> Result<(Key, Pin), Error> {
|
||
let mut hasher = BlobHasher::new(Algorithm::GitBlobSha256, len);
|
||
let mut chunk = vec![0u8; PUT_CHUNK];
|
||
let mut received = 0u64;
|
||
|
||
match self.tier_for(len) {
|
||
Tier::Kv => {
|
||
let mut value: Vec<u8> = Vec::new();
|
||
loop {
|
||
let n = stream
|
||
.read(&mut chunk)
|
||
.await
|
||
.map_err(|e| Error::Io(e.to_string()))?;
|
||
if n == 0 {
|
||
break;
|
||
}
|
||
received = received.saturating_add(n as u64);
|
||
if received > len {
|
||
return Err(Error::Verification);
|
||
}
|
||
hasher.update(&chunk[..n]);
|
||
value.extend_from_slice(&chunk[..n]);
|
||
}
|
||
if received != len {
|
||
return Err(Error::Verification);
|
||
}
|
||
let key = Key::from(hasher.finish());
|
||
let pin = self.gc().acquire(&key).await;
|
||
self.kv().put(&key.as_bytes(), &value).await?;
|
||
Ok((key, pin))
|
||
}
|
||
Tier::Large => {
|
||
let engine = match self.large() {
|
||
Some(engine) => engine,
|
||
None => return Err(Error::Io("no large tier in this mode".to_owned())),
|
||
};
|
||
let mut stage = engine.put_staged().await?;
|
||
let fed: Result<(), Error> = 'stream: loop {
|
||
let n = match stream.read(&mut chunk).await {
|
||
Ok(n) => n,
|
||
Err(e) => break 'stream Err(Error::Io(e.to_string())),
|
||
};
|
||
if n == 0 {
|
||
break 'stream Ok(());
|
||
}
|
||
received = received.saturating_add(n as u64);
|
||
if received > len {
|
||
break 'stream Err(Error::Verification);
|
||
}
|
||
hasher.update(&chunk[..n]);
|
||
if let Err(e) = stage.append(&chunk[..n]).await {
|
||
break 'stream Err(e);
|
||
}
|
||
};
|
||
match fed {
|
||
Err(err) => {
|
||
stage.discard().await;
|
||
Err(err)
|
||
}
|
||
Ok(()) if received != len => {
|
||
stage.discard().await;
|
||
Err(Error::Verification)
|
||
}
|
||
Ok(()) => {
|
||
let key = Key::from(hasher.finish());
|
||
let pin = self.gc().acquire(&key).await;
|
||
stage.commit(&key.as_bytes()).await?;
|
||
Ok((key, pin))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The unknown-length internal path: buffer to the dispatch
|
||
/// threshold; on mid-stream overflow the buffered prefix flushes
|
||
/// into a fresh stage file and the stream continues appending
|
||
/// (rule 1) — kv-only mode rejects at that same overflow
|
||
/// (ADR-012 §6.2). At stream end, the derivation pass runs over the
|
||
/// eventual stored artifact as a whole: the buffer (kv form) or the
|
||
/// staged bytes read back through the cursor's `ReadCursor` seam
|
||
/// (staged form, rule 2). Every failure path discards (rule 3).
|
||
async fn put_unknown<R: AsyncRead + Unpin + Send>(
|
||
&self,
|
||
mut stream: R,
|
||
) -> Result<(Key, Pin), Error> {
|
||
let mut buffered = Vec::with_capacity(PUT_CHUNK);
|
||
let mut stage: Option<AnyStaging> = None;
|
||
let mut streamed_total = 0u64;
|
||
let mut chunk = vec![0u8; PUT_CHUNK];
|
||
|
||
let fed: Result<(), Error> = 'feed: loop {
|
||
let n = match stream.read(&mut chunk).await {
|
||
Ok(n) => n,
|
||
Err(e) => break 'feed Err(Error::Io(e.to_string())),
|
||
};
|
||
if n == 0 {
|
||
break 'feed Ok(());
|
||
}
|
||
streamed_total = streamed_total.saturating_add(n as u64);
|
||
|
||
if let Some(staged) = stage.as_mut() {
|
||
let append = staged.append(&chunk[..n]).await;
|
||
if let Err(e) = append {
|
||
if let Some(cursor) = stage.take() {
|
||
cursor.discard().await;
|
||
}
|
||
break 'feed Err(e);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
let overflow = match self.threshold() {
|
||
Some(t) => (buffered.len() as u64).saturating_add(n as u64) > t,
|
||
None => false,
|
||
};
|
||
if !overflow {
|
||
buffered.extend_from_slice(&chunk[..n]);
|
||
continue;
|
||
}
|
||
match self.mode {
|
||
KvOnly => {
|
||
let t = self.threshold;
|
||
break 'feed Err(Error::Io(format!(
|
||
"kv-only mode rejects an over-threshold put: streamed {streamed_total} bytes past the threshold {t}"
|
||
)));
|
||
}
|
||
_ => match self.large() {
|
||
Some(engine) => {
|
||
let mut staged = match engine.put_staged().await {
|
||
Ok(staged) => staged,
|
||
Err(e) => break 'feed Err(e),
|
||
};
|
||
if let Err(e) = staged.append(&buffered).await {
|
||
staged.discard().await;
|
||
break 'feed Err(e);
|
||
}
|
||
if let Err(e) = staged.append(&chunk[..n]).await {
|
||
staged.discard().await;
|
||
break 'feed Err(e);
|
||
}
|
||
stage = Some(staged);
|
||
}
|
||
None => {
|
||
break 'feed Err(Error::Io("no large tier in this mode".to_owned()));
|
||
}
|
||
},
|
||
}
|
||
};
|
||
|
||
match fed {
|
||
Err(err) => {
|
||
if let Some(cursor) = stage.take() {
|
||
cursor.discard().await;
|
||
}
|
||
Err(err)
|
||
}
|
||
Ok(()) => match stage.take() {
|
||
Some(staged) => {
|
||
// Rule 2, staged form: the derivation pass restarts
|
||
// over preamble + staged bytes, the artifact read
|
||
// back through the cursor's ReadCursor seam.
|
||
let mut hasher = BlobHasher::new(Algorithm::GitBlobSha256, streamed_total);
|
||
let mut offset = 0u64;
|
||
let derived: Result<(), Error> = 'pass: loop {
|
||
if offset >= streamed_total {
|
||
break 'pass Ok(());
|
||
}
|
||
let want = (streamed_total - offset).min(PUT_CHUNK as u64);
|
||
let bytes = match staged.read_at(offset, want).await {
|
||
Ok(bytes) => bytes,
|
||
Err(e) => break 'pass Err(e),
|
||
};
|
||
if bytes.is_empty() {
|
||
break 'pass Err(Error::Io(
|
||
"staged artifact under-read during the derivation pass".to_owned(),
|
||
));
|
||
}
|
||
hasher.update(&bytes);
|
||
offset += bytes.len() as u64;
|
||
};
|
||
match derived {
|
||
Err(err) => {
|
||
staged.discard().await;
|
||
Err(err)
|
||
}
|
||
Ok(()) => {
|
||
let key = Key::from(hasher.finish());
|
||
let pin = self.gc().acquire(&key).await;
|
||
staged.commit(&key.as_bytes()).await?;
|
||
Ok((key, pin))
|
||
}
|
||
}
|
||
}
|
||
None => {
|
||
// Rule 2, buffer form: one derivation pass over the
|
||
// complete buffered artifact — the kv commit's whole
|
||
// stored value.
|
||
if buffered.len() as u64 != streamed_total {
|
||
return Err(Error::Io(
|
||
"buffered artifact length disagrees with the streamed total".to_owned(),
|
||
));
|
||
}
|
||
let mut hasher = BlobHasher::new(Algorithm::GitBlobSha256, streamed_total);
|
||
hasher.update(&buffered);
|
||
let key = Key::from(hasher.finish());
|
||
let pin = self.gc().acquire(&key).await;
|
||
self.kv().put(&key.as_bytes(), &buffered).await?;
|
||
Ok((key, pin))
|
||
}
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **The delete-window commit batch size** — ~100/batch, iroh's proven
|
||
/// shape (ADR-005 §Sweep; POC #1 finding 3's named shape adopted).
|
||
const SWEEP_DELETE_BATCH: usize = 100;
|
||
|
||
/// The GC protocol's facade surface (ADR-005; gc-and-namespaces.md;
|
||
/// store-api.md §Lifecycle, §Invariants 4/5): the liveness
|
||
/// registration verbs, the direct `delete` with its arbitration-then-
|
||
/// delete refusal, and the `sweep()` delete-window protocol. This is
|
||
/// the architecture's most intricate state machine in its single-node
|
||
/// form — the fleet extension (DB-backed pins, sweep lease, SQL
|
||
/// arbitration) extends it without changing its invariants.
|
||
///
|
||
/// **The state machine, one instance at a time.** All shared GC state
|
||
/// rides the crate-internal `GcHome` behind the crate-internal
|
||
/// `Store::gc` accessor: one arbitration lock
|
||
/// over per-key pin refcounts, in-flight delete markers, and the
|
||
/// registered source lists ([`crate::gc`]'s module doc carries the
|
||
/// named design points — the marker mechanism that closes the
|
||
/// sweep-vs-put race without engine I/O under the lock, and the
|
||
/// single-node sweep lease).
|
||
///
|
||
/// **Abort-as-data** (ADR-012 §1): every sweep returns
|
||
/// `Ok(SweepReport)`; aborts are `GcAbortCause` values in the report,
|
||
/// never errors. `Error::GcRefuse` is the direct-delete refusal alone,
|
||
/// decided here at the facade — it never fires below the facade (the
|
||
/// error module's altitude discipline). The full protection set
|
||
/// (ADR-012 §6.4): pinned, re-observed live via a registered source,
|
||
/// or named by a protect callback.
|
||
impl Store {
|
||
/// Register a [`LivenessSource`] — the live-shared GC seam (ADR-005
|
||
/// §Marks 1; `register_liveness_source` verbatim; POC #1 finding
|
||
/// 7's finding is the verb's pinning reason). The source is
|
||
/// consulted **live** at mark time and at delete-time arbitration —
|
||
/// never a snapshot at registration. The returned
|
||
/// [`Registration`] token deregisters via
|
||
/// [`Store::unregister_liveness_source`] (ADR-012 §6.4's correction
|
||
/// flow). Zero registered sources make every sweep abort with
|
||
/// [`GcAbortCause::NoLivenessSources`] — the safe default: a
|
||
/// consumer without registered liveness sources cannot have its
|
||
/// content deleted.
|
||
pub fn register_liveness_source(&self, source: Arc<dyn LivenessSource>) -> Registration {
|
||
self.gc.register_source(source)
|
||
}
|
||
|
||
/// Deregister a liveness source previously registered on this
|
||
/// store. `false` when the token was already consumed or never
|
||
/// came from [`Store::register_liveness_source`].
|
||
pub fn unregister_liveness_source(&self, registration: Registration) -> bool {
|
||
self.gc.unregister_source(registration)
|
||
}
|
||
|
||
/// Register a [`ProtectCallback`] — ADR-005 §Marks 3's protection
|
||
/// source. Consulted pre-sweep (it may add externally-known hashes
|
||
/// or fail the sweep into [`GcAbortCause::ProtectFailed`] report
|
||
/// data) and at direct-delete arbitration, where a
|
||
/// protect-callback-named key is refused (`Error::GcRefuse`, ADR-012
|
||
/// §6.4's full protection set). Registration replaces nothing and
|
||
/// composes: each callback's set unions into the protection.
|
||
pub fn register_protect_callback(&self, callback: Arc<dyn ProtectCallback>) -> Registration {
|
||
self.gc.register_protect(callback)
|
||
}
|
||
|
||
/// Deregister a protect callback previously registered on this
|
||
/// store. `false` when the token was already consumed or never
|
||
/// came from [`Store::register_protect_callback`].
|
||
pub fn unregister_protect_callback(&self, registration: Registration) -> bool {
|
||
self.gc.unregister_protect(registration)
|
||
}
|
||
|
||
/// **Direct `delete(key)`** — fall-through consult (kv first, then
|
||
/// large; ADR-012 §6.4's "arbitration-then-delete where the entry
|
||
/// lives"), then the full protection set arbitration at delete
|
||
/// time: a key that is pinned, re-observed live via a registered
|
||
/// source, or named by a protect callback is refused with
|
||
/// [`Error::GcRefuse`] — nothing deleted. Unprotected keys delete
|
||
/// (across both homes when a degenerate pool holds the key twice);
|
||
/// absent keys carry [`Error::Missing`]; malformed key bytes
|
||
/// [`Error::KeyInvalid`]. A failed protect callback is refused
|
||
/// conservatively (`GcRefuse`, nothing deleted) — the direct-path
|
||
/// reading of abort-as-data's safe side, recorded in the facade
|
||
/// module doc.
|
||
///
|
||
/// Outside-of-sweep deletion is unusual by posture — most deletion
|
||
/// flows through sweeps (ADR-005); an embedder's correction flow
|
||
/// deregisters/re-registers around the delete (ADR-012 §6.4).
|
||
pub async fn delete(&self, key: &[u8]) -> Result<(), Error> {
|
||
let key = boundary_key(key)?;
|
||
let bytes = key.as_bytes();
|
||
|
||
let present = if self.kv().has(&bytes).await? {
|
||
true
|
||
} else {
|
||
match self.large() {
|
||
Some(large) => large.has(&bytes).await?,
|
||
None => false,
|
||
}
|
||
};
|
||
if !present {
|
||
return Err(Error::Missing);
|
||
}
|
||
|
||
match self.arbitrated_delete(&key, true).await? {
|
||
crate::gc::DeleteOutcome::Cancelled => Err(Error::GcRefuse),
|
||
crate::gc::DeleteOutcome::Deleted(true) => Ok(()),
|
||
// The entry vanished between the existence probe and the
|
||
// window: some other path resolved it after the probe.
|
||
crate::gc::DeleteOutcome::Deleted(false) => Err(Error::Missing),
|
||
}
|
||
}
|
||
|
||
/// **`sweep()`** — the explicit mark-and-sweep with the delete
|
||
/// window (ADR-005 §Sweep), in two forms under one signature: the
|
||
/// **durable form** runs where the kv engine is SQL-backed and hosts
|
||
/// the window (ADR-012 §5's one-window-host rule — sqlite and
|
||
/// postgres in today's set; the branch keys off the engine enum,
|
||
/// never a flag), and the
|
||
/// **inline form** (ADR-012 §5: `local`/`mem` stage candidates
|
||
/// in-process and execute the window protocol inline in the same
|
||
/// sweep) runs verbatim elsewhere. Sweeps are never errors:
|
||
/// every abort is report data in `SweepReport.aborted`
|
||
/// (`Ok(SweepReport)`), and a completed sweep's counts are exact
|
||
/// relative to its form's schedule — the inline form reports
|
||
/// `candidates_staged == deleted + cancelled_by_arbitration` over its
|
||
/// same-sweep staged set; the durable form reports
|
||
/// `candidates_staged` as the candidates staged *this* sweep and
|
||
/// `deleted`/`cancelled` as the *overdue* set executed this sweep
|
||
/// (staged and executed are different sets there — the delay
|
||
/// separates them, ADR-012 §6.7; `deleted`/`cancelled` are both 0 on
|
||
/// any abort).
|
||
///
|
||
/// The protocol, as run here:
|
||
///
|
||
/// 1. **Single-flight**: the in-process sweep lease (ADR-013 §2's
|
||
/// single-node analog); a contended second sweeper aborts with
|
||
/// [`GcAbortCause::SweeperLock`], nothing deleted, nothing
|
||
/// staged or executed.
|
||
/// 2. **Zero registered liveness sources** aborts with
|
||
/// [`GcAbortCause::NoLivenessSources`] before anything is marked —
|
||
/// the safe default (nothing deleted).
|
||
/// 3. **Protect**: each registered [`ProtectCallback`] is consulted
|
||
/// pre-sweep; its keys union into the live set; a callback that
|
||
/// fails to answer aborts with [`GcAbortCause::ProtectFailed`] —
|
||
/// a flaky protection source skips the sweep rather than risk
|
||
/// deletion (iroh's `ProtectOutcome::Abort` conclusion). On the
|
||
/// durable form this consult's coverage extends to the executor
|
||
/// phase, so an abort here precedes any overdue execution too.
|
||
/// 4. **Durable form (SQL-backed kv engine)**, per sweep under the
|
||
/// lease: **execute overdue** first (window_take re-arbitrates
|
||
/// each overdue candidate at execution time — pin-before-publish
|
||
/// during the window delay cancels the candidate — and deletes in
|
||
/// batches), then **mark** (unchanged), then **stage** the fresh
|
||
/// candidate-dead set at `visible_after = mark_end + window` (the
|
||
/// constructor-tunable delay, ADR-008 §2). The executor of the
|
||
/// staged rows is the next sweep (ADR-008 §2; ADR-012 §6.7).
|
||
/// **Inline form**: the mark phase enumerates the whole pool
|
||
/// (both tiers' complete `list()`, ADR-003 — a malformed pool
|
||
/// list is fatal `Err(KeyInvalid)`); the live set computed from
|
||
/// all three sources (registered sources, pins, protect
|
||
/// callbacks); the candidate-dead set staged (sorted, in-process).
|
||
/// 5. **Delete window**: per candidate, **arbitration at delete
|
||
/// time** (not once upfront) under the arbitration lock — pinned
|
||
/// or re-observed-live cancels the candidate (counted in
|
||
/// `cancelled_by_arbitration`); otherwise the candidate deletes
|
||
/// (counted in `deleted`), in ~100-key batches.
|
||
///
|
||
/// **The test-asserted property** (ADR-005): a visible pool entry is
|
||
/// never deleted while liveness protects it, and an in-flight put
|
||
/// is never deleted by a sweep started before it committed — the
|
||
/// in-flight delete markers plus the blocked pin mint are the
|
||
/// mechanism ([`crate::gc`]'s module doc), and the concurrency
|
||
/// tests below assert it (the durable form's race holds *across the
|
||
/// window delay* — a put landing during the delay pins in-process
|
||
/// and the execution-time re-arbitration cancels the candidate).
|
||
pub async fn sweep(&self) -> Result<SweepReport, Error> {
|
||
let started = SystemTime::now();
|
||
|
||
if self.gc.registered_source_count() == 0 {
|
||
return Ok(Self::sweep_report(
|
||
0,
|
||
0,
|
||
0,
|
||
Some(GcAbortCause::NoLivenessSources),
|
||
started,
|
||
));
|
||
}
|
||
|
||
if !self.gc.try_begin_sweep() {
|
||
return Ok(Self::sweep_report(
|
||
0,
|
||
0,
|
||
0,
|
||
Some(GcAbortCause::SweeperLock),
|
||
started,
|
||
));
|
||
}
|
||
|
||
let result = self.sweep_window(started).await;
|
||
self.gc.end_sweep();
|
||
result
|
||
}
|
||
|
||
/// The sweep body, under the sweep lease: the protect consult
|
||
/// (whose abort shape is [`GcAbortCause::ProtectFailed`] — and whose
|
||
/// coverage extends to the durable form's execution phase), then
|
||
/// the form branch on the delete window's host — the kv-engine
|
||
/// enum itself (ADR-012 §5's one-window-host rule), never a flag:
|
||
/// the **durable form** on a SQL-backed kv engine, the **inline
|
||
/// form** verbatim on `local`/`mem`. Operational failures (engine
|
||
/// I/O, a malformed pool or window list) are the `Err` half of the
|
||
/// return shape (ADR-012 §1); every deliberate no-op is report data
|
||
/// instead.
|
||
async fn sweep_window(&self, started: SystemTime) -> Result<SweepReport, Error> {
|
||
let mut live: BTreeSet<Key> = BTreeSet::new();
|
||
|
||
for callback in self.gc.registered_protects() {
|
||
match callback.protect() {
|
||
Some(keys) => {
|
||
live.extend(keys);
|
||
}
|
||
None => {
|
||
return Ok(Self::sweep_report(
|
||
0,
|
||
0,
|
||
0,
|
||
Some(GcAbortCause::ProtectFailed),
|
||
started,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
match &self.kv {
|
||
#[cfg(feature = "mem")]
|
||
KvEngine::Mem(_) => self.sweep_window_inline(live, started).await,
|
||
#[cfg(feature = "postgres")]
|
||
KvEngine::Postgres(engine) => self.sweep_window_durable(engine, live, started).await,
|
||
#[cfg(feature = "sqlite")]
|
||
KvEngine::Sqlite(engine) => self.sweep_window_durable(engine, live, started).await,
|
||
}
|
||
}
|
||
|
||
/// The durable form's body, shared by every SQL-backed kv-engine
|
||
/// arm of the form branch (the branch keys off the kv-engine
|
||
/// enum, ADR-012 §5's one-window-host rule — the host this runs
|
||
/// on is the kv engine the constructor built). The overdue staged
|
||
/// rows execute first — each re-arbitrated at execution time —
|
||
/// then this sweep's own mark and stage at
|
||
/// `visible_after = mark_end + window`.
|
||
async fn sweep_window_durable<W: EngineWindowState>(
|
||
&self,
|
||
host: &W,
|
||
live: BTreeSet<Key>,
|
||
started: SystemTime,
|
||
) -> Result<SweepReport, Error> {
|
||
let (deleted, cancelled_by_arbitration) = self.execute_overdue_window(host).await?;
|
||
let candidates_staged = self.mark_and_stage_window(host, live).await?;
|
||
Ok(Self::sweep_report(
|
||
candidates_staged,
|
||
deleted,
|
||
cancelled_by_arbitration,
|
||
None,
|
||
started,
|
||
))
|
||
}
|
||
|
||
/// The inline form (ADR-012 §5): the mark → arbitrate-at-delete-
|
||
/// time → batched commit body, unchanged — the non-SQL engines
|
||
/// stage candidates in-process and execute the window protocol
|
||
/// inline in the same sweep. Preserved verbatim across the durable
|
||
/// form's arrival.
|
||
async fn sweep_window_inline(
|
||
&self,
|
||
mut live: BTreeSet<Key>,
|
||
started: SystemTime,
|
||
) -> Result<SweepReport, Error> {
|
||
let mut pool = typed_keys(self.kv().list().await?)?;
|
||
if let Some(large) = self.large() {
|
||
pool.extend(typed_keys(large.list().await?)?);
|
||
}
|
||
live.extend(self.gc.pinned_keys());
|
||
for source in self.gc.registered_sources() {
|
||
live.extend(source.live_keys());
|
||
}
|
||
|
||
let candidates: Vec<Key> = {
|
||
let enumerated: BTreeSet<Key> = pool.into_iter().collect();
|
||
let mut candidates: Vec<Key> = enumerated.difference(&live).cloned().collect();
|
||
candidates.sort();
|
||
candidates
|
||
};
|
||
let candidates_staged = candidates.len() as u64;
|
||
|
||
let mut deleted = 0u64;
|
||
let mut cancelled_by_arbitration = 0u64;
|
||
for chunk in candidates.chunks(SWEEP_DELETE_BATCH) {
|
||
for key in chunk {
|
||
match self.arbitrated_delete(key, false).await? {
|
||
// A candidate already resolved elsewhere (deleted
|
||
// between mark and its window turn) settles here
|
||
// too: the completed sweep's counts stay exact over
|
||
// the staged set.
|
||
crate::gc::DeleteOutcome::Deleted(true) => deleted += 1,
|
||
_ => cancelled_by_arbitration += 1,
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(Self::sweep_report(
|
||
candidates_staged,
|
||
deleted,
|
||
cancelled_by_arbitration,
|
||
None,
|
||
started,
|
||
))
|
||
}
|
||
|
||
/// **The durable form's executor step** (ADR-012 §6.7): take the
|
||
/// overdue staged candidates (`visible_after <= now`) off the
|
||
/// window and execute each through the same
|
||
/// [`Store::arbitrated_delete`] path — the re-arbitration happens
|
||
/// at execution time, so a put landing (pin-before-publish) during
|
||
/// the window delay cancels its candidate here — in ~100-key
|
||
/// batches. The consult posture on this path is the sweep's protect
|
||
/// consult (recorded named design point): it already ran for this
|
||
/// sweep, above, and its coverage extends to the executor phase —
|
||
/// so these arbitrations carry `consult_protect = false`, the same
|
||
/// posture as the inline form's delete-time arbitration. A vanished
|
||
/// candidate (deleted during the delay by a direct delete) settles
|
||
/// into the cancelled count.
|
||
async fn execute_overdue_window<W: EngineWindowState>(
|
||
&self,
|
||
host: &W,
|
||
) -> Result<(u64, u64), Error> {
|
||
let overdue: Vec<Key> = typed_keys(host.window_take(SystemTime::now()).await?)?;
|
||
let mut deleted = 0u64;
|
||
let mut cancelled_by_arbitration = 0u64;
|
||
for chunk in overdue.chunks(SWEEP_DELETE_BATCH) {
|
||
for key in chunk {
|
||
match self.arbitrated_delete(key, false).await? {
|
||
crate::gc::DeleteOutcome::Deleted(true) => deleted += 1,
|
||
_ => cancelled_by_arbitration += 1,
|
||
}
|
||
}
|
||
}
|
||
Ok((deleted, cancelled_by_arbitration))
|
||
}
|
||
|
||
/// **The durable form's mark + stage step**: the mark phase exactly
|
||
/// the inline form's (pool enumerated, live set from pins/registered
|
||
/// sources plus the protect consult's keys, candidate-dead set
|
||
/// computed), then the candidate-dead set staged into the window at
|
||
/// `visible_after = mark_end + window` — the constructor-tunable
|
||
/// delay (ADR-008 §2; the store carries the value its constructor
|
||
/// took), so execution waits at earliest for the next sweep past
|
||
/// the deadline. Returns the staged-candidate count.
|
||
async fn mark_and_stage_window<W: EngineWindowState>(
|
||
&self,
|
||
host: &W,
|
||
mut live: BTreeSet<Key>,
|
||
) -> Result<u64, Error> {
|
||
let mut pool = typed_keys(self.kv().list().await?)?;
|
||
if let Some(large) = self.large() {
|
||
pool.extend(typed_keys(large.list().await?)?);
|
||
}
|
||
live.extend(self.gc.pinned_keys());
|
||
for source in self.gc.registered_sources() {
|
||
live.extend(source.live_keys());
|
||
}
|
||
|
||
let enumerated: BTreeSet<Key> = pool.into_iter().collect();
|
||
let mut candidates: Vec<Key> = enumerated.difference(&live).cloned().collect();
|
||
candidates.sort();
|
||
|
||
let mark_end = SystemTime::now();
|
||
let visible_after = mark_end.checked_add(self.window_delay).ok_or_else(|| {
|
||
Error::Io("the delete-window delay overflows the system clock".to_owned())
|
||
})?;
|
||
let staged: Vec<WindowCandidate> = candidates
|
||
.iter()
|
||
.map(|key| WindowCandidate {
|
||
key: key.as_bytes().to_vec(),
|
||
visible_after,
|
||
})
|
||
.collect();
|
||
host.window_stage(&staged).await?;
|
||
Ok(candidates.len() as u64)
|
||
}
|
||
|
||
/// A finished report constructor — every sweep outcome is a
|
||
/// completed record (ADR-012 §1: the report is data, not an error
|
||
/// carrier).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn sweep_report(
|
||
candidates_staged: u64,
|
||
deleted: u64,
|
||
cancelled_by_arbitration: u64,
|
||
aborted: Option<GcAbortCause>,
|
||
started: SystemTime,
|
||
) -> SweepReport {
|
||
SweepReport {
|
||
candidates_staged,
|
||
deleted,
|
||
cancelled_by_arbitration,
|
||
aborted,
|
||
started_at: started,
|
||
finished_at: SystemTime::now(),
|
||
}
|
||
}
|
||
|
||
/// Arbitrate, then delete where the entry lives, with the
|
||
/// in-flight delete marker held across the delete and released on
|
||
/// every path (the store-side composition of [`GcHome::arbitrate`]
|
||
/// + [`GcHome::conclude_delete`]).
|
||
async fn arbitrated_delete(
|
||
&self,
|
||
key: &Key,
|
||
consult_protect: bool,
|
||
) -> Result<crate::gc::DeleteOutcome, Error> {
|
||
match self.gc.arbitrate(key, consult_protect).await? {
|
||
Arbitration::Cancelled => Ok(crate::gc::DeleteOutcome::Cancelled),
|
||
Arbitration::Proceed => {
|
||
let result = self.delete_everywhere(key).await;
|
||
self.gc.conclude_delete(key);
|
||
Ok(crate::gc::DeleteOutcome::Deleted(result?))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Delete the key's pool entry wherever it physically lives — the
|
||
/// kv home and the large home (normal pools hold exactly one;
|
||
/// immutability means both homes of a degenerate duplicate are the
|
||
/// same dead content). `false` when the entry was already absent:
|
||
/// per-home `Missing` is not an operational error here, it is the
|
||
/// presence answer.
|
||
async fn delete_everywhere(&self, key: &Key) -> Result<bool, Error> {
|
||
let bytes = key.as_bytes();
|
||
let mut deleted = match self.kv().delete(&bytes).await {
|
||
Ok(()) => true,
|
||
Err(Error::Missing) => false,
|
||
Err(err) => return Err(err),
|
||
};
|
||
if let Some(large) = self.large() {
|
||
match large.delete(&bytes).await {
|
||
Ok(()) => deleted = true,
|
||
Err(Error::Missing) => {}
|
||
Err(err) => return Err(err),
|
||
}
|
||
}
|
||
Ok(deleted)
|
||
}
|
||
}
|
||
|
||
#[cfg(all(test, feature = "mem"))]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::backend::{slice_digest, Backend};
|
||
use crate::dispatch::DEFAULT_DISPATCH_THRESHOLD;
|
||
use crate::hashing::Algorithm;
|
||
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();
|
||
}
|
||
|
||
/// Entry planters straight into the tier engines (bypassing the
|
||
/// put seam, which is a later task): opaque key bytes + value,
|
||
/// exactly what the engines' CAS tables accept. Planting
|
||
/// mismatching content under a key is how the read path's
|
||
/// 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 = "postgres")]
|
||
KvEngine::Postgres(engine) => engine.put(key, value).await.expect("kv plant"),
|
||
#[cfg(feature = "sqlite")]
|
||
KvEngine::Sqlite(engine) => engine.put(key, value).await.expect("kv plant"),
|
||
}
|
||
}
|
||
|
||
async fn plant_large(store: &Store, key: &[u8], value: &[u8]) {
|
||
match store.large() {
|
||
Some(LargeEngine::Local(engine)) => engine.put(key, value).await.expect("large plant"),
|
||
None => panic!("planting into a mode with no large tier"),
|
||
}
|
||
}
|
||
|
||
fn key_for(content: &[u8]) -> Key {
|
||
Key::derive(content, content.len() as u64)
|
||
}
|
||
|
||
fn sha1_key_for(content: &[u8]) -> Key {
|
||
Key::from(Algorithm::GitBlobSha1.derive(content, content.len() as u64))
|
||
}
|
||
|
||
/// The both-tier-miss shapes every read op must resolve to
|
||
/// (store-api.md §Error model; ADR-012 §6.5 ruling 5) — pinned on
|
||
/// all three constructible modes, virgin-store reads as no-ops.
|
||
async fn assert_virgin_read_shapes(store: &Store) {
|
||
let key = key_for(b"nothing ever landed").as_bytes();
|
||
|
||
assert!(
|
||
store.get(&key).await.unwrap().is_none(),
|
||
"get's both-tier miss is Ok(None)"
|
||
);
|
||
assert_eq!(store.stat(&key).await.unwrap(), None, "stat misses as None");
|
||
assert_eq!(
|
||
store.read_range(&key, 0, 16).await.unwrap_err(),
|
||
Error::Missing,
|
||
"read_range's both-tier miss is Err(Missing) — the pinned shape difference vs get"
|
||
);
|
||
assert!(
|
||
!store.has(&key).await.unwrap(),
|
||
"has's both-tier miss is false"
|
||
);
|
||
assert_eq!(
|
||
store.list().await.unwrap(),
|
||
Vec::<Key>::new(),
|
||
"virgin-store list is empty"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virgin_store_reads_are_no_ops_dual_tier() {
|
||
let root = temp_root("read-virgin-dual");
|
||
let store = Store::dual_tier_mem_local(root.path(), 1024)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
assert_virgin_read_shapes(&store).await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virgin_store_reads_are_no_ops_mem_only() {
|
||
let store = Store::mem_only();
|
||
assert_virgin_read_shapes(&store).await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virgin_store_reads_are_no_ops_kv_only() {
|
||
let store = Store::kv_only_mem(64);
|
||
assert_virgin_read_shapes(&store).await;
|
||
}
|
||
|
||
/// `get` returns the pinned `(len, stream)` shape from the kv tier:
|
||
/// the tuple carries the metadata length, the `Blob` serves
|
||
/// bounded windows (clamped at EOF) and the ADR-006 range seam.
|
||
#[tokio::test]
|
||
async fn get_returns_the_pinned_len_stream_shape_from_kv() {
|
||
let store = Store::mem_only();
|
||
let content = b"small-tier content";
|
||
let key = key_for(content);
|
||
plant_kv(&store, &key.as_bytes(), content).await;
|
||
|
||
let (len, blob) = store
|
||
.get(&key.as_bytes())
|
||
.await
|
||
.expect("get succeeds")
|
||
.expect("entry present");
|
||
assert_eq!(len, content.len() as u64, "tuple len is the entry length");
|
||
assert_eq!(blob.len(), len);
|
||
assert!(!blob.is_empty());
|
||
|
||
let whole = blob.read_at(0, len).await.expect("full window");
|
||
assert_eq!(whole, content, "full window matches");
|
||
assert_eq!(
|
||
blob.read_at(0, len + 17).await.expect("over-read"),
|
||
content,
|
||
"over-read clamps at the entry's end"
|
||
);
|
||
|
||
let (slice, digest) = blob.read_range(6, 6).await.expect("ranged window");
|
||
assert_eq!(slice, &content[6..12]);
|
||
assert_eq!(
|
||
digest,
|
||
slice_digest(&content[6..12]),
|
||
"slice digest is SHA-256 over exactly the returned bytes"
|
||
);
|
||
|
||
let (full, full_digest) = blob.read_range(0, len).await.expect("full range");
|
||
assert_eq!(full, content);
|
||
assert_eq!(full_digest, slice_digest(content));
|
||
}
|
||
|
||
/// The zero-length degenerate: a kv-tier empty entry get's as
|
||
/// `Some((0, blob))`, its windows read empty-never-error, and
|
||
/// `stat` carries exact zero.
|
||
#[tokio::test]
|
||
async fn zero_length_kv_entry_reads_as_empty_blob() {
|
||
let store = Store::mem_only();
|
||
let key = key_for(b"");
|
||
plant_kv(&store, &key.as_bytes(), b"").await;
|
||
|
||
let (len, blob) = store.get(&key.as_bytes()).await.unwrap().expect("present");
|
||
assert_eq!(len, 0);
|
||
assert!(blob.is_empty());
|
||
assert_eq!(blob.read_at(0, 8).await.unwrap(), Vec::<u8>::new());
|
||
|
||
assert_eq!(
|
||
store.stat(&key.as_bytes()).await.unwrap(),
|
||
Some(EntryMeta {
|
||
len: 0,
|
||
algorithm: key.algorithm()
|
||
})
|
||
);
|
||
}
|
||
|
||
/// `get`/`stat`/`read_range`/`has` fall through to the large tier
|
||
/// on a kv miss — consult order, not length routing: a
|
||
/// sub-threshold entry planted only in the large tier is still
|
||
/// found even though `tier_for` routes its length to kv.
|
||
#[tokio::test]
|
||
async fn read_ops_fall_through_kv_miss_to_large() {
|
||
let root = temp_root("read-fall-through");
|
||
let store = Store::dual_tier_mem_local(root.path(), 1024)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
let content = b"a sub-threshold entry planted directly in the large tier";
|
||
let key = key_for(content);
|
||
|
||
assert_eq!(
|
||
store.tier_for(content.len() as u64),
|
||
Tier::Kv,
|
||
"the length *routes* kv — the read still lands via the large tier"
|
||
);
|
||
plant_large(&store, &key.as_bytes(), content).await;
|
||
|
||
let (len, blob) = store
|
||
.get(&key.as_bytes())
|
||
.await
|
||
.expect("fall-through get succeeds")
|
||
.expect("large-tier entry found via fall-through");
|
||
assert_eq!(len, content.len() as u64);
|
||
assert_eq!(blob.read_at(0, len).await.unwrap(), content);
|
||
|
||
assert_eq!(
|
||
store.stat(&key.as_bytes()).await.unwrap(),
|
||
Some(EntryMeta {
|
||
len: content.len() as u64,
|
||
algorithm: key.algorithm()
|
||
}),
|
||
"stat falls through too"
|
||
);
|
||
assert!(
|
||
store.has(&key.as_bytes()).await.unwrap(),
|
||
"has falls through"
|
||
);
|
||
|
||
let (slice, digest) = store
|
||
.read_range(&key.as_bytes(), 4, 8)
|
||
.await
|
||
.expect("read_range falls through");
|
||
assert_eq!(slice, &content[4..12]);
|
||
assert_eq!(digest, slice_digest(&slice));
|
||
}
|
||
|
||
/// The kv tier is the *first* consult on every key-addressed op:
|
||
/// the same key bytes planted with different (mismatching) content
|
||
/// in both tiers — get/read_range/stat/has must all answer from
|
||
/// the kv tier's entry.
|
||
#[tokio::test]
|
||
async fn kv_tier_is_the_first_consult_on_every_key_addressed_op() {
|
||
let root = temp_root("read-kv-first");
|
||
let store = Store::dual_tier_mem_local(root.path(), 1024)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
const KV_CONTENT: &[u8] = b"the kv tier's entry";
|
||
let key = key_for(KV_CONTENT);
|
||
plant_kv(&store, &key.as_bytes(), KV_CONTENT).await;
|
||
plant_large(&store, &key.as_bytes(), b"decoy content in the large tier").await;
|
||
|
||
let (len, blob) = store
|
||
.get(&key.as_bytes())
|
||
.await
|
||
.expect("get succeeds")
|
||
.expect("kv hit wins");
|
||
assert_eq!(
|
||
blob.read_at(0, len).await.unwrap(),
|
||
KV_CONTENT,
|
||
"kv tier first — and its content verifies against the key"
|
||
);
|
||
|
||
let (slice, _) = store
|
||
.read_range(&key.as_bytes(), 0, 4)
|
||
.await
|
||
.expect("ranged");
|
||
assert_eq!(
|
||
slice,
|
||
&KV_CONTENT[0..4],
|
||
"read_range also consults kv first"
|
||
);
|
||
|
||
assert!(store.has(&key.as_bytes()).await.unwrap());
|
||
assert_eq!(
|
||
store.stat(&key.as_bytes()).await.unwrap(),
|
||
Some(EntryMeta {
|
||
len: KV_CONTENT.len() as u64,
|
||
algorithm: key.algorithm()
|
||
})
|
||
);
|
||
}
|
||
|
||
/// Verification-on-get, kv tier (ADR-006 decision 1): a
|
||
/// materialized get hash-checks against the key; mismatch is
|
||
/// `Err(Verification)` and never falls through further. `read_range`
|
||
/// by contrast carries no canonical check — the slice digest is its
|
||
/// whole verification story (ADR-006 decision 2).
|
||
#[tokio::test]
|
||
async fn kv_get_whole_blob_verifies_and_never_falls_through_past_mismatch() {
|
||
let store = Store::mem_only();
|
||
let key = key_for(b"the derived address");
|
||
let bytes = key.as_bytes();
|
||
plant_kv(&store, &bytes, b"content that is not the key").await;
|
||
|
||
assert_eq!(
|
||
store.get(&bytes).await.unwrap_err(),
|
||
Error::Verification,
|
||
"kv materialized reads hash-check at get time"
|
||
);
|
||
assert_eq!(
|
||
store.read_range(&bytes, 0, 8).await.unwrap(),
|
||
(b"content ".to_vec(), slice_digest(b"content ")),
|
||
"read_range carries no canonical check — the slice-digest posture"
|
||
);
|
||
assert!(
|
||
store.stat(&bytes).await.unwrap().is_some(),
|
||
"stat is a metadata probe, not a verifier"
|
||
);
|
||
}
|
||
|
||
/// Verification-on-get, large tier (the recorded posture's other
|
||
/// half): a streaming get opens metadata + handle only — it does
|
||
/// not re-derive the canonical digest at open. The tier's
|
||
/// integrity story is the put path's whole-blob verification pass
|
||
/// plus the per-range slice digests (ADR-006 decisions 1–2).
|
||
#[tokio::test]
|
||
async fn large_streaming_get_does_not_re_derive_at_open() {
|
||
let root = temp_root("read-large-no-reverify");
|
||
let store = Store::dual_tier_mem_local(root.path(), 8)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
let key = key_for(b"the derived address");
|
||
plant_large(&store, &key.as_bytes(), b"stored bytes").await;
|
||
|
||
assert!(
|
||
store.get(&key.as_bytes()).await.unwrap().is_some(),
|
||
"streaming open never re-derives (mismatching content and all)"
|
||
);
|
||
}
|
||
|
||
/// `stat` carries len + algorithm for both algorithms (the
|
||
/// "type" at this layer is the hash algorithm, ADR-002) and is a
|
||
/// `size` ride — the fall-through consult, `None` when absent.
|
||
#[tokio::test]
|
||
async fn stat_carries_len_and_algorithm_from_size_probes() {
|
||
let root = temp_root("read-stat");
|
||
let store = Store::dual_tier_mem_local(root.path(), 1024)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
|
||
let sha256_key = key_for(b"canonical");
|
||
plant_kv(&store, &sha256_key.as_bytes(), b"canonical").await;
|
||
let legacy_key = sha1_key_for(b"legacy");
|
||
plant_large(&store, &legacy_key.as_bytes(), b"legacy").await;
|
||
|
||
assert_eq!(
|
||
store.stat(&sha256_key.as_bytes()).await.unwrap(),
|
||
Some(EntryMeta {
|
||
len: 9,
|
||
algorithm: Algorithm::GitBlobSha256
|
||
})
|
||
);
|
||
assert_eq!(
|
||
store
|
||
.stat(&legacy_key.as_bytes())
|
||
.await
|
||
.unwrap()
|
||
.expect("legacy entry present")
|
||
.algorithm,
|
||
Algorithm::GitBlobSha1,
|
||
"the algorithm comes from the key"
|
||
);
|
||
assert_eq!(
|
||
store.stat(&sha256_key.as_bytes()).await.unwrap(),
|
||
store.stat(&sha256_key.as_bytes()).await.unwrap(),
|
||
"stat is a pure probe"
|
||
);
|
||
}
|
||
|
||
/// `list` returns the sorted, typed union of both tiers'
|
||
/// enumerations — both algorithms, dedup-free by key identity.
|
||
#[tokio::test]
|
||
async fn list_returns_the_sorted_typed_union_of_both_tiers() {
|
||
let root = temp_root("read-list");
|
||
let store = Store::dual_tier_mem_local(root.path(), 1024)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
|
||
let kv_keys = [
|
||
key_for(b"kv entry one"),
|
||
sha1_key_for(b"kv legacy"),
|
||
key_for(b"kv entry two"),
|
||
];
|
||
for key in &kv_keys {
|
||
plant_kv(&store, &key.as_bytes(), b"kv bytes").await;
|
||
}
|
||
let large_keys = [key_for(b"large entry one"), key_for(b"large entry two")];
|
||
for key in &large_keys {
|
||
plant_large(&store, &key.as_bytes(), b"large bytes").await;
|
||
}
|
||
|
||
let mut expected: Vec<Key> = kv_keys.into_iter().chain(large_keys).collect();
|
||
expected.sort();
|
||
|
||
let listed = store.list().await.expect("list succeeds");
|
||
assert_eq!(listed, expected, "complete, typed, both tiers");
|
||
for window in listed.windows(2) {
|
||
assert!(
|
||
window[0] < window[1],
|
||
"output is sorted in the keys' total order"
|
||
);
|
||
}
|
||
for key in &listed {
|
||
assert!(
|
||
store.has(&key.as_bytes()).await.unwrap(),
|
||
"every listed key addresses a real entry"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A malformed pool list is fatal (`Error::KeyInvalid`), never a
|
||
/// silent skip — ADR-003's malformed-list ruling applied at the
|
||
/// facade boundary; the engines own their own body-level checks.
|
||
#[tokio::test]
|
||
async fn list_of_malformed_pool_keys_is_fatal() {
|
||
let store = Store::mem_only();
|
||
plant_kv(&store, &[0x07; 33], b"stray in the table").await;
|
||
|
||
assert_eq!(
|
||
store.list().await.unwrap_err(),
|
||
Error::KeyInvalid,
|
||
"malformed pool bytes are fatal on list"
|
||
);
|
||
}
|
||
|
||
/// `KeyInvalid` at the facade boundary on every key-addressed
|
||
/// op (typed conversion happens there and only there, ADR-002/003)
|
||
/// — malformed bytes reject on hit paths (a planted entry) and
|
||
/// miss paths alike, before any tier is consulted and never as a
|
||
/// `None`/`false` guess.
|
||
#[tokio::test]
|
||
async fn malformed_key_bytes_reject_keyinvalid_on_every_op() {
|
||
let store = Store::mem_only();
|
||
let anchor = key_for(b"an anchor entry");
|
||
plant_kv(&store, &anchor.as_bytes(), b"anchor").await;
|
||
|
||
let key_bytes = anchor.as_bytes();
|
||
let malformed = [
|
||
Vec::new(), // empty
|
||
key_bytes[..key_bytes.len() - 1].to_vec(), // truncated digest
|
||
[0x00u8; 33].to_vec(), // reserved tag, full width
|
||
[0x03u8; 33].to_vec(), // unknown tag
|
||
[0x01u8; 21].to_vec(), // right tag, wrong digest length
|
||
];
|
||
for raw in &malformed {
|
||
assert_eq!(
|
||
store.get(raw).await.unwrap_err(),
|
||
Error::KeyInvalid,
|
||
"get rejects malformed key bytes: {raw:?}"
|
||
);
|
||
assert_eq!(
|
||
store.stat(raw).await.unwrap_err(),
|
||
Error::KeyInvalid,
|
||
"stat rejects malformed key bytes: {raw:?}"
|
||
);
|
||
assert_eq!(
|
||
store.read_range(raw, 0, 1).await.unwrap_err(),
|
||
Error::KeyInvalid,
|
||
"read_range rejects malformed key bytes: {raw:?}"
|
||
);
|
||
assert_eq!(
|
||
store.has(raw).await.unwrap_err(),
|
||
Error::KeyInvalid,
|
||
"has rejects malformed key bytes: {raw:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// The GC protocol (the gc-protocol work): pin registry, registered
|
||
// liveness sources, protect callback, delete windows, direct-delete
|
||
// refusal — ADR-005/012/013 on the facade.
|
||
// ------------------------------------------------------------------
|
||
|
||
/// A set-backed liveness source — shared-handle semantics, the POC
|
||
/// finding-7 shape: the store consults the same set this test
|
||
/// mutates, so its answers are live-shared, never snapshots.
|
||
#[derive(Default)]
|
||
struct SetSource {
|
||
live: parking_lot::Mutex<BTreeSet<Key>>,
|
||
}
|
||
|
||
impl SetSource {
|
||
fn add(&self, key: Key) {
|
||
self.live.lock().insert(key);
|
||
}
|
||
|
||
fn remove(&self, key: &Key) {
|
||
self.live.lock().remove(key);
|
||
}
|
||
}
|
||
|
||
impl LivenessSource for SetSource {
|
||
fn live_keys(&self) -> Vec<Key> {
|
||
self.live.lock().iter().cloned().collect()
|
||
}
|
||
|
||
fn contains(&self, key: &Key) -> bool {
|
||
self.live.lock().contains(key)
|
||
}
|
||
}
|
||
|
||
/// A set-backed protect callback; `None` models the
|
||
/// failed-to-answer case (abort/refuse safe side).
|
||
#[derive(Default)]
|
||
struct SetProtect {
|
||
set: parking_lot::Mutex<Option<Vec<Key>>>,
|
||
}
|
||
|
||
impl SetProtect {
|
||
fn set_keys(&self, keys: Vec<Key>) {
|
||
*self.set.lock() = Some(keys);
|
||
}
|
||
|
||
fn fail(&self) {
|
||
*self.set.lock() = None;
|
||
}
|
||
}
|
||
|
||
impl ProtectCallback for SetProtect {
|
||
fn protect(&self) -> Option<Vec<Key>> {
|
||
self.set.lock().clone()
|
||
}
|
||
}
|
||
|
||
/// A protect callback that blocks mid-consult (the sweep lease's
|
||
/// contended-window fixture): it announces its entry on a channel,
|
||
/// waits on a second, then answers with an empty protection set.
|
||
struct LatchProtect {
|
||
entered: std::sync::mpsc::Sender<()>,
|
||
release: parking_lot::Mutex<Option<std::sync::mpsc::Receiver<()>>>,
|
||
}
|
||
|
||
impl ProtectCallback for LatchProtect {
|
||
fn protect(&self) -> Option<Vec<Key>> {
|
||
self.entered.send(()).ok();
|
||
let receiver = self.release.lock().take();
|
||
if let Some(receiver) = receiver {
|
||
let _ = receiver.recv();
|
||
}
|
||
Some(Vec::new())
|
||
}
|
||
}
|
||
|
||
/// `sweep()` with zero registered liveness sources is the safe
|
||
/// default: `Ok(SweepReport)` carrying `NoLivenessSources`, nothing
|
||
/// deleted, the pool intact (store-api.md §Lifecycle).
|
||
#[tokio::test]
|
||
async fn sweep_with_zero_sources_aborts_as_data_and_deletes_nothing() {
|
||
let store = Store::mem_only();
|
||
let key = key_for(b"unprotected content");
|
||
plant_kv(&store, &key.as_bytes(), b"unprotected content").await;
|
||
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.aborted, Some(GcAbortCause::NoLivenessSources));
|
||
assert_eq!(report.candidates_staged, 0);
|
||
assert_eq!(report.deleted, 0);
|
||
assert_eq!(report.cancelled_by_arbitration, 0);
|
||
assert!(
|
||
store.has(&key.as_bytes()).await.unwrap(),
|
||
"an aborted sweep deletes nothing"
|
||
);
|
||
}
|
||
|
||
/// With sources registered, an empty pool sweeps to a zero report
|
||
/// — no abort, exact zero counts.
|
||
#[tokio::test]
|
||
async fn sweep_with_sources_and_an_empty_pool_is_a_zero_report() {
|
||
let store = Store::mem_only();
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.aborted, None);
|
||
assert_eq!(report.candidates_staged, 0);
|
||
assert_eq!(report.deleted, 0);
|
||
assert_eq!(report.cancelled_by_arbitration, 0);
|
||
}
|
||
|
||
/// Exact counts on a mixed pool (store-api.md invariant 4): the
|
||
/// registered source's key and a pinned key mark live, both tiers'
|
||
/// dead entries stage and delete — `candidates_staged == deleted +
|
||
/// cancelled_by_arbitration`.
|
||
#[tokio::test]
|
||
async fn sweep_counts_are_exact_across_both_tiers() {
|
||
let root = temp_root("gc-exact-counts");
|
||
let store = Store::dual_tier_mem_local(root.path(), 1024)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
let source = Arc::new(SetSource::default());
|
||
store.register_liveness_source(source.clone());
|
||
|
||
let live = key_for(b"kept by the registered source");
|
||
plant_kv(&store, &live.as_bytes(), b"kept by the registered source").await;
|
||
source.add(live.clone());
|
||
|
||
let pinned = key_for(b"kept by the pin");
|
||
plant_kv(&store, &pinned.as_bytes(), b"kept by the pin").await;
|
||
let pin = store.gc().acquire(&pinned).await;
|
||
|
||
let kv_dead = key_for(b"dead kv entry");
|
||
plant_kv(&store, &kv_dead.as_bytes(), b"dead kv entry").await;
|
||
let large_dead = key_for(b"dead large entry with bulk");
|
||
plant_large(
|
||
&store,
|
||
&large_dead.as_bytes(),
|
||
b"dead large entry with bulk",
|
||
)
|
||
.await;
|
||
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.aborted, None);
|
||
assert_eq!(report.candidates_staged, 2);
|
||
assert_eq!(report.deleted, 2, "both tiers' dead entries deleted");
|
||
assert_eq!(report.cancelled_by_arbitration, 0);
|
||
assert_eq!(
|
||
report.candidates_staged,
|
||
report.deleted + report.cancelled_by_arbitration,
|
||
"the completed sweep's counts are exact over the staged set"
|
||
);
|
||
assert!(store.has(&live.as_bytes()).await.unwrap());
|
||
assert!(store.has(&pinned.as_bytes()).await.unwrap());
|
||
assert!(!store.has(&kv_dead.as_bytes()).await.unwrap());
|
||
assert!(!store.has(&large_dead.as_bytes()).await.unwrap());
|
||
|
||
drop(pin);
|
||
}
|
||
|
||
/// A dedup re-put's guard refcounts the existing entry: two pins
|
||
/// compose, each drop decrements, and only the last drop exposes
|
||
/// the entry to a sweep (ADR-005's dedup-refcount ruling).
|
||
#[tokio::test]
|
||
async fn dedup_reput_guards_refcount_under_the_arbitration_lock() {
|
||
let store = Store::mem_only();
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
let content = b"refcounted content";
|
||
let key = key_for(content);
|
||
plant_kv(&store, &key.as_bytes(), content).await;
|
||
|
||
let first = store.gc().acquire(&key).await;
|
||
let second = store.gc().acquire(&key).await;
|
||
assert_eq!(store.gc().pin_count(&key), 2, "dedup re-put refcounts");
|
||
drop(first);
|
||
assert_eq!(store.gc().pin_count(&key), 1);
|
||
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.candidates_staged, 0, "one refcount still protects");
|
||
assert!(store.has(&key.as_bytes()).await.unwrap());
|
||
|
||
drop(second);
|
||
assert_eq!(store.gc().pin_count(&key), 0, "zero removes the entry row");
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.candidates_staged, 1);
|
||
assert_eq!(report.deleted, 1);
|
||
assert!(!store.has(&key.as_bytes()).await.unwrap());
|
||
}
|
||
|
||
/// Batch pins protect the whole group; batch drop releases every
|
||
/// member in one ordered step — no early single-pin release
|
||
/// (ADR-005 §Marks 2).
|
||
#[tokio::test]
|
||
async fn batch_pins_drop_together_in_one_ordered_step() {
|
||
let store = Store::mem_only();
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
let a = key_for(b"batch member a");
|
||
let b = key_for(b"batch member b");
|
||
plant_kv(&store, &a.as_bytes(), b"batch member a").await;
|
||
plant_kv(&store, &b.as_bytes(), b"batch member b").await;
|
||
|
||
let mut batch = store.gc().new_batch();
|
||
batch.add(store.gc().acquire(&a).await);
|
||
batch.add(store.gc().acquire(&b).await);
|
||
assert_eq!(batch.len(), 2);
|
||
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.candidates_staged, 0, "the batch protects both");
|
||
assert!(store.has(&a.as_bytes()).await.unwrap());
|
||
assert!(store.has(&b.as_bytes()).await.unwrap());
|
||
|
||
drop(batch);
|
||
assert_eq!(
|
||
store.gc().pin_count(&a) + store.gc().pin_count(&b),
|
||
0,
|
||
"batch drop released every member refcount"
|
||
);
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.candidates_staged, 2);
|
||
assert_eq!(report.deleted, 2);
|
||
}
|
||
|
||
/// The protect callback adds externally-known hashes (they stay
|
||
/// alive) and aborts as report data on failure — nothing deleted,
|
||
/// `ProtectFailed` in `Ok(SweepReport)` (ADR-012 §1).
|
||
#[tokio::test]
|
||
async fn protect_callback_adds_keys_and_aborts_as_data() {
|
||
let store = Store::mem_only();
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
let protect = Arc::new(SetProtect::default());
|
||
store.register_protect_callback(protect.clone());
|
||
|
||
let named = key_for(b"protected by the callback");
|
||
plant_kv(&store, &named.as_bytes(), b"protected by the callback").await;
|
||
let dead = key_for(b"dead while the callback answers");
|
||
plant_kv(&store, &dead.as_bytes(), b"dead while the callback answers").await;
|
||
protect.set_keys(vec![named.clone()]);
|
||
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.aborted, None);
|
||
assert_eq!(report.candidates_staged, 1, "the named key never staged");
|
||
assert_eq!(report.deleted, 1);
|
||
assert!(store.has(&named.as_bytes()).await.unwrap());
|
||
assert!(!store.has(&dead.as_bytes()).await.unwrap());
|
||
|
||
protect.fail();
|
||
let another_dead = key_for(b"dead while the callback fails");
|
||
plant_kv(
|
||
&store,
|
||
&another_dead.as_bytes(),
|
||
b"dead while the callback fails",
|
||
)
|
||
.await;
|
||
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.aborted, Some(GcAbortCause::ProtectFailed));
|
||
assert_eq!(
|
||
report.deleted, 0,
|
||
"a failed protection source deletes nothing"
|
||
);
|
||
assert!(store.has(&another_dead.as_bytes()).await.unwrap());
|
||
assert!(store.has(&named.as_bytes()).await.unwrap());
|
||
}
|
||
|
||
/// The live-shared seam: registration never snapshots — content
|
||
/// added to the set after registering is seen by the next sweep;
|
||
/// content removed exposes the entry again (POC #1 finding 7).
|
||
#[tokio::test]
|
||
async fn liveness_sources_are_consulted_live_not_snapshotted() {
|
||
let store = Store::mem_only();
|
||
let source = Arc::new(SetSource::default());
|
||
store.register_liveness_source(source.clone());
|
||
|
||
let key = key_for(b"live-shared content");
|
||
plant_kv(&store, &key.as_bytes(), b"live-shared content").await;
|
||
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.deleted, 1, "unprotected at mark, deleted");
|
||
|
||
plant_kv(&store, &key.as_bytes(), b"live-shared content").await;
|
||
source.add(key.clone());
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(
|
||
report.candidates_staged, 0,
|
||
"the registration after registration is consulted live"
|
||
);
|
||
assert!(store.has(&key.as_bytes()).await.unwrap());
|
||
|
||
source.remove(&key);
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.deleted, 1, "deregistration re-exposes the entry");
|
||
assert!(!store.has(&key.as_bytes()).await.unwrap());
|
||
}
|
||
|
||
/// Unregistration consumes the token (returning `false` the second
|
||
/// time) and the corrected zero-source posture aborts sweeps again
|
||
/// — ADR-012 §6.4's correction-flow prerequisite.
|
||
#[tokio::test]
|
||
async fn unregistration_consumes_the_token_and_restores_the_safe_default() {
|
||
let store = Store::mem_only();
|
||
let registration = store.register_liveness_source(Arc::new(SetSource::default()));
|
||
|
||
assert!(
|
||
store.unregister_liveness_source(registration),
|
||
"the token deregisters once"
|
||
);
|
||
assert!(
|
||
!store.unregister_liveness_source(registration),
|
||
"the token does not deregister twice"
|
||
);
|
||
|
||
let protect_token = store.register_protect_callback(Arc::new(SetProtect::default()));
|
||
assert!(
|
||
!store.unregister_liveness_source(protect_token),
|
||
"a protect token is not a source token"
|
||
);
|
||
assert!(store.unregister_protect_callback(protect_token));
|
||
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.aborted, Some(GcAbortCause::NoLivenessSources));
|
||
}
|
||
|
||
/// Direct `delete` refuses the full protection set with the typed
|
||
/// `Error::GcRefuse` — pinned, registered-source-live,
|
||
/// protect-callback-named (ADR-012 §6.4) — and deletes unprotected
|
||
/// keys through the fall-through dispatch; absent keys carry
|
||
/// `Err(Missing)`; malformed bytes `Err(KeyInvalid)`.
|
||
#[tokio::test]
|
||
async fn direct_delete_refuses_the_full_protection_set() {
|
||
let root = temp_root("gc-direct-delete");
|
||
let store = Store::dual_tier_mem_local(root.path(), 1024)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
let source = Arc::new(SetSource::default());
|
||
store.register_liveness_source(source.clone());
|
||
let protect = Arc::new(SetProtect::default());
|
||
store.register_protect_callback(protect.clone());
|
||
|
||
let pinned = key_for(b"pinned refusal");
|
||
plant_kv(&store, &pinned.as_bytes(), b"pinned refusal").await;
|
||
let _pin = store.gc().acquire(&pinned).await;
|
||
|
||
let source_live = key_for(b"source-live refusal");
|
||
plant_large(&store, &source_live.as_bytes(), b"source-live refusal").await;
|
||
source.add(source_live.clone());
|
||
|
||
let protect_named = key_for(b"protect-named refusal");
|
||
plant_kv(&store, &protect_named.as_bytes(), b"protect-named refusal").await;
|
||
protect.set_keys(vec![protect_named.clone()]);
|
||
|
||
let unprotected_kv = key_for(b"unprotected kv deletes");
|
||
plant_kv(
|
||
&store,
|
||
&unprotected_kv.as_bytes(),
|
||
b"unprotected kv deletes",
|
||
)
|
||
.await;
|
||
let unprotected_large = key_for(b"unprotected large deletes");
|
||
plant_large(
|
||
&store,
|
||
&unprotected_large.as_bytes(),
|
||
b"unprotected large deletes",
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(
|
||
store.delete(&pinned.as_bytes()).await.unwrap_err(),
|
||
Error::GcRefuse,
|
||
"pinned keys refuse"
|
||
);
|
||
assert!(
|
||
store.has(&pinned.as_bytes()).await.unwrap(),
|
||
"nothing deleted on refusal"
|
||
);
|
||
assert_eq!(
|
||
store.delete(&source_live.as_bytes()).await.unwrap_err(),
|
||
Error::GcRefuse,
|
||
"registered-source-live keys refuse"
|
||
);
|
||
assert!(store.has(&source_live.as_bytes()).await.unwrap());
|
||
assert_eq!(
|
||
store.delete(&protect_named.as_bytes()).await.unwrap_err(),
|
||
Error::GcRefuse,
|
||
"protect-callback-named keys refuse"
|
||
);
|
||
assert!(store.has(&protect_named.as_bytes()).await.unwrap());
|
||
|
||
store
|
||
.delete(&unprotected_kv.as_bytes())
|
||
.await
|
||
.expect("unprotected kv keys delete");
|
||
assert!(!store.has(&unprotected_kv.as_bytes()).await.unwrap());
|
||
store
|
||
.delete(&unprotected_large.as_bytes())
|
||
.await
|
||
.expect("unprotected large keys delete through the fall-through");
|
||
assert!(!store.has(&unprotected_large.as_bytes()).await.unwrap());
|
||
|
||
let absent = key_for(b"never planted");
|
||
assert_eq!(
|
||
store.delete(&absent.as_bytes()).await.unwrap_err(),
|
||
Error::Missing,
|
||
"absent keys carry Missing"
|
||
);
|
||
assert_eq!(
|
||
store.delete(&[0x03; 33]).await.unwrap_err(),
|
||
Error::KeyInvalid,
|
||
"malformed key bytes reject at the boundary"
|
||
);
|
||
}
|
||
|
||
/// Sequential evidence of the conversion ordering primitive: after
|
||
/// the ordered step, the pin refcount is released **and** the
|
||
/// registration is visible — under one arbitration-lock section
|
||
/// (ADR-005's pinned conversion ordering).
|
||
#[tokio::test]
|
||
async fn pin_conversion_releases_and_registers_in_one_ordered_step() {
|
||
let store = Store::mem_only();
|
||
let source = Arc::new(SetSource::default());
|
||
store.register_liveness_source(source.clone());
|
||
|
||
let key = key_for(b"converted protection");
|
||
let pin = store.gc().acquire(&key).await;
|
||
assert_eq!(store.gc().pin_count(&key), 1);
|
||
|
||
store.gc().convert(pin, || source.add(key.clone()));
|
||
|
||
assert_eq!(
|
||
store.gc().pin_count(&key),
|
||
0,
|
||
"the pin's release is one half of the ordered step"
|
||
);
|
||
assert!(
|
||
source.contains(&key),
|
||
"the registration is the other half — the same section"
|
||
);
|
||
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(
|
||
report.candidates_staged, 0,
|
||
"the converted registration protects through the source"
|
||
);
|
||
}
|
||
|
||
/// The ordered step under race: a pin converted into a source is
|
||
/// never an unprotected instant — a concurrent direct delete must
|
||
/// observe the pin or the registration at every schedule and
|
||
/// refuse, never delete (ADR-005's conversion-ordering invariant,
|
||
/// raced).
|
||
#[tokio::test]
|
||
async fn conversion_raced_against_direct_delete_never_deletes() {
|
||
let store = Store::mem_only();
|
||
let source = Arc::new(SetSource::default());
|
||
store.register_liveness_source(source.clone());
|
||
|
||
for round in 0..64 {
|
||
let content = format!("converted protection round {round}");
|
||
let key = key_for(content.as_bytes());
|
||
let bytes = key.as_bytes();
|
||
plant_kv(&store, &bytes, content.as_bytes()).await;
|
||
|
||
let pin = store.gc().acquire(&key).await;
|
||
let (result, ()) = tokio::join!(store.delete(&bytes), async {
|
||
store.gc().convert(pin, {
|
||
let source = Arc::clone(&source);
|
||
let key = key.clone();
|
||
move || source.add(key)
|
||
})
|
||
},);
|
||
assert_eq!(
|
||
result,
|
||
Err(Error::GcRefuse),
|
||
"round {round}: no schedule leaves the key unprotected "
|
||
);
|
||
assert!(
|
||
store.has(&bytes).await.unwrap(),
|
||
"nothing deleted while the protection converted"
|
||
);
|
||
|
||
source.remove(&key);
|
||
store
|
||
.delete(&bytes)
|
||
.await
|
||
.expect("unprotected now, deletes");
|
||
}
|
||
}
|
||
|
||
/// The test-asserted property (ADR-005): a sweep racing an
|
||
/// in-flight pinned put never deletes the protected entry — the
|
||
/// pin-before-publish ordering holds from the sweep side, and the
|
||
/// delete-then-recover arm lands byte-identical. Counts stay exact
|
||
/// in every schedule, and the surviving pin keeps protecting.
|
||
#[tokio::test]
|
||
async fn sweep_racing_a_pinned_put_never_deletes_the_protected_entry() {
|
||
let store = Store::mem_only();
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
|
||
for round in 0..64 {
|
||
let content = format!("race round {round}");
|
||
let key = key_for(content.as_bytes());
|
||
let bytes = key.as_bytes();
|
||
|
||
plant_kv(&store, &bytes, content.as_bytes()).await;
|
||
|
||
let (report, pin) = tokio::join!(store.sweep(), async {
|
||
let pin = store.gc().acquire(&key).await;
|
||
plant_kv(&store, &bytes, content.as_bytes()).await;
|
||
pin
|
||
},);
|
||
|
||
let report = report.expect("sweeps are never errors");
|
||
assert_eq!(report.aborted, None, "round {round}");
|
||
assert_eq!(
|
||
report.candidates_staged,
|
||
report.deleted + report.cancelled_by_arbitration,
|
||
"round {round}: exact counts in every schedule"
|
||
);
|
||
|
||
assert!(
|
||
store.has(&bytes).await.unwrap(),
|
||
"round {round}: the entry survives (canceled, or delete-then-recovered)"
|
||
);
|
||
let (_, blob) = store
|
||
.get(&bytes)
|
||
.await
|
||
.expect("get succeeds")
|
||
.expect("entry present");
|
||
assert_eq!(
|
||
blob.read_at(0, content.len() as u64).await.unwrap(),
|
||
content.as_bytes(),
|
||
"round {round}: the recovered re-put is byte-identical"
|
||
);
|
||
|
||
drop(pin);
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(
|
||
report.deleted, 1,
|
||
"round {round}: with the pin dropped the entry is dead — exactly one deletion"
|
||
);
|
||
assert!(!store.has(&bytes).await.unwrap());
|
||
}
|
||
}
|
||
|
||
/// An in-flight put whose publish lands after the sweep's mark is
|
||
/// never enumerated by it — the sweep started before the commit
|
||
/// and cannot delete what its mark never saw (ADR-005's
|
||
/// pin-before-publish invariant, the fresh-put arm).
|
||
#[tokio::test]
|
||
async fn put_publishing_after_a_sweep_started_is_never_deleted() {
|
||
let store = Store::mem_only();
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
|
||
let content = b"published after the mark";
|
||
let key = key_for(content);
|
||
let bytes = key.as_bytes();
|
||
let dead = key_for(b"a dead witness for the concurrent sweep");
|
||
plant_kv(
|
||
&store,
|
||
&dead.as_bytes(),
|
||
b"a dead witness for the concurrent sweep",
|
||
)
|
||
.await;
|
||
|
||
let (report, pin) = tokio::join!(store.sweep(), async {
|
||
let pin = store.gc().acquire(&key).await;
|
||
tokio::task::yield_now().await;
|
||
tokio::task::yield_now().await;
|
||
plant_kv(&store, &bytes, content).await;
|
||
pin
|
||
},);
|
||
|
||
let report = report.expect("sweeps are never errors");
|
||
assert_eq!(report.deleted, 1, "the dead witness deleted");
|
||
assert_eq!(
|
||
report.candidates_staged, 1,
|
||
"the in-flight put's entry never listed — nothing staged for it"
|
||
);
|
||
assert!(
|
||
store.has(&bytes).await.unwrap(),
|
||
"the published entry survived"
|
||
);
|
||
assert_eq!(store.gc().pin_count(&key), 1, "the pin still protects");
|
||
|
||
drop(pin);
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.deleted, 1, "now unprotected, deleted");
|
||
}
|
||
|
||
/// `has`/`get` during a delete window observe no torn state: each
|
||
/// observation is either the untouched full content or clean
|
||
/// absence — existence flips atomically per key, content never
|
||
/// partial (the pool's immutable-entry posture).
|
||
#[tokio::test]
|
||
async fn reads_during_a_delete_window_observe_no_torn_state() {
|
||
let store = Store::mem_only();
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
|
||
let content = b"whole-content visibility under the window";
|
||
let key = key_for(content);
|
||
let bytes = key.as_bytes();
|
||
plant_kv(&store, &bytes, content).await;
|
||
|
||
let observer = {
|
||
let store = &store;
|
||
let bytes = bytes.clone();
|
||
let content_len = content.len() as u64;
|
||
async move {
|
||
let mut seen_true = false;
|
||
let mut seen_absence = false;
|
||
for _ in 0..200 {
|
||
if store.has(&bytes).await.unwrap() {
|
||
seen_true = true;
|
||
assert!(
|
||
!seen_absence,
|
||
"no re-appearance: existence flips once per key"
|
||
);
|
||
let (_, blob) = store
|
||
.get(&bytes)
|
||
.await
|
||
.expect("get succeeds")
|
||
.expect("has said true");
|
||
let whole = blob.read_at(0, content_len).await.unwrap();
|
||
assert_eq!(
|
||
whole, content,
|
||
"an observed entry presents its exact, whole content"
|
||
);
|
||
} else {
|
||
seen_absence = true;
|
||
}
|
||
tokio::task::yield_now().await;
|
||
}
|
||
(seen_true, seen_absence)
|
||
}
|
||
};
|
||
|
||
let ((seen_true, seen_absence), report) = tokio::join!(observer, store.sweep());
|
||
let report = report.expect("sweeps are never errors");
|
||
assert_eq!(report.deleted, 1);
|
||
assert!(seen_true || seen_absence, "the observer ran alongside");
|
||
assert!(
|
||
!store.has(&bytes).await.unwrap(),
|
||
"after the window the entry is gone"
|
||
);
|
||
}
|
||
|
||
/// The single-node sweep is single-flight — the in-process analog
|
||
/// of the named exclusive sweep lease (ADR-013 §2): a second
|
||
/// sweeper contending behind the first aborts as report data
|
||
/// (`SweeperLock`), nothing deleted.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||
async fn concurrent_sweeps_single_flight_and_the_second_aborts_as_data() {
|
||
let store = Arc::new(Store::mem_only());
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
|
||
let (entered_tx, entered_rx) = std::sync::mpsc::channel::<()>();
|
||
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
|
||
store.register_protect_callback(Arc::new(LatchProtect {
|
||
entered: entered_tx,
|
||
release: parking_lot::Mutex::new(Some(release_rx)),
|
||
}));
|
||
|
||
let dead = key_for(b"dead entry under the contended sweeps");
|
||
plant_kv(
|
||
&store,
|
||
&dead.as_bytes(),
|
||
b"dead entry under the contended sweeps",
|
||
)
|
||
.await;
|
||
|
||
let first = {
|
||
let store = Arc::clone(&store);
|
||
tokio::spawn(async move { store.sweep().await })
|
||
};
|
||
|
||
while entered_rx
|
||
.recv_timeout(std::time::Duration::from_millis(500))
|
||
.is_err()
|
||
{
|
||
tokio::task::yield_now().await;
|
||
}
|
||
|
||
let second = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(
|
||
second.aborted,
|
||
Some(GcAbortCause::SweeperLock),
|
||
"the contended sweeper aborts as report data"
|
||
);
|
||
assert_eq!(second.deleted, 0);
|
||
assert!(
|
||
store.has(&dead.as_bytes()).await.unwrap(),
|
||
"the contended sweep deleted nothing"
|
||
);
|
||
|
||
release_tx.send(()).ok();
|
||
let first = first
|
||
.await
|
||
.expect("first sweep task joined")
|
||
.expect("first sweep returns a report");
|
||
assert_eq!(first.aborted, None);
|
||
assert_eq!(first.deleted, 1, "the lease holder sweeps");
|
||
assert!(!store.has(&dead.as_bytes()).await.unwrap());
|
||
}
|
||
|
||
/// Sweeps surface operational failures as errors (ADR-012 §1's
|
||
/// error half): a malformed pool list is fatal `KeyInvalid`, never
|
||
/// a silent skip — through the sweep's own mark enumeration too.
|
||
#[tokio::test]
|
||
async fn sweep_of_a_malformed_pool_list_is_an_operational_error() {
|
||
let store = Store::mem_only();
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
plant_kv(&store, &[0x07; 33], b"stray in the table").await;
|
||
|
||
assert_eq!(
|
||
store.sweep().await.unwrap_err(),
|
||
Error::KeyInvalid,
|
||
"a malformed pool list is fatal through the sweep"
|
||
);
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// The put seam (store-put-paths): the two internal paths behind one
|
||
// signature, the three numbered unknown-length rules, pin-before-
|
||
// publish with dedup refcounting, the Batch seam, kv-only rejection
|
||
// (ADR-003/005/010/012 §6.2) — on the facade's write half.
|
||
// ------------------------------------------------------------------
|
||
|
||
use std::collections::VecDeque;
|
||
use std::io::Cursor;
|
||
use std::pin::Pin as TestPin;
|
||
use std::task::{Context, Poll};
|
||
use tokio::io::ReadBuf;
|
||
|
||
/// A stream cut at controlled chunk boundaries (`sizes`, in order;
|
||
/// tail data falls out whole) — the fixture that makes mid-stream
|
||
/// overflow, flush ordering, and staged continuation observable
|
||
/// past the put loop's own chunking.
|
||
struct Chunked {
|
||
data: Vec<u8>,
|
||
cursor: usize,
|
||
sizes: VecDeque<usize>,
|
||
}
|
||
|
||
impl Chunked {
|
||
fn new(data: &[u8], sizes: &[usize]) -> Chunked {
|
||
Chunked {
|
||
data: data.to_vec(),
|
||
cursor: 0,
|
||
sizes: sizes.iter().copied().collect(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl AsyncRead for Chunked {
|
||
fn poll_read(
|
||
mut self: TestPin<&mut Self>,
|
||
_cx: &mut Context<'_>,
|
||
buf: &mut ReadBuf<'_>,
|
||
) -> Poll<std::io::Result<()>> {
|
||
if self.cursor >= self.data.len() || buf.remaining() == 0 {
|
||
return Poll::Ready(Ok(()));
|
||
}
|
||
let remaining = self.data.len() - self.cursor;
|
||
let wanted = self
|
||
.sizes
|
||
.front()
|
||
.copied()
|
||
.unwrap_or(remaining)
|
||
.min(remaining)
|
||
.min(buf.remaining());
|
||
buf.put_slice(&self.data[self.cursor..self.cursor + wanted]);
|
||
self.cursor += wanted;
|
||
if let Some(front) = self.sizes.front_mut() {
|
||
*front -= wanted;
|
||
if *front == 0 {
|
||
self.sizes.pop_front();
|
||
}
|
||
}
|
||
Poll::Ready(Ok(()))
|
||
}
|
||
}
|
||
|
||
/// A stream that delivers `fail_after` bytes then errors mid-stream —
|
||
/// the non-restartable source's failure (rule 3's shape).
|
||
struct Failing {
|
||
data: Vec<u8>,
|
||
cursor: usize,
|
||
fail_after: usize,
|
||
}
|
||
|
||
impl Failing {
|
||
fn new(data: &[u8], fail_after: usize) -> Failing {
|
||
Failing {
|
||
data: data.to_vec(),
|
||
cursor: 0,
|
||
fail_after,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl AsyncRead for Failing {
|
||
fn poll_read(
|
||
mut self: TestPin<&mut Self>,
|
||
_cx: &mut Context<'_>,
|
||
buf: &mut ReadBuf<'_>,
|
||
) -> Poll<std::io::Result<()>> {
|
||
let message = "stream failure (test fixture)";
|
||
if self.cursor >= self.fail_after {
|
||
return Poll::Ready(Err(std::io::Error::other(message)));
|
||
}
|
||
let wanted = (self.data.len() - self.cursor)
|
||
.min(self.fail_after - self.cursor)
|
||
.min(buf.remaining());
|
||
if wanted == 0 {
|
||
return Poll::Ready(Err(std::io::Error::other(message)));
|
||
}
|
||
buf.put_slice(&self.data[self.cursor..self.cursor + wanted]);
|
||
self.cursor += wanted;
|
||
Poll::Ready(Ok(()))
|
||
}
|
||
}
|
||
|
||
/// A stream nobody may ever read — the fixture proving kv-only's
|
||
/// known-length rejection happens *immediately*, before content
|
||
/// flows (ADR-012 §6.2).
|
||
struct Unreadable;
|
||
|
||
impl AsyncRead for Unreadable {
|
||
fn poll_read(
|
||
self: TestPin<&mut Self>,
|
||
_cx: &mut Context<'_>,
|
||
_buf: &mut ReadBuf<'_>,
|
||
) -> Poll<std::io::Result<()>> {
|
||
Poll::Ready(Err(std::io::Error::other(
|
||
"the stream must not be read (test fixture)",
|
||
)))
|
||
}
|
||
}
|
||
|
||
/// Files currently in the local tier's staging area — the residue
|
||
/// counter of the store-level stage-hygiene assertions.
|
||
fn store_stage_file_count(root: &Path) -> usize {
|
||
match fs::read_dir(root.join("staging")) {
|
||
Ok(entries) => entries.count(),
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0,
|
||
Err(e) => panic!("staging dir unreadable: {e}"),
|
||
}
|
||
}
|
||
|
||
/// INV-1, kv tier, known-length path: `put(Some(len), stream)`
|
||
/// round-trips byte-identical, lands in the routed tier (kv), and
|
||
/// the pin rides out with the put.
|
||
#[tokio::test]
|
||
async fn known_length_put_round_trips_on_the_kv_tier() {
|
||
let root = temp_root("put-known-kv");
|
||
let store = Store::dual_tier_mem_local(root.path(), 64)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
let content = b"a known-length small blob";
|
||
let canonical = key_for(content);
|
||
|
||
let (key, pin) = store
|
||
.put(Some(content.len() as u64), Cursor::new(&content[..]))
|
||
.await
|
||
.expect("kv put publishes");
|
||
assert_eq!(key, canonical, "the canonical derivation addresses the put");
|
||
assert_eq!(store.gc().pin_count(&canonical), 1, "the pin rode out");
|
||
assert!(
|
||
store.kv().has(&canonical.as_bytes()).await.unwrap(),
|
||
"landed in the routed tier (kv)"
|
||
);
|
||
assert!(
|
||
!store
|
||
.large()
|
||
.unwrap()
|
||
.has(&canonical.as_bytes())
|
||
.await
|
||
.unwrap(),
|
||
"no large-tier copy on the kv route"
|
||
);
|
||
let (len, blob) = store
|
||
.get(&canonical.as_bytes())
|
||
.await
|
||
.expect("get succeeds")
|
||
.expect("entry present");
|
||
assert_eq!(len, content.len() as u64);
|
||
assert_eq!(blob.read_at(0, len).await.unwrap(), &content[..]);
|
||
|
||
drop(pin);
|
||
}
|
||
|
||
/// INV-1, large tier, known-length path: over-threshold known
|
||
/// content streams into the staged put with hashing in the same
|
||
/// pass, the derivation completing at stream end; commit-rename
|
||
/// publishes; zero stage files remain.
|
||
#[tokio::test]
|
||
async fn known_length_put_round_trips_on_the_large_tier_through_the_staged_put() {
|
||
let root = temp_root("put-known-large");
|
||
let store = Store::dual_tier_mem_local(root.path(), 64)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
let content = vec![42u8; 300];
|
||
let canonical = key_for(&content);
|
||
|
||
let (key, pin) = store
|
||
.put(Some(300), Cursor::new(&content[..]))
|
||
.await
|
||
.expect("large staged put publishes");
|
||
assert_eq!(key, canonical);
|
||
assert_eq!(store.gc().pin_count(&canonical), 1);
|
||
assert!(
|
||
store
|
||
.large()
|
||
.unwrap()
|
||
.has(&canonical.as_bytes())
|
||
.await
|
||
.unwrap(),
|
||
"landed in the routed tier (large, commit-rename)"
|
||
);
|
||
assert!(
|
||
!store.kv().has(&canonical.as_bytes()).await.unwrap(),
|
||
"no kv copy on the large route"
|
||
);
|
||
let (len, blob) = store
|
||
.get(&canonical.as_bytes())
|
||
.await
|
||
.expect("get succeeds")
|
||
.expect("entry present");
|
||
assert_eq!(len, 300);
|
||
assert_eq!(blob.read_at(0, len).await.unwrap(), content);
|
||
assert_eq!(
|
||
store_stage_file_count(root.path()),
|
||
0,
|
||
"commit-rename left zero stage files"
|
||
);
|
||
|
||
drop(pin);
|
||
}
|
||
|
||
/// INV-1, both tiers, unknown-length path: sub-threshold content
|
||
/// commits through the buffered kv form; over-threshold content
|
||
/// flushed-through staging — both round-trip byte-identical and
|
||
/// both arrive under the canonical derivation over the eventual
|
||
/// stored artifact as a whole (rule 2 asserted by key equality:
|
||
/// anything but preamble + the wholeArtifact derivation would
|
||
/// derive a different key).
|
||
#[tokio::test]
|
||
async fn unknown_length_put_round_trips_on_both_tiers() {
|
||
let root = temp_root("put-unknown");
|
||
let store = Store::dual_tier_mem_local(root.path(), 64)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
|
||
let small = b"small unknown-length content";
|
||
let (key, pin) = store
|
||
.put(None, Cursor::new(&small[..]))
|
||
.await
|
||
.expect("buffered put publishes");
|
||
assert_eq!(key, key_for(small), "rule 2, buffer form");
|
||
assert!(store.kv().has(&key.as_bytes()).await.unwrap());
|
||
drop(pin);
|
||
|
||
let big: Vec<u8> = (0..200u32).map(|i| (i % 251) as u8).collect();
|
||
let (key, pin) = store
|
||
.put(None, Chunked::new(&big, &[7; 30]))
|
||
.await
|
||
.expect("staged put publishes");
|
||
assert_eq!(key, key_for(&big), "rule 2, staged-bytes form");
|
||
assert!(
|
||
store.large().unwrap().has(&key.as_bytes()).await.unwrap(),
|
||
"the over-threshold unknown put landed large"
|
||
);
|
||
let (len, blob) = store
|
||
.get(&key.as_bytes())
|
||
.await
|
||
.expect("get succeeds")
|
||
.expect("entry present");
|
||
assert_eq!(len, big.len() as u64);
|
||
assert_eq!(blob.read_at(0, len).await.unwrap(), big);
|
||
assert_eq!(
|
||
store_stage_file_count(root.path()),
|
||
0,
|
||
"zero stage files on success"
|
||
);
|
||
drop(pin);
|
||
}
|
||
|
||
/// Rule 1 (ADR-003 option (d)): threshold exceeded mid-stream — the
|
||
/// buffered prefix flushes into a fresh stage file and the stream
|
||
/// *continues appending into it*; no restart anywhere, nothing
|
||
/// migrates tiers, and the flushed artifact derives canonically.
|
||
#[tokio::test]
|
||
async fn mid_stream_overflow_flushes_into_a_fresh_stage_and_the_stream_continues() {
|
||
let root = temp_root("put-rule1-flush");
|
||
let store = Store::dual_tier_mem_local(root.path(), 8)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
let content: Vec<u8> = (0..40u32).map(|i| (i % 199) as u8).collect();
|
||
// Chunks of 3: two fill the buffer to 6; the third (would-be 9)
|
||
// crosses the threshold mid-stream and triggers the flush; the
|
||
// rest stream on into the stage.
|
||
let chunked = Chunked::new(&content, &[3; 14]);
|
||
|
||
let (key, pin) = store
|
||
.put(None, chunked)
|
||
.await
|
||
.expect("mid-stream overflow flushes; the stream continues");
|
||
assert_eq!(
|
||
key,
|
||
key_for(&content),
|
||
"the flushed artifact derives canonically"
|
||
);
|
||
assert!(
|
||
!store.kv().has(&key.as_bytes()).await.unwrap(),
|
||
"nothing migrated to the kv tier on a large-route put"
|
||
);
|
||
assert!(
|
||
store.large().unwrap().has(&key.as_bytes()).await.unwrap(),
|
||
"the stage holds the whole streamed artifact"
|
||
);
|
||
let (_, blob) = store
|
||
.get(&key.as_bytes())
|
||
.await
|
||
.expect("get succeeds")
|
||
.expect("entry present");
|
||
assert_eq!(
|
||
blob.read_at(0, content.len() as u64).await.unwrap(),
|
||
content,
|
||
"the flushed prefix and the continued stream are one artifact"
|
||
);
|
||
assert_eq!(
|
||
store_stage_file_count(root.path()),
|
||
0,
|
||
"the stage published by rename; zero residue"
|
||
);
|
||
drop(pin);
|
||
}
|
||
|
||
/// The dispatch seam's boundary through the put path: an
|
||
/// unknown-length stream ending exactly at the threshold stays kv
|
||
/// (≤ threshold routes kv; the overflow is strictly past it).
|
||
#[tokio::test]
|
||
async fn unknown_length_exactly_at_threshold_stays_kv() {
|
||
let root = temp_root("put-boundary");
|
||
let store = Store::dual_tier_mem_local(root.path(), 8)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
let content = b"01234567"; // exactly 8 bytes
|
||
let chunked = Chunked::new(content, &[4, 4]);
|
||
|
||
let (key, pin) = store
|
||
.put(None, chunked)
|
||
.await
|
||
.expect("at-threshold stream commits through the buffer");
|
||
assert_eq!(key, key_for(content));
|
||
assert!(
|
||
store.kv().has(&key.as_bytes()).await.unwrap(),
|
||
"exactly-at-threshold stays kv"
|
||
);
|
||
assert!(
|
||
!store.large().unwrap().has(&key.as_bytes()).await.unwrap(),
|
||
"no large-tier copy on the boundary"
|
||
);
|
||
drop(pin);
|
||
}
|
||
|
||
/// Rule 3 (ADR-003): a stream error before commit leaves no entry
|
||
/// and no stage file — on the buffered kv form, on the flushed
|
||
/// staged form, and on both known-length tiers; the stage-hygiene
|
||
/// invariant extended to the buffered case.
|
||
#[tokio::test]
|
||
async fn stream_errors_before_commit_leave_no_entry_and_no_stage_file() {
|
||
let root = temp_root("put-rule3-error");
|
||
let store = Store::dual_tier_mem_local(root.path(), 8)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
let content: Vec<u8> = (0..40u32).map(|i| (i % 173) as u8).collect();
|
||
|
||
// Flushed staged form: the overflow flush ran, then the stream
|
||
// errored — the stage discards.
|
||
let err = store
|
||
.put(None, Failing::new(&content, 12))
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(err, Error::Io(_)),
|
||
"stream failures surface as Io: {err}"
|
||
);
|
||
assert_eq!(
|
||
store.list().await.unwrap(),
|
||
Vec::<Key>::new(),
|
||
"no entry landed from the errored staging put"
|
||
);
|
||
assert_eq!(
|
||
store_stage_file_count(root.path()),
|
||
0,
|
||
"no stage file remains"
|
||
);
|
||
|
||
// Buffered kv form: error before the threshold — the buffer
|
||
// discards exactly like a stage.
|
||
let err = store
|
||
.put(None, Failing::new(&content, 5))
|
||
.await
|
||
.unwrap_err();
|
||
assert!(matches!(err, Error::Io(_)));
|
||
assert_eq!(store.list().await.unwrap(), Vec::<Key>::new());
|
||
assert_eq!(store_stage_file_count(root.path()), 0);
|
||
|
||
// Known-length large path: mid-stage failure discards.
|
||
let err = store
|
||
.put(Some(40), Failing::new(&content, 12))
|
||
.await
|
||
.unwrap_err();
|
||
assert!(matches!(err, Error::Io(_)));
|
||
assert_eq!(store.list().await.unwrap(), Vec::<Key>::new());
|
||
assert_eq!(store_stage_file_count(root.path()), 0);
|
||
|
||
// Known-length kv path: never published.
|
||
let err = store
|
||
.put(Some(40), Failing::new(&content, 5))
|
||
.await
|
||
.unwrap_err();
|
||
assert!(matches!(err, Error::Io(_)));
|
||
assert_eq!(store.list().await.unwrap(), Vec::<Key>::new());
|
||
assert_eq!(store_stage_file_count(root.path()), 0);
|
||
}
|
||
|
||
/// Pin-before-publish (ADR-005) on both put paths and both tiers:
|
||
/// a sweep running alongside an in-flight put never deletes the
|
||
/// protected entry — the pin is minted before the entry can appear
|
||
/// in any `list()` — and every completed schedule keeps exact
|
||
/// counts. The surviving pin protects; dropping it re-exposes.
|
||
#[tokio::test]
|
||
async fn pin_before_publish_holds_on_both_put_paths_and_both_tiers() {
|
||
let root = temp_root("put-pin");
|
||
let store = Store::dual_tier_mem_local(root.path(), 64)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
|
||
let big: Vec<u8> = (0..200u32).map(|i| (i % 241) as u8).collect();
|
||
let big_other: Vec<u8> = (0..288u32).map(|i| (i % 223) as u8).collect();
|
||
let rounds: [(Vec<u8>, Option<u64>); 4] = [
|
||
(b"pinned known small".to_vec(), Some(18)),
|
||
(b"still-pinned unknown small content".to_vec(), None),
|
||
(big, Some(200)),
|
||
(big_other, None),
|
||
];
|
||
|
||
for (round, (content, len)) in rounds.iter().enumerate() {
|
||
let expected = key_for(content);
|
||
let (report, put) = tokio::join!(store.sweep(), async {
|
||
store.put(*len, Cursor::new(&content[..])).await
|
||
});
|
||
let report = report.expect("sweeps are never errors");
|
||
let (key, pin) = put.expect("put published");
|
||
assert_eq!(key, expected, "round {round}");
|
||
assert_eq!(report.aborted, None, "round {round}");
|
||
assert_eq!(
|
||
report.candidates_staged,
|
||
report.deleted + report.cancelled_by_arbitration,
|
||
"round {round}: exact counts in every schedule"
|
||
);
|
||
assert!(
|
||
store.has(&key.as_bytes()).await.unwrap(),
|
||
"round {round}: the entry survived the race"
|
||
);
|
||
assert_eq!(
|
||
store.gc().pin_count(&expected),
|
||
1,
|
||
"round {round}: the minted pin still protects"
|
||
);
|
||
let (_, blob) = store
|
||
.get(&key.as_bytes())
|
||
.await
|
||
.expect("get succeeds")
|
||
.expect("entry present");
|
||
assert_eq!(
|
||
blob.read_at(0, content.len() as u64).await.unwrap(),
|
||
&content[..],
|
||
"round {round}: the published entry is byte-identical"
|
||
);
|
||
|
||
drop(pin);
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.deleted, 1, "round {round}: unpinned, deleted");
|
||
assert!(!store.has(&key.as_bytes()).await.unwrap());
|
||
}
|
||
}
|
||
|
||
/// Dedup (store-api.md invariant 2; ADR-005): a re-put of identical
|
||
/// content acquires no new pool entry and refcounts the existing
|
||
/// one — across put paths (known vs unknown) and across both tiers;
|
||
/// the refcounts compose under the same lock and drop in sequence.
|
||
#[tokio::test]
|
||
async fn dedup_reputs_refcount_the_existing_entry_from_any_path() {
|
||
let root = temp_root("put-dedup");
|
||
let store = Store::dual_tier_mem_local(root.path(), 64)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
|
||
let content = b"deduplicated content";
|
||
let (known_key, first) = store
|
||
.put(Some(content.len() as u64), Cursor::new(&content[..]))
|
||
.await
|
||
.expect("known-path put");
|
||
let (unknown_key, second) = store
|
||
.put(None, Cursor::new(&content[..]))
|
||
.await
|
||
.expect("unknown-path re-put");
|
||
assert_eq!(known_key, unknown_key, "one derivation, one address");
|
||
assert_eq!(
|
||
store.list().await.unwrap(),
|
||
vec![known_key.clone()],
|
||
"one pool entry"
|
||
);
|
||
assert_eq!(
|
||
store.gc().pin_count(&known_key),
|
||
2,
|
||
"the re-put refcounted the existing entry"
|
||
);
|
||
|
||
drop(first);
|
||
assert_eq!(store.gc().pin_count(&known_key), 1);
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.candidates_staged, 0, "one refcount still protects");
|
||
|
||
drop(second);
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.deleted, 1);
|
||
assert!(!store.has(&known_key.as_bytes()).await.unwrap());
|
||
|
||
// The same composition across the large tier: two staged puts
|
||
// of identical content — one entry, two refcounts.
|
||
let big = [91u8; 200];
|
||
let (first_key, first) = store
|
||
.put(Some(200), Cursor::new(&big[..]))
|
||
.await
|
||
.expect("known staged put");
|
||
let (_, second) = store
|
||
.put(None, Cursor::new(&big[..]))
|
||
.await
|
||
.expect("unknown staged re-put");
|
||
assert_eq!(
|
||
store.list().await.unwrap(),
|
||
vec![first_key.clone()],
|
||
"one pool entry across both staged paths"
|
||
);
|
||
assert_eq!(store.gc().pin_count(&first_key), 2);
|
||
drop(first);
|
||
drop(second);
|
||
}
|
||
|
||
/// `Batch` (store-api.md §Write path; ADR-005 §Marks 2): multi-put
|
||
/// writes group under one batch-scoped pin — members publish pin-
|
||
/// before-publish exactly as lone puts do, both tiers, and the
|
||
/// group's pins drop together on batch commit.
|
||
#[tokio::test]
|
||
async fn batch_groups_multi_put_writes_and_members_drop_together() {
|
||
let root = temp_root("put-batch");
|
||
let store = Store::dual_tier_mem_local(root.path(), 64)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
|
||
let small_a = b"manifest member a";
|
||
let small_b = b"manifest member b";
|
||
let big = vec![77u8; 200];
|
||
|
||
let mut batch = store.batch();
|
||
assert!(batch.is_empty(), "a fresh batch protects nothing");
|
||
let key_a = store
|
||
.put_in_batch(&mut batch, Some(17), Cursor::new(&small_a[..]))
|
||
.await
|
||
.expect("batch member a");
|
||
let key_b = store
|
||
.put_in_batch(&mut batch, None, Cursor::new(&small_b[..]))
|
||
.await
|
||
.expect("batch member b");
|
||
let key_big = store
|
||
.put_in_batch(&mut batch, Some(200), Cursor::new(&big[..]))
|
||
.await
|
||
.expect("batch member large");
|
||
assert_eq!(batch.len(), 3);
|
||
assert_eq!(key_a, key_for(small_a));
|
||
assert_eq!(key_b, key_for(small_b));
|
||
assert_eq!(key_big, key_for(&big));
|
||
|
||
for key in [&key_a, &key_b, &key_big] {
|
||
assert_eq!(
|
||
store.gc().pin_count(key),
|
||
1,
|
||
"each member's pin rides in the group"
|
||
);
|
||
}
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(
|
||
report.candidates_staged, 0,
|
||
"the batch-scoped pin protects the whole group"
|
||
);
|
||
assert!(store.has(&key_a.as_bytes()).await.unwrap());
|
||
assert!(store.has(&key_big.as_bytes()).await.unwrap());
|
||
|
||
batch.commit();
|
||
for key in [&key_a, &key_b, &key_big] {
|
||
assert_eq!(
|
||
store.gc().pin_count(key),
|
||
0,
|
||
"no early single-pin release and no late release either"
|
||
);
|
||
}
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.deleted, 3, "unprotected after the batch ends");
|
||
assert!(!store.has(&key_a.as_bytes()).await.unwrap());
|
||
assert!(!store.has(&key_b.as_bytes()).await.unwrap());
|
||
assert!(!store.has(&key_big.as_bytes()).await.unwrap());
|
||
}
|
||
|
||
/// Batch drop is the failure path: a member put error leaves the
|
||
/// landed members batch-protected and contributes nothing to the
|
||
/// group; member liveness never outlives the batch.
|
||
#[tokio::test]
|
||
async fn batch_member_put_failure_does_not_endanger_landed_members() {
|
||
let root = temp_root("put-batch-failure");
|
||
let store = Store::dual_tier_mem_local(root.path(), 64)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
|
||
let landed = b"the member that landed";
|
||
let mut batch = store.batch();
|
||
let key = store
|
||
.put_in_batch(&mut batch, Some(22), Cursor::new(&landed[..]))
|
||
.await
|
||
.expect("landed member published");
|
||
assert_eq!(store.gc().pin_count(&key), 1);
|
||
|
||
let content: Vec<u8> = (0..40u32).map(|i| (i % 157) as u8).collect();
|
||
let err = store
|
||
.put_in_batch(&mut batch, None, Failing::new(&content, 12))
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(err, Error::Io(_)),
|
||
"the failed member surfaces its error"
|
||
);
|
||
assert_eq!(batch.len(), 1, "the failed member joined nothing");
|
||
assert_eq!(
|
||
store.gc().pin_count(&key),
|
||
1,
|
||
"landed members stay protected"
|
||
);
|
||
assert_eq!(
|
||
store.list().await.unwrap(),
|
||
vec![key.clone()],
|
||
"the failed member published no entry"
|
||
);
|
||
|
||
drop(batch);
|
||
assert_eq!(
|
||
store.gc().pin_count(&key),
|
||
0,
|
||
"batch drop released every member together"
|
||
);
|
||
let report = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(report.deleted, 1);
|
||
}
|
||
|
||
/// kv-only mode's put-time rejection (ADR-012 §6.2): known-length
|
||
/// over-threshold rejects *immediately* (the stream is never read);
|
||
/// unknown-length rejects at mid-stream threshold overflow — zero
|
||
/// residue on either rejection, and at/exactly-threshold puts land.
|
||
#[tokio::test]
|
||
async fn kv_only_mode_rejects_over_threshold_puts_at_the_two_points() {
|
||
let store = Store::kv_only_mem(64);
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
|
||
let err = store.put(Some(65), Unreadable).await.unwrap_err();
|
||
assert!(
|
||
err.to_string().contains("kv-only mode rejects"),
|
||
"the immediate rejection carries its carrier message: {err}"
|
||
);
|
||
assert_eq!(
|
||
store.list().await.unwrap(),
|
||
Vec::<Key>::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);
|
||
|
||
let streamed = vec![3u8; 100];
|
||
let err = store
|
||
.put(None, Chunked::new(&streamed, &[10; 10]))
|
||
.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![key_for(&at_threshold)],
|
||
"zero residue"
|
||
);
|
||
|
||
let just_under = vec![5u8; 64];
|
||
let (key, pin) = store
|
||
.put(None, Chunked::new(&just_under, &[8; 8]))
|
||
.await
|
||
.expect("at-threshold unknown stream lands through the buffer");
|
||
assert_eq!(key, key_for(&just_under));
|
||
assert!(store.has(&key.as_bytes()).await.unwrap());
|
||
drop(pin);
|
||
}
|
||
|
||
/// The declared-len mismatch (the put seam's named design point):
|
||
/// a known-length stream yielding fewer or more bytes than `len`
|
||
/// rejects with `Error::Verification` on either tier — zero residue
|
||
/// either way (nothing published, no stage file left).
|
||
#[tokio::test]
|
||
async fn declared_len_mismatch_rejects_verification_with_zero_residue() {
|
||
let root = temp_root("put-mismatch");
|
||
let store = Store::dual_tier_mem_local(root.path(), 8)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
let content = b"0123456789"; // 10 bytes
|
||
|
||
assert_eq!(
|
||
store
|
||
.put(Some(5), Cursor::new(&content[..]))
|
||
.await
|
||
.unwrap_err(),
|
||
Error::Verification,
|
||
"more bytes than declared (kv route)"
|
||
);
|
||
assert_eq!(
|
||
store
|
||
.put(Some(20), Cursor::new(&content[..]))
|
||
.await
|
||
.unwrap_err(),
|
||
Error::Verification,
|
||
"fewer bytes than declared (kv route)"
|
||
);
|
||
assert_eq!(
|
||
store.list().await.unwrap(),
|
||
Vec::<Key>::new(),
|
||
"zero residue on the kv mismatches"
|
||
);
|
||
|
||
let big = [5u8; 32];
|
||
assert_eq!(
|
||
store
|
||
.put(Some(16), Cursor::new(&big[..]))
|
||
.await
|
||
.unwrap_err(),
|
||
Error::Verification,
|
||
"more bytes than declared (large route)"
|
||
);
|
||
assert_eq!(
|
||
store
|
||
.put(Some(64), Cursor::new(&big[..]))
|
||
.await
|
||
.unwrap_err(),
|
||
Error::Verification,
|
||
"fewer bytes than declared (large route)"
|
||
);
|
||
assert_eq!(
|
||
store.list().await.unwrap(),
|
||
Vec::<Key>::new(),
|
||
"zero residue on the staged mismatches"
|
||
);
|
||
assert_eq!(
|
||
store_stage_file_count(root.path()),
|
||
0,
|
||
"the discarded stages left zero stage files"
|
||
);
|
||
}
|
||
|
||
/// mem-only accepts every length on both put paths — no dispatch
|
||
/// threshold in effect (ADR-011 §4), nothing to reject; the sole
|
||
/// mem engine holds even over-default-threshold content.
|
||
#[tokio::test]
|
||
async fn mem_only_accepts_every_length_on_both_put_paths() {
|
||
let store = Store::mem_only();
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
assert_eq!(store.threshold(), None, "no threshold in effect");
|
||
|
||
let small = b"mem-only content";
|
||
let (key, pin) = store
|
||
.put(Some(16), Cursor::new(&small[..]))
|
||
.await
|
||
.expect("known put");
|
||
assert_eq!(key, key_for(small));
|
||
drop(pin);
|
||
|
||
let big = vec![1u8; (DEFAULT_DISPATCH_THRESHOLD + 1) as usize];
|
||
let (key, pin) = store
|
||
.put(None, Cursor::new(&big[..]))
|
||
.await
|
||
.expect("over-default-threshold unknown put routes to the sole kv engine");
|
||
assert_eq!(key, key_for(&big));
|
||
let (len, blob) = store
|
||
.get(&key.as_bytes())
|
||
.await
|
||
.expect("get succeeds")
|
||
.expect("entry present");
|
||
assert_eq!(len, big.len() as u64);
|
||
assert_eq!(blob.read_at(0, len).await.unwrap(), big);
|
||
drop(pin);
|
||
}
|
||
|
||
/// The zero-length degenerate on both put paths: empty content
|
||
/// round-trips through its canonical key (one entry, both paths
|
||
/// dedup into it), reading empty-never-error.
|
||
#[tokio::test]
|
||
async fn zero_length_puts_round_trip_on_both_paths() {
|
||
let root = temp_root("put-empty");
|
||
let store = Store::dual_tier_mem_local(root.path(), 8)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
let empty = b"";
|
||
|
||
let (known, pin) = store
|
||
.put(Some(0), Cursor::new(&empty[..]))
|
||
.await
|
||
.expect("known empty put");
|
||
assert_eq!(known, key_for(empty));
|
||
assert!(store.kv().has(&known.as_bytes()).await.unwrap());
|
||
drop(pin);
|
||
|
||
let (unknown, pin) = store
|
||
.put(None, Cursor::new(&empty[..]))
|
||
.await
|
||
.expect("unknown empty put");
|
||
assert_eq!(unknown, known, "empty content dedups into one entry");
|
||
assert_eq!(store.list().await.unwrap(), vec![known.clone()]);
|
||
drop(pin);
|
||
|
||
let (len, blob) = store
|
||
.get(&known.as_bytes())
|
||
.await
|
||
.unwrap()
|
||
.expect("entry present");
|
||
assert_eq!(len, 0);
|
||
assert!(blob.is_empty());
|
||
assert_eq!(blob.read_at(0, 8).await.unwrap(), Vec::<u8>::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"),
|
||
#[cfg(feature = "postgres")]
|
||
KvEngine::Postgres(_) => 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::<Key>::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::<Key>::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);
|
||
}
|
||
}
|
||
|
||
/// The sweep's durable form (feature `sqlite`): the staged
|
||
/// re-arbitrated delete (ADR-008 §2; ADR-012 §5/§6.7) — the first
|
||
/// durable delete window, single-node — beside the inline form. The
|
||
/// two-sweep exact-count shapes, the race across the delay, the
|
||
/// vanished-candidate form, the abort shapes, and the delay knob's
|
||
/// staging semantics.
|
||
#[cfg(all(test, feature = "sqlite"))]
|
||
mod sqlite_window_tests {
|
||
use super::*;
|
||
use crate::backend::Backend;
|
||
use std::fs;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
|
||
/// A fresh temp site per test (the construction tests' helper
|
||
/// shape); Drop removes the file tree and the WAL sidecars.
|
||
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-window-{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)
|
||
}
|
||
|
||
fn sqlite_engine(store: &Store) -> &SqliteBackend {
|
||
match store.kv() {
|
||
#[cfg(feature = "mem")]
|
||
KvEngine::Mem(_) => panic!("these tests construct sqlite stores"),
|
||
#[cfg(feature = "postgres")]
|
||
KvEngine::Postgres(_) => panic!("these tests construct sqlite stores"),
|
||
#[cfg(feature = "sqlite")]
|
||
KvEngine::Sqlite(engine) => engine,
|
||
}
|
||
}
|
||
|
||
async fn plant_kv(store: &Store, key: &Key, value: &[u8]) {
|
||
sqlite_engine(store)
|
||
.put(&key.as_bytes(), value)
|
||
.await
|
||
.expect("kv plant");
|
||
}
|
||
|
||
async fn plant_large(store: &Store, key: &Key, value: &[u8]) {
|
||
match store.large() {
|
||
Some(LargeEngine::Local(engine)) => engine
|
||
.put(&key.as_bytes(), value)
|
||
.await
|
||
.expect("large plant"),
|
||
None => panic!("planting into a mode with no large tier"),
|
||
}
|
||
}
|
||
|
||
/// A set-backed liveness source — the live-shared seam, shared
|
||
/// with the wave-2 tests' fixture shape.
|
||
#[derive(Default)]
|
||
struct SetSource {
|
||
live: parking_lot::Mutex<BTreeSet<Key>>,
|
||
}
|
||
|
||
impl SetSource {
|
||
fn add(&self, key: Key) {
|
||
self.live.lock().insert(key);
|
||
}
|
||
}
|
||
|
||
impl LivenessSource for SetSource {
|
||
fn live_keys(&self) -> Vec<Key> {
|
||
self.live.lock().iter().cloned().collect()
|
||
}
|
||
|
||
fn contains(&self, key: &Key) -> bool {
|
||
self.live.lock().contains(key)
|
||
}
|
||
}
|
||
|
||
/// A set-backed protect callback; `None` models the
|
||
/// failed-to-answer case.
|
||
#[derive(Default)]
|
||
struct SetProtect {
|
||
set: parking_lot::Mutex<Option<Vec<Key>>>,
|
||
}
|
||
|
||
impl SetProtect {
|
||
fn set_keys(&self, keys: Vec<Key>) {
|
||
*self.set.lock() = Some(keys);
|
||
}
|
||
|
||
fn fail(&self) {
|
||
*self.set.lock() = None;
|
||
}
|
||
}
|
||
|
||
impl ProtectCallback for SetProtect {
|
||
fn protect(&self) -> Option<Vec<Key>> {
|
||
self.set.lock().clone()
|
||
}
|
||
}
|
||
|
||
/// A protect callback that blocks mid-consult — the contention
|
||
/// window for the single-flight assert on a sqlite store.
|
||
struct LatchProtect {
|
||
entered: std::sync::mpsc::Sender<()>,
|
||
release: parking_lot::Mutex<Option<std::sync::mpsc::Receiver<()>>>,
|
||
}
|
||
|
||
impl ProtectCallback for LatchProtect {
|
||
fn protect(&self) -> Option<Vec<Key>> {
|
||
self.entered.send(()).ok();
|
||
let receiver = self.release.lock().take();
|
||
if let Some(receiver) = receiver {
|
||
let _ = receiver.recv();
|
||
}
|
||
Some(Vec::new())
|
||
}
|
||
}
|
||
|
||
/// A non-destructive read of the window is not on the seam — but a
|
||
/// far-horizon `window_take` drains it entirely, which is exactly
|
||
/// the observation the assertions below need (and leaves the
|
||
/// window clean for the next step of the test).
|
||
async fn window_drain(store: &Store) -> Vec<Key> {
|
||
let far = SystemTime::now()
|
||
.checked_add(Duration::from_secs(365 * 24 * 3600))
|
||
.unwrap();
|
||
let raws = sqlite_engine(store).window_take(far).await.unwrap();
|
||
typed_keys(raws).expect("the window holds typed keys")
|
||
}
|
||
|
||
/// The two-sweep exact-count shape at the degenerate delay (the
|
||
/// test tuning, ADR-008 §2): sweep 1 stages N — nothing deleted,
|
||
/// the pool intact; sweep 2 executes exactly N —
|
||
/// `deleted + cancelled == N` against the staged set, staged-this-
|
||
/// sweep and executed-this-sweep different sets, and the window
|
||
/// left empty.
|
||
#[tokio::test]
|
||
async fn durable_two_sweep_exact_counts_at_delay_zero() {
|
||
let site = temp_site("two-sweep");
|
||
let store = Store::dual_tier_sqlite_local(&site.database, site.path(), 64, Duration::ZERO)
|
||
.await
|
||
.expect("dual-tier sqlite+local constructs");
|
||
let source = Arc::new(SetSource::default());
|
||
store.register_liveness_source(source.clone());
|
||
|
||
let live = key_for(b"kept by the registered source");
|
||
plant_kv(&store, &live, b"kept by the registered source").await;
|
||
source.add(live.clone());
|
||
|
||
let dead_kv = key_for(b"dead kv entry in the window");
|
||
plant_kv(&store, &dead_kv, b"dead kv entry in the window").await;
|
||
let dead_large = key_for(b"dead large entry in the window");
|
||
plant_large(&store, &dead_large, b"dead large entry in the window").await;
|
||
|
||
let first = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(first.aborted, None);
|
||
assert_eq!(
|
||
first.candidates_staged, 2,
|
||
"both tiers' dead entries staged"
|
||
);
|
||
assert_eq!(
|
||
first.deleted, 0,
|
||
"staging is not executing — the delay separates"
|
||
);
|
||
assert_eq!(first.cancelled_by_arbitration, 0);
|
||
|
||
assert!(
|
||
store.has(&dead_kv.as_bytes()).await.unwrap(),
|
||
"sweep 1 staged, did not execute: the kv entry is intact"
|
||
);
|
||
assert!(
|
||
store.has(&dead_large.as_bytes()).await.unwrap(),
|
||
"sweep 1 staged, did not execute: the large entry is intact"
|
||
);
|
||
assert!(store.has(&live.as_bytes()).await.unwrap());
|
||
|
||
let second = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(second.aborted, None);
|
||
assert_eq!(second.candidates_staged, 0, "nothing newly dead");
|
||
assert_eq!(
|
||
second.deleted + second.cancelled_by_arbitration,
|
||
2,
|
||
"sweep 2 executed exactly the staged set"
|
||
);
|
||
assert_eq!(second.deleted, 2);
|
||
assert_eq!(second.cancelled_by_arbitration, 0);
|
||
|
||
assert!(!store.has(&dead_kv.as_bytes()).await.unwrap());
|
||
assert!(!store.has(&dead_large.as_bytes()).await.unwrap());
|
||
assert!(store.has(&live.as_bytes()).await.unwrap());
|
||
assert!(
|
||
window_drain(&store).await.is_empty(),
|
||
"the executed rows left the window"
|
||
);
|
||
}
|
||
|
||
/// The delay knob is the staging's cadence (ADR-008 §2): at a
|
||
/// future-facing delay the staged rows are not overdue at
|
||
/// `window_take(now)` — no execution, no deletion — and a repeated
|
||
/// sweep re-stages the still-dead set without duplicating rows.
|
||
#[tokio::test]
|
||
async fn durable_stage_at_a_future_delay_waits_past_the_window() {
|
||
let site = temp_site("future-delay");
|
||
let store = Store::kv_only_sqlite(&site.database, 64, Duration::from_secs(600))
|
||
.await
|
||
.expect("kv-only sqlite constructs");
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
|
||
let dead_a = key_for(b"dead under a future-facing delay a");
|
||
plant_kv(&store, &dead_a, b"dead under a future-facing delay a").await;
|
||
let dead_b = key_for(b"dead under a future-facing delay b");
|
||
plant_kv(&store, &dead_b, b"dead under a future-facing delay b").await;
|
||
|
||
let first = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(first.aborted, None);
|
||
assert_eq!(first.candidates_staged, 2);
|
||
assert_eq!(first.deleted, 0, "nothing is overdue at staging time");
|
||
|
||
let second = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(second.candidates_staged, 2, "still dead, re-staged");
|
||
assert_eq!(
|
||
second.deleted, 0,
|
||
"the window delay pushed execution out again"
|
||
);
|
||
|
||
let drained = window_drain(&store).await;
|
||
let mut expected = vec![dead_a.clone(), dead_b.clone()];
|
||
expected.sort();
|
||
assert_eq!(drained, expected);
|
||
assert!(store.has(&dead_a.as_bytes()).await.unwrap());
|
||
assert!(store.has(&dead_b.as_bytes()).await.unwrap());
|
||
}
|
||
|
||
/// The race across the delay (the window form of ADR-005's
|
||
/// test-asserted property): a put landing during the window (the
|
||
/// pin minted between the staging sweep and the executor sweep,
|
||
/// pin-before-publish) survives — the execution-time re-arbitration
|
||
/// cancels its candidate — while the unpinned twin deletes. The
|
||
/// cancelled candidate re-stages (and executes) only once its
|
||
/// protection drops.
|
||
#[tokio::test]
|
||
async fn durable_race_across_the_delay_put_during_window_survives() {
|
||
let site = temp_site("race-across-delay");
|
||
let store = Store::kv_only_sqlite(&site.database, 64, Duration::ZERO)
|
||
.await
|
||
.expect("kv-only sqlite constructs");
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
|
||
let pinned = key_for(b"the twin a put lands under during the window");
|
||
plant_kv(
|
||
&store,
|
||
&pinned,
|
||
b"the twin a put lands under during the window",
|
||
)
|
||
.await;
|
||
let unpinned = key_for(b"the unpinned twin");
|
||
plant_kv(&store, &unpinned, b"the unpinned twin").await;
|
||
|
||
let staged = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(staged.candidates_staged, 2);
|
||
assert_eq!(staged.deleted, 0);
|
||
|
||
{
|
||
let _pin = store.gc().acquire(&pinned).await;
|
||
assert_eq!(store.gc().pin_count(&pinned), 1, "pin-before-publish");
|
||
|
||
let executed = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(executed.aborted, None);
|
||
assert_eq!(
|
||
executed.deleted + executed.cancelled_by_arbitration,
|
||
2,
|
||
"execution-time re-arbitration ran over the taken set"
|
||
);
|
||
assert_eq!(executed.deleted, 1, "the unpinned twin deleted");
|
||
assert_eq!(
|
||
executed.cancelled_by_arbitration, 1,
|
||
"the pinned candidate cancelled"
|
||
);
|
||
assert!(
|
||
store.has(&pinned.as_bytes()).await.unwrap(),
|
||
"a put landing during the window survives the next sweep's execution"
|
||
);
|
||
assert!(!store.has(&unpinned.as_bytes()).await.unwrap());
|
||
}
|
||
|
||
// The pin dropped: the still-present candidate re-stages, and
|
||
// then (and only then) executes — its window row was consumed
|
||
// by the cancellation, not by a delete.
|
||
let restaged = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(
|
||
restaged.candidates_staged, 1,
|
||
"unpinned, the candidate re-stages"
|
||
);
|
||
assert_eq!(restaged.deleted, 0);
|
||
|
||
let executed = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(executed.deleted, 1, "the re-staged candidate executes");
|
||
assert!(!store.has(&pinned.as_bytes()).await.unwrap());
|
||
}
|
||
|
||
/// The vanished-candidate form: a staged candidate deleted during
|
||
/// the delay by a direct delete settles into
|
||
/// `cancelled_by_arbitration` at execution time — the vanished-
|
||
/// candidate accounting carries over (nothing to delete, counts
|
||
/// stay exact).
|
||
#[tokio::test]
|
||
async fn durable_vanished_candidate_settles_into_cancelled() {
|
||
let site = temp_site("vanished");
|
||
let store = Store::kv_only_sqlite(&site.database, 64, Duration::ZERO)
|
||
.await
|
||
.expect("kv-only sqlite constructs");
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
|
||
let victim = key_for(b"deleted during the delay");
|
||
plant_kv(&store, &victim, b"deleted during the delay").await;
|
||
|
||
let staged = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(staged.candidates_staged, 1);
|
||
|
||
store
|
||
.delete(&victim.as_bytes())
|
||
.await
|
||
.expect("the direct delete lands on the unprotected entry");
|
||
|
||
let executed = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(executed.aborted, None);
|
||
assert_eq!(executed.candidates_staged, 0);
|
||
assert_eq!(executed.deleted, 0);
|
||
assert_eq!(
|
||
executed.cancelled_by_arbitration, 1,
|
||
"the vanished candidate settles into the cancelled count"
|
||
);
|
||
assert!(window_drain(&store).await.is_empty());
|
||
}
|
||
|
||
/// The abort shapes on a sqlite store delete nothing, execute
|
||
/// nothing overdue, and stage nothing: zero sources
|
||
/// (`NoLivenessSources`), a failing protect callback
|
||
/// (`ProtectFailed`), and lease contention (`SweeperLock`).
|
||
#[tokio::test]
|
||
async fn durable_aborts_delete_nothing_and_execute_nothing() {
|
||
let site = temp_site("aborts-sources");
|
||
let store = Store::kv_only_sqlite(&site.database, 64, Duration::ZERO)
|
||
.await
|
||
.expect("kv-only sqlite constructs");
|
||
let dead = key_for(b"dead while no sources are registered");
|
||
plant_kv(&store, &dead, b"dead while no sources are registered").await;
|
||
|
||
let aborted = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(aborted.aborted, Some(GcAbortCause::NoLivenessSources));
|
||
assert_eq!(aborted.candidates_staged, 0);
|
||
assert_eq!(aborted.deleted, 0);
|
||
assert!(store.has(&dead.as_bytes()).await.unwrap());
|
||
assert!(window_drain(&store).await.is_empty(), "nothing staged");
|
||
}
|
||
|
||
/// `ProtectFailed` aborts before the executor step on the durable
|
||
/// form: an overdue row staged by an earlier sweep is not taken
|
||
/// while the protection consult fails — abort precedes execution.
|
||
#[tokio::test]
|
||
async fn durable_protect_failed_aborts_before_executing_the_overdue_set() {
|
||
let site = temp_site("aborts-protect");
|
||
let store = Store::kv_only_sqlite(&site.database, 64, Duration::ZERO)
|
||
.await
|
||
.expect("kv-only sqlite constructs");
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
let protect = Arc::new(SetProtect::default());
|
||
store.register_protect_callback(protect.clone());
|
||
|
||
let staged_dead = key_for(b"staged dead while the consult answers");
|
||
plant_kv(
|
||
&store,
|
||
&staged_dead,
|
||
b"staged dead while the consult answers",
|
||
)
|
||
.await;
|
||
protect.set_keys(Vec::new());
|
||
let first = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(first.aborted, None);
|
||
assert_eq!(first.candidates_staged, 1);
|
||
|
||
protect.fail();
|
||
let later_dead = key_for(b"dead while the consult fails");
|
||
plant_kv(&store, &later_dead, b"dead while the consult fails").await;
|
||
|
||
let aborted = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(aborted.aborted, Some(GcAbortCause::ProtectFailed));
|
||
assert_eq!(aborted.candidates_staged, 0);
|
||
assert_eq!(aborted.deleted, 0);
|
||
assert_eq!(aborted.cancelled_by_arbitration, 0);
|
||
assert!(
|
||
store.has(&staged_dead.as_bytes()).await.unwrap(),
|
||
"the overdue candidate was not executed past the abort"
|
||
);
|
||
assert!(
|
||
store.has(&later_dead.as_bytes()).await.unwrap(),
|
||
"and nothing was deleted"
|
||
);
|
||
assert_eq!(
|
||
window_drain(&store).await,
|
||
vec![staged_dead],
|
||
"the overdue row still waits in the window"
|
||
);
|
||
}
|
||
|
||
/// Contention on the single-node lease aborts the second sweeper
|
||
/// as report data on the durable form too — nothing executed,
|
||
/// nothing staged, nothing deleted (`Ok(SweepReport)`, ADR-012 §1).
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||
async fn durable_lease_contention_aborts_the_second_sweeper_as_data() {
|
||
let site = temp_site("aborts-lease");
|
||
let store = Arc::new(
|
||
Store::kv_only_sqlite(&site.database, 64, Duration::ZERO)
|
||
.await
|
||
.expect("kv-only sqlite constructs"),
|
||
);
|
||
store.register_liveness_source(Arc::new(SetSource::default()));
|
||
|
||
let (entered_tx, entered_rx) = std::sync::mpsc::channel::<()>();
|
||
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
|
||
store.register_protect_callback(Arc::new(LatchProtect {
|
||
entered: entered_tx,
|
||
release: parking_lot::Mutex::new(Some(release_rx)),
|
||
}));
|
||
|
||
let dead = key_for(b"dead under the contended sweeps");
|
||
plant_kv(&store, &dead, b"dead under the contended sweeps").await;
|
||
|
||
let first = {
|
||
let store = Arc::clone(&store);
|
||
tokio::spawn(async move { store.sweep().await })
|
||
};
|
||
|
||
while entered_rx
|
||
.recv_timeout(std::time::Duration::from_millis(500))
|
||
.is_err()
|
||
{
|
||
tokio::task::yield_now().await;
|
||
}
|
||
|
||
let second = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(second.aborted, Some(GcAbortCause::SweeperLock));
|
||
assert_eq!(second.deleted, 0);
|
||
assert_eq!(second.candidates_staged, 0, "nothing staged");
|
||
assert!(store.has(&dead.as_bytes()).await.unwrap());
|
||
assert!(window_drain(&store).await.is_empty(), "nothing executed");
|
||
|
||
release_tx.send(()).ok();
|
||
let first = first
|
||
.await
|
||
.expect("first sweep task joined")
|
||
.expect("first sweep returns a report");
|
||
assert_eq!(first.aborted, None);
|
||
assert_eq!(first.candidates_staged, 1, "the lease holder stages");
|
||
assert_eq!(first.deleted, 0, "and executes nothing at staging time");
|
||
assert!(
|
||
store.has(&dead.as_bytes()).await.unwrap(),
|
||
"the lease holder staged, did not execute"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The wave-4 constructor wiring's tests (feature `postgres`): the
|
||
/// postgres modes construct and introspect correctly, the GC schema
|
||
/// ran at construction (both GC tables in the fresh schema — the
|
||
/// fleet host's both-tables row), the window-delay default is the
|
||
/// pinned constant and is constructor-tunable, the kv-only rejection
|
||
/// wiring is the mode's put seam unchanged, the fleet-capable-kv
|
||
/// shape (kv = postgres + large = local) round-trips end-to-end
|
||
/// through both tiers, and the sweep's form branch runs the durable
|
||
/// form on the postgres host. Env-driven — the engine tests'
|
||
/// convention — skipping cleanly server-less.
|
||
#[cfg(all(test, feature = "postgres"))]
|
||
mod postgres_construction_tests {
|
||
use super::*;
|
||
use crate::postgres::test_support::SchemaJanitor;
|
||
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 root per test (mirrors the mem construction
|
||
/// tests' helper); Drop removes the 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-pg-{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);
|
||
}
|
||
}
|
||
|
||
/// A fresh engine-owned schema per test — the postgres suite
|
||
/// factory's fresh-schema-per-open convention — so the GC-schema
|
||
/// assertions observe genuine creation.
|
||
static SCHEMA_SEQ: AtomicU64 = AtomicU64::new(0);
|
||
|
||
fn fresh_schema(tag: &str) -> String {
|
||
format!(
|
||
"abt_sctor_{}_{}_{}",
|
||
tag,
|
||
std::process::id(),
|
||
SCHEMA_SEQ.fetch_add(1, Ordering::Relaxed)
|
||
)
|
||
}
|
||
|
||
fn env_nonempty(name: &str) -> Option<String> {
|
||
std::env::var(name).ok().filter(|v| !v.is_empty())
|
||
}
|
||
|
||
/// The harness connection settings ride the environment
|
||
/// (`ALKBLOBS_PG_HOST` required; port/user/password/db default to
|
||
/// the harness server's values). Absent → the caller skips.
|
||
fn harness_dsn() -> Option<String> {
|
||
let host = env_nonempty("ALKBLOBS_PG_HOST")?;
|
||
Some(format!(
|
||
"host={host} port={} user={} password={} dbname={}",
|
||
env_nonempty("ALKBLOBS_PG_PORT").unwrap_or_else(|| "15432".to_owned()),
|
||
env_nonempty("ALKBLOBS_PG_USER").unwrap_or_else(|| "postgres".to_owned()),
|
||
env_nonempty("ALKBLOBS_PG_PASSWORD").unwrap_or_else(|| "poc".to_owned()),
|
||
env_nonempty("ALKBLOBS_PG_DB").unwrap_or_else(|| "blobs".to_owned()),
|
||
))
|
||
}
|
||
|
||
/// The skip note — printed exactly once per skipped test.
|
||
fn skip_note() {
|
||
eprintln!(
|
||
"skipping (no postgres harness server): set ALKBLOBS_PG_HOST/PORT/USER/PASSWORD/DB"
|
||
);
|
||
}
|
||
|
||
/// A fresh-schema opts for the harness server — with the schema
|
||
/// janitor that drops it at test end (success, early return, or
|
||
/// unwind) — or `None` (skip note printed) server-less.
|
||
fn fresh_opts(tag: &str) -> Option<(PgOpts, SchemaJanitor)> {
|
||
let dsn = harness_dsn()?;
|
||
let schema = fresh_schema(tag);
|
||
Some((
|
||
PgOpts {
|
||
schema: schema.clone(),
|
||
..Default::default()
|
||
},
|
||
SchemaJanitor::new(&dsn, &schema),
|
||
))
|
||
}
|
||
|
||
fn key_for(content: &[u8]) -> Key {
|
||
Key::derive(content, content.len() as u64)
|
||
}
|
||
|
||
/// The postgres kv engine behind the constructed store — the
|
||
/// schema observations' instrument.
|
||
fn postgres_engine(store: &Store) -> &PostgresBackend {
|
||
match store.kv() {
|
||
#[cfg(feature = "postgres")]
|
||
KvEngine::Postgres(engine) => engine,
|
||
#[cfg(feature = "mem")]
|
||
KvEngine::Mem(_) => panic!("these tests construct postgres stores"),
|
||
#[cfg(feature = "sqlite")]
|
||
KvEngine::Sqlite(_) => panic!("these tests construct postgres stores"),
|
||
}
|
||
}
|
||
|
||
/// The engine-neutral GC-schema call ran at construction on the
|
||
/// fleet host's applicability row: the pool table exists (the
|
||
/// engine's own bootstrap) and *both* GC tables exist in the
|
||
/// fresh schema (postgres's both-tables row, ADR-012 §5).
|
||
async fn assert_gc_schema_ran(store: &Store) {
|
||
let engine = postgres_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 schema"
|
||
);
|
||
assert!(
|
||
engine.table_exists("gc_pins").await.unwrap(),
|
||
"ensure_gc_schema ran at construction: the pins table exists (the fleet host's both-tables row)"
|
||
);
|
||
}
|
||
|
||
/// The dual-tier construction: both tiers present, mode/threshold/
|
||
/// window-delay introspection exact, boundary routing
|
||
/// deterministic, and the GC schema ran (both GC tables in the
|
||
/// fresh schema).
|
||
#[tokio::test]
|
||
async fn dual_tier_postgres_local_constructs_and_routes() {
|
||
let Some((opts, _janitor)) = fresh_opts("dual-tier") else {
|
||
skip_note();
|
||
return;
|
||
};
|
||
let root = temp_root("dual-tier");
|
||
let dsn = harness_dsn().expect("harness dsn re-read");
|
||
let store = Store::dual_tier_postgres_local(
|
||
&dsn,
|
||
opts,
|
||
root.path(),
|
||
1024,
|
||
DEFAULT_DELETE_WINDOW_DELAY,
|
||
)
|
||
.await
|
||
.expect("dual-tier postgres+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_postgres_constructs_and_rejects_over_threshold_puts() {
|
||
let Some((opts, _janitor)) = fresh_opts("kv-only") else {
|
||
skip_note();
|
||
return;
|
||
};
|
||
let dsn = harness_dsn().expect("harness dsn re-read");
|
||
let store = Store::kv_only_postgres(&dsn, opts, 64, DEFAULT_DELETE_WINDOW_DELAY)
|
||
.await
|
||
.expect("kv-only postgres 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::<Key>::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::<Key>::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 Some((opts, _janitor)) = fresh_opts("delay-default") else {
|
||
skip_note();
|
||
return;
|
||
};
|
||
let dsn = harness_dsn().expect("harness dsn re-read");
|
||
let root = temp_root("delay-default");
|
||
let store = Store::dual_tier_postgres_local(
|
||
&dsn,
|
||
opts,
|
||
root.path(),
|
||
64,
|
||
DEFAULT_DELETE_WINDOW_DELAY,
|
||
)
|
||
.await
|
||
.expect("constructs at the shipped default");
|
||
assert_eq!(store.window_delay(), Duration::from_secs(300));
|
||
|
||
let Some((opts, _janitor)) = fresh_opts("delay-tuned") else {
|
||
return;
|
||
};
|
||
let store = Store::kv_only_postgres(&dsn, opts, 64, Duration::from_secs(90))
|
||
.await
|
||
.expect("constructs at a custom tuning");
|
||
assert_eq!(store.window_delay(), Duration::from_secs(90));
|
||
|
||
let Some((opts, _janitor)) = fresh_opts("delay-zero") else {
|
||
return;
|
||
};
|
||
let root = temp_root("delay-zero");
|
||
let store = Store::dual_tier_postgres_local(&dsn, opts, root.path(), 64, Duration::ZERO)
|
||
.await
|
||
.expect("the degenerate zero tuning is legal");
|
||
assert_eq!(store.window_delay(), Duration::ZERO);
|
||
|
||
let Some((opts, _janitor)) = fresh_opts("delay-long") else {
|
||
return;
|
||
};
|
||
Store::kv_only_postgres(&dsn, opts, 64, Duration::from_secs(6000))
|
||
.await
|
||
.expect("a long delay is a legal tuning too");
|
||
}
|
||
|
||
/// The fleet-capable-kv shape round-trips end-to-end: a
|
||
/// sub-threshold put lands on the kv tier (postgres), 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_postgres_shape_round_trips_end_to_end_through_both_tiers() {
|
||
let Some((opts, _janitor)) = fresh_opts("round-trip") else {
|
||
skip_note();
|
||
return;
|
||
};
|
||
let dsn = harness_dsn().expect("harness dsn re-read");
|
||
let root = temp_root("round-trip");
|
||
let store = Store::dual_tier_postgres_local(&dsn, opts, root.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 (postgres)"
|
||
);
|
||
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
|
||
.expect("get succeeds")
|
||
.expect("kv entry present");
|
||
assert_eq!(len, small.len() as u64, "whole-blob length exact");
|
||
assert_eq!(
|
||
blob.read_at(0, len).await.expect("full window"),
|
||
small,
|
||
"kv-tier round-trip byte-identical"
|
||
);
|
||
drop(pin);
|
||
|
||
let large = vec![7u8; 128];
|
||
let (key, pin) = store
|
||
.put(Some(large.len() as u64), Cursor::new(&large[..]))
|
||
.await
|
||
.expect("staged put publishes");
|
||
assert_eq!(key, key_for(&large));
|
||
assert!(
|
||
store.large().unwrap().has(&key.as_bytes()).await.unwrap(),
|
||
"landed on the large tier (local)"
|
||
);
|
||
assert!(
|
||
!store.kv().has(&key.as_bytes()).await.unwrap(),
|
||
"no kv-tier copy on the large route"
|
||
);
|
||
let (len, blob) = store
|
||
.get(&key.as_bytes())
|
||
.await
|
||
.expect("get succeeds")
|
||
.expect("large entry present");
|
||
assert_eq!(len, large.len() as u64);
|
||
assert_eq!(
|
||
blob.read_at(0, len).await.expect("full window"),
|
||
large,
|
||
"large-tier round-trip byte-identical"
|
||
);
|
||
drop(pin);
|
||
|
||
let mut expected: Vec<Key> = [key_for(small), key_for(&large)].to_vec();
|
||
expected.sort();
|
||
assert_eq!(
|
||
store.list().await.expect("list succeeds"),
|
||
expected,
|
||
"the pool list is the sorted union of both tiers"
|
||
);
|
||
}
|
||
|
||
/// A set-backed liveness source (the construction tests' shared
|
||
/// sweep instrument).
|
||
#[derive(Default)]
|
||
struct SetSource {
|
||
live: parking_lot::Mutex<BTreeSet<Key>>,
|
||
}
|
||
|
||
impl LivenessSource for SetSource {
|
||
fn live_keys(&self) -> Vec<Key> {
|
||
self.live.lock().iter().cloned().collect()
|
||
}
|
||
|
||
fn contains(&self, key: &Key) -> bool {
|
||
self.live.lock().contains(key)
|
||
}
|
||
}
|
||
|
||
/// The sweep's form branch runs the **durable form** on the
|
||
/// postgres host (the branch keyed off the kv-engine enum,
|
||
/// ADR-012 §5's one-window-host rule): a zero-delay sweep stages
|
||
/// this sweep's dead candidates and executes none of them (the
|
||
/// inline form would delete in the same sweep), and the *next*
|
||
/// sweep executes the overdue set through time-of-delete
|
||
/// re-arbitration.
|
||
#[tokio::test]
|
||
async fn sweep_runs_the_durable_form_on_the_postgres_host() {
|
||
let source = Arc::new(SetSource::default());
|
||
|
||
let Some((opts, _janitor)) = fresh_opts("durable-sweep") else {
|
||
drop(source);
|
||
skip_note();
|
||
return;
|
||
};
|
||
let dsn = harness_dsn().expect("harness dsn re-read");
|
||
let root = temp_root("durable-sweep");
|
||
let store = Store::dual_tier_postgres_local(&dsn, opts, root.path(), 64, Duration::ZERO)
|
||
.await
|
||
.expect("dual-tier constructs");
|
||
store.register_liveness_source(source.clone());
|
||
|
||
let dead = key_for(b"a dead candidate under the durable form");
|
||
store
|
||
.put(
|
||
Some(dead_content().len() as u64),
|
||
Cursor::new(&dead_content()[..]),
|
||
)
|
||
.await
|
||
.expect("the candidate's put publishes");
|
||
assert_eq!(store.list().await.unwrap(), vec![dead.clone()]);
|
||
|
||
let first = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(first.aborted, None);
|
||
assert_eq!(
|
||
first.candidates_staged, 1,
|
||
"the dead candidate staged into the durable window"
|
||
);
|
||
assert_eq!(
|
||
first.deleted, 0,
|
||
"the durable form executes nothing in its own staging sweep"
|
||
);
|
||
assert!(
|
||
store.has(&dead.as_bytes()).await.unwrap(),
|
||
"the staged candidate still waits out its window"
|
||
);
|
||
|
||
let second = store.sweep().await.expect("sweeps are never errors");
|
||
assert_eq!(second.aborted, None);
|
||
assert_eq!(
|
||
second.deleted, 1,
|
||
"the next sweep executed the overdue candidate"
|
||
);
|
||
assert_eq!(
|
||
second.candidates_staged, 0,
|
||
"the pool holds nothing dead after the execution"
|
||
);
|
||
assert!(
|
||
!store.has(&dead.as_bytes()).await.unwrap(),
|
||
"the expired candidate is gone"
|
||
);
|
||
}
|
||
|
||
/// The content the durable-sweep test's candidate carries — the
|
||
/// key derives over it, the entry is genuinely dead (nothing
|
||
/// liveness-protects it; the registered source set stays empty).
|
||
fn dead_content() -> Vec<u8> {
|
||
b"a dead candidate under the durable form".to_vec()
|
||
}
|
||
}
|