feat(gc): the pg-lo engine-state window family — the conditional window host (wave 5)
- impl EngineWindowState on PgLoBackend: ensure_gc_schema creates gc_window only (never gc_pins, never the embedder's liveness table), window_stage upserts idempotent per (key, deadline), window_take is the one atomic DELETE ... RETURNING step over the <= boundary with the 42P01 -> empty virgin-take split - nothing of EngineFleetState exists on this engine (ADR-012 §5's pg-lo row: fleet pins live on the kv host, sweep lock no); the one-window-host dormancy posture is recorded in the impl doc - five contract tests mirror the postgres/sqlite suites (schema-only + row shape, stage idempotence/restage, take-exact-set/never-twice, the <= boundary with nanosecond slack, both virgin shapes); env-driven, skipping cleanly server-less, green live against the harness server Verification: cargo test (default + --features pg-lo, server-less and live), clippy -D warnings (default, pg-lo, + the pure builds "pg-lo,mem" and "postgres,pg-lo"), cargo fmt --check, cargo doc --no-deps
This commit is contained in:
1 parent
fb24f30e0a
commit
c8fc519978
2 files changed
+468
-13
No files matched your search
+398
-4
@@ -175,10 +175,16 @@
|
|||||||
//! Nothing of [`crate::gc::EngineFleetState`] exists on this engine
|
//! Nothing of [`crate::gc::EngineFleetState`] exists on this engine
|
||||||
//! (ADR-012 §5's pg-lo row: pin clauses **no** — fleet pins live on
|
//! (ADR-012 §5's pg-lo row: pin clauses **no** — fleet pins live on
|
||||||
//! the kv host; sweep lock **no**). The window-family seam clauses
|
//! the kv host; sweep lock **no**). The window-family seam clauses
|
||||||
//! ([`crate::gc::EngineWindowState`]) are `engine-pg-lo-window`
|
//! ([`crate::gc::EngineWindowState`]) are implemented here (the
|
||||||
//! (separate task); the constructor wiring is
|
//! engine-pg-lo-window task): `ensure_gc_schema` creates `gc_window`
|
||||||
//! `store-pg-lo-construction` — this module ships the engine and its
|
//! only — never `gc_pins`, never the embedder's liveness table — and
|
||||||
//! in-module unit tests only.
|
//! the window clauses are conditional per the one-window-host rule
|
||||||
|
//! (dormant on a dual-SQL instance — kv=sqlite/postgres + large=pg-lo
|
||||||
|
//! stages on the kv engine; active on the kv=mem + large=pg-lo shape).
|
||||||
|
//! Whether the store core drives them at construction is the
|
||||||
|
//! constructor wiring's business (`store-pg-lo-construction`); this
|
||||||
|
//! module ships the clauses, their engine-level contract tests, and
|
||||||
|
//! the rest of the engine (unit tests only).
|
||||||
//!
|
//!
|
||||||
//! Tests need a live server: connection settings ride the environment
|
//! Tests need a live server: connection settings ride the environment
|
||||||
//! (`ALKBLOBS_PG_HOST/PORT/USER/PASSWORD/DB`); tests skip cleanly
|
//! (`ALKBLOBS_PG_HOST/PORT/USER/PASSWORD/DB`); tests skip cleanly
|
||||||
@@ -193,6 +199,7 @@ use std::time::SystemTime;
|
|||||||
use deadpool_postgres::{Object, Pool};
|
use deadpool_postgres::{Object, Pool};
|
||||||
|
|
||||||
use crate::backend::{clamped, Backend, ReadCursor, StagingCursor};
|
use crate::backend::{clamped, Backend, ReadCursor, StagingCursor};
|
||||||
|
use crate::gc::{EngineWindowState, WindowCandidate};
|
||||||
use crate::pg_common::{
|
use crate::pg_common::{
|
||||||
build_pool, err, is_undefined_table, pg_err, qualified_table, quote_identifier, size_u64,
|
build_pool, err, is_undefined_table, pg_err, qualified_table, quote_identifier, size_u64,
|
||||||
time_to_nanos, PgOpts, DEFAULT_SCHEMA,
|
time_to_nanos, PgOpts, DEFAULT_SCHEMA,
|
||||||
@@ -223,6 +230,11 @@ pub(crate) struct PgLoBackend {
|
|||||||
/// construction: `"schema"."lo_entries"` — the quoted-identifier
|
/// construction: `"schema"."lo_entries"` — the quoted-identifier
|
||||||
/// boundary every statement composes through.
|
/// boundary every statement composes through.
|
||||||
table: String,
|
table: String,
|
||||||
|
/// The engine-owned GC window-table reference, quoted once at
|
||||||
|
/// construction: `"schema"."gc_window"` — the window family's row
|
||||||
|
/// home (ADR-012 §5's pg-lo row: the window clauses and the window
|
||||||
|
/// table only).
|
||||||
|
window_table: String,
|
||||||
/// The engine-owned schema name (the test-only catalog probes').
|
/// The engine-owned schema name (the test-only catalog probes').
|
||||||
schema: String,
|
schema: String,
|
||||||
/// Test-only commit-failure injection, standing in for the media
|
/// Test-only commit-failure injection, standing in for the media
|
||||||
@@ -260,6 +272,7 @@ impl PgLoBackend {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
pool,
|
pool,
|
||||||
table: qualified_table(&schema, "lo_entries"),
|
table: qualified_table(&schema, "lo_entries"),
|
||||||
|
window_table: qualified_table(&schema, "gc_window"),
|
||||||
schema,
|
schema,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fail_commit: false,
|
fail_commit: false,
|
||||||
@@ -276,6 +289,7 @@ impl PgLoBackend {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
pool,
|
pool,
|
||||||
table: qualified_table(schema, "lo_entries"),
|
table: qualified_table(schema, "lo_entries"),
|
||||||
|
window_table: qualified_table(schema, "gc_window"),
|
||||||
schema: schema.to_owned(),
|
schema: schema.to_owned(),
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fail_commit: false,
|
fail_commit: false,
|
||||||
@@ -412,6 +426,103 @@ CREATE TABLE IF NOT EXISTS {table} (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The GC window schema's DDL — idempotent, the window-only row (ADR-012
|
||||||
|
/// §5's pg-lo row): `gc_window` keyed by the candidate key, the
|
||||||
|
/// timestamp `BIGINT` nanos. `gc_pins` is never named here (fleet pins
|
||||||
|
/// live on the kv host) and neither is the embedder's liveness table
|
||||||
|
/// (the schema-ownership rule — the sweep reads it and never owns it).
|
||||||
|
fn gc_window_ddl(schema: &str) -> String {
|
||||||
|
let schema_q = quote_identifier(schema);
|
||||||
|
let window = qualified_table(schema, "gc_window");
|
||||||
|
format!(
|
||||||
|
"CREATE SCHEMA IF NOT EXISTS {schema_q};
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS {window} (
|
||||||
|
key bytea PRIMARY KEY,
|
||||||
|
visible_after bigint NOT NULL
|
||||||
|
);
|
||||||
|
"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window family (ADR-012 §5's pg-lo row): the window-table-only
|
||||||
|
/// schema and the durable staged delete window. Same semantics as the
|
||||||
|
/// sqlite and postgres reference implementations — different dialect;
|
||||||
|
/// nothing of the fleet family exists on this engine.
|
||||||
|
impl EngineWindowState for PgLoBackend {
|
||||||
|
/// Create `gc_window (key bytea PRIMARY KEY, visible_after bigint)`
|
||||||
|
/// — this engine's applicability row is the window table only;
|
||||||
|
/// `gc_pins` is never created here (ADR-012 §5: fleet pins live on
|
||||||
|
/// the kv host) and the embedder's liveness table is never created
|
||||||
|
/// in any case (ADR-008's schema-ownership rule). Idempotent, so
|
||||||
|
/// the twice-at-open call form is a no-op. Whether the store core
|
||||||
|
/// calls this at construction is the one-window-host rule's
|
||||||
|
/// business (`store-pg-lo-construction` wires the active host) —
|
||||||
|
/// the clause body exists and is contract-tested per applicability
|
||||||
|
/// regardless of dormancy.
|
||||||
|
async fn ensure_gc_schema(&self) -> Result<(), Error> {
|
||||||
|
let client = self.pool.get().await.map_err(err)?;
|
||||||
|
client
|
||||||
|
.batch_execute(&gc_window_ddl(&self.schema))
|
||||||
|
.await
|
||||||
|
.map_err(pg_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage candidates into the durable window — the upsert
|
||||||
|
/// discipline: a re-stage of the same key at the same deadline is
|
||||||
|
/// idempotent (one row, value unchanged), a re-stage at a new
|
||||||
|
/// deadline updates the row's `visible_after` rather than
|
||||||
|
/// duplicating it (the sqlite/postgres engines' recorded
|
||||||
|
/// semantics).
|
||||||
|
async fn window_stage(&self, candidates: &[WindowCandidate]) -> Result<(), Error> {
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let client = self.pool.get().await.map_err(err)?;
|
||||||
|
let sql = format!(
|
||||||
|
"INSERT INTO {} (key, visible_after) VALUES ($1, $2) \
|
||||||
|
ON CONFLICT (key) DO UPDATE SET visible_after = excluded.visible_after",
|
||||||
|
self.window_table
|
||||||
|
);
|
||||||
|
let stmt = client.prepare_cached(&sql).await.map_err(pg_err)?;
|
||||||
|
for candidate in candidates {
|
||||||
|
let visible_after = time_to_nanos(candidate.visible_after);
|
||||||
|
client
|
||||||
|
.execute(&stmt, &[&candidate.key, &visible_after])
|
||||||
|
.await
|
||||||
|
.map_err(pg_err)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take overdue candidates (`visible_after <= now` — the `<=`
|
||||||
|
/// boundary is the seam doc's pin): one atomic step, the single
|
||||||
|
/// `DELETE … RETURNING` statement selecting and removing the rows
|
||||||
|
/// together, so a row is never handed out twice and an aborted body
|
||||||
|
/// leaves no partial take. A missing table maps to empty (the
|
||||||
|
/// postgres twin's virgin take; the recorded read-only split).
|
||||||
|
async fn window_take(&self, now: SystemTime) -> Result<Vec<Vec<u8>>, Error> {
|
||||||
|
let client = self.pool.get().await.map_err(err)?;
|
||||||
|
let horizon = time_to_nanos(now);
|
||||||
|
let sql = format!(
|
||||||
|
"DELETE FROM {} WHERE visible_after <= $1 RETURNING key",
|
||||||
|
self.window_table
|
||||||
|
);
|
||||||
|
let stmt = match client.prepare_cached(&sql).await {
|
||||||
|
Ok(stmt) => stmt,
|
||||||
|
Err(e) if is_undefined_table(&e) => return Ok(Vec::new()),
|
||||||
|
Err(e) => return Err(err(e)),
|
||||||
|
};
|
||||||
|
let rows = client.query(&stmt, &[&horizon]).await.map_err(pg_err)?;
|
||||||
|
let mut keys = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.try_get::<_, Vec<u8>>(0).map_err(pg_err))
|
||||||
|
.collect::<Result<Vec<Vec<u8>>, _>>()?;
|
||||||
|
keys.sort();
|
||||||
|
Ok(keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `lo_create` — a fresh LO oid inside the caller's tx.
|
/// `lo_create` — a fresh LO oid inside the caller's tx.
|
||||||
async fn lo_create(client: &Object) -> Result<u32, Error> {
|
async fn lo_create(client: &Object) -> Result<u32, Error> {
|
||||||
let stmt = client
|
let stmt = client
|
||||||
@@ -1622,6 +1733,7 @@ mod tests {
|
|||||||
let failing = PgLoBackend {
|
let failing = PgLoBackend {
|
||||||
pool: backend.pool.clone(),
|
pool: backend.pool.clone(),
|
||||||
table: backend.table.clone(),
|
table: backend.table.clone(),
|
||||||
|
window_table: backend.window_table.clone(),
|
||||||
schema: backend.schema.clone(),
|
schema: backend.schema.clone(),
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fail_commit: true,
|
fail_commit: true,
|
||||||
@@ -1702,6 +1814,7 @@ mod tests {
|
|||||||
let failing = PgLoBackend {
|
let failing = PgLoBackend {
|
||||||
pool: backend.pool.clone(),
|
pool: backend.pool.clone(),
|
||||||
table: backend.table.clone(),
|
table: backend.table.clone(),
|
||||||
|
window_table: backend.window_table.clone(),
|
||||||
schema: backend.schema.clone(),
|
schema: backend.schema.clone(),
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fail_commit: true,
|
fail_commit: true,
|
||||||
@@ -2026,6 +2139,287 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The window-family helpers (the postgres engine's suite is the
|
||||||
|
/// reference — same semantics, this dialect's harness). A
|
||||||
|
/// fixed-instant deadline family for the window tests (epoch-based
|
||||||
|
/// signed nanos, so pre-epoch coverage rides the same encoding):
|
||||||
|
/// -10s (past, pre-epoch), +1s (past), +2s (future), +1h (far
|
||||||
|
/// future).
|
||||||
|
fn nanos_to_time(n: i64) -> SystemTime {
|
||||||
|
if n >= 0 {
|
||||||
|
std::time::UNIX_EPOCH + std::time::Duration::from_nanos(n as u64)
|
||||||
|
} else {
|
||||||
|
std::time::UNIX_EPOCH - std::time::Duration::from_nanos((-n) as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at(nanos: i64) -> SystemTime {
|
||||||
|
nanos_to_time(nanos)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A constructed backend with the GC schema ensured — the clause
|
||||||
|
/// tests' fixture (store construction wires the ensure call where
|
||||||
|
/// the one-window-host rule makes the window active; the engine
|
||||||
|
/// constructor itself never creates the GC table).
|
||||||
|
async fn harness_gc(tag: &str) -> Option<Harness> {
|
||||||
|
let backend = harness(tag).await?;
|
||||||
|
backend.ensure_gc_schema().await.unwrap();
|
||||||
|
Some(backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stages(key: &[u8], visible_after: SystemTime) -> WindowCandidate {
|
||||||
|
WindowCandidate {
|
||||||
|
key: key.to_vec(),
|
||||||
|
visible_after,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gc_window rows as the engine stores them — the raw encoding,
|
||||||
|
/// read back through the engine-owned table itself.
|
||||||
|
async fn window_rows(backend: &PgLoBackend) -> Vec<(Vec<u8>, i64)> {
|
||||||
|
let client = backend.pool.get().await.expect("checkout");
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT key, visible_after FROM {} ORDER BY key",
|
||||||
|
backend.window_table
|
||||||
|
);
|
||||||
|
let rows = client.query(sql.as_str(), &[]).await.expect("window rows");
|
||||||
|
let mut out: Vec<(Vec<u8>, i64)> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| (row.get::<_, Vec<u8>>(0), row.get::<_, i64>(1)))
|
||||||
|
.collect();
|
||||||
|
out.sort();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine-owned schema's full table list — the schema-ownership
|
||||||
|
/// test's instrument (exactly the engine's tables, nothing more).
|
||||||
|
async fn schema_tables(backend: &PgLoBackend) -> Vec<String> {
|
||||||
|
let client = backend.pool.get().await.expect("checkout");
|
||||||
|
let rows = client
|
||||||
|
.query(
|
||||||
|
"SELECT table_name::text FROM information_schema.tables \
|
||||||
|
WHERE table_schema = $1 ORDER BY table_name",
|
||||||
|
&[&backend.schema],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("schema tables");
|
||||||
|
rows.iter().map(|row| row.get::<_, String>(0)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schema contract (the pg-lo row): `ensure_gc_schema` creates
|
||||||
|
/// `gc_window` only — `gc_pins` is never created by pg-lo (fleet
|
||||||
|
/// pins live on the kv host, ADR-012 §5) and the embedder's
|
||||||
|
/// liveness table is never created in any case (the
|
||||||
|
/// schema-ownership rule: exactly the engine's two tables exist) —
|
||||||
|
/// the call is idempotent, and the gc_window row shape is
|
||||||
|
/// bytea/bigint.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gc_schema_creates_window_table_only() {
|
||||||
|
let Some(backend) = harness("window-schema").await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
backend.ensure_gc_schema().await.unwrap();
|
||||||
|
backend.ensure_gc_schema().await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
schema_tables(&backend).await,
|
||||||
|
vec!["gc_window", "lo_entries"],
|
||||||
|
"the engine's two tables exactly — no gc_pins, no embedder liveness table"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!backend.table_exists("gc_pins").await.unwrap(),
|
||||||
|
"gc_pins is never created by pg-lo (fleet pins live on the kv host)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = backend.pool.get().await.unwrap();
|
||||||
|
let columns: Vec<String> = client
|
||||||
|
.query(
|
||||||
|
"SELECT data_type::text FROM information_schema.columns \
|
||||||
|
WHERE table_schema = $1 AND table_name = 'gc_window' \
|
||||||
|
ORDER BY ordinal_position",
|
||||||
|
&[&backend.schema],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.get::<_, String>(0))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
columns,
|
||||||
|
["bytea", "bigint"],
|
||||||
|
"the gc_window row shape: key/visible_after"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Staging contract: idempotent per `(key, deadline)` — the same
|
||||||
|
/// candidate staged twice leaves exactly one row — and a re-stage
|
||||||
|
/// at a new deadline updates the row (upsert on the key) rather
|
||||||
|
/// than duplicating it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn window_stage_is_idempotent_and_restage_updates_the_deadline() {
|
||||||
|
let Some(backend) = harness_gc("window-stage").await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let key = b"\x01window-key".to_vec();
|
||||||
|
let deadline = at(1_000_000_000);
|
||||||
|
|
||||||
|
backend
|
||||||
|
.window_stage(&[stages(&key, deadline)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
backend
|
||||||
|
.window_stage(&[stages(&key, deadline)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
window_rows(&backend).await,
|
||||||
|
vec![(key.clone(), 1_000_000_000)],
|
||||||
|
"re-stage at the same deadline is one idempotent row"
|
||||||
|
);
|
||||||
|
|
||||||
|
backend
|
||||||
|
.window_stage(&[stages(&key, at(2_000_000_000))])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
window_rows(&backend).await,
|
||||||
|
vec![(key.clone(), 2_000_000_000)],
|
||||||
|
"a re-stage at a new deadline updates the row"
|
||||||
|
);
|
||||||
|
|
||||||
|
let other = b"\x01window-other".to_vec();
|
||||||
|
backend
|
||||||
|
.window_stage(&[stages(&other, deadline)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
window_rows(&backend).await.len(),
|
||||||
|
2,
|
||||||
|
"distinct keys are distinct rows"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take contract: exactly the overdue set (`visible_after <= now`),
|
||||||
|
/// removed atomically — a second take returns nothing — with future
|
||||||
|
/// rows left in place, and the key round-trips through the raw
|
||||||
|
/// encoding (signed nanos, so a pre-epoch deadline rides along).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn window_take_returns_the_overdue_set_and_leaves_future_rows() {
|
||||||
|
let Some(backend) = harness_gc("window-take").await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let past_pre_epoch = b"\x01win-past-pre-epoch".to_vec();
|
||||||
|
let past = b"\x01win-past".to_vec();
|
||||||
|
let future = b"\x01win-future".to_vec();
|
||||||
|
let far_future = b"\x01win-far-future".to_vec();
|
||||||
|
|
||||||
|
backend
|
||||||
|
.window_stage(&[
|
||||||
|
stages(&past_pre_epoch, at(-1_000_000_000)),
|
||||||
|
stages(&past, at(1_000_000_000)),
|
||||||
|
stages(&future, at(2_000_000_000)),
|
||||||
|
stages(&far_future, at(3_600_000_000_000)),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let taken = backend.window_take(at(1_000_000_001)).await.unwrap();
|
||||||
|
let mut expected = vec![past_pre_epoch.clone(), past.clone()];
|
||||||
|
expected.sort();
|
||||||
|
assert_eq!(taken, expected, "exactly the overdue set");
|
||||||
|
assert_eq!(
|
||||||
|
backend.window_take(at(1_000_000_001)).await.unwrap(),
|
||||||
|
Vec::<Vec<u8>>::new(),
|
||||||
|
"the rows left the table: never handed out twice"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
window_rows(&backend).await,
|
||||||
|
vec![
|
||||||
|
(far_future.clone(), 3_600_000_000_000),
|
||||||
|
(future.clone(), 2_000_000_000)
|
||||||
|
],
|
||||||
|
"future rows left in place"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The take boundary is `visible_after == now` (the seam doc's
|
||||||
|
/// `<=`): a candidate visible exactly at the horizon is overdue;
|
||||||
|
/// one nanosecond later it is not.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn window_take_boundary_visible_after_equal_now_is_overdue() {
|
||||||
|
let Some(backend) = harness_gc("window-boundary").await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let equal = b"\x01win-equal-boundary".to_vec();
|
||||||
|
let just_later = b"\x01win-nanosecond-later".to_vec();
|
||||||
|
|
||||||
|
backend
|
||||||
|
.window_stage(&[
|
||||||
|
stages(&equal, at(1_500_000_000)),
|
||||||
|
stages(&just_later, at(1_500_000_001)),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
backend.window_take(at(1_500_000_000)).await.unwrap(),
|
||||||
|
vec![equal.clone()],
|
||||||
|
"visible_after == now is overdue (the `<=` boundary)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
backend.window_take(at(1_500_000_000)).await.unwrap(),
|
||||||
|
Vec::<Vec<u8>>::new(),
|
||||||
|
"the not-yet-overdue row stays (one nanosecond of slack)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
backend.window_take(at(1_500_000_001)).await.unwrap(),
|
||||||
|
vec![just_later.clone()],
|
||||||
|
"the horizon itself moved, so the row is now overdue"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Virgin-store shapes (the recorded read-only split): on a
|
||||||
|
/// schema-less database `window_take` maps a missing table to empty
|
||||||
|
/// (the read-tolerance parity with the postgres twin), while
|
||||||
|
/// `window_stage` is a write and surfaces the engine error without
|
||||||
|
/// silently creating schema. A constructed backend with an empty
|
||||||
|
/// window table also takes empty.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn window_virgin_store_shapes() {
|
||||||
|
let Some(tableless) = harness_schemaless("window-virgin").await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let key = b"\x01virgin-window-key".to_vec();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
tableless.window_take(at(1_000_000_000)).await.unwrap(),
|
||||||
|
Vec::<Vec<u8>>::new(),
|
||||||
|
"schema-less take is a no-op read"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tableless.window_take(at(1_000_000_000)).await.unwrap(),
|
||||||
|
Vec::<Vec<u8>>::new(),
|
||||||
|
"still empty on a repeat"
|
||||||
|
);
|
||||||
|
match tableless.window_stage(&[stages(&key, at(1))]).await {
|
||||||
|
Err(Error::Io(message)) => {
|
||||||
|
assert!(
|
||||||
|
message.contains("does not exist"),
|
||||||
|
"the write surfaces the engine error, not a silent schema create: {message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
other => panic!("schema-less stage is the engine error, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(backend) = harness_gc("window-empty").await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
backend.window_take(SystemTime::now()).await.unwrap(),
|
||||||
|
Vec::<Vec<u8>>::new(),
|
||||||
|
"empty window table takes empty"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Zero LO residue on the shared harness database (the C7 gate's
|
/// Zero LO residue on the shared harness database (the C7 gate's
|
||||||
/// permanent probe): every pg-lo test runs on its own dedicated
|
/// permanent probe): every pg-lo test runs on its own dedicated
|
||||||
/// database and never opens a pool against `blobs`, so the shared
|
/// database and never opens a pool against `blobs`, so the shared
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: engine-pg-lo-window
|
id: engine-pg-lo-window
|
||||||
name: pg-lo engine-state window family — the conditional window host
|
name: pg-lo engine-state window family — the conditional window host
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: [engine-pg-lo]
|
depends_on: [engine-pg-lo]
|
||||||
scope: narrow
|
scope: narrow
|
||||||
risk: low
|
risk: low
|
||||||
@@ -70,22 +70,22 @@ engine-level contract tests.
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] The pg-lo backend implements `EngineWindowState` — the window
|
- [x] The pg-lo backend implements `EngineWindowState` — the window
|
||||||
family only; no pin-clause or sweep-lock body exists anywhere in
|
family only; no pin-clause or sweep-lock body exists anywhere in
|
||||||
the pg-lo engine (nothing of `EngineFleetState`)
|
the pg-lo engine (nothing of `EngineFleetState`)
|
||||||
- [ ] `ensure_gc_schema` creates `gc_window` only; `gc_pins` is never
|
- [x] `ensure_gc_schema` creates `gc_window` only; `gc_pins` is never
|
||||||
created by pg-lo (contract-tested); the embedder's liveness
|
created by pg-lo (contract-tested); the embedder's liveness
|
||||||
table is never created
|
table is never created
|
||||||
- [ ] `window_stage` idempotent per `(key, deadline)`; a re-stage at a
|
- [x] `window_stage` idempotent per `(key, deadline)`; a re-stage at a
|
||||||
new deadline updates the row
|
new deadline updates the row
|
||||||
- [ ] `window_take` returns exactly the overdue set (`<= now`
|
- [x] `window_take` returns exactly the overdue set (`<= now`
|
||||||
boundary), removes it atomically, leaves future rows; empty on
|
boundary), removes it atomically, leaves future rows; empty on
|
||||||
none/virgin
|
none/virgin
|
||||||
- [ ] Contract tests env-driven (`ALKBLOBS_PG_*`), skipping cleanly
|
- [x] Contract tests env-driven (`ALKBLOBS_PG_*`), skipping cleanly
|
||||||
server-less; green against the harness server (`pglo-poc`,
|
server-less; green against the harness server (`pglo-poc`,
|
||||||
:15432, db `blobs`, `postgres`/`poc`) at acceptance; the janitor
|
:15432, db `blobs`, `postgres`/`poc`) at acceptance; the janitor
|
||||||
discipline rides from the start (amendment A2)
|
discipline rides from the start (amendment A2)
|
||||||
- [ ] `cargo test` (default) green; `cargo test --features pg-lo`
|
- [x] `cargo test` (default) green; `cargo test --features pg-lo`
|
||||||
green server-less (skipping); `cargo clippy --all-targets -- -D
|
green server-less (skipping); `cargo clippy --all-targets -- -D
|
||||||
warnings` and `cargo fmt --check` green (default + `--features
|
warnings` and `cargo fmt --check` green (default + `--features
|
||||||
pg-lo`); the pg-lo pure builds clean (`--no-default-features
|
pg-lo`); the pg-lo pure builds clean (`--no-default-features
|
||||||
@@ -110,8 +110,69 @@ engine-level contract tests.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
> To be filled by implementation agent
|
> Implemented in `src/pg_lo.rs` only. `PgLoBackend` gained a
|
||||||
|
> `window_table` field (the quoted `"schema"."gc_window"` reference,
|
||||||
|
> quoted-identifier boundary, constructed in `new` and `new_schemaless`
|
||||||
|
> — the two `#[cfg(test)]` failure-injection struct literals picked up
|
||||||
|
> the new field mechanically) and a `gc_window_ddl` body beside
|
||||||
|
> `bootstrap_ddl`. The `EngineWindowState` impl mirrors the postgres
|
||||||
|
> reference implementation statement-for-statement (same upsert / same
|
||||||
|
> single `DELETE … RETURNING` take, this engine's `pg_err`/`err`
|
||||||
|
> mapping and `time_to_nanos` from the shared `pg_common` home); the
|
||||||
|
> per-engine reverse decoder (`nanos_to_time`) lives in the test
|
||||||
|
> module like the postgres suite's. Recorded design points:
|
||||||
|
>
|
||||||
|
> - **The `gc_window`-only schema row** — `ensure_gc_schema` runs
|
||||||
|
> `CREATE SCHEMA IF NOT EXISTS` + `CREATE TABLE gc_window (key bytea
|
||||||
|
> PRIMARY KEY, visible_after bigint NOT NULL)` only; the schema's DDL
|
||||||
|
> names no `gc_pins` and no liveness table (ADR-010 §2's
|
||||||
|
> schema-ownership rule). The engine's constructor continues to
|
||||||
|
> create only `lo_entries` — the GC table is created by the clause
|
||||||
|
> alone, and whether the store core calls it at construction is the
|
||||||
|
> one-window-host rule's business (`store-pg-lo-construction` wires
|
||||||
|
> the active host) — untouched this task.
|
||||||
|
> - **Nothing of `EngineFleetState`** — no pin clause, no sweep-lock
|
||||||
|
> body anywhere in the module (grep-clean; only doc mentions carry
|
||||||
|
> the names), and `gc_pins`'s absence is contract-tested
|
||||||
|
> (`gc_schema_creates_window_table_only` asserts the engine-owned
|
||||||
|
> schema holds exactly `["gc_window", "lo_entries"]`).
|
||||||
|
> - **Dormancy posture recorded** — the impl doc states the
|
||||||
|
> one-window-host rule reading: clauses dormant on a dual-SQL
|
||||||
|
> instance (kv=sqlite/postgres + large=pg-lo stages on the kv
|
||||||
|
> engine), active on the kv=mem + large=pg-lo shape; the clause body
|
||||||
|
> exists and is contract-tested per applicability regardless.
|
||||||
|
>
|
||||||
|
> No store-core changes (`store.rs` untouched — the sweep's durable
|
||||||
|
> form drives the seam through `KvEngine`, which needs no new arm for
|
||||||
|
> this task).
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> To be filled on completion
|
> Completed 2026-10-12 (wave 5). `src/pg_lo.rs` implements
|
||||||
|
> `EngineWindowState` — the window family only (ADR-012 §5's pg-lo
|
||||||
|
> row): `ensure_gc_schema` creates `gc_window (key bytea PRIMARY KEY,
|
||||||
|
> visible_after bigint)` in the engine's own schema and never
|
||||||
|
> `gc_pins` or the embedder's liveness table (contract-tested — the
|
||||||
|
> schema holds exactly `gc_window` + `lo_entries`); `window_stage`
|
||||||
|
> upserts idempotently per `(key, deadline)` with same-deadline
|
||||||
|
> idempotence and new-deadline update; `window_take(now)` is the one
|
||||||
|
> atomic `DELETE … RETURNING` step over `visible_after <= now` (the
|
||||||
|
> `<=` boundary pinned), keys sorted, with the 42P01 → empty
|
||||||
|
> virgin-take read tolerance and stage-less-write surfacing. Timestamps
|
||||||
|
> ride the crate-wide i64-nanos encoding (`pg_common::time_to_nanos`;
|
||||||
|
> the reverse decoder is the test module's per-engine body). Five
|
||||||
|
> contract tests mirror the postgres/sqlite suites: schema-only +
|
||||||
|
> row-shape + idempotence-of-call, stage idempotence/restage-at-new-
|
||||||
|
> deadline, take-exact-overdue-set/never-twice/future-left, the
|
||||||
|
> `<=` boundary with nanosecond slack, and both virgin shapes
|
||||||
|
> (schema-less take empty twice, schema-less stage surfaces the engine
|
||||||
|
> error, empty-window take). Env-driven (`ALKBLOBS_PG_*`), skipping
|
||||||
|
> cleanly server-less, green live against the harness server (23/23
|
||||||
|
> in-module tests, per-test dedicated databases + `DatabaseJanitor`)
|
||||||
|
> with zero LO residue on the shared db gate green. Verified: `cargo
|
||||||
|
> test` (69/19/3 ok; 92/19/3 with `--features pg-lo` server-less),
|
||||||
|
> clippy `-D warnings` green (default, `--features pg-lo`,
|
||||||
|
> `--no-default-features --features "pg-lo,mem"`, `--no-default-
|
||||||
|
> features --features "postgres,pg-lo"`), `cargo fmt --check` green,
|
||||||
|
> `cargo doc --no-deps` clean. Remaining: constructor wiring is
|
||||||
|
> `store-pg-lo-construction`.
|
||||||
Reference in new issue
Block a user