Postgres engine: streams — StreamHandle (auto-commit publishes + best-effort pg_notify wake, ASC reads with the extent guard, monotone offsets, pool-connection trim) and the durable subscribe receiver (async bridge, wake-driven re-drains, reconnect gap-heal, shutdown-only terminal close, stateless idle wake runtime for the sync save) (task pg-engine-streams)

This commit is contained in:
glm-5.3-flash committed 2026-10-09 08:05:34 +00:00
1 parent 8f5c2add5e
commit cb067bb4be
7 files changed
+2254 -45

No files matched your search

+1
View File
@@ -39,6 +39,7 @@ mod resolution;
mod schema;
mod seam;
mod store;
mod stream;
mod tx;
pub use opts::{DEFAULT_MAX_SIZE, PgOpts};
+15 -2
View File
@@ -261,12 +261,22 @@ impl Store for PgStore {
fn stream<'a>(
&'a self,
_name: &str,
name: &str,
) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::StreamHandle>>> {
if let Err(e) = alkstore::validate_shared_name(name) {
return Box::pin(async move { Err(e) });
}
if let Err(e) = self.closed_check() {
return Box::pin(async move { Err(e) });
}
Box::pin(async { Err(stub("stream wiring lands with the streams task")) })
let handle = crate::stream::PgStreamHandle::new(
name.to_string(),
self.pool.clone(),
self.schema.clone(),
self.forwarder.clone(),
self.closed.clone(),
);
Box::pin(async move { Ok(Box::new(handle) as Box<dyn alkstore::StreamHandle>) })
}
fn queue<'a>(
@@ -348,5 +358,8 @@ mod open_tests;
#[cfg(test)]
mod notify_tests;
#[cfg(test)]
mod stream_tests;
#[cfg(test)]
mod tx_tests;
+7 -8
View File
@@ -659,11 +659,10 @@ async fn store_trait_methods_are_wiring_stubs() {
.notify("stub_ch_probe", serde_json::json!("x"))
.await
.unwrap();
let err = match store.stream("s").await {
Err(e) => e,
Ok(_) => panic!("stream must be a stub"),
};
assert!(matches!(err, alkstore::Error::Database(_)));
// The stream constructor is wired (the streams task's): returns a
// named handle — full behavior the stream tests' scope.
let handle = store.stream("s").await.unwrap();
assert_eq!(handle.name(), "s");
let err = stub_err!(store.queue("q", alkstore::QueueOpts::default()).await);
assert!(matches!(err, alkstore::Error::Database(_)));
let err = stub_err!(store.outbox("o").await);
@@ -692,8 +691,8 @@ async fn store_trait_methods_are_wiring_stubs() {
// The remaining stub messages name their landing task (the
// wave-3 posture's "… wiring lands with the … task" shape) — in
// the source chain (`Database`'s Display is opaque; ADR-008 §5's
// detail-carriage). `stream` sampled as the representative.
let err = stub_err!(store.stream("ch").await);
// detail-carriage). `queue` sampled as the representative.
let err = stub_err!(store.queue("ch", alkstore::QueueOpts::default()).await);
match err {
alkstore::Error::Database(source) => {
let msg = source.to_string();
@@ -702,7 +701,7 @@ async fn store_trait_methods_are_wiring_stubs() {
"stub errors name their landing task, got: {msg}"
);
}
other => panic!("stream stub must be Database, got {other:?}"),
other => panic!("queue stub must be Database, got {other:?}"),
}
drop(store);
File diff suppressed because it is too large. Load diff
+856
View File
@@ -0,0 +1,856 @@
//! The stream mechanism's Postgres arm (`pg-engine-streams`; ADR-015;
//! ADR-019 §1/§6; ADR-021 §5; ADR-023 §2):
//!
//! - `publish` / `publish_with_key` — the auto-commit append paths:
//! pool checkout, one parameterized `INSERT INTO … RETURNING id`
//! (`id BIGSERIAL` is the assigned offset — engine-assigned,
//! monotone per stream, immutable; the nullable `"key"` column is
//! carried metadata with the non-empty-key rule: an empty `Some` key
//! is `InvalidName`, matching the tx path), `encode_payload` at the
//! seam with the typed `Codec` error `?`-ed, return to pool. Then
//! **wake**: a `SELECT pg_notify($1, '')` on the stream's wake
//! channel — the mechanism-name-is-the-channel realization (the
//! plan's Wave 4 section) — as a *separate statement* on the same
//! pooled connection (auto-commit: its own implicit transaction;
//! the wake is best-effort, not commit-atomic with the insert —
//! the no-replay hole covers any gap between the two statements;
//! **the durable row is the truth** and every read path re-reads
//! from it). A failed wake is logged and swallowed — the durable
//! row already landed, and wake feeds must never fail a publish.
//! - `read_since` / `read_from_consumer` — pool reads, `ORDER BY id
//! ASC` (global FIFO per stream, ADR-015 §4), decoded into
//! [`StreamEvent`] via the core constructor's `from_row` (the
//! decode owner lives here, shared with the tx path). The extent
//! guard (ADR-023 §2) short-circuits `limit <= 0` to the empty
//! `Vec` at trait-impl entry, before any round trip — pg's `LIMIT`
//! with a negative is a server error, which the guard makes
//! unreachable. `read_from_consumer` anchors at the consumer's
//! stored checkpoint (absent consumer = 0, ADR-019 §1).
//! - `save_offset` / `get_offset` — the monotone upsert (a save below
//! the stored checkpoint is a silent no-op — one owner of the
//! monotonicity rule, the `GREATEST`-clamped + `WHERE`-guarded
//! upsert `save_offset_tx` also rides) and the checkpoint read.
//! Both save forms' `Err` arm carries `Database` only (ADR-021 §5).
//! - `trim_to(horizon)` — `DELETE … WHERE id <= horizon` on a pool
//! connection (wakes nothing — ADR-015 §5's posture; the boundary
//! argument is total per ADR-023 §2: a negative horizon deletes
//! nothing, idempotently). Surviving events keep their offsets
//! (gaps legal, never renumbered).
//! - `subscribe(consumer)` — the durable receiver, the structural
//! twin of the SQLite engine's bridge with the pg deltas (the
//! wake substrate is the forwarder's fanout, natively async — no
//! blocking bridge thread):
//! - **Attach-before-read**: the forwarder channel subscription is
//! taken *before* the initial cursor read, so a commit cannot
//! fall between the attach read and the wake wait (a wake for a
//! commit already re-read sits queued; a re-drain that sees
//! nothing new costs an empty pass, never a missed event).
//! - Initial cursor read of the consumer's *stored* offset; page-
//! drain to the tail (`id ASC`); park on the wake feed (this
//! stream's wake channel plus the reserved reconnect-wake —
//! both trigger re-drains); re-drain on wake. Wakes are
//! best-effort hints (ADR-006): overtrigger on purpose, coalesc-
//! ed by the re-read from the in-memory cursor.
//! - **Close semantics (ADR-021 §5's pg arm)**: the receiver stays
//! open across forwarder reconnects — the synthetic
//! reconnect-wake arrives on the wake feed and simply triggers a
//! re-drain (which *is* the gap healing: the no-replay hole's
//! missed wakes are covered by the re-drain reading the durable
//! rows); closes only at engine shutdown (`recv() -> None`,
//! terminal).
//! - **Position semantics**: `position` = the last-*yielded* event's
//! offset (0 before any yield ⇒ `save_offset()` before any yield
//! is a no-op, ADR-021 §5); the bridge's internal cursor advances
//! on drained-from-storage events — separate from the position —
//! so a stalled consumer's `save_offset()` never checkpoints
//! unobserved events. Backpressure: a bounded tokio channel and
//! backpressure-aware `send().await` — a stalled consumer parks
//! the bridge task and delivery resumes exactly where it stopped.
//! - The receiver's `read_since` is offset-anchored and extent-
//! guarded like the direct form; its sync `save_offset()` sends
//! the last-yielded offset through the same monotone op (ADR-019
//! §6). The trait's sync signature on this natively-async engine
//! is bridged by a dedicated wake runtime (see
//! [`WAKE_RUNTIME`]) — the honest sync→async seam, never
//! `spawn_blocking`-reentrancy tricks.
//! - `recv()`'s `Err` carries `Database` only (ADR-021 §5 — non-
//! `Database` read errors are remapped with the original as
//! source, the SQLite `database_only` helper's shape); `try_recv`
//! arms mirror the wake receiver: idle `Ok(None)`, `Err(Closed)`
//! after the shutdown-close disconnect.
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use deadpool_postgres::Pool;
use serde_json::Value;
use tokio::runtime::{Handle, Runtime, RuntimeFlavor};
use alkstore::{
BoxedFuture, Error, EventReceiver, Result, StreamEvent, StreamHandle, validate_local_name,
validate_shared_name,
};
use crate::forwarder::{Forwarder, RawNotification};
use crate::schema::{QualifiedTable, tables};
use crate::seam::{database_error, pg_error, pool_error};
use crate::tx::encode_payload_bytes;
/// How many events a wake-driven re-read fetches per page while
/// draining to the tail.
const SUBSCRIBE_PAGE: i64 = 256;
/// The bridge's tokio-channel capacity — the smoothing buffer between
/// the bridge task's re-read loop and the consumer.
const EVENT_CHANNEL_CAPACITY: usize = 16;
/// The per-process idle wake runtime the receiver's **sync**
/// `save_offset` (and any other sync receiver op needing async
/// storage work) drives its round trip through.
///
/// Why this exists: the trait's `save_offset(&mut self)` is synchronous,
/// but on this engine the checkpoint save is an async pool round trip —
/// there is no blocking writer slot to lease (the pg posture is no
/// `spawn_blocking` seam and pool clients are runtime-tied). The bridge
/// options were probed exhaustively:
///
/// - `Handle::current().block_on` from within an async context — panics
/// ("cannot start a runtime from within a runtime") on both flavors;
/// - `Handle::block_on` from a *foreign* runtime's worker/blocking
/// threads — also panics (it blocks the calling thread while that
/// thread belongs to another runtime's driver, the same enter guard);
/// - `Runtime::new().block_on` nested inside a worker thread — panics
/// likewise, and builds a fresh runtime per call (unbounded cost).
///
/// The survivor is a dedicated, runtime-*independent* driver: a fresh
/// current-thread runtime per blocked save fails the budget rule, so
/// instead a single lazily-built multi-thread runtime is stored
/// process-wide ([`WAKE_RUNTIME`]); the save calls
/// `wake_runtime().block_on(save_future)` **from a spawned std
/// thread** — the one context that is never inside any runtime (a
/// task's stack runs *on* a worker thread; a fresh thread starts
/// outside every enter guard). Concurrent saves park on their own
/// threads and multiplex through one shared runtime; a save issued
/// from a foreign runtime's `spawn_blocking` or from a plain thread
/// skips the spawn and blocks directly.
///
/// The runtime carries **no engine state** — it is purely the sync
/// call's exec context for its one `.await`; tokio-postgres clients
/// bind to whatever runtime drives *them*, and each save checks out
/// its own pool connection inside the driven future, on the runtime
/// that will drive its connection's I/O. Idle cost: one parked
/// runtime (one blocked driver thread, ~nothing else).
fn wake_runtime() -> &'static Runtime {
static WAKE_RUNTIME: OnceLock<Runtime> = OnceLock::new();
WAKE_RUNTIME.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.expect("the wake runtime builds (a fixed 1-worker multi-thread runtime)")
})
}
/// Drive `fut` to completion from a **synchronous** context, on the
/// dedicated idle wake runtime
/// (see [`wake_runtime`] for the full rationale).
///
/// The calling context decides the driving posture:
///
/// - plain thread / foreign-runtime blocking thread → `block_on`
/// directly on the calling thread (already outside every runtime —
/// no spawn, no extra thread);
/// - inside a multi-thread runtime's worker (the async consumer calling
/// `save_offset().unwrap()` in its task) → `block_in_place` frees
/// this worker, then `block_on` on the dedicated runtime (the
/// probed-safe shape — a runtime's `block_on` is legal from a
/// different runtime's `block_in_place` context);
/// - inside a current-thread runtime's worker → a spawned std thread
/// does the blocking (the only escape from a current-thread worker —
/// `block_in_place` panics there), the caller joins it.
fn drive_sync<F>(fut: F) -> Result<F::Output>
where
F: std::future::Future + Send,
F::Output: Send,
{
let in_runtime = Handle::try_current().is_ok();
if !in_runtime {
return Ok(wake_runtime().block_on(fut));
}
let handle = Handle::current();
match handle.runtime_flavor() {
RuntimeFlavor::MultiThread => {
tokio::task::block_in_place(move || Ok(wake_runtime().block_on(fut)))
}
RuntimeFlavor::CurrentThread => {
let boxed: std::pin::Pin<Box<dyn std::future::Future<Output = F::Output> + Send>> =
Box::pin(fut);
let joined = std::thread::scope(|scope| {
let inner = scope.spawn(move || wake_runtime().block_on(boxed));
inner
.join()
.map_err(|_| database_error("the receiver save's driver thread panicked"))
})?;
Ok(joined)
}
flavor => Err(database_error(format!(
"the receiver save cannot bridge from runtime flavor {flavor:?}"
))),
}
}
/// The stream-scoped handle (see the module docs).
pub(crate) struct PgStreamHandle {
name: String,
pool: Pool,
schema: String,
forwarder: Arc<Forwarder>,
closed: Arc<AtomicBool>,
}
impl std::fmt::Debug for PgStreamHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PgStreamHandle")
.field("name", &self.name)
.finish_non_exhaustive()
}
}
impl PgStreamHandle {
pub(crate) fn new(
name: String,
pool: Pool,
schema: String,
forwarder: Arc<Forwarder>,
closed: Arc<AtomicBool>,
) -> Self {
Self {
name,
pool,
schema,
forwarder,
closed,
}
}
}
/// Qualified table reference for this engine-owned schema.
fn table(schema: &str, name: &str) -> String {
QualifiedTable { schema, name }.to_string()
}
/// The auto-commit publish path (see the module docs). Stream-name
/// validation fires at the entry point, before any round trip and
/// before the payload is touched (ADR-008 §4); a present key must be
/// non-empty (the tx path's rule).
fn publish_with_key(
pool: Pool,
schema: String,
closed: Arc<AtomicBool>,
stream: String,
key: Option<String>,
payload: Value,
) -> BoxedFuture<'static, Result<i64>> {
if let Err(e) = validate_shared_name(&stream) {
return Box::pin(async move { Err(e) });
}
let key = match key {
Some(k) if k.is_empty() || k.trim().is_empty() => {
return Box::pin(async move { Err(Error::InvalidName { name: k }) });
}
other => other,
};
Box::pin(async move {
if closed.load(Ordering::Acquire) {
return Err(database_error("the store is closed"));
}
let bytes = encode_payload_bytes(&payload)?;
let created_at = crate::resolution::now_unix();
let conn = pool.get().await.map_err(pool_error)?;
let events = table(&schema, tables::EVENTS);
let row = conn
.query_one(
&format!(
"INSERT INTO {events} (stream, \"key\", payload, created_at)
VALUES ($1, $2, $3, $4)
RETURNING id"
),
&[&stream, &key, &bytes, &created_at],
)
.await
.map_err(pg_error)?;
let offset: i64 = row.get(0);
// Then wake: pg_notify on the stream's wake channel — a
// separate statement (its own implicit tx on this
// auto-commit connection; best-effort, not commit-atomic
// with the insert — the no-replay hole covers a gap between
// them; the durable row is the truth and every reader
// re-reads from it). A wake failure is logged and swallowed:
// the durable row landed; no wake fee may ever fail (or
// stall) a publish.
if let Err(wake_err) = conn.query_one("SELECT pg_notify($1, '')", &[&stream]).await {
eprintln!(
"alkstore-postgres: stream publish wake failed for {stream:?} \
(the durable row landed; readers re-drain on their next hint): {wake_err}"
);
}
Ok(offset)
})
}
/// One decoded page from the pool: `id > from`, ASC, up to `limit`
/// (the extent guard is the caller's — this helper is contract-blind
/// to it). The stream decode owner: shared by the auto-commit reads
/// (this module) and the tx reads (`tx.rs`) — the `stream` field
/// mapping and the `Codec` posture guard live once.
pub(crate) async fn stream_read_page(
pool: Pool,
schema: String,
stream: String,
from: i64,
limit: i64,
) -> Result<Vec<StreamEvent>> {
let conn = pool.get().await.map_err(pool_error)?;
let events = table(&schema, tables::EVENTS);
let rows = conn
.query(
&format!(
"SELECT id, \"key\", payload, created_at
FROM {events}
WHERE stream = $1 AND id > $2
ORDER BY id ASC
LIMIT $3"
),
&[&stream, &from, &limit],
)
.await
.map_err(pg_error)?;
stream_events_from_rows(&rows, stream)
}
/// Decode a read page into the contract's [`StreamEvent`] values (the
/// core constructor's `from_row`). Malformed rows map to `Error::Codec`
/// (the decode-side posture guard, ADR-021 §2). One owner: the tx
/// module imports this; no forked decoders.
pub(crate) fn stream_events_from_rows(
rows: &[tokio_postgres::Row],
stream: String,
) -> Result<Vec<StreamEvent>> {
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let key: Option<String> = row
.try_get(1)
.map_err(|e| Error::Codec(format!("stream row key must be text: {e}")))?;
let payload: Option<Vec<u8>> = row
.try_get(2)
.map_err(|e| Error::Codec(format!("stream row payload must be bytea: {e}")))?;
out.push(StreamEvent::from_row(
row.get(0),
stream.clone(),
key,
payload.unwrap_or_default(),
row.get(3),
));
}
Ok(out)
}
/// The checkpoint read helper (absent consumer = 0, ADR-019 §1).
async fn stream_get_offset(pool: &Pool, schema: &str, stream: &str, consumer: &str) -> Result<i64> {
let conn = pool.get().await.map_err(pool_error)?;
let offsets = table(schema, tables::OFFSETS);
let row = conn
.query_opt(
&format!("SELECT \"offset\" FROM {offsets} WHERE stream = $1 AND consumer = $2"),
&[&stream, &consumer],
)
.await
.map_err(pg_error)?;
Ok(row.map(|r| r.get(0)).unwrap_or(0))
}
/// The monotone checkpoint save (one owner of the monotonicity rule —
/// the same GREATEST-clamped, WHERE-guarded upsert `save_offset_tx`
/// rides; a save at-or-below the stored checkpoint updates nothing,
/// ADR-019 §6). First save seeds the checkpoint, clamped at 0 (the
/// offsets table's non-negative domain CHECK would abort a negative
/// seed; the clamp preserves both the domain and the monotone rule —
/// a negative save at-or-below the stored checkpoint is the same
/// silent no-op a 0-save is).
async fn stream_save_offset(
pool: &Pool,
schema: &str,
stream: &str,
consumer: &str,
offset: i64,
) -> Result<()> {
let conn = pool.get().await.map_err(pool_error)?;
let offsets = table(schema, tables::OFFSETS);
conn.execute(
&format!(
"INSERT INTO {offsets} AS o (stream, consumer, \"offset\")
VALUES ($1, $2, GREATEST($3::bigint, 0))
ON CONFLICT (stream, consumer) DO UPDATE
SET \"offset\" = EXCLUDED.\"offset\"
WHERE EXCLUDED.\"offset\" > o.\"offset\""
),
&[&stream, &consumer, &offset],
)
.await
.map_err(pg_error)?;
Ok(())
}
impl StreamHandle for PgStreamHandle {
fn name(&self) -> &str {
&self.name
}
fn publish<'a>(&'a self, payload: Value) -> BoxedFuture<'a, Result<i64>> {
publish_with_key(
self.pool.clone(),
self.schema.clone(),
self.closed.clone(),
self.name.clone(),
None,
payload,
)
}
fn publish_with_key<'a>(
&'a self,
key: Option<String>,
payload: Value,
) -> BoxedFuture<'a, Result<i64>> {
publish_with_key(
self.pool.clone(),
self.schema.clone(),
self.closed.clone(),
self.name.clone(),
key,
payload,
)
}
fn read_since<'a>(
&'a self,
offset: i64,
limit: i64,
) -> BoxedFuture<'a, Result<Vec<StreamEvent>>> {
if self.closed.load(Ordering::Acquire) {
return Box::pin(async { Err(database_error("the store is closed")) });
}
// The extent guard (ADR-023 §2): `limit <= 0` reads nothing —
// a value, never an error — at trait-impl entry, before any
// round trip (pg's LIMIT with a negative is a server error,
// unreachable behind this guard).
if limit <= 0 {
return Box::pin(async { Ok(Vec::new()) });
}
Box::pin(stream_read_page(
self.pool.clone(),
self.schema.clone(),
self.name.clone(),
offset,
limit,
))
}
fn read_from_consumer<'a>(
&'a self,
consumer: &str,
limit: i64,
) -> BoxedFuture<'a, Result<Vec<StreamEvent>>> {
if let Err(e) = validate_local_name(consumer) {
return Box::pin(async move { Err(e) });
}
if self.closed.load(Ordering::Acquire) {
return Box::pin(async { Err(database_error("the store is closed")) });
}
if limit <= 0 {
return Box::pin(async { Ok(Vec::new()) });
}
let pool = self.pool.clone();
let schema = self.schema.clone();
let stream = self.name.clone();
let consumer = consumer.to_string();
Box::pin(async move {
let from = stream_get_offset(&pool, &schema, &stream, &consumer).await?;
stream_read_page(pool, schema, stream, from, limit).await
})
}
fn save_offset<'a>(&'a self, consumer: &str, offset: i64) -> BoxedFuture<'a, Result<()>> {
if let Err(e) = validate_local_name(consumer) {
return Box::pin(async move { Err(e) });
}
if self.closed.load(Ordering::Acquire) {
return Box::pin(async { Err(database_error("the store is closed")) });
}
let pool = self.pool.clone();
let schema = self.schema.clone();
let stream = self.name.clone();
let consumer = consumer.to_string();
Box::pin(
async move { stream_save_offset(&pool, &schema, &stream, &consumer, offset).await },
)
}
fn get_offset<'a>(&'a self, consumer: &str) -> BoxedFuture<'a, Result<i64>> {
if let Err(e) = validate_local_name(consumer) {
return Box::pin(async move { Err(e) });
}
if self.closed.load(Ordering::Acquire) {
return Box::pin(async { Err(database_error("the store is closed")) });
}
let pool = self.pool.clone();
let schema = self.schema.clone();
let stream = self.name.clone();
let consumer = consumer.to_string();
Box::pin(async move { stream_get_offset(&pool, &schema, &stream, &consumer).await })
}
fn trim_to<'a>(&'a self, horizon: i64) -> BoxedFuture<'a, Result<i64>> {
if self.closed.load(Ordering::Acquire) {
return Box::pin(async { Err(database_error("the store is closed")) });
}
let pool = self.pool.clone();
let schema = self.schema.clone();
let stream = self.name.clone();
Box::pin(async move {
// Wakes nothing (ADR-015 §5's posture — trim is a
// bounded-growth op, not an event). The boundary argument
// is total (ADR-023 §2): a negative horizon deletes
// nothing idempotently (plain SQL, no guard — `id <= -7`
// matches no rows).
let conn = pool.get().await.map_err(pool_error)?;
let events = table(&schema, tables::EVENTS);
let deleted = conn
.execute(
&format!("DELETE FROM {events} WHERE stream = $1 AND id <= $2"),
&[&stream, &horizon],
)
.await
.map_err(pg_error)?;
Ok(deleted as i64)
})
}
fn subscribe<'a>(&'a self, consumer: &str) -> BoxedFuture<'a, Result<Box<dyn EventReceiver>>> {
if let Err(e) = validate_local_name(consumer) {
return Box::pin(async move { Err(e) });
}
if self.closed.load(Ordering::Acquire) {
return Box::pin(async { Err(database_error("the store is closed")) });
}
let name = self.name.clone();
let pool = self.pool.clone();
let schema = self.schema.clone();
let forwarder = self.forwarder.clone();
let closed = self.closed.clone();
let consumer = consumer.to_string();
Box::pin(async move { spawn_bridge(name, consumer, pool, schema, forwarder, closed).await })
}
}
/// The `subscribe` bridge construction: the forwarder subscription is
/// taken before any read (a commit cannot fall between the attach
/// read and the wake wait), the bridge task then replays from the
/// stored offset and parks on the wake feed. Subscribing a failed
/// forwarder (`None` — shutdown raced) fails closed after
/// unregistering the just-registered LISTEN (no registration leak —
/// the notify path's same rule); a bridge-spawn failure (runtime
/// gone) also fails `Database`.
async fn spawn_bridge(
stream: String,
consumer: String,
pool: Pool,
schema: String,
forwarder: Arc<Forwarder>,
store_closed: Arc<AtomicBool>,
) -> Result<Box<dyn EventReceiver>> {
// Registration first — a failed registration here (never: LISTEN
// failures carry the registry entry's recovery, but a shutdown
// race can kill the command channel) fails the whole subscribe.
forwarder.register(&stream).await?;
let Some(broadcast_rx) = forwarder.subscribe() else {
// The subscribe raced (or arrived past) a shutdown: fail
// closed — no receiver can ever deliver again. Unregister
// first: the acked LISTEN must not leak past the failed
// listen (a next-closest `subscribe` re-issues it).
forwarder.unregister(&stream);
return Err(database_error("the store is closed"));
};
let (event_tx, event_rx) =
tokio::sync::mpsc::channel::<Result<StreamEvent>>(EVENT_CHANNEL_CAPACITY);
let spawned = tokio::spawn(run_subscribe_loop(
stream.clone(),
consumer.clone(),
pool.clone(),
schema.clone(),
store_closed.clone(),
broadcast_rx,
event_tx,
));
// tokio::spawn only fails on a shut-down runtime — and the boxed
// receiver must never surface as a lie after its bridge never
// started. (Also: no panics cross the seam, the W-2 posture.)
if spawned.is_finished() {
spawned.abort();
return Err(database_error(
"failed to spawn the stream bridge task (runtime shutting down)",
));
}
Ok(Box::new(PgEventReceiver {
stream,
consumer,
rx: event_rx,
forwarder,
position: 0,
pool,
schema,
store_closed,
_bridge: spawned,
}))
}
/// The bridge loop body (the subscribe task; the SQLite arm's
/// std-thread shape, natively async here):
///
/// 1. read the consumer's stored offset (the attach read),
/// 2. drain pages until the tail (`id ASC`),
/// 3. park on the wake feed (this stream's channel + the reserved
/// reconnect-wake), re-drain on wake,
/// 4. a read failure while the store is open surfaces one
/// `Err(Database)` and waits for the next wake; a read failure
/// after store close exits the loop (the consumer sees the close).
///
/// Wake-feed disconnect (the forwarder's shutdown take drops the
/// broadcast) is the driver arm: the loop exits, the mpsc sender
/// drops, the consumer's `recv()` yields `None` — terminal, only at
/// engine shutdown.
enum WakeTick {
Ok,
Closed,
}
async fn wait_wake(rx: &mut tokio::sync::broadcast::Receiver<RawNotification>) -> WakeTick {
match rx.recv().await {
Ok(_raw) => WakeTick::Ok,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
// Still surfaced — treat like a wake: re-drain.
WakeTick::Ok
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => WakeTick::Closed,
}
}
/// One drain-page arm: a decoded page (possibly empty), or the loop's
/// exit state.
enum PageArm {
Page(Vec<StreamEvent>),
Err(Error),
Closed,
}
async fn drain_page(
pool: &Pool,
schema: &str,
stream: &str,
cursor: i64,
store_closed: &AtomicBool,
) -> PageArm {
if store_closed.load(Ordering::Acquire) {
return PageArm::Closed;
}
match stream_read_page(
pool.clone(),
schema.to_string(),
stream.to_string(),
cursor,
SUBSCRIBE_PAGE,
)
.await
{
Ok(events) => PageArm::Page(events),
Err(_e) if store_closed.load(Ordering::Acquire) => PageArm::Closed,
Err(e) => PageArm::Err(database_only(e)),
}
}
async fn run_subscribe_loop(
stream: String,
consumer: String,
pool: Pool,
schema: String,
store_closed: Arc<AtomicBool>,
mut wake_rx: tokio::sync::broadcast::Receiver<RawNotification>,
event_tx: tokio::sync::mpsc::Sender<Result<StreamEvent>>,
) {
// 1. The attach read: the consumer's stored offset.
if store_closed.load(Ordering::Acquire) {
return;
}
let mut cursor = match stream_get_offset(&pool, &schema, &stream, &consumer).await {
Ok(offset) => offset,
Err(_e) if store_closed.load(Ordering::Acquire) => return,
Err(e) => {
let _ = event_tx.send(Err(database_only(e))).await;
return;
}
};
loop {
// 2. Drain to the tail, page by page.
loop {
match drain_page(&pool, &schema, &stream, cursor, &store_closed).await {
PageArm::Closed => return,
PageArm::Err(e) => {
if event_tx.send(Err(e)).await.is_err() {
return;
}
break;
}
PageArm::Page(events) => {
if events.is_empty() {
break;
}
for event in events {
cursor = event.offset;
if event_tx.send(Ok(event)).await.is_err() {
return;
}
}
}
}
}
// 3. Park on the wake feed (this stream's channel — and the
// reserved reconnect-wake: the same re-drain heals a
// gap's missed wakes by reading the durable rows).
match wait_wake(&mut wake_rx).await {
WakeTick::Closed => return,
WakeTick::Ok => {}
}
}
}
/// `recv()`'s `Err` arm carries `Database` only (ADR-021 §5): a
/// read error that mapped elsewhere (the decode-side `Codec` posture
/// guard) is remapped into the opaque fallback with the original error
/// preserved as the source.
fn database_only(e: Error) -> Error {
match e {
already @ Error::Database(_) => already,
other => Error::database(other),
}
}
/// The durable subscription receiver: the consumer side of the
/// bridge. Dropping it drops the mpsc receiver — the bridge's next
/// `send` fails and the bridge task exits (and its `_bridge` handle
/// drop-detaches the task body), while the forwarder registration
/// rides the receiver's `Drop` (unregister → UNLISTEN at the
/// last-subscriber drop — one refcount owner per channel).
pub(crate) struct PgEventReceiver {
stream: String,
consumer: String,
rx: tokio::sync::mpsc::Receiver<Result<StreamEvent>>,
forwarder: Arc<Forwarder>,
position: i64,
pool: Pool,
schema: String,
store_closed: Arc<AtomicBool>,
_bridge: tokio::task::JoinHandle<()>,
}
impl Drop for PgEventReceiver {
fn drop(&mut self) {
self.forwarder.unregister(&self.stream);
}
}
impl PgEventReceiver {
/// The monotone save of this receiver's position through the
/// shared op. The sync signature on the natively-async engine is
/// bridged on the dedicated idle wake runtime (see
/// [`drive_sync`]; the runtime carries no engine state — the
/// save future checks out its own pool connection). `position ==
/// 0` (nothing yielded yet) is the no-op arm — the stored
/// checkpoint untouched (ADR-021 §5).
fn save_now(&mut self) -> Result<()> {
let position = self.position;
if position == 0 {
return Ok(());
}
if self.store_closed.load(Ordering::Acquire) {
return Err(database_error("the store is closed"));
}
let pool = self.pool.clone();
let schema = self.schema.clone();
let stream = self.stream.clone();
let consumer = self.consumer.clone();
drive_sync(async move {
stream_save_offset(&pool, &schema, &stream, &consumer, position).await
})?
}
}
impl EventReceiver for PgEventReceiver {
fn recv<'a>(&'a mut self) -> BoxedFuture<'a, Option<Result<StreamEvent>>> {
Box::pin(async move {
match self.rx.recv().await {
Some(Ok(event)) => {
self.position = event.offset;
Some(Ok(event))
}
// A transient read failure — the bridge keeps running
// and re-reads on the next wake.
Some(Err(e)) => Some(Err(e)),
// The bridge exited: shutdown disconnect — `None` =
// closed, terminal (it never reopens; recovery is a
// fresh `subscribe(consumer)`).
None => None,
}
})
}
fn try_recv(&mut self) -> Result<Option<StreamEvent>> {
match self.rx.try_recv() {
Ok(Ok(event)) => {
self.position = event.offset;
Ok(Some(event))
}
Ok(Err(e)) => Err(e),
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => Ok(None),
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => Err(Error::Closed),
}
}
fn read_since<'a>(
&'a mut self,
offset: i64,
limit: i64,
) -> BoxedFuture<'a, Result<Vec<StreamEvent>>> {
if self.store_closed.load(Ordering::Acquire) {
return Box::pin(async { Err(database_error("the store is closed")) });
}
if limit <= 0 {
return Box::pin(async { Ok(Vec::new()) });
}
Box::pin(stream_read_page(
self.pool.clone(),
self.schema.clone(),
self.stream.clone(),
offset,
limit,
))
}
fn save_offset(&mut self) -> Result<()> {
self.save_now()
}
fn offset(&self) -> i64 {
self.position
}
}
+5 -24
View File
@@ -79,6 +79,7 @@ use crate::resolution::{
};
use crate::schema::{QualifiedTable, tables};
use crate::seam::{pg_error, pool_error};
use crate::stream::stream_events_from_rows;
/// The notification payload boundary (the contract's pinned limit —
/// client-side checked, typed before any round trip; the predicate
@@ -637,27 +638,7 @@ fn job_from_row(row: &tokio_postgres::Row) -> Result<Job> {
))
}
/// Decode a read page into the contract's [`StreamEvent`] values (the
/// core constructor's `from_row`). Malformed rows map to `Error::Codec`.
fn stream_events_from_rows(
rows: &[tokio_postgres::Row],
stream: String,
) -> Result<Vec<StreamEvent>> {
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let key: Option<String> = row
.try_get(1)
.map_err(|e| Error::Codec(format!("stream row key must be text: {e}")))?;
let payload: Option<Vec<u8>> = row
.try_get(2)
.map_err(|e| Error::Codec(format!("stream row payload must be bytea: {e}")))?;
out.push(StreamEvent::from_row(
row.get(0),
stream.clone(),
key,
payload.unwrap_or_default(),
row.get(3),
));
}
Ok(out)
}
// The stream-page decode (`stream_events_from_rows`) lives in
// `stream.rs` — the one decode owner the tx reads and the auto-commit
// reads share (the `stream` field mapping and the `Codec` posture
// guard live once; ADR-012 §2's one-owner rule).
+129 -11
View File
@@ -1,7 +1,7 @@
---
id: pg-engine-streams
name: Postgres engine — streams (`StreamHandle`, reads, subscribe, trim)
status: pending
status: completed
depends_on: [pg-engine-seam-tx, pg-engine-notify-listen]
scope: moderate
risk: medium
@@ -83,27 +83,27 @@ engine-postgres.md's streams mapping row):
## Acceptance Criteria
- [ ] `publish`/`publish_with_key` return the assigned bigserial
- [x] `publish`/`publish_with_key` return the assigned bigserial
offset; key round-trips (`None` vs `Some`); `encode_payload`'s
`Codec` propagates; empty-`Some`-key rejected `InvalidName`
- [ ] Reads yield `offset ASC` (global FIFO); extent guard
- [x] Reads yield `offset ASC` (global FIFO); extent guard
(`limit <= 0` → empty `Vec`) at trait-impl entry; offsets
immutable (trim leaves gaps, never renumbers — tested)
- [ ] `save_offset` monotone (regression save = silent no-op);
- [x] `save_offset` monotone (regression save = silent no-op);
`get_offset` reads the checkpoint; save `Err` = `Database` only
- [ ] `trim_to` exact-boundary (`<=`), returns deleted count, wakes
- [x] `trim_to` exact-boundary (`<=`), returns deleted count, wakes
nothing, negative horizon deletes nothing; reads resume at the
horizon's first remaining row; saved offsets below the horizon
stay valid
- [ ] `subscribe`: replay from stored offset, wake-driven delivery,
- [x] `subscribe`: replay from stored offset, wake-driven delivery,
position = last-yielded offset, backpressure holds, receiver
stays open across forwarder reconnects (reconnect-wake triggers
re-drain — the gap-healing property tested), terminal close at
engine shutdown only
- [ ] Restart durability: publish → close store → reopen →
- [x] Restart durability: publish → close store → reopen →
`subscribe` resumes from the saved offset
- [ ] Validation + closed-store fail-closed on all entry points
- [ ] `cargo test -p alkstore-postgres` (harness server), clippy
- [x] Validation + closed-store fail-closed on all entry points
- [x] `cargo test -p alkstore-postgres` (harness server), clippy
`-D warnings`, fmt clean; gates green server-less
## References
@@ -119,8 +119,126 @@ engine-postgres.md's streams mapping row):
## Notes
> To be filled by implementation agent
> Decisions of record the implementation made that the description
> didn't pin:
- **The wake statement is `SELECT pg_notify($1, '')` on the same
pooled connection, posted-insert, best-effort**: auto-commit — the
notify fires at its own implicit tx's commit, a separate statement
per the description (the gap between insert-commit and wake-commit
is the no-replay hole; the durable row is the truth). A wake
failure is logged to stderr and swallowed — the durable row already
landed, and a wake-fee must never fail (or stall) a publish.
- **The subscribe receiver is a natively-async bridge task** (the
structural twin of the SQLite bridge, without the std thread): a
tokio task holds a broadcast subscription (taken before the attach
read — attach-before-read ordering), drains pages to the tail,
parks on `broadcast::recv`, re-drains on wake (including the
lagged arm — lagged is woken-state too, surfaced-by-drain).
Channel capacity 16 (the SQLite twin's smoothing-buffer shape);
`send().await` is backpressure-aware — a stalled consumer parks the
bridge, delivery resumes exactly where it stopped.
- **`EventReceiver::save_offset`'s sync signature needed a real
sync→async bridge** (this engine has no blocking writer slot to
lease — the SQLite twin's sync posture does not exist here). Probed
exhaustively: `Handle::block_on` panics on a runtime worker (both
flavors), `Runtime::new().block_on` nested panics and would build a
runtime per call, `block_in_place` + `block_on` of a *different*
runtime is safe only on multi-thread workers, and a plain
(non-runtime) thread can block directly. Landed shape: a lazily
built, process-wide, stateless one-worker idle "wake runtime"
(`stream.rs`'s `WAKE_RUNTIME`); `drive_sync` runs the save future
`block_on` it — directly on the caller's thread when outside any
runtime or inside a foreign `spawn_blocking`, via `block_in_place`
on a multi-thread worker, via a scoped std thread on a
current-thread worker (the only escape there). The runtime carries
no engine state; each save checks out its own pool connection *on
the runtime that drives it*. Idle cost: one parked runtime (one
blocked driver thread).
- **The bridge spawn failure arm is `is_finished()`-detected** (+
abort/`Database`): `tokio::spawn` cannot fail directly — a
shut-down runtime drops the task before it runs, so the spawn site
checks finished-ness and fails the subscribe closed (no boxed
receiver surfacing as a lie, the W-2 posture's pg shape).
- **Decode ownership moved**: `stream_events_from_rows` moved from
`tx.rs` into `stream.rs` as the one `pub(crate)` decode owner (the
tx reads import it — the SQLite twin's decode-sharing shape, the
`stream` field mapping and the `Codec` posture guard live once).
`NOTIFY_PAYLOAD_LIMIT` is not needed here — the wake payload is the
empty string (stream wakes carry no payload; the durable row is
the truth; the 8000-byte budget never binds).
- **`trim_to` is a plain parameterized DELETE on a pool connection**
(wakes nothing — no notify statement at all; ADR-015 §5). The
boundary argument needs *no* guard on SQL: `id <= -7` matches no
rows server-side, idempotently (the totality is native here —
contrast the SQLite arm's writer-lease).
- **The wake-feed arm shape in `run_subscribe_loop`**: store-close is
checked at every read arm (drain, attach) and exits the loop
(`PageArm::Closed`) — the consumer sees the terminal close via the
channel disconnect; a mid-drain error while open surfaces one
`Err(Database)` and waits for the next wake (the SQLite twin's
transient arm, verbatim). `database_only` remaps non-`Database`
errors (the decode-side `Codec` guard) into the opaque fallback
with the source preserved.
- **The `closed_check` helper from the store is not reused on the
handle** (the handle checks its own `closed` arc inline at each op
entry — the ops return from sync context into boxed futures, so
the check happens before the `Box::pin`, matching the SQLite
twin's placement).
- **Test-infra note**: the "wakes nothing" and gap-heal pins use the
kill_listener_backend shape from the notify tests (the
reconnect-wake then re-drain cycle is what heals the gap); the
drop-unregister pin probes the raw forwarder fanout (no
cross-session LISTEN catalog view exists — the notify tests'
finding reused). The trimmed-horizon reads-resume assertion pins
the first remaining row explicitly (`offsets[2]`).
## Summary
> To be filled on completion
> What landed, verified how:
- **`alkstore-postgres/src/stream.rs`** (new): the stream mechanism's
pg arm — `PgStreamHandle` (the full `StreamHandle` trait:
`publish`/`publish_with_key` auto-commit INSERT .. RETURNING id on
a pool connection with `encode_payload` at the seam and the
best-effort `pg_notify` wake on the stream's channel after the
insert (separate statement, failure logged-and-swallowed),
`read_since`/`read_from_consumer` with the ADR-023 §2 extent guard
and `id ASC` ordering, monotone `save_offset`/`get_offset` (the
shared upsert owner), `trim_to`'s pool-connection DELETE) and the
durable `subscribe` receiver (`PgEventReceiver` + the async bridge
task: attach-before-read, replay from the stored offset, wake-
driven re-drains — reconnect-wakes included, terminal close at
engine shutdown only, position = last-yielded offset, backpressure-
aware delivery, sync `save_offset` bridged through the stateless
idle wake runtime, `recv()` Err = `Database` only).
- **`tx.rs`**: now imports `stream_events_from_rows` from `stream.rs`
(the one decode owner; the forked copy deleted).
- **`store.rs`**: `Store::stream` wired (validation + closed-store
fail-closed + handle construction); the open task's stub-surface
test updated (`stream` asserted wired, `queue` the remaining-stub
representative).
- **Tests** (`store/stream_tests.rs`, 21 new): the round-trip
(offsets/stream-field/keys/decode), cross-store wake delivery, the
extent guard (0/-1/MIN on both read forms), trim (negative/zero/
exact-boundary/idempotent/no-renumbering/reads-resume/saved-offset-
survives/wakes-nothing), monotone saves across forms (incl. the
receiver pre-yield no-op), replay + wake-driven delivery, gap-heal
across a real backend-kill reconnect (the gap event arrives via
the re-drain; the receiver never closes), terminal shutdown close
(`None` + `Err(Closed)` + never reopens), tail-idle, independent
subscribers, receiver-drop UNLISTEN (raw-fanout probe), key
round-trip on every read form, byte-exact stored payload (the raw
BYTEA row vs the serde_json serialization), tx-compose
(commit/ghost/monotone-tx-save), receiver `read_since` anchoring +
extent guard, stalled-consumer saves-only-observed, backpressure
(48-event burst delivered exactly in ASC order), entry-point
validation (shared kinds + empty-Some keys + reserved-prefix-local
consumers + residue-free rejects), and closed-store fail-closed on
every op + constructors.
- **Verification**: `cargo test -p alkstore-postgres` against the
harness server (pglo-poc :15432): 72 lib + 9 schema tests green,
repeated ×4 (the stream subset 4× stable); workspace
`cargo test` green server-less (pg tests skip per convention, 10
result lines all ok); `cargo clippy --all-targets -- -D warnings`
clean; `cargo fmt --check` clean.