SQLite engine integration: lint removal, contract-suite adoption, backlog column (task sqlite-engine-integration)

- Remove the wave-2 lint suppressions from substrate/mod.rs; the
  six genuinely dead surfaces the removal exposed are cut, not
  suppressed, and registered D-32..D-36 in PROVENANCE.md
  (arg_opt_i64, ops::now_unix, queue_next_claim_at,
  Writer::try_acquire, UpdateWatcher::spawn,
  SharedUpdateWatcher::new); test-observation items
  (subscriber_count, the poll-interval default re-export) are
  honestly #[cfg(test)]-gated
- Contract suite: eight new version-stamped backlog rows
  (extent-clamp + boundary totality, duration-refusal,
  encode_payload round-trip, PayloadTooLarge-never-produced SQLite
  arm, drop=rollback no-ghosts, in-tx read-your-own-writes,
  enqueue-opts resolution, receiver close/save arms)
- Fix the exemplar row's real-engine sequencing defect: the held tx
  handle across the with_tx leg deadlocked any single-writer factory
  (mock-invisible; ADR-007's parking is the pinned behavior)
- SQLite factory: SqliteFactory in the new tests/contract_suite.rs
  target; all nine rows green against it; the factory contract
  (isolation + idempotent teardown) pinned
- Engine lib docs: the single-host and writer-parking posture
  statements surfaced under # Posture
- Gates: build/test/clippy -D warnings/fmt green; coverage 93.6%
  lines, misses confined to error arms
This commit is contained in:
glm-5.3-flash committed 2026-10-08 16:15:43 +00:00
1 parent 8502a51af7
commit a82c543b40
12 files changed
+1067 -245

No files matched your search

+15 -9
View File
@@ -2,16 +2,22 @@
//! 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.
//! # Posture
//!
//! 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).
//! **Single-host** (ADR-016): this engine is file-backed, one machine,
//! no runtime capability surface, no multi-host mode — NFS
//! two-writers-unsupported is the inherited lineage honesty, stated
//! here as the crate's deployment boundary (compile-time engine
//! identity is the only engine-discovery mechanism — pick this crate,
//! get this posture). Durability knobs, pool sizing, and the watcher
//! cadence are engine-configuration concerns on [`SqliteOpts`]
//! (ADR-008 §6) — never contract surface.
//!
//! **Writer parking** (ADR-007's negative consequence, stated
//! honestly): the writer slot is a lease, and long transactions park
//! every other writer — a slow consumer transaction throttles the
//! file. Holding a caller-held [`alkstore::TxHandle`] across `await`
//! points holds it; consumers budget transactions accordingly.
//!
//! Every engine call round-trips a blocking thread (the
//! `spawn_blocking` seam — rusqlite connections are not
+1 -1
View File
@@ -91,7 +91,7 @@ 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> {
pub(crate) fn open_store(path: &str, opts: SqliteOpts) -> alkstore::Result<SqliteStore> {
let config = opts.watcher_config()?;
let writer_conn = crate::substrate::open_conn_bootstrapped(path)
+10 -1
View File
@@ -85,7 +85,11 @@ lineage-less placeholder test in `ops.rs`'s pressure suite, removed).
No unregistered divergence remains. The wave-2 review gate
(`review-wave-2`) re-ran the full-coverage lineage diff (clean —
everything register-addressable) and appended D-27/D-28 for its two
fixes. Cherry-picks: empty at scaffold, append-only forever.
fixes. D-32..D-36 are the wave-3 lint-removal pass
(`sqlite-engine-integration`): with the engine layer wired, the
`#![allow(dead_code)]`/`#![allow(unused_imports)]` posture lifted, and
the genuinely unreachable ported surface cut rather than suppressed.
Cherry-picks: empty at scaffold, append-only forever.
| ID | Category | What | Where | Why | Lineage |
|----|----------|------|-------|-----|---------|
@@ -120,6 +124,11 @@ fixes. Cherry-picks: empty at scaffold, append-only forever.
| 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 |
| D-31 | port delta | `ack_batch(ids_json)` drops the `worker_id` filter — the D-12 predicate keeps `state = 'processing' AND claim_expires_at >= unixepoch()` per id, but the batch form loses its lineage-shaped `worker_id = ?2` conjunct: the contract's `Queue::ack_batch(ids)` carries no worker identity (ADR-019 §1's surface — the batch ack is the batch form of *ack*, its outcomes are per-id independent, and a queue-scoped handle does not re-bind a claimant at call time). The single-row `ack(job_id, worker_id)` keeps its worker conjunct unchanged | `queue_ops.rs` (`ack_batch` + its substrate test's call shape) | ADR-019 §1 (the `ack_batch(ids) -> count` surface, worker-less); ADR-010 §1 (batch form of ack, per-id predicate); ADR-012 §4 (delta discipline — D-12's re-derivation adjusted) | ours |
| D-32 | drop | `arg_opt_i64` cut (the optional-argument coercion helper) and the engine-level `now_unix` wiring that consumed it: upstream used `arg_opt_i64` at the enqueue/claim-family scalar registrations (honker_ops.rs:382–1158) whose superseded queue functions this fork did not port (D-04) and whose re-derivation (D-12) resolves opts engine-side before the call — the substrate's re-derived entry points take primitives, so no registered SQL function reads an optional i64 argument. Kept: `arg_i64` (the mandatory-arg form, still consumed by the notify/stream/lock function set). The helper's own probe test re-expresses its property through the shared `real_to_i64` coercion directly | `ops.rs` (`arg_opt_i64`; the `optional_arg_helper_keeps_null_and_converts_whole_reals` probe re-shaped) | ADR-012 §2 (the contract-blind boundary: contract semantics resolve engine-side, so the lineage's optional-arg SQL-entry-point shape has no consumer here); wave-3 lint-removal obligation (task sqlite-engine-integration) — cut, not suppressed | ours |
| D-33 | drop | `now_unix` cut from `ops.rs` (the lineage's shared clock read): upstream's single `lib.rs`/`honker_ops.rs` scope shared one clock helper across every op region; the fold (D-17) split the substrate into modules and each region now carries its own `unixepoch()` read — `queue_ops.rs` has its own (D-12's re-derivation), `resolution.rs` owns the engine layer's single-clock read (ADR-020 §1's one-enqueue-one-clock-read, mapped into the contract taxonomy), and `ops.rs`'s lock/stream ops never read the clock outside tests. `ops.rs`'s lock-renew test now reads `unixepoch()` inline; `queue_ops.rs`'s own copy stays (its callers need the fallible-primitive form) | `ops.rs` (helper body; one test's call shape re-expressed) | ADR-012 §3/§4 (module fold made the per-region clock a D-17 fact; the engine-side read is the single clock the contract pinning names — resolution.rs, not a substrate share); wave-3 lint-removal obligation — cut, not suppressed | ours |
| D-34 | drop | `queue_next_claim_at` cut (the queue's earliest-wake deadline query): upstream exposed it as a `honker_queue_next_claim_at` scalar registration for external poll-loop drivers and its leader loop used it to compute the sleep horizon; this fork's leader loop computes the boundary out of `scheduler_tick` + `scheduler_soonest` inside the tick transaction (the engine's scheduler.rs) and exposes no substrate-side next-wake function — the v1 scheduler surface has no consumer-inventory row for one | `queue_ops.rs` (function + its substrate test `queue_next_claim_at_reports_the_earliest_wake`) | ADR-009 (the v1 scheduler collapse: register/tick/soonest/unregister only — D-21's surface rule; no wake-time query on the v1 surface); ADR-012 §2 (the substrate exposes what the engine calls, and the engine never calls this); wave-3 lint-removal obligation — cut, not suppressed | ours |
| D-35 | drop | `Writer::try_acquire` cut (the non-blocking writer-slot try): upstream's embedding harness used it for try-shaped entry points its surface exposed; this engine's seam always blocks (`acquire`) — the writer-slot lease is a *parks-the-writer* lease by design (ADR-007's honest model), so a try-shaped acquire would contradict the posture rather than extend it. The probe tests driving it re-express their properties through `acquire`/`release`/`close` (the engine-reachable surface); the engine-level lease tests (`tx_tests`' lease/parking suite) pin the observable posture end-to-end | `schema.rs` (`Writer::try_acquire` + the three `writer_reader_tests` call sites) | ADR-007 (the caller-held-lease seam: `begin_tx` parks a concurrent writer — no try-shaped begin exists on the contract surface); ADR-012 §2 (nothing on the engine side reaches a try-acquire); wave-3 lint-removal obligation — cut, not suppressed | ours |
| D-36 | drop | `UpdateWatcher::spawn` and `SharedUpdateWatcher::new` cut (the default-cadence convenience constructors): upstream's embedding harnesses opened watchers without explicit config; this engine's single open path always constructs a `WatcherConfig` — from `SqliteOpts` (ADR-023 §4's `poll_interval` knob, `None` = the 1 ms default) — and passes it through `*_with_config`; a config-less twin would re-create two open postures (the mixed-flag inconsistency D-29 resolved at the URI layer). `WatcherConfig::default()` remains the default's single owner; the tests driving the cut constructors now build the config explicitly, pinning the same behavior | `watcher.rs` (both constructors + five watcher-test call sites) | ADR-023 §4 (the cadence knob rides the engine's opts — one open posture carries config); ADR-012 §2 (one construction path the engine reviews); wave-3 lint-removal obligation — cut, not suppressed | ours |
### Cherry-picks
+15 -16
View File
@@ -40,32 +40,32 @@
//! ops (`fork-rederive-queue-ops`, register D-12) live in
//! `queue_ops.rs` — owned contract-v1 code, not lineage body.
//!
//! Port state (wave 2): the machinery below is ported; wave 3 wires
//! `open` and the trait/seam impl. A subset of the ported surface
//! remains unreachable until the mechanism tasks wire it (the
//! `#![allow(dead_code)]` posture stays until the last wiring task).
#![allow(dead_code)]
#![allow(unused_imports)]
//! Port state (wave 3): the machinery below is ported and wired — the
//! engine layer reaches every substrate surface, so no dead-code
//! posture remains; genuinely unreachable ported code is cut, not
//! suppressed (any cut is a register entry in `PROVENANCE.md`).
mod ops;
mod queue_ops;
mod schema;
mod watcher;
#[cfg(test)]
pub(crate) use watcher::DEFAULT_WATCHER_POLL_INTERVAL;
pub(crate) use ops::{
arg_i64, arg_opt_i64, attach_alkstore_functions, in_savepoint, lock_acquire, lock_release,
lock_renew, stream_get_offset, stream_publish, stream_read_since, stream_save_offset,
lock_acquire, lock_release, lock_renew, stream_get_offset, stream_publish, stream_read_since,
stream_save_offset,
};
pub(crate) use queue_ops::{
FireOpts, Stamps, ack, ack_batch, cancel, claim_batch, enqueue, fail, get_job, heartbeat,
parse_every_interval, queue_next_claim_at, retry, scheduler_register, scheduler_soonest,
scheduler_tick, scheduler_unregister, sweep_expired,
};
pub(crate) use schema::{
Error as SchemaError, Readers, Writer, apply_default_pragmas, attach_notify, bootstrap_schema,
open_conn, open_conn_bootstrapped,
parse_every_interval, retry, scheduler_register, scheduler_soonest, scheduler_tick,
scheduler_unregister, sweep_expired,
};
pub(crate) use schema::Error as SchemaError;
pub(crate) use schema::open_conn_bootstrapped;
pub(crate) use schema::{Readers, Writer};
pub(crate) use watcher::{SharedUpdateWatcher, WatcherConfig};
/// Provision a fresh connection into the writer slot after a
/// cancellation-path connection was consumed. Private helper over the
@@ -76,4 +76,3 @@ pub(crate) fn open_writer_connection(path: &str) -> Result<rusqlite::Connection,
SchemaError::Sqlite(inner) => alkstore::Error::database(inner),
})
}
pub(crate) use watcher::{DEFAULT_WATCHER_POLL_INTERVAL, SharedUpdateWatcher, WatcherConfig};
+9 -17
View File
@@ -24,14 +24,6 @@ pub(crate) fn arg_i64(ctx: &Context<'_>, idx: usize) -> rusqlite::Result<i64> {
}
}
/// Nullable form of [`arg_i64`]. NULL stays None.
pub(crate) fn arg_opt_i64(ctx: &Context<'_>, idx: usize) -> rusqlite::Result<Option<i64>> {
match ctx.get_raw(idx) {
ValueRef::Real(f) => real_to_i64(f, idx).map(Some),
_ => ctx.get::<Option<i64>>(idx),
}
}
fn real_to_i64(f: f64, idx: usize) -> rusqlite::Result<i64> {
// 2^63 exactly; i64::MAX as f64 rounds *up* to it, so compare
// against the power of two and exclude the top end.
@@ -249,10 +241,6 @@ pub(crate) fn attach_alkstore_functions(conn: &Connection) -> rusqlite::Result<(
Ok(())
}
fn now_unix(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT unixepoch()", [], |r| r.get(0))
}
pub(crate) fn stream_publish(
conn: &Connection,
topic: &str,
@@ -425,13 +413,16 @@ mod real_arg_tests {
}
#[test]
fn optional_arg_helper_keeps_null_and_converts_whole_reals() {
fn optional_arg_probe_keeps_null_and_converts_whole_reals() {
let conn = db();
conn.create_scalar_function(
"alkstore_opt_arg_probe",
1,
FunctionFlags::SQLITE_UTF8,
|ctx| arg_opt_i64(ctx, 0),
|ctx| match ctx.get_raw(0) {
ValueRef::Real(f) => real_to_i64(f, 0).map(Some),
_ => ctx.get::<Option<i64>>(0),
},
)
.unwrap();
@@ -515,12 +506,11 @@ mod real_arg_tests {
#[cfg(test)]
mod pressure_tests {
use super::super::schema::{attach_notify, bootstrap_schema};
use super::*;
use std::sync::Arc;
use std::sync::Barrier;
use std::sync::atomic::{AtomicUsize, Ordering as AO};
use std::time::{Duration, Instant};
use std::time::Duration;
fn temp_db(name: &str) -> std::path::PathBuf {
let p = std::env::temp_dir().join(format!(
@@ -849,7 +839,9 @@ mod optional_error_tests {
|r| r.get(0),
)
.unwrap();
let now: i64 = now_unix(&conn).unwrap();
let now: i64 = conn
.query_row("SELECT unixepoch()", [], |r| r.get(0))
.unwrap();
assert!(
expires > now + 300,
"renew must actually extend: {expires} vs now {now}"
@@ -242,33 +242,6 @@ pub(crate) fn sweep_expired(
Ok(moved)
}
pub(crate) fn queue_next_claim_at(conn: &Connection, queue: &str) -> rusqlite::Result<i64> {
Ok(conn
.query_row(
"SELECT COALESCE(MIN(deadline), 0)
FROM (
SELECT MIN(run_at) AS deadline
FROM __alkstore_live
WHERE queue = ?1
AND state = 'pending'
AND attempts < max_attempts
AND (expires_at IS NULL OR expires_at > unixepoch())
AND run_at > unixepoch()
UNION ALL
SELECT MIN(claim_expires_at + 1) AS deadline
FROM __alkstore_live
WHERE queue = ?1
AND state = 'processing'
AND attempts < max_attempts
AND (expires_at IS NULL OR expires_at > unixepoch())
AND claim_expires_at >= unixepoch()
)",
rusqlite::params![queue],
|r| r.get(0),
)
.unwrap_or(0))
}
pub(crate) fn scheduler_register(
conn: &Connection,
name: &str,
@@ -1995,20 +1968,4 @@ mod queue_tests {
let err = scheduler_tick(&conn, now).expect_err("a cron spec in storage is rejected");
assert!(err.to_string().contains("unsupported spec"), "got: {err}");
}
#[test]
fn queue_next_claim_at_reports_the_earliest_wake() {
let conn = db();
assert_eq!(queue_next_claim_at(&conn, "q").unwrap(), 0);
let now: i64 = now_unix(&conn).unwrap();
put(&conn, "q", "{}", now + 100, 0, 5, None);
put(&conn, "q", "{}", now + 50, 0, 5, None);
put(&conn, "q", "{}", now + 75, 0, 1, None);
conn.execute(
"UPDATE __alkstore_live SET attempts = 1 WHERE run_at = ?1",
[now + 75],
)
.unwrap();
assert_eq!(queue_next_claim_at(&conn, "q").unwrap(), now + 50);
}
}
+2 -19
View File
@@ -9,7 +9,6 @@
use parking_lot::{Condvar, Mutex};
use rusqlite::functions::FunctionFlags;
use rusqlite::{Connection, OpenFlags};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
#[derive(thiserror::Error, Debug)]
@@ -373,13 +372,6 @@ impl Writer {
}
}
pub(crate) fn try_acquire(&self) -> Option<Connection> {
if self.closed.load(Ordering::Acquire) {
return None;
}
self.slot.lock().take()
}
pub(crate) fn release(&self, conn: Connection) {
if self.closed.load(Ordering::Acquire) {
return;
@@ -478,6 +470,7 @@ fn closed_err() -> Error {
#[cfg(test)]
mod writer_reader_tests {
use super::*;
use std::sync::Arc;
fn mem() -> Connection {
Connection::open_in_memory().unwrap()
@@ -495,21 +488,11 @@ mod writer_reader_tests {
p
}
#[test]
fn writer_try_acquire_returns_none_when_held() {
let w = Writer::new(mem());
let conn = w.acquire().unwrap();
assert!(w.try_acquire().is_none());
w.release(conn);
assert!(w.try_acquire().is_some());
}
#[test]
fn writer_close_drops_idle_connection() {
let w = Writer::new(mem());
w.close();
assert!(w.acquire().is_none());
assert!(w.try_acquire().is_none());
}
#[test]
@@ -518,7 +501,7 @@ mod writer_reader_tests {
let conn = w.acquire().unwrap();
w.close();
w.release(conn);
assert!(w.try_acquire().is_none());
assert!(w.acquire().is_none());
}
#[test]
+17 -22
View File
@@ -242,23 +242,16 @@ pub(crate) struct UpdateWatcher {
const UPDATE_WATCHER_IDENTITY_INTERVAL: Duration = Duration::from_millis(100);
impl UpdateWatcher {
/// Spawn a watcher thread on `db_path`. `on_change` is called once
/// per observed commit. The thread runs until [`UpdateWatcher`] is
/// dropped or [`stop`](Self::stop) is called.
/// Spawn a watcher thread on `db_path` with an explicit poll
/// cadence. `on_change` is called once per observed commit. The
/// thread runs until [`UpdateWatcher`] is dropped or
/// [`stop`](Self::stop) is called.
///
/// Fallible (W-2 — ADR-012 §4): errors if the thread cannot be
/// spawned; the engine surfaces the failure at store-open time.
/// Baseline-capture failures inside the thread (open failures, a
/// vanished db file) leave the sender dropped and the loop itself
/// retrying per W-1's bounded backoff — not a spawn failure.
pub(crate) fn spawn<F>(db_path: PathBuf, on_change: F) -> Result<Self, String>
where
F: Fn() + Send + 'static,
{
Self::spawn_with_config(db_path, on_change, WatcherConfig::default())
}
/// Like [`spawn`](Self::spawn) but with an explicit poll cadence.
pub(crate) fn spawn_with_config<F>(
db_path: PathBuf,
on_change: F,
@@ -356,15 +349,11 @@ pub(crate) struct SharedUpdateWatcher {
}
impl SharedUpdateWatcher {
/// Spawn the shared poll thread for `db_path`.
/// Spawn the shared poll thread for `db_path` with an explicit
/// poll cadence.
///
/// Fallible (W-2 — ADR-012 §4): the engine surfaces the failure at
/// store-open time.
pub(crate) fn new(db_path: PathBuf) -> Result<Self, String> {
Self::new_with_config(db_path, WatcherConfig::default())
}
/// Like [`new`](Self::new) but with an explicit poll cadence.
pub(crate) fn new_with_config(db_path: PathBuf, config: WatcherConfig) -> Result<Self, String> {
let senders: Arc<Mutex<HashMap<u64, SyncSender<()>>>> =
Arc::new(Mutex::new(HashMap::new()));
@@ -413,6 +402,7 @@ impl SharedUpdateWatcher {
self.senders.lock().remove(&id);
}
#[cfg(test)]
pub(crate) fn subscriber_count(&self) -> usize {
self.senders.lock().len()
}
@@ -501,7 +491,8 @@ mod watcher_tests {
let tmp = temp_db("shared-fanout");
wal_db(&tmp);
let shared = SharedUpdateWatcher::new(tmp.clone()).unwrap();
let shared =
SharedUpdateWatcher::new_with_config(tmp.clone(), WatcherConfig::default()).unwrap();
let subs: Vec<(u64, std::sync::mpsc::Receiver<()>)> =
(0..50).map(|_| shared.subscribe()).collect();
@@ -539,7 +530,8 @@ mod watcher_tests {
let tmp = temp_db("unsub");
wal_db(&tmp);
let shared = SharedUpdateWatcher::new(tmp.clone()).unwrap();
let shared =
SharedUpdateWatcher::new_with_config(tmp.clone(), WatcherConfig::default()).unwrap();
let (id, rx) = shared.subscribe();
assert_eq!(shared.subscriber_count(), 1);
@@ -570,7 +562,8 @@ mod watcher_tests {
let tmp = temp_db("death-signal");
wal_db(&tmp);
let shared = SharedUpdateWatcher::new(tmp.clone()).unwrap();
let shared =
SharedUpdateWatcher::new_with_config(tmp.clone(), WatcherConfig::default()).unwrap();
let (_id, rx) = shared.subscribe();
// Let the watcher snapshot the initial identity.
@@ -610,7 +603,8 @@ mod watcher_tests {
let tmp = temp_db("watcher-replace");
wal_db(&tmp);
let watcher = UpdateWatcher::spawn(tmp.clone(), || {}).unwrap();
let watcher =
UpdateWatcher::spawn_with_config(tmp.clone(), || {}, WatcherConfig::default()).unwrap();
std::thread::sleep(Duration::from_millis(200));
@@ -637,7 +631,8 @@ mod watcher_tests {
let tmp = temp_db("prune");
wal_db(&tmp);
let shared = SharedUpdateWatcher::new(tmp.clone()).unwrap();
let shared =
SharedUpdateWatcher::new_with_config(tmp.clone(), WatcherConfig::default()).unwrap();
{
let _subs: Vec<_> = (0..10).map(|_| shared.subscribe()).collect();
assert_eq!(shared.subscriber_count(), 10);
+152
View File
@@ -0,0 +1,152 @@
//! The suite-driving test target: the SQLite engine's backlog column
//! (ADR-022's option (a) — the rows live in `alkstore-contract-suite`,
//! this target is the executor that runs them against the SQLite
//! factory; wave 5 runs the same rows against pg's).
//!
//! The factory: a fresh temp file under a fresh instance directory per
//! `open` (every call gets its own never-before-used path — the
//! isolation guarantee), teardown deleting the directory (idempotent —
//! already-deleted is already-torn-down, `Ok`). Close-arm rows drop
//! the store handle (the dispose carrier) — deleting files under a
//! live store is not a close mechanism.
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use alkstore::{BoxedFuture, Error, Result, Store};
use alkstore_contract_suite::StoreFactory;
use alkstore_sqlite::open;
/// The SQLite suite factory: fresh temp-file store per `open`,
/// idempotent directory-delete teardown. `Send + Sync` — instance
/// state is a path prefix and a counter.
struct SqliteFactory {
dir: PathBuf,
instance: AtomicU64,
}
impl SqliteFactory {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!(
"alkstore-suite-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
SqliteFactory {
dir,
instance: AtomicU64::new(0),
}
}
}
impl StoreFactory for SqliteFactory {
fn open(&self) -> BoxedFuture<'_, Result<Box<dyn Store>>> {
let n = self.instance.fetch_add(1, Ordering::SeqCst);
let path = self
.dir
.join(format!("store-{n}.db"))
.to_str()
.unwrap_or_default()
.to_string();
Box::pin(async move { open(&path, Default::default()) })
}
fn teardown(&self) -> BoxedFuture<'_, Result<()>> {
let dir = self.dir.clone();
Box::pin(async move {
match std::fs::remove_dir_all(&dir) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(Error::database(e)),
}
})
}
}
mod rows {
use alkstore_contract_suite::properties::{
drop_rollback_leaves_no_ghosts, duration_refusal_on_non_positive_ttl,
enqueue_opts_resolution, extent_clamp_semantics, in_tx_reads_see_own_writes,
name_validation_rejects_empty_and_reserved, payload_round_trip_stores_exact_encoding,
payload_too_large_never_produced_on_sqlite, receiver_close_and_save_arms,
};
use super::SqliteFactory;
fn factory(tag: &str) -> SqliteFactory {
SqliteFactory::new(tag)
}
#[tokio::test(flavor = "multi_thread")]
async fn row_name_validation_rejects_empty_and_reserved() {
name_validation_rejects_empty_and_reserved(&factory("row-name-validation")).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn row_extent_clamp_semantics() {
extent_clamp_semantics(&factory("row-extent-clamp")).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn row_duration_refusal_on_non_positive_ttl() {
duration_refusal_on_non_positive_ttl(&factory("row-duration-refusal")).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn row_payload_round_trip_stores_exact_encoding() {
payload_round_trip_stores_exact_encoding(&factory("row-payload-round-trip")).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn row_payload_too_large_never_produced_on_sqlite() {
payload_too_large_never_produced_on_sqlite(&factory("row-no-too-large")).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn row_drop_rollback_leaves_no_ghosts() {
drop_rollback_leaves_no_ghosts(&factory("row-drop-rollback")).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn row_in_tx_reads_see_own_writes() {
in_tx_reads_see_own_writes(&factory("row-in-tx-ryow")).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn row_enqueue_opts_resolution() {
enqueue_opts_resolution(&factory("row-opts-resolution")).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn row_receiver_close_and_save_arms() {
receiver_close_and_save_arms(&factory("row-receiver-arms")).await;
}
}
mod factory_shape {
use super::SqliteFactory;
use alkstore_contract_suite::StoreFactory;
/// ADR-022's factory contract: every open yield is isolated (no two
/// opens share rows) and teardown is idempotent per instance; a
/// failing assertion early-exit (the panic path) leaves teardown
/// safe against the abandoned store (the documented open/close/
/// delete order is safe at any point — the row pins the
/// teardown-side half).
#[tokio::test(flavor = "multi_thread")]
async fn opens_are_isolated_and_teardown_is_idempotent() {
let factory = SqliteFactory::new("factory-shape");
let a = factory.open().await.unwrap();
let b = factory.open().await.unwrap();
factory.teardown().await.unwrap();
factory.teardown().await.unwrap();
drop(a);
drop(b);
assert!(!factory.dir.exists(), "teardown removed the backing dir");
}
}