Files
alkstore/alkstore-sqlite/src/notify.rs
T

150 lines
5.8 KiB
Rust

//! The notify/listen mechanism's SQLite arm — the wake half
//! (`sqlite-engine-notify-listen`; `notify_tx` landed with the seam):
//!
//! - `notify` — the auto-commit path: acquire the writer slot, run
//! the substrate's `notify()` SQL function (one statement, one
//! auto-commit transaction), release. Commit-atomic by SQLite's
//! autocommit; rollback is impossible on this path (nothing holds
//! the statement back). No payload limit on SQLite — this engine
//! never produces [`Error::PayloadTooLarge`](alkstore::Error) (ADR-016
//! §5); the payload crosses the notify table as the serde_json
//! serialization's UTF-8 text, the byte form ADR-020 §4 pins.
//! - `listen` — the watcher-fanout bridge: one `spawn_blocking` thread
//! per subscription (`engine-sqlite.md`'s mapping row) looping
//! `recv()` on the substrate's sync feed (1-slot — bursts coalesce)
//! and `blocking_send`-ing [`Wake`] values into a tokio channel the
//! [`WakeReceiver`] owns. Wakes are coalesced hints (overtrigger on
//! purpose, ADR-006): the watcher fires on *any* commit, so a wake
//! may arrive for unrelated writes, may coalesce, or may repeat —
//! the receiver delivers `Wake { channel }` only, never payloads or
//! ids (ADR-008 §3); consumers are idempotent on wake.
//!
//! Close semantics (ADR-006, ADR-008 §3): watcher death
//! (`WatcherDeathGuard`) clears the subscriber feed, the bridge thread
//! exits, and the receiver sees the close — `recv() -> None`, terminal
//! (it never reopens); `try_recv` / `recv_timeout` carry
//! [`Error::Closed`], distinguishable from idle's `Ok(None)`;
//! `recv_timeout`'s timeout expiry without a wake is `Ok(None)` — the
//! documented idle arm, never a timeout signal.
//!
//! Unsubscribe is the receiver's own [`Drop`]: the substrate prunes
//! disconnected subscribers lazily (at the next fanout), so the
//! receiver unsubscribes eagerly on drop — a dropped subscriber leaves
//! the feed immediately and the bridge thread exits on the disconnect.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use serde_json::Value;
use alkstore::{BoxedFuture, Error, Result, Wake, WakeReceiver};
use crate::seam::{sqlite_error, with_writer};
use crate::substrate::{SharedUpdateWatcher, Writer};
/// The bridge's tokio-channel capacity. The substrate's 1-slot sync
/// feed stays the coalescing point; this buffer only smooths the
/// bridge-thread → consumer handoff.
const WAKE_CHANNEL_CAPACITY: usize = 16;
/// The auto-commit notify path (see the module docs). Name validation
/// fires at the entry point, before any round trip and before the
/// payload is touched (ADR-008 §4).
pub(crate) fn notify(
writer: Arc<Writer>,
channel: &str,
payload: Value,
) -> BoxedFuture<'static, Result<()>> {
if let Err(e) = alkstore::validate_shared_name(channel) {
return Box::pin(async move { Err(e) });
}
let channel = channel.to_string();
Box::pin(async move {
with_writer(writer, move |conn| {
let bytes = alkstore::encode_payload(&payload)?;
let text = String::from_utf8(bytes)
.map_err(|e| Error::Codec(format!("notify payload must be utf-8 text: {e}")))?;
conn.query_row(
"SELECT notify(?1, ?2)",
rusqlite::params![channel, text],
|_| Ok(()),
)
.map_err(sqlite_error)?;
Ok(())
})
.await
})
}
/// The listen path (see the module docs). Name validation fires at the
/// entry point; a listen against a closed store fails closed with
/// [`Error::Database`](alkstore::Error) (the engine-wide closed-store
/// posture — `begin_tx`/`notify` yield the same shape).
pub(crate) fn listen(
watcher: Arc<SharedUpdateWatcher>,
closed: Arc<AtomicBool>,
channel: &str,
) -> Result<Box<dyn WakeReceiver>> {
alkstore::validate_shared_name(channel)?;
if closed.load(Ordering::Acquire) {
return Err(Error::database(std::io::Error::other(
"the store is closed",
)));
}
let (id, rx) = watcher.subscribe();
let (wake_tx, wake_rx) = tokio::sync::mpsc::channel(WAKE_CHANNEL_CAPACITY);
let channel = channel.to_string();
let _bridge = tokio::task::spawn_blocking(move || {
while let Ok(()) = rx.recv() {
if wake_tx.blocking_send(Wake::new(channel.clone())).is_err() {
break;
}
}
});
Ok(Box::new(SqliteWakeReceiver {
wake_rx,
watcher,
id,
}))
}
/// The consumer-visible wake receiver: the tokio side of the bridge.
/// Its [`Drop`] unsubscribes the substrate feed (the subscriber is
/// pruned eagerly — before the watcher's next fanout would).
struct SqliteWakeReceiver {
wake_rx: tokio::sync::mpsc::Receiver<Wake>,
watcher: Arc<SharedUpdateWatcher>,
id: u64,
}
impl Drop for SqliteWakeReceiver {
fn drop(&mut self) {
self.watcher.unsubscribe(self.id);
}
}
impl WakeReceiver for SqliteWakeReceiver {
fn recv<'a>(&'a mut self) -> BoxedFuture<'a, Option<Wake>> {
Box::pin(async move { self.wake_rx.recv().await })
}
fn try_recv(&mut self) -> Result<Option<Wake>> {
match self.wake_rx.try_recv() {
Ok(wake) => Ok(Some(wake)),
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => Ok(None),
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => Err(Error::Closed),
}
}
fn recv_timeout<'a>(&'a mut self, timeout: Duration) -> BoxedFuture<'a, Result<Option<Wake>>> {
Box::pin(async move {
match tokio::time::timeout(timeout, self.wake_rx.recv()).await {
Ok(Some(wake)) => Ok(Some(wake)),
Ok(None) => Err(Error::Closed),
Err(_expired) => Ok(None),
}
})
}
}