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.
722 lines
25 KiB
Rust
722 lines
25 KiB
Rust
//! Connection open posture, notify machinery, bootstrap, and the
|
|
//! writer/reader connection architecture — ported from honker-core
|
|
//! `lib.rs` @ `f4e53c6` (upstream names preserved; the table family
|
|
//! renamed `_honker_*` → `__alkstore_*` per ADR-011; the bootstrap's
|
|
//! duplicate-column race swallow re-keyed to `pragma_table_info`
|
|
//! per ADR-012 §5; the at-attach notifications pruning cap per
|
|
//! ADR-010 §6 / register D-16).
|
|
|
|
use parking_lot::{Condvar, Mutex};
|
|
use rusqlite::functions::FunctionFlags;
|
|
use rusqlite::{Connection, OpenFlags};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
#[derive(thiserror::Error, Debug)]
|
|
pub(crate) enum Error {
|
|
#[error("Database error: {0}")]
|
|
Sqlite(#[from] rusqlite::Error),
|
|
}
|
|
|
|
pub(crate) const DEFAULT_PRAGMAS: &str = "PRAGMA busy_timeout = 5000;
|
|
PRAGMA synchronous = NORMAL;
|
|
PRAGMA foreign_keys = ON;
|
|
PRAGMA cache_size = -32000;
|
|
PRAGMA temp_store = MEMORY;
|
|
PRAGMA wal_autocheckpoint = 10000;";
|
|
|
|
pub(crate) fn apply_default_pragmas(conn: &Connection) -> rusqlite::Result<()> {
|
|
conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
|
|
set_journal_mode_wal(conn)?;
|
|
conn.execute_batch(DEFAULT_PRAGMAS)
|
|
}
|
|
|
|
fn set_journal_mode_wal(conn: &Connection) -> rusqlite::Result<()> {
|
|
let already_wal = |conn: &Connection| -> rusqlite::Result<bool> {
|
|
let mode: String = conn.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
|
|
Ok(mode.eq_ignore_ascii_case("wal"))
|
|
};
|
|
|
|
if already_wal(conn)? {
|
|
return Ok(());
|
|
}
|
|
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(5000);
|
|
let mut backoff = std::time::Duration::from_millis(1);
|
|
loop {
|
|
match conn.execute_batch("PRAGMA journal_mode = WAL;") {
|
|
Ok(()) => return Ok(()),
|
|
Err(e) => {
|
|
if already_wal(conn).unwrap_or(false) {
|
|
return Ok(());
|
|
}
|
|
if std::time::Instant::now() >= deadline {
|
|
return Err(e);
|
|
}
|
|
std::thread::sleep(backoff);
|
|
backoff = (backoff * 2).min(std::time::Duration::from_millis(50));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Upper bound on `__alkstore_notifications` rows before the next
|
|
/// attach prunes oldest-first past it (register D-16 — ADR-010 §6's
|
|
/// at-attach cap; engine-internal hygiene, not consumer-visible).
|
|
///
|
|
/// Not upstream. Upstream's `attach_notify` never prunes ("no magic
|
|
/// timer" — callers invoke `prune_notifications` explicitly); this
|
|
/// substrate's postures pins hygiene at attach so the engine grows
|
|
/// nothing unbounded by deferring to a caller who never prunes.
|
|
/// Rows are only trimmed while their senders' transactions are
|
|
/// committed — pruning runs on a fresh connection before its
|
|
/// `notify()` function is registered, so no in-flight wake row can
|
|
/// race the DELETE.
|
|
pub(crate) const NOTIFY_ATTACH_MAX_ROWS: i64 = 10_000;
|
|
|
|
/// Install the `__alkstore_notifications` table and the
|
|
/// `notify(channel, payload)` SQL scalar function on `conn`.
|
|
/// Idempotent.
|
|
pub(crate) fn attach_notify(conn: &Connection) -> Result<(), Error> {
|
|
conn.execute_batch(
|
|
"CREATE TABLE IF NOT EXISTS __alkstore_notifications (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
channel TEXT NOT NULL,
|
|
payload TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
);
|
|
CREATE INDEX IF NOT EXISTS __alkstore_notifications_recent
|
|
ON __alkstore_notifications(channel, id);",
|
|
)?;
|
|
|
|
conn.execute(
|
|
"DELETE FROM __alkstore_notifications
|
|
WHERE id IN (
|
|
SELECT id FROM __alkstore_notifications
|
|
ORDER BY id DESC
|
|
LIMIT -1 OFFSET ?1
|
|
)",
|
|
[NOTIFY_ATTACH_MAX_ROWS],
|
|
)?;
|
|
|
|
conn.create_scalar_function("notify", 2, FunctionFlags::SQLITE_UTF8, |ctx| {
|
|
let channel: String = ctx.get(0)?;
|
|
let payload: String = ctx.get(1)?;
|
|
let db = unsafe { ctx.get_connection() }?;
|
|
let mut ins = db.prepare_cached(
|
|
"INSERT INTO __alkstore_notifications (channel, payload) VALUES (?1, ?2)",
|
|
)?;
|
|
let id = ins.insert(rusqlite::params![channel, payload])?;
|
|
Ok(id)
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) const BOOTSTRAP_ALKSTORE_SQL: &str = "
|
|
CREATE TABLE IF NOT EXISTS __alkstore_live (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
queue TEXT NOT NULL,
|
|
payload TEXT NOT NULL,
|
|
state TEXT NOT NULL DEFAULT 'pending',
|
|
priority INTEGER NOT NULL DEFAULT 0,
|
|
run_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
worker_id TEXT,
|
|
claim_expires_at INTEGER,
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
expires_at INTEGER,
|
|
claimed_at INTEGER,
|
|
visibility_timeout_s INTEGER NOT NULL DEFAULT 300,
|
|
backoff_base_s INTEGER NOT NULL DEFAULT 5,
|
|
dead_letter_retention_s INTEGER
|
|
);
|
|
CREATE INDEX IF NOT EXISTS __alkstore_live_claim
|
|
ON __alkstore_live(queue, priority DESC, run_at, id)
|
|
WHERE state IN ('pending', 'processing');
|
|
CREATE INDEX IF NOT EXISTS __alkstore_live_pending_deadline
|
|
ON __alkstore_live(queue, run_at)
|
|
WHERE state = 'pending';
|
|
CREATE INDEX IF NOT EXISTS __alkstore_live_processing_deadline
|
|
ON __alkstore_live(queue, claim_expires_at)
|
|
WHERE state = 'processing';
|
|
CREATE TABLE IF NOT EXISTS __alkstore_dead (
|
|
id INTEGER PRIMARY KEY,
|
|
queue TEXT NOT NULL,
|
|
payload TEXT NOT NULL,
|
|
priority INTEGER NOT NULL DEFAULT 0,
|
|
run_at INTEGER NOT NULL DEFAULT 0,
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
max_attempts INTEGER NOT NULL DEFAULT 0,
|
|
last_error TEXT,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
died_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
visibility_timeout_s INTEGER NOT NULL DEFAULT 300,
|
|
backoff_base_s INTEGER NOT NULL DEFAULT 5,
|
|
dead_letter_retention_s INTEGER,
|
|
claimed_at INTEGER,
|
|
expires_at INTEGER
|
|
);
|
|
CREATE INDEX IF NOT EXISTS __alkstore_dead_retention
|
|
ON __alkstore_dead(queue, died_at);
|
|
CREATE TABLE IF NOT EXISTS __alkstore_locks (
|
|
name TEXT PRIMARY KEY,
|
|
owner TEXT NOT NULL,
|
|
expires_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS __alkstore_scheduler_tasks (
|
|
name TEXT PRIMARY KEY,
|
|
queue TEXT NOT NULL,
|
|
spec TEXT NOT NULL,
|
|
payload TEXT NOT NULL,
|
|
priority INTEGER NOT NULL DEFAULT 0,
|
|
expires_s INTEGER,
|
|
next_fire_at INTEGER NOT NULL,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
visibility_timeout_s INTEGER NOT NULL DEFAULT 300,
|
|
backoff_base_s INTEGER NOT NULL DEFAULT 5,
|
|
dead_letter_retention_s INTEGER
|
|
);
|
|
CREATE TABLE IF NOT EXISTS __alkstore_stream (
|
|
offset INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
topic TEXT NOT NULL,
|
|
key TEXT,
|
|
payload TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
);
|
|
CREATE INDEX IF NOT EXISTS __alkstore_stream_topic
|
|
ON __alkstore_stream(topic, offset);
|
|
CREATE TABLE IF NOT EXISTS __alkstore_stream_consumers (
|
|
name TEXT NOT NULL,
|
|
topic TEXT NOT NULL,
|
|
offset INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (name, topic)
|
|
);
|
|
";
|
|
|
|
fn column_present(conn: &Connection, table: &str, column: &str) -> bool {
|
|
let mut stmt = match conn.prepare(&format!(
|
|
"SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"
|
|
)) {
|
|
Ok(stmt) => stmt,
|
|
Err(_) => return false,
|
|
};
|
|
stmt.query_row([column], |_| Ok(true)).unwrap_or(false)
|
|
}
|
|
|
|
fn add_column_if_absent(
|
|
conn: &Connection,
|
|
table: &str,
|
|
column: &str,
|
|
ddl: &str,
|
|
) -> Result<(), Error> {
|
|
if column_present(conn, table, column) {
|
|
return Ok(());
|
|
}
|
|
match conn.execute(&format!("ALTER TABLE {table} ADD COLUMN {ddl}"), []) {
|
|
Ok(_) => Ok(()),
|
|
Err(e) => {
|
|
if column_present(conn, table, column) {
|
|
Ok(())
|
|
} else {
|
|
Err(e.into())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Install the alkstore queue schema on `conn`. Idempotent.
|
|
///
|
|
/// Fresh bootstrap only (ADR-012 §5): a file carrying honker's legacy
|
|
/// `_honker_*` tables gets inert orphans — never touched, never read,
|
|
/// never migrated. The migrations below exist for the same-schema
|
|
/// case: a database this bootstrap already created in an earlier
|
|
/// revision, missing a column appended since.
|
|
pub(crate) fn bootstrap_schema(conn: &Connection) -> Result<(), Error> {
|
|
conn.execute_batch(BOOTSTRAP_ALKSTORE_SQL)?;
|
|
add_column_if_absent(
|
|
conn,
|
|
"__alkstore_scheduler_tasks",
|
|
"enabled",
|
|
"enabled INTEGER NOT NULL DEFAULT 1",
|
|
)?;
|
|
add_column_if_absent(
|
|
conn,
|
|
"__alkstore_scheduler_tasks",
|
|
"max_attempts",
|
|
"max_attempts INTEGER NOT NULL DEFAULT 3",
|
|
)?;
|
|
add_column_if_absent(
|
|
conn,
|
|
"__alkstore_scheduler_tasks",
|
|
"visibility_timeout_s",
|
|
"visibility_timeout_s INTEGER NOT NULL DEFAULT 300",
|
|
)?;
|
|
add_column_if_absent(
|
|
conn,
|
|
"__alkstore_scheduler_tasks",
|
|
"backoff_base_s",
|
|
"backoff_base_s INTEGER NOT NULL DEFAULT 5",
|
|
)?;
|
|
add_column_if_absent(
|
|
conn,
|
|
"__alkstore_scheduler_tasks",
|
|
"dead_letter_retention_s",
|
|
"dead_letter_retention_s INTEGER",
|
|
)?;
|
|
add_column_if_absent(conn, "__alkstore_live", "claimed_at", "claimed_at INTEGER")?;
|
|
add_column_if_absent(
|
|
conn,
|
|
"__alkstore_live",
|
|
"visibility_timeout_s",
|
|
"visibility_timeout_s INTEGER NOT NULL DEFAULT 300",
|
|
)?;
|
|
add_column_if_absent(
|
|
conn,
|
|
"__alkstore_live",
|
|
"backoff_base_s",
|
|
"backoff_base_s INTEGER NOT NULL DEFAULT 5",
|
|
)?;
|
|
add_column_if_absent(
|
|
conn,
|
|
"__alkstore_live",
|
|
"dead_letter_retention_s",
|
|
"dead_letter_retention_s INTEGER",
|
|
)?;
|
|
add_column_if_absent(
|
|
conn,
|
|
"__alkstore_dead",
|
|
"visibility_timeout_s",
|
|
"visibility_timeout_s INTEGER NOT NULL DEFAULT 300",
|
|
)?;
|
|
add_column_if_absent(
|
|
conn,
|
|
"__alkstore_dead",
|
|
"backoff_base_s",
|
|
"backoff_base_s INTEGER NOT NULL DEFAULT 5",
|
|
)?;
|
|
add_column_if_absent(
|
|
conn,
|
|
"__alkstore_dead",
|
|
"dead_letter_retention_s",
|
|
"dead_letter_retention_s INTEGER",
|
|
)?;
|
|
add_column_if_absent(conn, "__alkstore_dead", "claimed_at", "claimed_at INTEGER")?;
|
|
add_column_if_absent(conn, "__alkstore_dead", "expires_at", "expires_at INTEGER")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn open_conn(path: &str, install_notify: bool) -> Result<Connection, Error> {
|
|
// No SQLITE_OPEN_URI (ADR-023 §3 delta): the path argument is a
|
|
// plain filesystem path — URI-interpreted `?name=value` suffixes
|
|
// would mutate connection semantics outside the engine
|
|
// constructor's opts.
|
|
let conn = Connection::open_with_flags(
|
|
path,
|
|
OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
|
|
)?;
|
|
apply_default_pragmas(&conn)?;
|
|
if install_notify {
|
|
attach_notify(&conn)?;
|
|
}
|
|
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,
|
|
closed: AtomicBool,
|
|
path: String,
|
|
}
|
|
|
|
impl Writer {
|
|
pub(crate) fn new(path: String, conn: Connection) -> Self {
|
|
Self {
|
|
slot: Mutex::new(Some(conn)),
|
|
available: Condvar::new(),
|
|
closed: AtomicBool::new(false),
|
|
path,
|
|
}
|
|
}
|
|
|
|
/// The abandonment arm (the auto-commit seam's panic-safety armor,
|
|
/// review 004 item 1): a connection that cannot return to the slot
|
|
/// — a panicked op leaves its statement state unknown — is dropped,
|
|
/// not released, and the slot is replenished with a fresh
|
|
/// bootstrapped open from the writer's own path. The release's slot
|
|
/// notify re-opens the waiters. A failed replenish leaves the slot
|
|
/// empty (the established arms' tolerance — the tick's
|
|
/// poisoned-disposition arm and the tx seam's `ConnLease` drop arm
|
|
/// both take `if let Ok(fresh)`), with the failure printed for
|
|
/// diagnosability.
|
|
pub(crate) fn replenish(&self) {
|
|
if self.closed.load(Ordering::Acquire) {
|
|
return;
|
|
}
|
|
match open_conn_bootstrapped(&self.path) {
|
|
Ok(fresh) => self.release(fresh),
|
|
Err(e) => eprintln!(
|
|
"alkstore: error: writer slot replenish after an abandoned \
|
|
operation failed to open a fresh connection: {e}"
|
|
),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn acquire(&self) -> Option<Connection> {
|
|
let mut guard = self.slot.lock();
|
|
loop {
|
|
if self.closed.load(Ordering::Acquire) {
|
|
return None;
|
|
}
|
|
if let Some(c) = guard.take() {
|
|
return Some(c);
|
|
}
|
|
self.available.wait(&mut guard);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn release(&self, conn: Connection) {
|
|
if self.closed.load(Ordering::Acquire) {
|
|
return;
|
|
}
|
|
let mut guard = self.slot.lock();
|
|
*guard = Some(conn);
|
|
self.available.notify_one();
|
|
}
|
|
|
|
pub(crate) fn close(&self) {
|
|
self.closed.store(true, Ordering::Release);
|
|
let mut guard = self.slot.lock();
|
|
guard.take();
|
|
self.available.notify_all();
|
|
}
|
|
}
|
|
|
|
pub(crate) struct Readers {
|
|
pool: Mutex<Vec<Connection>>,
|
|
outstanding: Mutex<usize>,
|
|
available: Condvar,
|
|
path: String,
|
|
max: usize,
|
|
closed: AtomicBool,
|
|
}
|
|
|
|
impl Readers {
|
|
pub(crate) fn new(path: String, max: usize) -> Self {
|
|
Self {
|
|
pool: Mutex::new(Vec::new()),
|
|
outstanding: Mutex::new(0),
|
|
available: Condvar::new(),
|
|
path,
|
|
max: max.max(1),
|
|
closed: AtomicBool::new(false),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn acquire(&self) -> Result<Connection, Error> {
|
|
loop {
|
|
if self.closed.load(Ordering::Acquire) {
|
|
return Err(closed_err());
|
|
}
|
|
let mut pool = self.pool.lock();
|
|
if let Some(c) = pool.pop() {
|
|
return Ok(c);
|
|
}
|
|
let mut out = self.outstanding.lock();
|
|
if *out < self.max {
|
|
*out += 1;
|
|
drop(out);
|
|
drop(pool);
|
|
match open_conn_bootstrapped(&self.path) {
|
|
Ok(conn) => {
|
|
if self.closed.load(Ordering::Acquire) {
|
|
*self.outstanding.lock() -= 1;
|
|
drop(conn);
|
|
return Err(closed_err());
|
|
}
|
|
return Ok(conn);
|
|
}
|
|
Err(e) => {
|
|
*self.outstanding.lock() -= 1;
|
|
return Err(e);
|
|
}
|
|
}
|
|
}
|
|
drop(out);
|
|
self.available.wait(&mut pool);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn release(&self, conn: Connection) {
|
|
if self.closed.load(Ordering::Acquire) {
|
|
return;
|
|
}
|
|
let mut pool = self.pool.lock();
|
|
pool.push(conn);
|
|
self.available.notify_one();
|
|
}
|
|
|
|
/// The abandonment arm (the auto-commit seam's panic-safety
|
|
/// armor, review 004 item 1): the abandoned connection is dropped,
|
|
/// not released. `outstanding` counts connections in existence —
|
|
/// every connection carries its fresh-open increment for life, and
|
|
/// release/pop never decrement — so the abandoned connection's
|
|
/// decrement restores the pool's open-budget, and a waiter wakes
|
|
/// under the pool lock (the release's notify discipline) to
|
|
/// re-evaluate: pool miss + under `max` ⇒ fresh open.
|
|
pub(crate) fn abandon(&self) {
|
|
{
|
|
let mut out = self.outstanding.lock();
|
|
*out = out.saturating_sub(1);
|
|
}
|
|
let _pool = self.pool.lock();
|
|
self.available.notify_one();
|
|
}
|
|
|
|
pub(crate) fn close(&self) {
|
|
self.closed.store(true, Ordering::Release);
|
|
self.pool.lock().clear();
|
|
self.available.notify_all();
|
|
}
|
|
}
|
|
|
|
fn closed_err() -> Error {
|
|
Error::Sqlite(rusqlite::Error::SqliteFailure(
|
|
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_MISUSE),
|
|
Some("Database is closed".to_string()),
|
|
))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod writer_reader_tests {
|
|
use super::*;
|
|
use std::sync::Arc;
|
|
|
|
fn mem() -> Connection {
|
|
Connection::open_in_memory().unwrap()
|
|
}
|
|
|
|
fn temp_db(name: &str) -> std::path::PathBuf {
|
|
let p = std::env::temp_dir().join(format!(
|
|
"alkstore-{name}-{}-{:?}.db",
|
|
std::process::id(),
|
|
std::thread::current().id()
|
|
));
|
|
let _ = std::fs::remove_file(&p);
|
|
let _ = std::fs::remove_file(format!("{}-wal", p.display()));
|
|
let _ = std::fs::remove_file(format!("{}-shm", p.display()));
|
|
p
|
|
}
|
|
|
|
#[test]
|
|
fn writer_close_drops_idle_connection() {
|
|
let w = Writer::new(":memory:".to_string(), mem());
|
|
w.close();
|
|
assert!(w.acquire().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn writer_close_drops_returned_connection() {
|
|
let w = Writer::new(":memory:".to_string(), mem());
|
|
let conn = w.acquire().unwrap();
|
|
w.close();
|
|
w.release(conn);
|
|
assert!(w.acquire().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn readers_close_returns_closed_err() {
|
|
let tmp = temp_db("readers-close");
|
|
let _ = std::fs::remove_file(&tmp);
|
|
let c = Connection::open(&tmp).unwrap();
|
|
c.execute_batch("PRAGMA journal_mode=WAL;").unwrap();
|
|
drop(c);
|
|
|
|
let r = Readers::new(tmp.to_string_lossy().into_owned(), 4);
|
|
let c = r.acquire().unwrap();
|
|
r.release(c);
|
|
r.close();
|
|
match r.acquire() {
|
|
Err(Error::Sqlite(rusqlite::Error::SqliteFailure(_, Some(msg)))) => {
|
|
assert!(msg.contains("Database is closed"));
|
|
}
|
|
other => panic!("expected closed err, got {other:?}"),
|
|
}
|
|
let _ = std::fs::remove_file(&tmp);
|
|
}
|
|
|
|
#[test]
|
|
fn readers_open_failure_does_not_leak_capacity() {
|
|
let r = Arc::new(Readers::new(
|
|
"/this/parent/does/not/exist/alkstore-readers-leak.db".into(),
|
|
2,
|
|
));
|
|
for i in 0..5 {
|
|
let r = r.clone();
|
|
let handle = std::thread::spawn(move || r.acquire());
|
|
let result = handle.join().unwrap();
|
|
assert!(
|
|
result.is_err(),
|
|
"attempt {i}: expected open failure, got Ok"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn open_conn_applies_wal_and_default_pragmas() {
|
|
let tmp = temp_db("open-pragmas");
|
|
let conn = open_conn(tmp.to_str().unwrap(), false).unwrap();
|
|
let mode: String = conn
|
|
.pragma_query_value(None, "journal_mode", |r| r.get(0))
|
|
.unwrap();
|
|
assert!(mode.eq_ignore_ascii_case("wal"));
|
|
let busy: i64 = conn
|
|
.pragma_query_value(None, "busy_timeout", |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(busy, 5000);
|
|
drop(conn);
|
|
let _ = std::fs::remove_file(&tmp);
|
|
let _ = std::fs::remove_file(format!("{}-wal", tmp.display()));
|
|
let _ = std::fs::remove_file(format!("{}-shm", tmp.display()));
|
|
}
|
|
|
|
#[test]
|
|
fn open_conn_treats_uri_shaped_path_as_plain_filename() {
|
|
// ADR-023 §3: no SQLITE_OPEN_URI — a `?name=value` suffix must
|
|
// be a literal filename (the file exists with that name), not
|
|
// URI interpretation (which for a bare-relative "file"-less
|
|
// string with query params would error or mutate semantics).
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"alkstore-uri-literal-{}-{:?}",
|
|
std::process::id(),
|
|
std::thread::current().id()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("plain.db?mode=memory");
|
|
let conn = open_conn(path.to_str().unwrap(), false).unwrap();
|
|
conn.execute("CREATE TABLE t (x)", []).unwrap();
|
|
drop(conn);
|
|
let plain = dir.join("plain.db?mode=memory");
|
|
assert!(plain.exists(), "literal-named file must exist on disk");
|
|
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;
|
|
const OPENERS: usize = 16;
|
|
|
|
for round in 0..ROUNDS {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"alkstore-open-race-{}-{round}-{:?}",
|
|
std::process::id(),
|
|
std::thread::current().id()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("pressure.db");
|
|
|
|
let barrier = Arc::new(std::sync::Barrier::new(OPENERS));
|
|
let handles: Vec<_> = (0..OPENERS)
|
|
.map(|_| {
|
|
let path = path.clone();
|
|
let barrier = Arc::clone(&barrier);
|
|
std::thread::spawn(move || {
|
|
let conn = Connection::open(&path)?;
|
|
barrier.wait();
|
|
apply_default_pragmas(&conn)?;
|
|
let mode: String =
|
|
conn.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
|
|
assert!(
|
|
mode.eq_ignore_ascii_case("wal"),
|
|
"expected WAL after open, got {mode}"
|
|
);
|
|
Ok::<_, rusqlite::Error>(())
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let failures: Vec<String> = handles
|
|
.into_iter()
|
|
.filter_map(|h| h.join().unwrap().err().map(|e| e.to_string()))
|
|
.collect();
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
assert!(
|
|
failures.is_empty(),
|
|
"round {round}: concurrent opens failed: {failures:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|