Files
alkstore/alkstore-sqlite/src/store.rs
T
glm-5.3-flash 22f25ffafd sqlite: panic-safety armor for the auto-commit seams (review 004 item 1)
with_writer/with_reader/blocking_read release their slot/pool
connection only on the normal return — a panic inside the closure
wedged the single writer slot forever (Writer::acquire waits on a
condvar) and permanently shrank the reader pool (outstanding never
decremented on abandonment). The seams now ride RAII drop-guards
(ConnLease's idiom): the normal arm defuses and releases unchanged;
the unwind arm drops the unknown-state connection and takes the
established abandonment dispositions — Writer::replenish() (the tick's
poisoned arm as a substrate method, the writer now holding its path)
and Readers::abandon() (outstanding decrement + pool-locked notify).
Three panic-injection tests pin the properties; no reachable panic
path exists in production — this is the defense-in-depth armor the
review scoped. Task: sqlite-fix-seam-panic-safety.
2026-10-11 09:55:45 +00:00

329 lines
12 KiB
Rust

//! The SQLite engine's store: the `open` constructor, the connection
//! architecture (engine-sqlite.md's "Connection architecture"), and
//! the [`Store`] trait wiring.
//!
//! Connection architecture: one writer connection behind the
//! substrate's `Writer` slot (WAL single-writer modeled honestly), a
//! pool of reader connections (fresh opens on demand up to
//! [`SqliteOpts::max_readers`]), and one `SharedUpdateWatcher` poll
//! thread over the db path. Every connection — writer and pooled
//! readers alike — runs the substrate's full bootstrap at open:
//! pragmas (WAL, `synchronous=NORMAL`, busy timeout), the notify
//! machinery, the substrate's SQL function set, and the schema
//! bootstrap.
//!
//! Watcher spawn is fallible in the substrate (ADR-012 §4's W-2); the
//! failure maps into [`Error::Database`](alkstore::Error::Database)
//! engine-side and `open` fails if the watcher cannot start. On
//! watcher death the substrate's `WatcherDeathGuard` closes every
//! subscriber (ADR-006) — the death-guard plumbing exists from open
//! onward; the consumer-visible receiver bridging arrives with
//! `listen`.
//!
//! Drop/close: the watcher's poll thread is joined (releasing its
//! connection and the db file handle) and the writer slot + reader
//! pool are closed; [`Drop`](SqliteStore::drop) delegates to
//! [`SqliteStore::close`].
//!
//! Every engine call round-trips the `spawn_blocking` seam
//! ([`crate::seam`]) — the substrate is sync machinery (std threads,
//! blocking iterators) bridged at the trait seam, per the family
//! REQ-TTY-01 posture. Single-host by nature: file-backed, one
//! machine; cross-process on one host is verified ground
//! (`data_version` is cross-process by nature); NFS
//! two-writers-unsupported (the lineage's honesty
//! posture, inherited — ADR-016).
use alkstore::Store;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use crate::opts::SqliteOpts;
use crate::seam::{database_error, sqlite_error};
use crate::substrate::{Readers, SharedUpdateWatcher, Writer};
/// The SQLite engine's store handle. Consumers hold
/// [`alkstore::Store`] — the trait is the contract surface; the type
/// exists so the engine can own its machinery and so tests can reach
/// the close path directly.
pub struct SqliteStore {
writer: Arc<Writer>,
readers: Arc<Readers>,
watcher: Arc<SharedUpdateWatcher>,
db_path: PathBuf,
closed: Arc<AtomicBool>,
/// The store's commit-fault arm (`sqlite-commit-error-arm`) — born
/// disarmed; a test arms it through [`SqliteStore::arm_commit_fault`]
/// so the next handle commit fails through the real commit error
/// arm. Dead in production builds.
#[cfg(test)]
commit_fault: crate::seam::commit_fault::Flag,
/// The store's rollback-fault arm (review 003 Finding 3) — born
/// disarmed; a test arms it through
/// [`SqliteStore::arm_rollback_fault`] so the next ROLLBACK on a
/// drop path (the handle's or the cancelled op's lease's) fails
/// through the real rollback-failure drop arms. Dead in production
/// builds.
#[cfg(test)]
rollback_fault: crate::seam::rollback_fault::Flag,
}
impl std::fmt::Debug for SqliteStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SqliteStore")
.field("db_path", &self.db_path)
.finish_non_exhaustive()
}
}
impl SqliteStore {
/// Explicit close: stops the watcher, disconnects every subscriber
/// (the death-guard close — receivers see them close), closes the
/// reader pool, and closes the writer slot. Idempotent. [`Drop`]
/// delegates here.
pub fn close(&self) {
self.closed.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = self.watcher.close();
self.readers.close();
self.writer.close();
}
/// Arm the commit-fault seam (`sqlite-commit-error-arm`): the
/// next `commit` through a handle of this store fails with a
/// `SQLITE_FULL`-shaped driver error and replenishes the writer
/// slot through the real commit error arm. One-store scoping keeps
/// parallel test stores unaffected.
#[cfg(test)]
pub(crate) fn arm_commit_fault(&self) {
crate::seam::commit_fault::arm(&self.commit_fault)
}
/// Arm the rollback-fault seam (review 003 Finding 3): the next
/// `ROLLBACK` on a drop path of this store (a transaction handle
/// dropped open, or a tx-op future dropped mid-op) fails with a
/// `SQLITE_FULL`-shaped driver error and replenishes the writer
/// slot through the real rollback-failure drop arm. One-store
/// scoping (disarms on first consumption) keeps parallel test
/// stores unaffected.
#[cfg(test)]
pub(crate) fn arm_rollback_fault(&self) {
crate::seam::rollback_fault::arm(&self.rollback_fault)
}
}
impl Drop for SqliteStore {
fn drop(&mut self) {
self.close();
}
}
/// Open a SQLite-backed store at `path`.
///
/// The path is a **plain filesystem path** (ADR-023 §3): no URI
/// interpretation — a `?name=value` suffix is part of the literal
/// filename (`:memory:` works without URI mode). The full connection
/// architecture boots: the writer slot, the reader pool, and the
/// update watcher (whose spawn is fallible — open fails with
/// [`Error::Database`](alkstore::Error::Database) if it cannot start).
pub fn open(path: &str, opts: SqliteOpts) -> alkstore::Result<Box<dyn Store>> {
Ok(Box::new(open_store(path, opts)?))
}
pub(crate) fn open_store(path: &str, opts: SqliteOpts) -> alkstore::Result<SqliteStore> {
let config = opts.watcher_config()?;
let writer_conn = crate::substrate::open_conn_bootstrapped(path)
.map_err(|e| match e {
crate::substrate::SchemaError::Sqlite(inner) => sqlite_error(inner),
})
.map_err(|e| database_error(format!("failed to open/bootstrap writer connection: {e}")))?;
let watcher = SharedUpdateWatcher::new_with_config(PathBuf::from(path), config)
.map_err(|e| database_error(format!("failed to spawn update watcher: {e}")))?;
Ok(SqliteStore {
writer: Arc::new(Writer::new(path.to_string(), writer_conn)),
readers: Arc::new(Readers::new(path.to_string(), opts.max_readers)),
watcher: Arc::new(watcher),
db_path: PathBuf::from(path),
closed: Arc::new(AtomicBool::new(false)),
#[cfg(test)]
commit_fault: crate::seam::commit_fault::disarmed(),
#[cfg(test)]
rollback_fault: crate::seam::rollback_fault::disarmed(),
})
}
impl Store for SqliteStore {
fn begin_tx(
&self,
) -> alkstore::BoxedFuture<'_, alkstore::Result<Box<dyn alkstore::TxHandle + Send>>> {
let writer = self.writer.clone();
let db_path = self.db_path.to_string_lossy().into_owned();
let reopen: std::sync::Arc<
dyn Fn() -> alkstore::Result<rusqlite::Connection> + Send + Sync,
> = std::sync::Arc::new(move || crate::seam::open_writer_connection(&db_path));
#[cfg(test)]
let fut = crate::tx::SqliteTxHandle::begin(
writer,
reopen,
self.commit_fault.clone(),
self.rollback_fault.clone(),
);
#[cfg(not(test))]
let fut = crate::tx::SqliteTxHandle::begin(writer, reopen);
fut
}
fn notify<'a>(
&'a self,
channel: &str,
payload: serde_json::Value,
) -> alkstore::BoxedFuture<'a, alkstore::Result<()>> {
crate::notify::notify(self.writer.clone(), channel, payload)
}
fn listen<'a>(
&'a self,
channel: &str,
) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::WakeReceiver>>> {
let watcher = self.watcher.clone();
let closed = self.closed.clone();
let channel = channel.to_string();
Box::pin(async move { crate::notify::listen(watcher, closed, &channel) })
}
fn stream<'a>(
&'a self,
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 self.closed.load(std::sync::atomic::Ordering::Acquire) {
return Box::pin(async { Err(crate::seam::database_error("the store is closed")) });
}
let name = name.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 {
Ok(Box::new(crate::stream::SqliteStreamHandle::new(
name, writer, readers, watcher, closed,
)) as Box<dyn alkstore::StreamHandle>)
})
}
fn queue<'a>(
&'a self,
name: &str,
opts: alkstore::QueueOpts,
) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::Queue>>> {
if let Err(e) = alkstore::validate_shared_name(name) {
return Box::pin(async move { Err(e) });
}
if self.closed.load(std::sync::atomic::Ordering::Acquire) {
return Box::pin(async { Err(crate::seam::database_error("the store is closed")) });
}
let name = name.to_string();
let writer = self.writer.clone();
let readers = self.readers.clone();
let closed = self.closed.clone();
Box::pin(async move {
Ok(Box::new(crate::queue::SqliteQueueHandle::new(
name, opts, writer, readers, closed,
)) as Box<dyn alkstore::Queue>)
})
}
fn outbox<'a>(
&'a self,
name: &str,
) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::Outbox>>> {
if self.closed.load(std::sync::atomic::Ordering::Acquire) {
return Box::pin(async { Err(crate::seam::closed_store_error()) });
}
let name = name.to_string();
let writer = self.writer.clone();
let closed = self.closed.clone();
Box::pin(async move { crate::outbox::new_outbox_handle(&name, writer, closed) })
}
fn try_lock<'a>(
&'a self,
name: &str,
owner: &str,
ttl: i64,
) -> alkstore::BoxedFuture<'a, alkstore::Result<Option<Box<dyn alkstore::Lock>>>> {
let name = name.to_string();
let owner = owner.to_string();
let writer = self.writer.clone();
let closed = self.closed.clone();
crate::lock::try_lock(&name, &owner, ttl, writer, closed)
}
fn schedule<'a>(
&'a self,
name: &str,
spec: &str,
queue: &str,
payload: serde_json::Value,
opts: alkstore::ScheduleOpts,
) -> alkstore::BoxedFuture<'a, alkstore::Result<alkstore::Schedule>> {
let writer = self.writer.clone();
let closed = self.closed.clone();
let name = name.to_string();
let spec = spec.to_string();
let queue = queue.to_string();
crate::scheduler::schedule(&name, &spec, &queue, payload, opts, writer, closed)
}
fn unschedule<'a>(&'a self, name: &str) -> alkstore::BoxedFuture<'a, alkstore::Result<bool>> {
let writer = self.writer.clone();
let closed = self.closed.clone();
let name = name.to_string();
crate::scheduler::unschedule(&name, writer, closed)
}
fn run_schedules<'a>(
&'a self,
stop: alkstore::StopToken,
) -> alkstore::BoxedFuture<'a, alkstore::Result<()>> {
let writer = self.writer.clone();
let closed = self.closed.clone();
let db_path = self.db_path.to_string_lossy().into_owned();
#[cfg(test)]
let fut = crate::scheduler::run_schedules(
stop,
writer,
closed,
db_path,
self.commit_fault.clone(),
);
#[cfg(not(test))]
let fut = crate::scheduler::run_schedules(stop, writer, closed, db_path);
fut
}
}
#[cfg(test)]
mod lock_tests;
#[cfg(test)]
mod notify_tests;
#[cfg(test)]
mod open_tests;
#[cfg(test)]
mod outbox_tests;
#[cfg(test)]
mod queue_tests;
#[cfg(test)]
mod scheduler_tests;
#[cfg(test)]
mod seam_tests;
#[cfg(test)]
mod stream_tests;
#[cfg(test)]
mod tx_tests;