From 67b927b13c9a471f0b4e1b5a2626d8f4d2845728 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Mon, 12 Oct 2026 06:34:36 +0000 Subject: [PATCH] =?UTF-8?q?feat(store):=20the=20postgres=20constructor=20w?= =?UTF-8?q?iring=20=E2=80=94=20the=20fleet-capable=20store=20shapes=20(wav?= =?UTF-8?q?e=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `KvEngine::Postgres` slotted in: all six kv-op dispatch arms, the `KvCursor::Postgres` materializing body behind `AnyCursor`'s kv arm (the recorded sub-enum design point, landed as predicted), and the engine-neutral `ensure_gc_schema` call creating `gc_pins` + `gc_window` in the fresh schema at construction (the fleet host's both-tables row, ADR-012 §5) - The two named constructors behind feature `postgres` (default-off): `dual_tier_postgres_local(config, opts, root, threshold, window_delay)` — the fleet-capable-kv production shape — and `kv_only_postgres(config, opts, threshold, window_delay)`; the DSN connection settings + engine opts ride the constructor parameters, never invented defaults. `PgOpts` is pub and re-exported from `lib.rs` (it is on the public constructor signature now) - The sweep's durable form runs on the postgres host: the form body extracted into `sweep_window_durable` (sqlite's arm delegates, behavior unchanged), both SQL-backed arms keyed off the kv-engine enum, never a flag — wave 5's pg-lo dormancy slots in without reshaping - Non-fleet posture recorded on both constructors (ADR-012 §3): implicitly `fleet: false`, the ADR-005 in-process protocol verbatim, the `fleet: true` declaration is wave 6 and slots in without reshaping - Stale scope notes updated to the landed state (engine-representation doc, `KvCursor` design point, fleet-declaration paragraph, one-window-host paragraph, postgres module doc, gc.rs companion-seam note); the batch-delete helper's scope note stays (wave 6 consumer) - 5 new env-driven construction tests (skipping server-less): the modes construct and introspect, both GC tables exist in the fresh schema, the window-delay default is the pinned 5-min constant and tunable (`0` legal), the kv-only rejection wiring is unchanged, the dual-tier shape round-trips through both tiers, and the durable form is observed staging/executing across sweeps on the postgres host Verification: `cargo test` 69/19/3 ok; `cargo test --features postgres` 102/19/3 ok live (pglo-poc harness) and green server-less; `cargo test --no-default-features --features postgres` (the pg-pure build, ADR-013 §4) 75/2/3 ok server-less; `cargo test --all-features` 157/54/4 ok; clippy `-D warnings` green (default, postgres, pg-pure, all-features); `cargo fmt --check` green; `cargo doc --no-deps` clean. --- src/gc.rs | 6 +- src/lib.rs | 3 + src/postgres.rs | 47 +- src/store.rs | 752 +++++++++++++++++++++++++-- tasks/store-postgres-construction.md | 106 +++- 5 files changed, 842 insertions(+), 72 deletions(-) diff --git a/src/gc.rs b/src/gc.rs index eaa2765..682c196 100644 --- a/src/gc.rs +++ b/src/gc.rs @@ -66,9 +66,11 @@ //! //! The `#![allow(dead_code)]` below covers the companion-seam scope: //! the window family has its in-tree callers (store construction runs -//! `ensure_gc_schema` on the SQL-backed kv engine; the sweep's durable +//! `ensure_gc_schema` on every SQL-backed kv engine; the sweep's durable //! form drives `window_stage`/`window_take`), while the fleet family -//! waits for postgres's wave. It shrinks as that lands. +//! waits for wave 6 — its postgres clause bodies landed with wave 4, +//! but the store core consumes the family only under the `fleet: true` +//! declaration. It shrinks as that lands. #![allow(dead_code)] diff --git a/src/lib.rs b/src/lib.rs index 8f40ad9..3434850 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,5 +30,8 @@ pub mod ops; #[cfg(feature = "postgres")] pub mod postgres; +#[cfg(feature = "postgres")] +pub use postgres::PgOpts; + #[cfg(feature = "sqlite")] pub mod sqlite; diff --git a/src/postgres.rs b/src/postgres.rs index 229ba99..4478d04 100644 --- a/src/postgres.rs +++ b/src/postgres.rs @@ -114,7 +114,9 @@ //! lease. The store core drives these clauses in wave 6 (fleet mode) — //! under `fleet: false` none of the fleet clauses run, while //! `ensure_gc_schema` runs on every postgres store construction (the -//! `store-postgres-construction` wiring). The embedder's liveness table +//! `store-postgres-construction` wiring, landed: the constructors are +//! [`Store::dual_tier_postgres_local`] and [`Store::kv_only_postgres`], +//! both implicitly `fleet: false` per ADR-012 §3). The embedder's liveness table //! is never created here (the sweep reads it and never owns it — //! ADR-008's schema-ownership rule); the engine-side registration of //! named liveness-table references (ADR-012 §6.6) is @@ -262,20 +264,23 @@ pub(crate) fn qualified_table(schema: &str, table: &str) -> String { format!("{}.{}", quote_identifier(schema), quote_identifier(table)) } -/// Construction options for the [`PostgresBackend`]. Durability knobs, +/// Construction options for the postgres engine. Durability knobs, /// pool sizing, and the engine-owned schema name are -/// engine-configuration concerns — they never appear on the contract -/// surface (the alkstore `PgOpts` precedent; the constructor wiring -/// itself is `store-postgres-construction`). +/// engine-configuration concerns — they ride the store constructors' +/// parameters, never invented defaults (the alkstore `PgOpts` +/// precedent; `open(config, PgOpts)` adapted to the named-constructor +/// shape — the constructors are [`Store::dual_tier_postgres_local`] and +/// [`Store::kv_only_postgres`]). /// /// The connection settings themselves do not ride this struct — they -/// are the constructor's first argument (the tokio-postgres -/// [`tokio_postgres::Config`]-parseable form); the engine never invents -/// defaults for them. TLS: the engine hardwires `NoTls` on every -/// connection path regardless of the config's sslmode — v1 TLS is a -/// post-v1 deployment concern, mirroring the alkstore posture. +/// are the constructor's separate connection-settings argument (the +/// tokio-postgres [`tokio_postgres::Config`]-parseable form); the +/// engine never invents defaults for them. TLS: the engine hardwires +/// `NoTls` on every connection path regardless of the config's +/// sslmode — v1 TLS is a post-v1 deployment concern, mirroring the +/// alkstore posture. #[derive(Debug, Clone)] -pub(crate) struct PgOpts { +pub struct PgOpts { /// The engine-owned PostgreSQL schema holding the pool table. /// Default [`DEFAULT_SCHEMA`]; an empty string selects the default. /// The name is a quoted-identifier boundary ([`quote_identifier`]) @@ -461,6 +466,26 @@ impl PostgresBackend { } Ok(()) } + + /// The catalog table-existence probe — test-only: the store + /// construction test's instrument (mirrors the sqlite engine's + /// `sqlite_master` probe), answering for the engine-owned schema + /// the backend was constructed with. + #[cfg(test)] + pub(crate) async fn table_exists(&self, table: &str) -> Result { + let client = self.pool.get().await.map_err(err)?; + let row = client + .query_one( + "SELECT EXISTS ( + SELECT 1 FROM pg_tables WHERE schemaname = $1 AND tablename = $2 + )", + &[&self.schema, &table], + ) + .await + .map_err(pg_err)?; + let present: bool = row.try_get(0).map_err(pg_err)?; + Ok(present) + } } /// Build the deadpool pool with the shipped posture: per-session diff --git a/src/store.rs b/src/store.rs index c46ea48..a191b47 100644 --- a/src/store.rs +++ b/src/store.rs @@ -26,10 +26,11 @@ //! collapsing to the concrete facade type across all factories"). The //! public surface is engine-blind: consumers name modes and engines //! only through named constructors, feature-gated with their engine -//! (`mem_only` needs feature `mem`; the sqlite/postgres/pg-lo -//! constructors arrive with waves 3–5 and slot into the same enums and -//! constructor pattern without reshaping — adding an enum variant and -//! a named constructor is slotting in). +//! (`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): //! @@ -39,14 +40,18 @@ //! ADR-012 §6.3 names (both tiers load-bearing); the wave-3 //! constructible shape is `kv = sqlite` + `large = local` — the //! **default-build production shape** (feature `sqlite` is -//! default-on, ADR-013 §4). Over-threshold content routes to the -//! large tier; at-or-below to kv. +//! 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-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 @@ -179,15 +184,21 @@ //! group's pins drop together on [`Batch::commit`] or drop (the //! `gc-protocol` machinery drives). //! -//! **The fleet declaration defers to the engine waves.** ADR-012 §3 -//! rules fleet mode a constructor declaration (`fleet: true`), never -//! inferred — but it is a constructor *error* for every non-fleet-valid -//! configuration, and no fleet-valid configuration is constructible yet -//! (mem is never fleet-valid; `local` needs the shared-media -//! declaration of ADR-008 §3; the fleet-valid engines — postgres, pg-lo -//! — are waves 4–5). Adding the declaration then is additive; carrying -//! a flag that could only ever be rejected today would be dead -//! machinery. +//! **The 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 @@ -199,9 +210,9 @@ //! the gc-protocol task fills. //! //! **The durable delete window hosts on the kv engine when it is -//! SQL-backed (ADR-012 §5's one-window-host rule).** Sqlite — the -//! default build's kv engine — hosts: its constructors run the GC -//! schema's engine-neutral call (`ensure_gc_schema` on the +//! 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 @@ -209,12 +220,12 @@ //! 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 one kv-engine-enum match -//! arm, never a flag): the durable form runs where the host is -//! SQL-backed, 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. Both sqlite constructors carry the delete-window delay +//! 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 @@ -247,12 +258,14 @@ 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 = "sqlite"))] +#[cfg(any(feature = "mem", feature = "postgres", feature = "sqlite"))] use std::path::PathBuf; use self::StoreMode::{DualTier, KvOnly, MemOnly}; @@ -319,7 +332,14 @@ pub enum StoreMode { /// The kv-tier engine set — the closed per-tier engine enum of the /// engine-representation design point. Feature-gated variants slot in /// as their engine waves land: sqlite (wave 3 — landed, the default), -/// postgres (wave 4). +/// 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")] @@ -330,6 +350,13 @@ pub(crate) enum KvEngine { /// 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, @@ -346,7 +373,8 @@ pub(crate) enum LargeEngine { /// get body, `Blob`'s drive) per engine without adding a behavior; /// the sub-enum keeps [`AnyCursor`] a two-arm tier dispatch (kv / /// large) while kv engines multiply inside this one. Recorded for the -/// review gate: postgres (wave 4) slots one arm in here. +/// 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")] @@ -355,6 +383,11 @@ pub(crate) enum KvCursor { /// 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 { @@ -362,6 +395,8 @@ impl ReadCursor for KvCursor { 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(), } @@ -371,6 +406,8 @@ impl ReadCursor for KvCursor { 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, } @@ -461,6 +498,8 @@ impl KvEngine { 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, } @@ -473,6 +512,11 @@ impl KvEngine { .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) @@ -483,15 +527,19 @@ impl KvEngine { /// The GC schema's engine-neutral store-core call (ADR-010 §2's /// companion seam; ADR-012 §5's applicability table), run at - /// construction: a SQL-backed kv engine creates its window - /// table(s) — sqlite's body creates `gc_window` only — and an - /// in-process kv engine is structurally nothing (the + /// 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, } @@ -501,6 +549,8 @@ impl KvEngine { 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, } @@ -510,6 +560,8 @@ impl KvEngine { 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, } @@ -519,6 +571,8 @@ impl KvEngine { 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, } @@ -528,6 +582,8 @@ impl KvEngine { 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, } @@ -728,6 +784,114 @@ impl Store { }) } + /// **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 { + 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, + threshold: u64, + window_delay: Duration, + ) -> Result { + 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. @@ -1374,8 +1538,9 @@ impl Store { /// **`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 in today's - /// set; the branch keys off the engine enum, never a flag), and the + /// 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: @@ -1494,26 +1659,37 @@ impl Store { 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) => { - // The durable form (ADR-008 §2; ADR-012 §6.7): 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`. - let (deleted, cancelled_by_arbitration) = - self.execute_overdue_window(engine).await?; - let candidates_staged = self.mark_and_stage_window(engine, live).await?; - Ok(Self::sweep_report( - candidates_staged, - deleted, - cancelled_by_arbitration, - None, - started, - )) - } + 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( + &self, + host: &W, + live: BTreeSet, + started: SystemTime, + ) -> Result { + 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 @@ -1861,6 +2037,8 @@ mod tests { 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"), } @@ -3905,6 +4083,8 @@ mod sqlite_construction_tests { 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"), } } @@ -4214,6 +4394,8 @@ mod sqlite_window_tests { 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, } @@ -4643,3 +4825,475 @@ mod sqlite_window_tests { ); } } + +/// 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 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 { + 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 { + 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, or `None` (skip + /// note printed) server-less. + fn fresh_opts(tag: &str) -> Option { + harness_dsn()?; + Some(PgOpts { + schema: fresh_schema(tag), + ..Default::default() + }) + } + + 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) = 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) = 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::::new(), + "zero residue" + ); + + let streamed = [3u8; 100]; + let err = store + .put(None, Cursor::new(&streamed[..])) + .await + .unwrap_err(); + assert!( + err.to_string() + .contains("kv-only mode rejects an over-threshold put"), + "the overflow rejection is the mode's, not the stream's: {err}" + ); + assert_eq!( + store.list().await.unwrap(), + Vec::::new(), + "zero residue" + ); + + let at_threshold = vec![9u8; 64]; + let (key, pin) = store + .put(Some(64), Cursor::new(&at_threshold[..])) + .await + .expect("exactly-at-threshold known put lands"); + assert_eq!(key, key_for(&at_threshold)); + assert!(store.has(&key.as_bytes()).await.unwrap()); + drop(pin); + } + + /// The window-delay parameter: the shipped default is the pinned + /// 5-minute constant, the constructor's value passes through the + /// store's introspection, and `Duration::ZERO` is a legal + /// degenerate tuning (execute-on-next-sweep). + #[tokio::test] + async fn window_delay_default_is_pinned_and_constructor_tunable() { + assert_eq!( + DEFAULT_DELETE_WINDOW_DELAY, + Duration::from_secs(5 * 60), + "the shipped default is 5 minutes (ADR-008 §2)" + ); + + let Some(opts) = 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) = 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) = 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) = 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) = 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_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>, + } + + impl LivenessSource for SetSource { + fn live_keys(&self) -> Vec { + 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) = 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 { + b"a dead candidate under the durable form".to_vec() + } +} diff --git a/tasks/store-postgres-construction.md b/tasks/store-postgres-construction.md index 468b106..ecdd634 100644 --- a/tasks/store-postgres-construction.md +++ b/tasks/store-postgres-construction.md @@ -1,7 +1,7 @@ --- id: store-postgres-construction name: postgres constructor wiring — the fleet-capable store shapes -status: pending +status: completed depends_on: [engine-postgres, engine-postgres-fleet] scope: narrow risk: low @@ -79,24 +79,24 @@ a named constructor is slotting in"): ## Acceptance Criteria -- [ ] `KvEngine::Postgres` slots in; all six kv ops dispatch; the kv +- [x] `KvEngine::Postgres` slots in; all six kv ops dispatch; the kv cursor body slots into `KvCursor` (the design-point note updated to the landed state) -- [ ] `dual_tier_postgres_local` + `kv_only_postgres` constructors +- [x] `dual_tier_postgres_local` + `kv_only_postgres` constructors exist behind feature `postgres`, take connection settings + engine opts + threshold + window delay, fallible where I/O is fallible -- [ ] `ensure_gc_schema` runs at construction (both GC tables in the +- [x] `ensure_gc_schema` runs at construction (both GC tables in the fresh schema); the window-delay shipped default is 5 min (pinned constant) and constructor-tunable; `0` legal -- [ ] The non-fleet posture recorded (in-process protocol verbatim; +- [x] The non-fleet posture recorded (in-process protocol verbatim; the `fleet: true` declaration is wave 6, slotting in without reshaping) -- [ ] The one-window-host posture verified to cover postgres; the form +- [x] The one-window-host posture verified to cover postgres; the form branch keys off the kv-engine enum -- [ ] The pg-pure build (`--no-default-features --features postgres`) +- [x] The pg-pure build (`--no-default-features --features postgres`) compiles clean -- [ ] `cargo test` (default) green; `cargo test --features postgres` +- [x] `cargo test` (default) green; `cargo test --features postgres` green server-less (skipping); `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` green (default + `--features postgres`) @@ -122,8 +122,94 @@ a named constructor is slotting in"): ## Notes -> To be filled by implementation agent +> Implemented in `src/store.rs` + `src/postgres.rs` + `src/lib.rs` +> (wave 4, following `store-sqlite-construction` — the structural +> twin — point for point). Recorded design points, as resolved: +> +> - **Opts on the public surface** — `PgOpts` went `pub` (and is +> re-exported from `lib.rs` behind the feature gate): the +> constructors' engine-opts parameter is on the public constructor +> signature, so the type must be nameable there (the alkstore +> `open(config, PgOpts)` pin adapted to the named-constructor shape; +> the connection settings remain the separate DSN-string argument). +> - **`KvCursor` slotting** — postgres's materializing body slotted +> into the recorded sub-enum exactly as predicted: one variant, two +> `ReadCursor` impl arms, no reshaping of `AnyCursor`/`Blob`. The +> store.rs design-point note reads landed. +> - **`ensure_gc_schema`** rides the engine-neutral `KvEngine` call — +> the postgres arm forwards to the `EngineWindowState` clause (the +> fleet host's both-tables body: `gc_pins` + `gc_window` in the +> fresh schema), and both constructors dispatch through it (the +> store core calls the kv engine, never a typed postgres path). +> Construction failure is `Err(Error::Io)` per the fallible- +> constructor pattern (`?` throughout). +> - **The sweep's form branch** — the durable-form body was extracted +> into `sweep_window_durable` (behavior +> unchanged for sqlite, whose arm now delegates); both SQL-backed +> arms call it, so the form branch still keys off the kv-engine +> enum, never a flag, and wave 5's pg-lo dormancy slots in without +> reshaping. The one-window-host posture's wording now names both +> SQL-backed hosts (module doc + the sweep doc). +> - **Non-fleet posture** — both constructors' docs carry the +> ADR-012 §3 recording verbatim: implicitly `fleet: false`, the +> ADR-005 in-process protocol applies regardless of engine (in- +> process pins, in-process sweep lease, no `EngineFleetState` clause +> runs), and the `fleet: true` declaration is wave 6, slotting in +> without reshaping — no fleet parameter carried. +> - **Signature shape** — `dual_tier_postgres_local(config, opts, +> root, threshold, window_delay)` / `kv_only_postgres(config, opts, +> threshold, window_delay)`: connection settings first (the +> tokio-postgres `Config`-parseable DSN string), engine opts second, +> then the sqlite twins' remaining parameters (root/threshold/ +> window delay). The DSN is `&str` — `PostgresBackend::new` parses; +> no invented defaults anywhere. +> - **Clippy notes** — `KvEngine` needed a scoped +> `#[allow(clippy::large_enum_variant)]` under the postgres +> configs (the payloads are structurally dissimilar — one `Arc` vs +> the pooled-connection posture inline — and all dispatch rides the +> enum by reference, so the size difference costs nothing; recorded +> on the enum doc). The store-construction `table_exists` probe +> (pg_tables, schema-scoped) mirrors the sqlite engine's +> `#[cfg(test)]` instrument. +> - **Tests (5 new, env-driven, skipping server-less)** — dual-tier +> construction + routing + both-GC-tables schema check; kv-only +> construction + rejection wiring (immediate, overflow, zero +> residue, at-threshold lands); the window-delay default/tunings/ +> zero; the dual-tier round-trip through both tiers with tier- +> placement assertions; and a durable-form sweep smoke (zero-delay: +> first sweep stages and executes nothing — the inline form would +> delete in-sweep, so this is the form-branch-is-postgres proof — +> the second sweep executes the overdue set re-arbitrated at +> execution time). The test-side `KvEngine` matches in the existing +> test modules gained `Postgres` arms for `--all-features` +> exhaustiveness. +> - Stale scope notes updated to the landed state: the engine- +> representation doc, the `KvCursor` design point, the fleet- +> declaration paragraph, the one-window-host paragraph, the +> postgres module doc's forward reference — and `gc.rs`'s +> companion-seam note (the fleet family now "waits for wave 6", its +> clause bodies landed with wave 4). The batch-delete helper's +> scope note stays (its consumer is still wave 6). ## Summary -> To be filled on completion \ No newline at end of file +> Completed 2026-10-12 (wave 4). Postgres slotted into the facade: +> `KvEngine::Postgres` with all six kv-op dispatch arms, the +> `KvCursor::Postgres` materializing body, the `ensure_gc_schema` +> engine-neutral call creating `gc_pins` + `gc_window` in the fresh +> schema at construction, and the two named feature-gated +> constructors (`dual_tier_postgres_local`, `kv_only_postgres`) taking +> the DSN connection settings + `PgOpts` (now public and re-exported) + +> threshold + the constructor-tunable delete-window delay +> (`DEFAULT_DELETE_WINDOW_DELAY` pinned at 5 min, `0` legal) — both +> implicitly non-fleet per ADR-012 §3 with wave 6's declaration +> recorded as a non-reshaping slotting. The sweep's durable form runs +> on the postgres host through the kv-engine-enum-keyed branch +> (`sweep_window_durable`, shared with sqlite's arm). Verification: +> `cargo test` 69/19/3 ok (default), `cargo test --features postgres` +> 102/19/3 ok live and green server-less, `cargo test +> --no-default-features --features postgres` (the pg-pure build, ADR- +> 013 §4's first constructible configuration) 75/2/3 ok server-less, +> `cargo test --all-features` 157/54/4 ok, clippy `-D warnings` green +> on default + postgres + pg-pure + all-features, `cargo fmt --check` +> green, `cargo doc --no-deps` clean. \ No newline at end of file