SQLite engine open: SqliteOpts, connection architecture, spawn_blocking seam posture (task sqlite-engine-open-opts)

- SqliteOpts (poll_interval: Option<Duration>, None = 1 ms shipping
  default ADR-023 §4; max_readers, DEFAULT_MAX_READERS = 8; opts
  exemption from non_exhaustive per ADR-017 §3)
- open(path, opts) -> Box<dyn Store>: writer slot, reader pool,
  SharedUpdateWatcher with fallible spawn mapped W-2-style into
  Error::Database; plain-path posture (ADR-023 §3) pinned by test
- Substrate delta D-30: open_conn_bootstrapped — every connection
  (writer and pooled readers) carries the full bootstrap surface
  (pragmas, notify, alkstore functions, schema) per engine-sqlite.md;
  lineage opened readers pragmas-only. Registered in PROVENANCE.md
- spawn_blocking seam helper + error mappings (string/rusqlite ->
  Database, source chains preserved); helper cfg(test)-gated until
  sqlite-engine-seam-tx wires the trait impls
- Store trait stubbed with Database errors (no panics); close()/Drop
  join the watcher, clear subscribers (death-guard), close pool+writer
- 15 new tests (open boot, watcher fanout/death-close, cadence
  accounting, plain-path literal filename, :memory:, pool bounding,
  drop teardown, seam smoke incl. panic mapping); gates green
This commit is contained in:
glm-5.3-flash committed 2026-10-08 11:25:16 +00:00
1 parent 5474141f2b
commit 7d400906f5
11 files changed
+937 -13

No files matched your search

