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

+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}"),
}
}