Files
alkstore/alkstore-sqlite/src/stream.rs
T
glm-5.3-flash 513df0b311 SQLite engine: queues — Queue/JobHandle over the writer slot, engine-side backoff curve, extent guard (task sqlite-engine-queues)
queue.rs: SqliteQueueHandle (QueueOpts-carrying, stamp-resolving
enqueue over the seam's shared resolution arithmetic), claim_one/
claim_batch through the writer slot with ADR-023 §2's extent guard
(n <= 0 -> empty Vec at the trait-impl entry), the full JobHandle impl
(one-shot ack/retry/fail as 'static boxed futures, repeatable absolute
reset heartbeat, substrate's uniform validity predicate), and the
engine-owned equal-jitter exponential backoff (std-only RandomState
hash jitter, integerized inclusive [ceil(half), cap], 1-hour cap;
no rand dep - documented).

ack_batch loses its substrate worker filter (register D-31 - ADR-019
§1's worker-less batch form); job_from_json decode + stamps_with_
override moved to their one owners (queue.rs / resolution.rs);
reader-pool helpers lifted from stream.rs into seam.rs. Store::queue
wired; 10 acceptance tests (lifecycle/stamps, extent guard,
exactly-once under concurrency, backoff range + cap + override,
validity predicate + reclaim, dead-letter defaults + get_job
visibility, cancel, ack_batch, sweep both-states + retention,
validation + closed-store). Workspace 25+3+171 green, clippy
-D warnings, fmt clean; sqlite suite 4x green.
2026-10-08 12:59:57 +00:00

573 lines
21 KiB
Rust

//! The stream mechanism's SQLite arm (ADR-015; ADR-019 §1/§6; ADR-021
//! §5; ADR-023 §2):
//!
//! - `stream(name)` — the validated constructor. Construction is free
//! (validation + a closed-store check only — the table exists from
//! bootstrap); the handle carries the connection machinery's arcs.
//! - Auto-commit publishes — an append to the durable log through the
//! writer slot, returning the engine-assigned offset; keys are
//! carried metadata (ADR-015 §1) with the non-empty-key rule (an
//! empty `Some` key is `InvalidName`, matching the tx path).
//! - Cursor reads — reader-pool reads, `offset ASC` (global FIFO,
//! ADR-015 §4), decoded into [`StreamEvent`] via the core
//! constructor's `from_row` (the `topic` column carries the
//! contract's `stream` field — the one name delta). The extent guard
//! (ADR-023 §2) short-circuits `limit <= 0` to the empty `Vec` at
//! the trait-impl entry, before the substrate — the `LIMIT -1`
//! dialect artifact dies here.
//! - Checkpoints — explicit, monotone saves (a save below the stored
//! checkpoint is a silent no-op, ADR-019 §6); an absent consumer
//! reads 0.
//! - `trim_to` — the `DELETE … WHERE offset <= ?` bounded-growth op
//! inside the writer-slot lease (ADR-015 §5); the horizon is a
//! boundary argument, total per ADR-023 §2 (a negative horizon
//! deletes nothing, idempotently — no guard, pass through).
//! Surviving events keep their offsets.
//! - `subscribe` — the durable receiver: attach, replay from the
//! stored offset to the current tail, then wake-driven re-reads off
//! the watcher fanout (the same bridge shape as `listen`, yielding
//! `Result<StreamEvent>` per `EventReceiver`). Watcher death closes
//! it terminally (`recv() -> None`); recovery is a fresh
//! `subscribe(consumer)` resuming from the saved checkpoint
//! (ADR-021 §5).
//!
//! Subscribe specifics:
//!
//! - The watcher subscription is taken *before* any read, so no commit
//! can fall between the attach read and the wake wait (a tick for a
//! commit already re-read sits buffered; a drain that sees it costs
//! an empty re-poll, never a missed event).
//! - The bridge drains pages ([`SUBSCRIBE_PAGE`]) to the tail, then
//! parks on the wake feed. Wakes are coalesced hints (overtrigger on
//! purpose, ADR-006): a wake may arrive for unrelated writes — the
//! re-read from the in-memory cursor sees nothing new and parks
//! again.
//! - A read failure while the store is open surfaces one
//! `Err(Database)` on the receiver and waits for the next wake to
//! re-read (transient arm); a failure after store close exits the
//! loop (the consumer sees the close). An attach-read failure
//! surfaces one `Err(Database)` and closes the receiver — recovery
//! is a fresh `subscribe(consumer)`. Non-`Database` read errors
//! (the decode-side `Codec` posture guard) are remapped into
//! `Database` with the source chain preserved — `recv()`'s `Err` arm
//! carries `Database` only (ADR-021 §5).
//! - The bridge pushes drained events into a bounded tokio channel
//! and `blocking_send`s when full — a stalled consumer parks the
//! bridge thread, and delivery resumes exactly where it stopped. The
//! receiver's position is the last-*yielded* event's offset, so
//! `save_offset()` never checkpoints an event the consumer did not
//! observe.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use parking_lot::Mutex;
use serde_json::Value;
use alkstore::{
BoxedFuture, Error, EventReceiver, Result, StreamEvent, StreamHandle, validate_local_name,
validate_shared_name,
};
use crate::seam::{
ReaderLoopExit as LoopExit, blocking_read, closed_store_error, sqlite_error, with_reader,
with_writer,
};
use crate::substrate::{
Readers, SharedUpdateWatcher, Writer, stream_get_offset, stream_publish, stream_read_since,
stream_save_offset,
};
/// 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 thread's re-read loop and the consumer (the substrate's
/// 1-slot sync feed stays the coalescing point).
const EVENT_CHANNEL_CAPACITY: usize = 16;
/// The auto-commit publish paths (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.
fn publish_with_key(
writer: Arc<Writer>,
stream: &str,
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,
};
let stream = stream.to_string();
Box::pin(async move {
with_writer(writer, move |conn| {
let bytes = alkstore::encode_payload(&payload)?;
stream_publish(
conn,
&stream,
key.as_deref(),
std::str::from_utf8(&bytes)
.map_err(|e| Error::Codec(format!("row payload must be utf-8: {e}")))?,
)
.map_err(sqlite_error)
})
.await
})
}
/// One decoded page from the reader pool: `offset > from`, ASC, up to
/// `limit` (the extent guard is the caller's — this helper is
/// contract-blind to it).
fn read_page(
readers: Arc<Readers>,
stream: String,
from: i64,
limit: i64,
) -> BoxedFuture<'static, Result<Vec<StreamEvent>>> {
let read = move |conn: &rusqlite::Connection| {
let page = stream_read_since(conn, &stream, from, limit).map_err(sqlite_error)?;
stream_events_from_json_page(&page, stream.clone())
};
Box::pin(with_reader(readers, read))
}
/// Decode the substrate's stream-page JSON (the fork's inherited
/// return shape) into the contract's [`StreamEvent`] values via the
/// core constructor's `from_row`. Shared by the auto-commit reads
/// (this module) and the tx reads (`tx.rs`) — one decode owner. The
/// `topic` column carries the contract's `stream` field (the one name
/// delta, mapped at this layer); malformed rows map to `Error::Codec`
/// (decode-side failures is exactly what `Codec` is for — ADR-021 §2's
/// posture guard; the fork's writer always wrote well-formed rows).
pub(crate) fn stream_events_from_json_page(page: &str, stream: String) -> Result<Vec<StreamEvent>> {
let array: serde_json::Value = serde_json::from_str(page)
.map_err(|e| Error::Codec(format!("stream page must be json: {e}")))?;
let rows = array
.as_array()
.ok_or_else(|| Error::Codec("stream page must be a json array".to_string()))?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
out.push(StreamEvent::from_row(
serde_json::from_value(row["offset"].clone())
.map_err(|e| Error::Codec(format!("stream row offset must be i64: {e}")))?,
stream.clone(),
if row["key"].is_null() {
None
} else {
serde_json::from_value(row["key"].clone())
.map_err(|e| Error::Codec(format!("stream row key must be a string: {e}")))?
},
row["payload"]
.as_str()
.ok_or_else(|| Error::Codec("stream row payload must be a string".to_string()))?
.as_bytes()
.to_vec(),
serde_json::from_value(row["created_at"].clone())
.map_err(|e| Error::Codec(format!("stream row created_at must be i64: {e}")))?,
));
}
Ok(out)
}
/// The stream-scoped handle (see the module docs).
pub struct SqliteStreamHandle {
name: String,
writer: Arc<Writer>,
readers: Arc<Readers>,
watcher: Arc<SharedUpdateWatcher>,
closed: Arc<AtomicBool>,
}
impl std::fmt::Debug for SqliteStreamHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SqliteStreamHandle")
.field("name", &self.name)
.finish_non_exhaustive()
}
}
impl SqliteStreamHandle {
pub(crate) fn new(
name: String,
writer: Arc<Writer>,
readers: Arc<Readers>,
watcher: Arc<SharedUpdateWatcher>,
closed: Arc<AtomicBool>,
) -> Self {
Self {
name,
writer,
readers,
watcher,
closed,
}
}
}
impl StreamHandle for SqliteStreamHandle {
fn name(&self) -> &str {
&self.name
}
fn publish<'a>(&'a self, payload: Value) -> BoxedFuture<'a, Result<i64>> {
publish_with_key(self.writer.clone(), &self.name, None, payload)
}
fn publish_with_key<'a>(
&'a self,
key: Option<String>,
payload: Value,
) -> BoxedFuture<'a, Result<i64>> {
publish_with_key(self.writer.clone(), &self.name, 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(closed_store_error()) });
}
if limit <= 0 {
return Box::pin(async { Ok(Vec::new()) });
}
read_page(self.readers.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(closed_store_error()) });
}
if limit <= 0 {
return Box::pin(async { Ok(Vec::new()) });
}
let readers = self.readers.clone();
let stream = self.name.clone();
let consumer = consumer.to_string();
let read = move |conn: &rusqlite::Connection| {
let from = stream_get_offset(conn, &consumer, &stream).map_err(sqlite_error)?;
let page = stream_read_since(conn, &stream, from, limit).map_err(sqlite_error)?;
stream_events_from_json_page(&page, stream.clone())
};
Box::pin(with_reader(readers, read))
}
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(closed_store_error()) });
}
let writer = self.writer.clone();
let stream = self.name.clone();
let consumer = consumer.to_string();
Box::pin(async move {
with_writer(writer, move |conn| {
stream_save_offset(conn, &consumer, &stream, offset)
.map(|_| ())
.map_err(sqlite_error)
})
.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(closed_store_error()) });
}
let readers = self.readers.clone();
let stream = self.name.clone();
let consumer = consumer.to_string();
let read = move |conn: &rusqlite::Connection| {
stream_get_offset(conn, &consumer, &stream).map_err(sqlite_error)
};
Box::pin(with_reader(readers, read))
}
fn trim_to<'a>(&'a self, horizon: i64) -> BoxedFuture<'a, Result<i64>> {
if self.closed.load(Ordering::Acquire) {
return Box::pin(async { Err(closed_store_error()) });
}
let writer = self.writer.clone();
let stream = self.name.clone();
Box::pin(async move {
with_writer(writer, move |conn| {
let deleted = conn
.execute(
"DELETE FROM __alkstore_stream WHERE topic = ?1 AND offset <= ?2",
rusqlite::params![stream, horizon],
)
.map_err(sqlite_error)?;
Ok(deleted as i64)
})
.await
})
}
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(closed_store_error()) });
}
let name = self.name.clone();
let consumer = consumer.to_string();
let writer = self.writer.clone();
let readers = self.readers.clone();
let watcher = self.watcher.clone();
let closed = self.closed.clone();
Box::pin(async move {
spawn_bridge(name, consumer, writer, readers, watcher, closed)
.map(|rx| Box::new(rx) as Box<dyn EventReceiver>)
})
}
}
/// The `subscribe` bridge construction: the watcher subscription is
/// taken before any read, the bridge thread then replays and parks on
/// the wake feed. A thread-spawn failure unsubscribes and fails with
/// `Database` (no panics cross the seam, the W-2 posture).
fn spawn_bridge(
name: String,
consumer: String,
writer: Arc<Writer>,
readers: Arc<Readers>,
watcher: Arc<SharedUpdateWatcher>,
store_closed: Arc<AtomicBool>,
) -> Result<SqliteEventReceiver> {
let (id, wake_rx) = watcher.subscribe();
let wake_rx = Arc::new(Mutex::new(wake_rx));
let (event_tx, event_rx) =
tokio::sync::mpsc::channel::<Result<StreamEvent>>(EVENT_CHANNEL_CAPACITY);
let thread_name = name.clone();
let thread_consumer = consumer.clone();
let thread_readers = readers.clone();
let thread_closed = store_closed.clone();
let thread_tx = event_tx.clone();
let spawned = std::thread::Builder::new()
.name(format!("alkstore-stream-bridge-{thread_name}"))
.spawn(move || {
run_subscribe_loop(
thread_name,
thread_consumer,
thread_readers,
thread_closed,
wake_rx,
thread_tx,
);
});
if let Err(e) = spawned {
watcher.unsubscribe(id);
return Err(Error::database(std::io::Error::other(format!(
"failed to spawn stream bridge thread: {e}"
))));
}
Ok(SqliteEventReceiver {
name,
consumer,
rx: event_rx,
watcher,
id,
position: 0,
writer,
readers,
store_closed,
})
}
/// The bridge loop body (runs on its own thread — this file's honest
/// sync shape, mirroring `listen`'s bridge thread):
///
/// 1. read the consumer's stored offset (the attach read),
/// 2. drain pages until the tail (`offset ASC`),
/// 3. park on the wake feed (any commit ticks it), 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 death path — watcher death via the
/// cleared feed, or store close, disconnects the wake feed and ends
/// the loop; the receiver sees the channel close).
fn run_subscribe_loop(
name: String,
consumer: String,
readers: Arc<Readers>,
store_closed: Arc<AtomicBool>,
wake_rx: Arc<Mutex<std::sync::mpsc::Receiver<()>>>,
event_tx: tokio::sync::mpsc::Sender<Result<StreamEvent>>,
) {
let mut cursor = match blocking_read(&readers, &store_closed, |conn| {
stream_get_offset(conn, &consumer, &name).map_err(sqlite_error)
}) {
Ok(offset) => offset,
Err(LoopExit::Closed) => return,
Err(LoopExit::Failed(e)) => {
let _ = event_tx.blocking_send(Err(database_only(e)));
return;
}
};
loop {
loop {
match blocking_read(&readers, &store_closed, |conn| {
let page =
stream_read_since(conn, &name, cursor, SUBSCRIBE_PAGE).map_err(sqlite_error)?;
stream_events_from_json_page(&page, name.clone())
}) {
Err(LoopExit::Closed) => return,
Err(LoopExit::Failed(e)) => {
if event_tx.blocking_send(Err(database_only(e))).is_err() {
return;
}
break;
}
Ok(events) if events.is_empty() => break,
Ok(events) => {
for event in events {
cursor = event.offset;
if event_tx.blocking_send(Ok(event)).is_err() {
return;
}
}
}
}
}
match wake_rx.lock().recv() {
Ok(()) => {}
// Watcher death: the death-guard cleared the feed. Close.
Err(_) => return,
}
}
}
/// `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 tokio side of the bridge,
/// owning the consumer's position state. Its [`Drop`] unsubscribes the
/// substrate feed eagerly (before the watcher's next fanout would) —
/// the bridge thread exits on the failed send or the disconnected wake
/// feed, and the consumer sees the channel close (`None`, terminal).
///
/// `position` is the last-yielded event's offset (0 before any yield —
/// `save_offset()` is then a no-op, the stored checkpoint untouched,
/// ADR-021 §5; ADR-019 §6).
struct SqliteEventReceiver {
name: String,
consumer: String,
rx: tokio::sync::mpsc::Receiver<Result<StreamEvent>>,
watcher: Arc<SharedUpdateWatcher>,
id: u64,
position: i64,
writer: Arc<Writer>,
readers: Arc<Readers>,
store_closed: Arc<AtomicBool>,
}
impl Drop for SqliteEventReceiver {
fn drop(&mut self) {
self.watcher.unsubscribe(self.id);
}
}
impl EventReceiver for SqliteEventReceiver {
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: watcher death or store close —
// `None` = closed, terminal (it never reopens).
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(closed_store_error()) });
}
if limit <= 0 {
return Box::pin(async { Ok(Vec::new()) });
}
read_page(self.readers.clone(), self.name.clone(), offset, limit)
}
fn save_offset(&mut self) -> Result<()> {
let position = self.position;
if position == 0 {
return Ok(());
}
// The save runs through the same monotone op the direct form
// uses — the forms interleave and cannot regress (ADR-019 §6).
// The trait's signature is sync, so the storage round trip
// takes the writer slot on this thread (the slot is nearly
// always free between ops; a long open transaction parks this
// call — the honest lease posture surfaced, ADR-007).
if self.store_closed.load(Ordering::Acquire) {
return Err(closed_store_error());
}
let conn = match self.writer.acquire() {
Some(conn) => conn,
None => return Err(closed_store_error()),
};
let out = stream_save_offset(&conn, &self.consumer, &self.name, position)
.map(|_| ())
.map_err(sqlite_error);
self.writer.release(conn);
out
}
fn offset(&self) -> i64 {
self.position
}
}