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:
1 parent
5474141f2b
commit
7d400906f5
11 files changed
+937
-13
No files matched your search
@@ -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}"),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user