Generated
+1
View File
@@ -43,6 +43,7 @@ dependencies = [
"rusqlite",
"serde_json",
"thiserror",
"tokio",
]
[[package]]
+3 -1
View File
@@ -11,6 +11,7 @@ parking_lot = "0.12"
rusqlite = { version = "0.40", features = ["bundled", "functions"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["rt", "sync"] }
# file-id only supports unix and windows. Other targets (WASI, Redox,
# illumos, etc.) get the `(0, 0)` fallback in substrate::watcher's
@@ -19,4 +20,5 @@ thiserror = "2"
file-id = "0.2"
[dev-dependencies]
alkstore-contract-suite = { path = "../alkstore-contract-suite" }
alkstore-contract-suite = { path = "../alkstore-contract-suite" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
+25
View File
@@ -1 +1,26 @@
//! alkstore-sqlite — the SQLite engine: the core contract implemented
//! on rusqlite over the forked honker-core substrate carried in-tree
//! (ADR-001, ADR-011, ADR-013).
//!
//! Single-host by nature: file-backed, one machine; NFS
//! two-writers-unsupported (the lineage's honesty posture, inherited —
//! ADR-016). Durability knobs, pool sizing, and the watcher cadence
//! are engine-configuration concerns on [`SqliteOpts`] (ADR-008 §6) —
//! never contract surface.
//!
//! Long transactions park the writer: the writer slot is a lease, and
//! holding a caller-held [`alkstore::TxHandle`] across `await` points
//! holds it (ADR-007's honest model, surfaced so consumers budget
//! transactions).
//!
//! Every engine call round-trips a blocking thread (the
//! `spawn_blocking` seam — rusqlite connections are not
//! `Send`-across-await; the family REQ-TTY-01 posture).
mod opts;
mod seam;
mod store;
mod substrate;
pub use opts::{DEFAULT_MAX_READERS, SqliteOpts};
pub use store::{SqliteStore, open};
+68
View File
@@ -0,0 +1,68 @@
//! The SQLite engine's option struct and the watcher-config plumbing
//! (ADR-008 §6 — constructors and their option structs live in the
//! engine crates; engine-configuration concerns never touch the
//! contract surface).
//!
//! `#[non_exhaustive]` deliberately does not apply (ADR-017 §3): opts
//! structs are consumer-*constructed*, and the attribute on them would
//! push every construction through a builder. New fields may be added
//! with `Default` fallbacks, per the core opts modules' posture.
use std::time::Duration;
use crate::substrate::WatcherConfig;
/// The default reader-pool size (the shared watcher's poll cadence
/// rides [`SqliteOpts::poll_interval`]; this is the pool's own knob).
pub const DEFAULT_MAX_READERS: usize = 8;
/// Construction options for the SQLite engine's
/// [`open`](crate::open) constructor.
///
/// Durability knobs, pool sizing, and the watcher cadence are
/// engine-configuration concerns — they never appear on the contract
/// surface (ADR-008 §6); deployment.md carries the documented tuning
/// facts.
#[derive(Debug, Clone)]
pub struct SqliteOpts {
/// The watcher's `PRAGMA data_version` poll cadence. `None` = the
/// substrate's 1 ms shipping default (ADR-023 §4 — the default the
/// published wake-latency numbers were measured at); `Some(d)` sets
/// an explicit cadence (raise it for latency-tolerant deployments —
/// the documented idle cost at 1 ms is ~1000 poll reads/sec per
/// instance). Zero is rejected at `open` with
/// [`Error::Database`](alkstore::Error::Database) (the substrate's
/// `WatcherConfig::with_poll_interval` rejects zero; the mapping is
/// engine-layer work per ADR-012 §4's W-2 posture).
pub poll_interval: Option<Duration>,
/// Upper bound on pooled reader connections (claims/lookups ride
/// these). Fresh readers open on demand up to the bound; `0` clamps
/// to 1.
pub max_readers: usize,
}
impl Default for SqliteOpts {
fn default() -> Self {
Self {
poll_interval: None,
max_readers: DEFAULT_MAX_READERS,
}
}
}
impl SqliteOpts {
/// Resolve this opts into the substrate's
/// [`WatcherConfig`](crate::substrate::WatcherConfig), mapping the
/// substrate's string error into the contract taxonomy's
/// [`Error::Database`](alkstore::Error) arm (the W-2 posture —
/// engine-layer mapping, ADR-012 §4).
pub(crate) fn watcher_config(&self) -> Result<WatcherConfig, alkstore::Error> {
let config = WatcherConfig::default();
match self.poll_interval {
None => Ok(config),
Some(d) => config
.with_poll_interval(d)
.map_err(alkstore::Error::database),
}
}
}
+45
View File
@@ -0,0 +1,45 @@
//! The `spawn_blocking` seam (ADR-003, ADR-007) and the error
//! mapping — the two engine-wide postures established here that every
//! later mechanism task reuses.
//!
//! - Every engine call round-trips a blocking thread: rusqlite
//! connections are not `Send`-across-await, and the sync substrate
//! bridges at the trait seam (the family REQ-TTY-01 posture).
//! - Substrate errors map into the contract taxonomy's opaque
//! [`Error::Database`](alkstore::Error) fallback with the source
//! chain preserved (ADR-008 §5). No panics cross the seam: a panic
//! inside a bridged closure is the `JoinError` arm's `Database`
//! mapping, family-standard no-panics discipline maintained.
use alkstore::Error;
/// The blocking bridge every trait method's substrate round trip goes
/// through. Private: the seam is engine-internal — consumers meet it
/// only through the async trait surface. `cfg(test)`-gated until the
/// seam task (`sqlite-engine-seam-tx`) wires the trait impls; the
/// helper's shape and error path are pinned by this wave's smoke test.
#[cfg(test)]
pub(crate) async fn blocking<T, F>(f: F) -> Result<T, Error>
where
T: Send + 'static,
F: FnOnce() -> Result<T, Error> + Send + 'static,
{
match tokio::task::spawn_blocking(f).await {
Ok(result) => result,
Err(e) => Err(database_error(format!("blocking task failed: {e}"))),
}
}
/// Map a substrate string failure into the contract taxonomy. The
/// substrate is contract-blind — its fallible API surfaces strings
/// (the W-2 spawn posture, ADR-012 §4); the engine layer owns the
/// mapping, ADR-012 §2.
pub(crate) fn database_error(message: impl Into<String>) -> Error {
Error::database(std::io::Error::other(message.into()))
}
/// Map a `rusqlite::Error` into the contract taxonomy, source chain
/// preserved (ADR-008 §5 — no engine-specific variants minted).
pub(crate) fn sqlite_error(e: rusqlite::Error) -> Error {
Error::database(e)
}
+220
View File
@@ -0,0 +1,220 @@
//! 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`]); rusqlite connections are not
//! `Send`-across-await. Single-host by nature: file-backed, one
//! machine; NFS two-writers-unsupported (the lineage's honesty
//! posture, inherited — ADR-016).
use alkstore::Store;
use std::path::PathBuf;
use std::sync::Arc;
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,
}
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) {
let _ = self.watcher.close();
self.readers.close();
self.writer.close();
}
}
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)?))
}
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(writer_conn)),
readers: Arc::new(Readers::new(path.to_string(), opts.max_readers)),
watcher: Arc::new(watcher),
db_path: PathBuf::from(path),
})
}
impl Store for SqliteStore {
fn begin_tx(
&self,
) -> alkstore::BoxedFuture<'_, alkstore::Result<Box<dyn alkstore::TxHandle + Send>>> {
Box::pin(async { Err(database_error("begin_tx wiring lands with the seam task")) })
}
fn notify<'a>(
&'a self,
_channel: &str,
_payload: serde_json::Value,
) -> alkstore::BoxedFuture<'a, alkstore::Result<()>> {
Box::pin(async {
Err(database_error(
"notify wiring lands with the mechanism tasks",
))
})
}
fn listen<'a>(
&'a self,
_channel: &str,
) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::WakeReceiver>>> {
Box::pin(async {
Err(database_error(
"listen wiring lands with the mechanism tasks",
))
})
}
fn stream<'a>(
&'a self,
_name: &str,
) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::StreamHandle>>> {
Box::pin(async {
Err(database_error(
"stream wiring lands with the mechanism tasks",
))
})
}
fn queue<'a>(
&'a self,
_name: &str,
_opts: alkstore::QueueOpts,
) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::Queue>>> {
Box::pin(async {
Err(database_error(
"queue wiring lands with the mechanism tasks",
))
})
}
fn outbox<'a>(
&'a self,
_name: &str,
) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::Outbox>>> {
Box::pin(async {
Err(database_error(
"outbox wiring lands with the mechanism tasks",
))
})
}
fn try_lock<'a>(
&'a self,
_name: &str,
_owner: &str,
_ttl: i64,
) -> alkstore::BoxedFuture<'a, alkstore::Result<Option<Box<dyn alkstore::Lock>>>> {
Box::pin(async { Err(database_error("lock wiring lands with the mechanism tasks")) })
}
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>> {
Box::pin(async {
Err(database_error(
"schedule wiring lands with the mechanism tasks",
))
})
}
fn unschedule<'a>(&'a self, _name: &str) -> alkstore::BoxedFuture<'a, alkstore::Result<bool>> {
Box::pin(async {
Err(database_error(
"unschedule wiring lands with the mechanism tasks",
))
})
}
fn run_schedules<'a>(
&'a self,
_stop: alkstore::StopToken,
) -> alkstore::BoxedFuture<'a, alkstore::Result<()>> {
Box::pin(async {
Err(database_error(
"scheduler wiring lands with the mechanism tasks",
))
})
}
}
#[cfg(test)]
mod open_tests;
+415
View File
@@ -0,0 +1,415 @@
//! The `open` constructor's acceptance tests (`sqlite-engine-open-opts`):
//! the connection architecture boots, opts flow, the plain-path
//! posture holds, and close/drop teardown releases the file.
use std::path::PathBuf;
use std::time::Duration;
use crate::opts::{DEFAULT_MAX_READERS, SqliteOpts};
use crate::seam::blocking;
use crate::store::{open, open_store};
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!(
"alkstore-open-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn cleanup(dir: &PathBuf) {
let _ = std::fs::remove_dir_all(dir);
}
/// Acceptance: `open(path, opts)` boots the full connection
/// architecture — the writer slot takes writes, the reader pool
/// serves reads, and the substrate's bootstrap ran on every
/// connection (the `__alkstore_*` table family exists; the notify and
/// ops SQL functions respond on a fresh pooled reader).
#[tokio::test(flavor = "multi_thread")]
async fn open_boots_the_full_connection_architecture() {
let dir = temp_dir("boot");
let path = dir.join("store.db");
let store = open_store(path.to_str().unwrap(), SqliteOpts::default()).unwrap();
let writer_conn = store.writer.acquire().unwrap();
let mode: String = writer_conn
.pragma_query_value(None, "journal_mode", |r| r.get(0))
.unwrap();
assert!(
mode.eq_ignore_ascii_case("wal"),
"writer not in WAL: {mode}"
);
store.writer.release(writer_conn);
let reader_conn = store.readers.acquire().unwrap();
let reader_mode: String = reader_conn
.pragma_query_value(None, "journal_mode", |r| r.get(0))
.unwrap();
assert!(
reader_mode.eq_ignore_ascii_case("wal"),
"pooled reader not in WAL: {reader_mode}"
);
let notify_fn: i64 = reader_conn
.query_row("SELECT notify('__alkstore_probe', 'x')", [], |r| r.get(0))
.unwrap();
assert!(notify_fn >= 1, "pooled reader missing the function set");
let publish_offset: i64 = reader_conn
.query_row(
"SELECT alkstore_stream_publish('__alkstore_probe', NULL, '{}')",
[],
|r| r.get(0),
)
.unwrap();
assert!(publish_offset >= 1, "pooled reader missing the ops surface");
store.readers.release(reader_conn);
assert!(store.db_path.exists(), "db file at the plain path");
drop(store);
cleanup(&dir);
}
/// Acceptance: the watcher is up after open — a commit from the
/// writer slot fans out to a raw substrate subscriber (the guard the
/// death plumbing rides; the consumer-visible receiver bridge lands
/// with `listen`).
#[tokio::test(flavor = "multi_thread")]
async fn open_leaves_the_watcher_running_and_fanning_out() {
let dir = temp_dir("watcher");
let path = dir.join("store.db");
let store = open_store(path.to_str().unwrap(), SqliteOpts::default()).unwrap();
let (_id, rx) = store.watcher.subscribe();
let writer_conn = store.writer.acquire().unwrap();
writer_conn
.execute("CREATE TABLE t(x INTEGER PRIMARY KEY)", [])
.unwrap();
writer_conn.execute("INSERT INTO t VALUES (1)", []).unwrap();
store.writer.release(writer_conn);
let deadline = std::time::Instant::now() + Duration::from_secs(2);
let got = loop {
match rx.try_recv() {
Ok(()) => break true,
Err(std::sync::mpsc::TryRecvError::Empty) => {
assert!(
std::time::Instant::now() < deadline,
"watcher not fanning out after a commit"
);
std::thread::sleep(Duration::from_millis(5));
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => break false,
}
};
assert!(
got,
"subscriber channel disconnected instead of fanning out"
);
store.close();
cleanup(&dir);
}
/// Acceptance: watcher death handling wired — closing the shared
/// watcher disconnects every subscriber receiver (the death-guard
/// plumbing `listen` bridges into `recv() -> None`). The mechanism
/// lands with `listen`; the plumbing exists here.
#[tokio::test(flavor = "multi_thread")]
async fn closing_the_store_closes_watcher_subscribers() {
let dir = temp_dir("death");
let path = dir.join("store.db");
let store = open_store(path.to_str().unwrap(), SqliteOpts::default()).unwrap();
let (_id, rx) = store.watcher.subscribe();
store.close();
assert!(
rx.recv().is_err(),
"subscriber receiver must close with the watcher (WatcherDeathGuard)"
);
cleanup(&dir);
}
/// Acceptance: `poll_interval` flows to the watcher's config (the
/// config-surface accounting test — asserting the configured cadence,
/// not wall-clock timing; ADR-023 §4's backlog row; `None` = the 1 ms
/// default, zero rejected at open).
#[tokio::test(flavor = "multi_thread")]
async fn poll_interval_flows_to_the_watcher_config() {
use crate::substrate::DEFAULT_WATCHER_POLL_INTERVAL;
assert_eq!(
DEFAULT_WATCHER_POLL_INTERVAL,
Duration::from_millis(1),
"the substrate's shipping default is 1 ms (ADR-023 §4)"
);
assert_eq!(
SqliteOpts::default().poll_interval,
None,
"None = the substrate's 1 ms default"
);
let opts = SqliteOpts {
poll_interval: Some(Duration::from_millis(25)),
..Default::default()
};
let config = opts.watcher_config().unwrap();
assert_eq!(
config.poll_interval,
Duration::from_millis(25),
"the knob must flow to the watcher's configured cadence"
);
let zero_err = SqliteOpts {
poll_interval: Some(Duration::ZERO),
..Default::default()
}
.watcher_config()
.unwrap_err();
assert!(
matches!(zero_err, alkstore::Error::Database(_)),
"the W-2 mapping lands in Error::Database, got: {zero_err:?}"
);
let source = match &zero_err {
alkstore::Error::Database(source) => source.to_string(),
_ => unreachable!("matched above"),
};
assert!(
source.contains("poll interval must be positive"),
"the substrate's zero-interval rejection must ride the source chain, got: {source}"
);
}
/// The 1 ms default resolves at open without an explicit interval,
/// and a generous explicit interval opens just the same.
#[tokio::test(flavor = "multi_thread")]
async fn open_succeeds_with_default_and_explicit_poll_intervals() {
let dir = temp_dir("cadence");
for opts in [
SqliteOpts::default(),
SqliteOpts {
poll_interval: Some(Duration::from_millis(50)),
..Default::default()
},
] {
let path = dir.join(format!(
"cadence-{}.db",
opts.poll_interval.map(|d| d.as_millis()).unwrap_or(0)
));
let store = open_store(path.to_str().unwrap(), opts).unwrap();
assert!(store.db_path.exists());
store.close();
}
cleanup(&dir);
}
/// Acceptance: the plain-path posture holds (ADR-023 §3 pin — a
/// `?name=value`-suffixed path creates a file with that literal
/// name; no URI flag anywhere in the open path).
#[tokio::test(flavor = "multi_thread")]
async fn uri_shaped_open_path_is_a_literal_filename() {
let dir = temp_dir("plain-path");
let path = dir.join("plain.db?mode=memory&cache=shared");
let store = open_store(path.to_str().unwrap(), SqliteOpts::default()).unwrap();
let writer_conn = store.writer.acquire().unwrap();
writer_conn.execute("CREATE TABLE t(x)", []).unwrap();
store.writer.release(writer_conn);
store.close();
assert!(
path.exists(),
"the file must exist under its literal `?name=value` name (no URI interpretation)"
);
cleanup(&dir);
}
/// `:memory:` works without URI mode (ADR-023 §3): each connection is
/// its own private memory db, so the writer carries the bootstrap and
/// a fresh pooled reader opens the same way.
#[tokio::test(flavor = "multi_thread")]
async fn memory_paths_open_without_uri_mode() {
let store = open_store(":memory:", SqliteOpts::default()).unwrap();
let reader_conn = store.readers.acquire().unwrap();
let tables: i64 = reader_conn
.query_row(
"SELECT count(*) FROM sqlite_master
WHERE type = 'table' AND name LIKE '__alkstore_%'",
[],
|r| r.get(0),
)
.unwrap();
store.readers.release(reader_conn);
assert!(tables >= 7, ":memory: open must carry the bootstrap");
store.close();
}
/// Acceptance: reader-pool sizing — `max_readers` bounds the pool
/// (excess acquirers block until release) and `0` clamps to 1
/// (the substrate's max discipline).
#[tokio::test(flavor = "multi_thread")]
async fn reader_pool_size_is_bounded_by_opts() {
let dir = temp_dir("pool");
let path = dir.join("store.db");
let store = open_store(
path.to_str().unwrap(),
SqliteOpts {
max_readers: 2,
..Default::default()
},
)
.unwrap();
let c1 = store.readers.acquire().unwrap();
let c2 = store.readers.acquire().unwrap();
let readers = store.readers.clone();
let blocked = std::thread::spawn(move || readers.acquire());
std::thread::sleep(Duration::from_millis(100));
assert!(
!blocked.is_finished(),
"acquire above max_readers must block (pool bound enforced)"
);
store.readers.release(c2);
let c3 = blocked.join().unwrap().unwrap();
store.readers.release(c3);
store.readers.release(c1);
store.close();
let store = open_store(
path.to_str().unwrap(),
SqliteOpts {
max_readers: 0,
..Default::default()
},
)
.unwrap();
let only = store.readers.acquire().unwrap();
store.readers.release(only);
store.close();
cleanup(&dir);
}
/// The default reader count is the documented constant.
#[test]
fn default_max_readers_is_the_documented_constant() {
assert_eq!(DEFAULT_MAX_READERS, 8, "the documented default pool size");
assert_eq!(
SqliteOpts::default().max_readers,
DEFAULT_MAX_READERS,
"`Default` resolves the reader-pool knob to the documented default"
);
}
/// A pool beyond its bound opens fresh readers on demand up to the
/// bound; reads on those readers see a writer's committed data
/// (the WAL cross-connection read path the reader pool exists for).
#[tokio::test(flavor = "multi_thread")]
async fn pooled_readers_see_writer_commits() {
let dir = temp_dir("read-visibility");
let path = dir.join("store.db");
let store = open_store(path.to_str().unwrap(), SqliteOpts::default()).unwrap();
let writer_conn = store.writer.acquire().unwrap();
writer_conn
.execute("CREATE TABLE t(x INTEGER)", [])
.unwrap();
writer_conn
.execute_batch("INSERT INTO t VALUES (42);")
.unwrap();
store.writer.release(writer_conn);
let reader_conn = store.readers.acquire().unwrap();
let x: i64 = reader_conn
.query_row("SELECT x FROM t", [], |r| r.get(0))
.unwrap();
store.readers.release(reader_conn);
assert_eq!(x, 42, "pooled reader reads the writer's committed row");
store.close();
cleanup(&dir);
}
/// Explicit close: the watcher joins (its thread exits), the file
/// handle is released, and the store is gone-but-reopenable.
#[tokio::test(flavor = "multi_thread")]
async fn explicit_close_releases_the_db_file_and_permits_reopen() {
let dir = temp_dir("close");
let path = dir.join("store.db");
let store = open_store(path.to_str().unwrap(), SqliteOpts::default()).unwrap();
store.close();
let reopened = open(path.to_str().unwrap(), SqliteOpts::default()).unwrap();
drop(reopened);
cleanup(&dir);
}
/// `Drop` stops the watcher and closes connections — the same teardown
/// as explicit close, reached through the drop path.
#[tokio::test(flavor = "multi_thread")]
async fn drop_runs_the_full_teardown() {
let dir = temp_dir("drop");
let path = dir.join("store.db");
{
let store = open_store(path.to_str().unwrap(), SqliteOpts::default()).unwrap();
let (_id, rx) = store.watcher.subscribe();
drop(store);
assert!(
rx.recv().is_err(),
"drop must run the same teardown as close (subscriber closed)"
);
}
let reopened = open(path.to_str().unwrap(), SqliteOpts::default()).unwrap();
drop(reopened);
cleanup(&dir);
}
/// Acceptance: the `spawn_blocking` seam — every engine call's
/// substrate round trip rides this helper; smoked here through the
/// real bridge with a rusqlite-shaped closure (the pattern the
/// mechanism tasks reuse one-for-one).
#[tokio::test(flavor = "multi_thread")]
async fn the_blocking_bridge_round_trips_and_maps_errors() {
let ok = blocking(|| Ok::<_, alkstore::Error>(7i64)).await;
assert_eq!(ok.unwrap(), 7);
let err = blocking(|| -> Result<(), alkstore::Error> {
Err(alkstore::Error::database(std::io::Error::other(
"smoke failure",
)))
})
.await;
assert!(matches!(err, Err(alkstore::Error::Database(_))));
let panicking: Result<(), alkstore::Error> =
blocking(|| -> Result<(), alkstore::Error> { panic!("bridge smoke panic") }).await;
assert!(
matches!(&panicking, Err(alkstore::Error::Database(_))),
"a panic inside a bridged closure maps to Database (no panics cross the seam), got {panicking:?}"
);
}
/// `open` on an unopenable path fails cleanly — the substrate's
/// bootstrap errors map into `Error::Database` at the constructor, not
/// panics.
#[tokio::test(flavor = "multi_thread")]
async fn open_fails_cleanly_on_an_unopenable_path() {
let result = open(
"/this/parent/does/not/exist/alkstore-open-fail.db",
SqliteOpts::default(),
);
match result {
Err(alkstore::Error::Database(source)) => {
assert!(
!source.to_string().is_empty(),
"the source chain carries the substrate's detail"
);
}
Ok(_) => panic!("expected Err(Database) from an unopenable path, got Ok"),
Err(e) => panic!("expected Err(Database) from an unopenable path, got {e}"),
}
}
@@ -118,6 +118,7 @@ fixes. Cherry-picks: empty at scaffold, append-only forever.
| D-27 | re-derivation | wave-2 review fix — `scheduler_tick`'s fire enqueue resolved the schedule row's relative `expires_s` into the absolute row `expires_at` (`now + s` at the fire transaction, one clock read) instead of passing the relative value through; regression test pins a fired job's `expires_at = fire-instant + s` and its claimability. The pre-fix pass-through would have produced rows expired in 1970 under any `expires_s` schedule | `queue_ops.rs` (`scheduler_tick`, fire-expiry resolution + test) | ADR-020 §2 (relative `expires` → resolved absolute row value, resolved at enqueue against the single clock) — D-12's landed state carried the lineage's `expires_s` field shape without carrying the lineage's enqueue-side resolution; wave-2 review (task review-wave-2) re-derivation spot-check found it | ours |
| D-28 | hygiene | wave-2 review fix — the cross-mechanism pressure test's lock-churn branch exercised a per-producer lock name (literal `"p{producer}"` had no interpolation, so all producers contended on one name) with its result swallowed via `.unwrap_or(0)`; now interpolates, releases periodically, propagates errors | `ops.rs` (pressure test, lock branch) | test-quality only (no lineage counterpart, no library code touched); `fork-provenance-and-floor`'s ported-surface pressure intent | ours |
| D-29 | port delta | `SQLITE_OPEN_URI` dropped from `open_conn`'s flags (the watcher's own opens never carried it — the inherited posture was internally inconsistent); the path argument is a plain filesystem path, `?name=value` suffixes are literal filenames, no connection-semantics mutation outside the engine constructor's opts; pinned by test (`open_conn_treats_uri_shaped_path_as_plain_filename`) | `schema.rs` (`open_conn`) | ADR-023 §3 (plain-path open — the URI parameter surface is SQLite's, not this engine's; no consumer-inventory row names URI features) | ours |
| D-30 | port delta | `open_conn_bootstrapped` — new function stacking the engine's full per-connection open posture (pragmas, notify, `attach_alkstore_functions`, `bootstrap_schema`), and `Readers::acquire`'s open path switched from bare `open_conn` to it (fresh connections only — the pool never re-bootstraps a reused connection). The lineage's pools opened readers pragmas+notify only, the function set + schema living only on the writer; this engine's reader-slot operations (claim/ack, stream reads, lock ops, scheduler checks, leadership probes) run this substrate's SQL functions on pooled connections, so every pooled connection carries the full surface | `schema.rs` (`open_conn_bootstrapped`, `Readers::acquire` open path) | engine-sqlite.md's connection architecture ("each connection runs the substrate's bootstrap at open — the writer and each pooled reader") — engine-layer obligation of `sqlite-engine-open-opts`; landed with the engine's `open` wiring | ours |
### Cherry-picks
+5 -2
View File
@@ -53,5 +53,8 @@ mod queue_ops;
mod schema;
mod watcher;
pub(crate) use schema::{attach_notify, bootstrap_schema, open_conn};
pub(crate) use watcher::SharedUpdateWatcher;
pub(crate) use schema::{
Error as SchemaError, Readers, Writer, apply_default_pragmas, attach_notify, bootstrap_schema,
open_conn, open_conn_bootstrapped,
};
pub(crate) use watcher::{DEFAULT_WATCHER_POLL_INTERVAL, SharedUpdateWatcher, WatcherConfig};
+71 -1
View File
@@ -324,6 +324,27 @@ pub(crate) fn open_conn(path: &str, install_notify: bool) -> Result<Connection,
Ok(conn)
}
/// Open a connection carrying the engine's full per-connection
/// bootstrap: pragmas, notify machinery, this substrate's function
/// set, and schema bootstrap. The engine's `open` constructor runs
/// this on the writer and each pooled reader (engine-sqlite.md's
/// connection architecture: "each connection runs the substrate's
/// bootstrap at open").
///
/// D-30: the lineage's pools open readers through bare `open_conn`
/// (pragmas + notify only), the function set + schema living only on
/// the writer. This substrate extends the pool open path with
/// `attach_alkstore_functions` + `bootstrap_schema` — the engine's
/// reader-slot operations (claim/ack, stream reads, lock ops,
/// scheduler checks) run this substrate's SQL functions on pooled
/// connections, so every pooled connection must carry them.
pub(crate) fn open_conn_bootstrapped(path: &str) -> Result<Connection, Error> {
let conn = open_conn(path, true)?;
super::ops::attach_alkstore_functions(&conn)?;
bootstrap_schema(&conn)?;
Ok(conn)
}
pub(crate) struct Writer {
slot: Mutex<Option<Connection>>,
available: Condvar,
@@ -411,7 +432,7 @@ impl Readers {
*out += 1;
drop(out);
drop(pool);
match open_conn(&self.path, false) {
match open_conn_bootstrapped(&self.path) {
Ok(conn) => {
if self.closed.load(Ordering::Acquire) {
*self.outstanding.lock() -= 1;
@@ -577,6 +598,55 @@ mod writer_reader_tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn open_conn_bootstrapped_carries_the_full_surface_on_every_connection() {
let dir = std::env::temp_dir().join(format!(
"alkstore-bootstrapped-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("bootstrapped.db");
let first = open_conn_bootstrapped(path.to_str().unwrap()).unwrap();
drop(first);
for i in 0..3 {
let conn = open_conn_bootstrapped(path.to_str().unwrap())
.unwrap_or_else(|e| panic!("bootstrapped open {i} failed: {e}"));
let mode: String = conn
.pragma_query_value(None, "journal_mode", |r| r.get(0))
.unwrap();
assert!(mode.eq_ignore_ascii_case("wal"), "open {i}: not WAL");
let notify_fn: i64 = conn
.query_row("SELECT notify('__alkstore_probe', 'x')", [], |r| r.get(0))
.unwrap_or(0);
assert!(notify_fn >= 1, "open {i}: notify function missing");
let tables: i64 = conn
.query_row(
"SELECT count(*) FROM sqlite_master
WHERE type = 'table' AND name LIKE '__alkstore_%'",
[],
|r| r.get(0),
)
.unwrap();
assert!(
tables >= 7,
"open {i}: bootstrap schema missing (saw {tables} tables)"
);
let publish_ready: i64 = conn
.query_row(
"SELECT alkstore_stream_publish('__alkstore_probe', NULL, '{}')",
[],
|r| r.get(0),
)
.unwrap_or(0);
assert!(publish_ready >= 1, "open {i}: function set missing");
drop(conn);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn concurrent_opens_never_return_database_is_locked() {
const ROUNDS: usize = 8;
+83 -9
View File
@@ -1,7 +1,7 @@
---
id: sqlite-engine-open-opts
name: SQLite engine — `open` constructor, `SqliteOpts`, connection architecture
status: pending
status: completed
depends_on: [core-engine-value-constructors]
scope: moderate
risk: medium
@@ -53,21 +53,21 @@ verify bootstrap ran, verify the watcher is up, verify opts flow).
## Acceptance Criteria
- [ ] `SqliteOpts` exists with `poll_interval: Option<Duration>`
- [x] `SqliteOpts` exists with `poll_interval: Option<Duration>`
(None = 1 ms default) and reader-pool sizing; documented
- [ ] `open(path, opts)` boots the full connection architecture:
- [x] `open(path, opts)` boots the full connection architecture:
writer slot, reader pool, watcher (fallible spawn mapped to
`Database`), substrate bootstrap on every connection
- [ ] Plain-path posture holds: no URI flag re-introduced anywhere in
- [x] Plain-path posture holds: no URI flag re-introduced anywhere in
the open path (pinning test: a `?name=value`-suffixed path
creates a file with that literal name — ADR-023 §3's backlog row)
- [ ] `poll_interval` flows to the watcher config (config-surface
- [x] `poll_interval` flows to the watcher config (config-surface
accounting test — assert the watcher's configured cadence, not
wall-clock timing; ADR-023 §4's backlog row)
- [ ] Watcher death handling wired: subscribers' receivers close
- [x] Watcher death handling wired: subscribers' receivers close
(mechanism lands with `listen`, but the death-guard plumbing
exists here)
- [ ] `cargo test -p alkstore-sqlite`, clippy `-D warnings`, fmt clean
- [x] `cargo test -p alkstore-sqlite`, clippy `-D warnings`, fmt clean
## References
@@ -79,8 +79,82 @@ verify bootstrap ran, verify the watcher is up, verify opts flow).
## Notes
> To be filled by implementation agent
- **Concrete type + boxed constructor** (the task's implementer's
choice): `open()` returns `Box<dyn Store>`; the concrete
`SqliteStore` is also re-exported (pub) so tests and the later
mechanism tasks can reach the machinery — consumers hold the trait.
- **Substrate delta D-30** (registered in PROVENANCE.md): the engine
spec's "each connection runs the substrate's bootstrap" obligation
required reader opens to carry the full surface — the lineage
opened readers pragmas+notify only (functions + schema lived on the
writer alone). Added `open_conn_bootstrapped` (pragmas, notify,
`attach_alkstore_functions`, `bootstrap_schema`) and switched
`Readers::acquire`'s fresh-open path to it. Fresh connections only;
the pool never re-bootstraps reused connections (readers stay
read-mostly — writes ride the writer slot; the idempotent bootstrap
would be harmless but pointless per-acquire).
- **Zero `poll_interval` disposition**: `SqliteOpts::poll_interval =
Some(ZERO)` fails at `open` with `Error::Database` whose source
chain carries the substrate's "watcher poll interval must be
positive" (the W-2 mapping posture — engine-layer mapping of the
substrate's string into the taxonomy; no new variant, per ADR-008
§5). Pinned by test at the source-chain level.
- **`max_readers: 0` clamps to 1** (the substrate's existing `max()`
discipline, inherited — not re-decided here).
- **Defaults**: `DEFAULT_MAX_READERS = 8` (the lineage bindings'
default, carried) — re-exported from the crate root like core's
opts constants.
- **Trait stubs**: all twelve `Store` methods return
`Err(Database("… wiring lands with the … task"))` — nothing panics
(family standard); the mechanism tasks replace them. `with_tx` is
core's provided method — it surfaces the `begin_tx` stub naturally.
- **`spawn_blocking` seam posture**: the private `blocking` helper
exists in `seam.rs` (tokio promoted to a real dep, `rt`+`sync`) but
is `#[cfg(test)]`-gated until `sqlite-engine-seam-tx` wires the
trait impls — clippy `-D warnings` rejects dead code, and an
ungated unused helper would trip the gate every intermediate commit.
Its shape is pinned by a smoke test (panics inside bridged closures
map to `Database` — no panics cross the seam) and the seam task
drops the gate. `database_error(msg)` (string→`Database`, io::Error
source) and `sqlite_error(e)` (`rusqlite::Error`→`Database`, source
chain preserved) are the two mappings the mechanism tasks reuse.
- **`SqliteStore` accessors deliberately absent**: the test module
lives under `store::` and reaches the private fields directly;
`close()` is pub (explicit close, idempotent, `Drop` delegates).
`Debug` is hand-implemented (`finish_non_exhaustive`) — no derive.
- **Watcher death plumbing verified at the substrate seam**: the open
tests subscribe through the store's `SharedUpdateWatcher` and
assert receivers disconnect on close (and on drop's teardown); the
file-replacement death path is already pinned substrate-side
(`shared_update_watcher_signals_subscribers_on_watcher_death`).
- **`store/open_tests.rs`**: `open` returns `Box<dyn Store>`, so the
tests use `open_store` (crate-private, returns `SqliteStore`) — the
boxed public constructor itself is exercised by the reopen/close
tests.
## Summary
> To be filled on completion
Stood up `alkstore-sqlite`'s public surface: `SqliteOpts`
(`poll_interval: Option<Duration>` with None = the substrate's 1 ms
shipping default via `WatcherConfig::with_poll_interval`; `max_readers`
with `DEFAULT_MAX_READERS = 8`; not `#[non_exhaustive]` per ADR-017
§3's opts exemption; `Default`+`Clone`), the `open(path, opts) ->
Result<Box<dyn Store>>` constructor booting engine-sqlite.md's full
connection architecture (writer slot, reader pool,
`SharedUpdateWatcher` with fallible spawn mapped W-2-style into
`Error::Database` — open fails if the watcher cannot start), and the
`spawn_blocking` seam's helper + error mappings. Required substrate
delta D-30 (`open_conn_bootstrapped` + reader-pool open path carries
the full per-connection bootstrap — pragmas/notify/functions/schema —
per engine-sqlite.md's "each connection runs the substrate's
bootstrap"; the lineage opened readers pragmas-only), registered in
PROVENANCE.md. Store wiring: `close()`/`Drop` join the watcher,
clear subscribers (death-guard), close pool + writer slot. Trait
methods are `Database`-error stubs pending the mechanism tasks. 14
new engine tests (full-boot, watcher fanout, death-close, cadence
accounting ADR-023 §4, plain-path ADR-023 §3, `:memory:`, pool
bounding + 0-clamp, read visibility, close/reopen, drop teardown,
seam smoke incl. panic mapping, unopenable-path failure) + 1 substrate
test. Verified: `cargo test` (workspace: 25 core + 3 suite + 101
sqlite), `cargo clippy --all-targets -- -D warnings`, `cargo fmt
--check` all clean.