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:
1 parent
8502a51af7
commit
a82c543b40
12 files changed
+1067
-245
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user