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
@@ -37,4 +37,9 @@ pub mod properties;
|
|||||||
pub mod version_stamp;
|
pub mod version_stamp;
|
||||||
|
|
||||||
pub use factory::StoreFactory;
|
pub use factory::StoreFactory;
|
||||||
pub use properties::name_validation_rejects_empty_and_reserved;
|
pub use properties::{
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -6,11 +6,22 @@
|
|||||||
//! and the broken expectation — a failing row is a test failure, which
|
//! and the broken expectation — a failing row is a test failure, which
|
||||||
//! is the suite's purpose as a test-side artifact (relaxing the
|
//! is the suite's purpose as a test-side artifact (relaxing the
|
||||||
//! family-wide no-panic rule here only).
|
//! family-wide no-panic rule here only).
|
||||||
|
//!
|
||||||
|
//! Determinism: no wall-clock-timing assertions and no two-task race
|
||||||
|
//! windows — orchestration that would need one (runner spawning,
|
||||||
|
//! cross-connection wakes) stays engine-test-side; rows keep to
|
||||||
|
//! single-store sequential drives and the factory's isolation
|
||||||
|
//! guarantee. Rows exercise a *close/dispose* arm by dropping the
|
||||||
|
//! store handle (the dispose carrier every engine implements — the
|
||||||
|
//! drop path closes receivers terminally); teardown-then-fresh-reopen
|
||||||
|
//! rows would be factory-contract tests (ADR-022), not contract rows,
|
||||||
|
//! so none exist.
|
||||||
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use alkstore::{
|
use alkstore::{
|
||||||
BoxedFuture, Delivery, Error, JobHandle, RESERVED_PREFIX, Result, validate_shared_name,
|
BoxedFuture, Delivery, EnqueueOpts, Error, JobHandle, JobState, RESERVED_PREFIX, Result,
|
||||||
|
StreamEvent, encode_payload, validate_shared_name,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::factory::StoreFactory;
|
use crate::factory::StoreFactory;
|
||||||
@@ -208,117 +219,126 @@ pub async fn name_validation_rejects_empty_and_reserved(factory: &dyn StoreFacto
|
|||||||
outbox.run_once("", &mut NoDelivery).await,
|
outbox.run_once("", &mut NoDelivery).await,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tx paths — the twins validate identically (begin_tx route).
|
// Tx paths — the twins validate identically (begin_tx route). The
|
||||||
let mut tx = store.begin_tx().await.expect("begin_tx opens");
|
// battery lives in its own block so the handle drops before the
|
||||||
expect_invalid(
|
// `with_tx` leg below: a real engine's `begin_tx` parks a
|
||||||
"enqueue_tx(empty queue)",
|
// concurrent begin on the writer slot (ADR-007's honest model —
|
||||||
tx.enqueue_tx("", Default::default(), json!(null)).await,
|
// the mock harness never saw this), so a hold-over handle here
|
||||||
);
|
// would deadlock the row, not test it.
|
||||||
expect_reserved(
|
{
|
||||||
"enqueue_tx(reserved queue)",
|
let mut tx = store.begin_tx().await.expect("begin_tx opens");
|
||||||
tx.enqueue_tx(RESERVED_PREFIX, Default::default(), json!(null))
|
expect_invalid(
|
||||||
.await,
|
"enqueue_tx(empty queue)",
|
||||||
);
|
tx.enqueue_tx("", Default::default(), json!(null)).await,
|
||||||
expect_invalid(
|
);
|
||||||
"publish_tx(empty stream)",
|
expect_reserved(
|
||||||
tx.publish_tx("", json!(null)).await,
|
"enqueue_tx(reserved queue)",
|
||||||
);
|
tx.enqueue_tx(RESERVED_PREFIX, Default::default(), json!(null))
|
||||||
expect_reserved(
|
.await,
|
||||||
"publish_tx(reserved stream)",
|
);
|
||||||
tx.publish_tx(RESERVED_PREFIX, json!(null)).await,
|
expect_invalid(
|
||||||
);
|
"publish_tx(empty stream)",
|
||||||
expect_invalid(
|
tx.publish_tx("", json!(null)).await,
|
||||||
"notify_tx(empty channel)",
|
);
|
||||||
tx.notify_tx("", json!(null)).await,
|
expect_reserved(
|
||||||
);
|
"publish_tx(reserved stream)",
|
||||||
expect_reserved(
|
tx.publish_tx(RESERVED_PREFIX, json!(null)).await,
|
||||||
"notify_tx(reserved channel)",
|
);
|
||||||
tx.notify_tx(RESERVED_PREFIX, json!(null)).await,
|
expect_invalid(
|
||||||
);
|
"notify_tx(empty channel)",
|
||||||
expect_invalid(
|
tx.notify_tx("", json!(null)).await,
|
||||||
"publish_with_key_tx(empty stream)",
|
);
|
||||||
tx.publish_with_key_tx("", None, json!(null)).await,
|
expect_reserved(
|
||||||
);
|
"notify_tx(reserved channel)",
|
||||||
expect_reserved(
|
tx.notify_tx(RESERVED_PREFIX, json!(null)).await,
|
||||||
"publish_with_key_tx(reserved stream)",
|
);
|
||||||
tx.publish_with_key_tx(RESERVED_PREFIX, None, json!(null))
|
expect_invalid(
|
||||||
.await,
|
"publish_with_key_tx(empty stream)",
|
||||||
);
|
tx.publish_with_key_tx("", None, json!(null)).await,
|
||||||
expect_invalid(
|
);
|
||||||
"publish_with_key_tx(empty key)",
|
expect_reserved(
|
||||||
tx.publish_with_key_tx("work", Some(String::new()), json!(null))
|
"publish_with_key_tx(reserved stream)",
|
||||||
.await,
|
tx.publish_with_key_tx(RESERVED_PREFIX, None, json!(null))
|
||||||
);
|
.await,
|
||||||
expect_invalid(
|
);
|
||||||
"publish_with_key_tx(whitespace key)",
|
expect_invalid(
|
||||||
tx.publish_with_key_tx("work", Some(" ".to_string()), json!(null))
|
"publish_with_key_tx(empty key)",
|
||||||
.await,
|
tx.publish_with_key_tx("work", Some(String::new()), json!(null))
|
||||||
);
|
.await,
|
||||||
expect_invalid(
|
);
|
||||||
"save_offset_tx(empty stream)",
|
expect_invalid(
|
||||||
tx.save_offset_tx("", "consumer", 0).await,
|
"publish_with_key_tx(whitespace key)",
|
||||||
);
|
tx.publish_with_key_tx("work", Some(" ".to_string()), json!(null))
|
||||||
expect_reserved(
|
.await,
|
||||||
"save_offset_tx(reserved stream)",
|
);
|
||||||
tx.save_offset_tx(RESERVED_PREFIX, "consumer", 0).await,
|
expect_invalid(
|
||||||
);
|
"save_offset_tx(empty stream)",
|
||||||
expect_invalid(
|
tx.save_offset_tx("", "consumer", 0).await,
|
||||||
"save_offset_tx(empty consumer)",
|
);
|
||||||
tx.save_offset_tx("work", "", 0).await,
|
expect_reserved(
|
||||||
);
|
"save_offset_tx(reserved stream)",
|
||||||
expect_invalid("get_job_tx(empty queue)", tx.get_job_tx("", 1).await);
|
tx.save_offset_tx(RESERVED_PREFIX, "consumer", 0).await,
|
||||||
expect_reserved(
|
);
|
||||||
"get_job_tx(reserved queue)",
|
expect_invalid(
|
||||||
tx.get_job_tx(RESERVED_PREFIX, 1).await,
|
"save_offset_tx(empty consumer)",
|
||||||
);
|
tx.save_offset_tx("work", "", 0).await,
|
||||||
expect_invalid(
|
);
|
||||||
"get_offset_tx(empty stream)",
|
expect_invalid("get_job_tx(empty queue)", tx.get_job_tx("", 1).await);
|
||||||
tx.get_offset_tx("", "consumer").await,
|
expect_reserved(
|
||||||
);
|
"get_job_tx(reserved queue)",
|
||||||
expect_reserved(
|
tx.get_job_tx(RESERVED_PREFIX, 1).await,
|
||||||
"get_offset_tx(reserved stream)",
|
);
|
||||||
tx.get_offset_tx(RESERVED_PREFIX, "consumer").await,
|
expect_invalid(
|
||||||
);
|
"get_offset_tx(empty stream)",
|
||||||
expect_invalid(
|
tx.get_offset_tx("", "consumer").await,
|
||||||
"read_since_tx(empty stream)",
|
);
|
||||||
tx.read_since_tx("", 0, 10).await,
|
expect_reserved(
|
||||||
);
|
"get_offset_tx(reserved stream)",
|
||||||
expect_reserved(
|
tx.get_offset_tx(RESERVED_PREFIX, "consumer").await,
|
||||||
"read_since_tx(reserved stream)",
|
);
|
||||||
tx.read_since_tx(RESERVED_PREFIX, 0, 10).await,
|
expect_invalid(
|
||||||
);
|
"read_since_tx(empty stream)",
|
||||||
expect_invalid(
|
tx.read_since_tx("", 0, 10).await,
|
||||||
"read_from_consumer_tx(empty stream)",
|
);
|
||||||
tx.read_from_consumer_tx("", "consumer", 10).await,
|
expect_reserved(
|
||||||
);
|
"read_since_tx(reserved stream)",
|
||||||
expect_reserved(
|
tx.read_since_tx(RESERVED_PREFIX, 0, 10).await,
|
||||||
"read_from_consumer_tx(reserved stream)",
|
);
|
||||||
tx.read_from_consumer_tx(RESERVED_PREFIX, "consumer", 10)
|
expect_invalid(
|
||||||
.await,
|
"read_from_consumer_tx(empty stream)",
|
||||||
);
|
tx.read_from_consumer_tx("", "consumer", 10).await,
|
||||||
expect_invalid(
|
);
|
||||||
"read_from_consumer_tx(empty consumer)",
|
expect_reserved(
|
||||||
tx.read_from_consumer_tx("work", "", 10).await,
|
"read_from_consumer_tx(reserved stream)",
|
||||||
);
|
tx.read_from_consumer_tx(RESERVED_PREFIX, "consumer", 10)
|
||||||
expect_invalid(
|
.await,
|
||||||
"get_offset_tx(empty consumer)",
|
);
|
||||||
tx.get_offset_tx("work", "").await,
|
expect_invalid(
|
||||||
);
|
"read_from_consumer_tx(empty consumer)",
|
||||||
expect_invalid(
|
tx.read_from_consumer_tx("work", "", 10).await,
|
||||||
"outbox_enqueue_tx(empty outbox)",
|
);
|
||||||
tx.outbox_enqueue_tx("", Default::default(), json!(null))
|
expect_invalid(
|
||||||
.await,
|
"get_offset_tx(empty consumer)",
|
||||||
);
|
tx.get_offset_tx("work", "").await,
|
||||||
expect_reserved(
|
);
|
||||||
"outbox_enqueue_tx(reserved outbox)",
|
expect_invalid(
|
||||||
tx.outbox_enqueue_tx(RESERVED_PREFIX, Default::default(), json!(null))
|
"outbox_enqueue_tx(empty outbox)",
|
||||||
.await,
|
tx.outbox_enqueue_tx("", Default::default(), json!(null))
|
||||||
);
|
.await,
|
||||||
|
);
|
||||||
|
expect_reserved(
|
||||||
|
"outbox_enqueue_tx(reserved outbox)",
|
||||||
|
tx.outbox_enqueue_tx(RESERVED_PREFIX, Default::default(), json!(null))
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Tx path via `with_tx` (the provided wrapper): a validation error
|
// Tx path via `with_tx` (the provided wrapper): a validation error
|
||||||
// inside the closure propagates as the wrapper's `Err` (and the
|
// inside the closure propagates as the wrapper's `Err` (and the
|
||||||
// disposition rolls back) — the commit-atomic path cannot bypass
|
// disposition rolls back) — the commit-atomic path cannot bypass
|
||||||
// validation either.
|
// validation either. The battery's handle dropped above (never
|
||||||
|
// committed — validation never reached the storage layer), so the
|
||||||
|
// writer slot is free for this wrapper's own begin.
|
||||||
let result = store
|
let result = store
|
||||||
.with_tx(Box::new(|tx| {
|
.with_tx(Box::new(|tx| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
@@ -331,3 +351,597 @@ pub async fn name_validation_rejects_empty_and_reserved(factory: &dyn StoreFacto
|
|||||||
|
|
||||||
factory.teardown().await.expect("factory teardown");
|
factory.teardown().await.expect("factory teardown");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Extent-clamp semantics** — extents clamp to empty, boundaries are
|
||||||
|
/// total (the numeric-domain rule).
|
||||||
|
///
|
||||||
|
/// `claim_batch(n <= 0)` claims nothing: empty `Vec`, no error, no
|
||||||
|
/// dialect reinterpretation — a non-positive extent never hands out
|
||||||
|
/// more rows than a positive-`n` claim on the same queue state would
|
||||||
|
/// (the SQLite `LIMIT -1` dialect artifact is dead at the trait-impl
|
||||||
|
/// entry, before any substrate round trip; the queued rows survive
|
||||||
|
/// untouched and remain claimable after). Both stream reads clamp
|
||||||
|
/// identically: `read_since(limit <= 0)` and
|
||||||
|
/// `read_from_consumer(limit <= 0)` return the empty `Vec` at the
|
||||||
|
/// trait-impl entry. Boundaries are total without a guard:
|
||||||
|
/// `trim_to(horizon < min surviving offset)` deletes nothing
|
||||||
|
/// (idempotent, `Ok(0)`) and surviving rows' offsets are never
|
||||||
|
/// renumbered.
|
||||||
|
///
|
||||||
|
/// Contract stamp: ADR-023 §2 (numeric domains: extents clamp to
|
||||||
|
/// empty, boundaries total); ADR-010 §1 (the no-work-is-a-value
|
||||||
|
/// vocabulary the clamp composes with).
|
||||||
|
///
|
||||||
|
/// Runnable on any engine (SQLite now, pg at wave 5 via the same row).
|
||||||
|
pub async fn extent_clamp_semantics(factory: &dyn StoreFactory) {
|
||||||
|
let store = factory.open().await.expect("factory opens a store");
|
||||||
|
|
||||||
|
let queue = store
|
||||||
|
.queue("work", alkstore::QueueOpts::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
for i in 0..5 {
|
||||||
|
ids.push(
|
||||||
|
queue
|
||||||
|
.enqueue(json!({"i": i}), EnqueueOpts::default())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for n in [0i64, -1, i64::MIN] {
|
||||||
|
let claimed = queue.claim_batch("w1", n).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
claimed.is_empty(),
|
||||||
|
"claim_batch(n = {n}) must clamp to the empty Vec"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The negative extent never became "no limit": the five rows are
|
||||||
|
// still pending and a positive-n claim on the same state hands out
|
||||||
|
// exactly its extent.
|
||||||
|
let claimed = queue.claim_batch("w1", 2).await.unwrap();
|
||||||
|
assert_eq!(claimed.len(), 2, "a positive extent claims normally");
|
||||||
|
assert_eq!(
|
||||||
|
claimed.iter().map(|h| h.job().id).collect::<Vec<_>>(),
|
||||||
|
ids[..2]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
queue.get_job(ids[2]).await.unwrap().unwrap().state,
|
||||||
|
JobState::Pending,
|
||||||
|
"the guarded calls destroyed nothing"
|
||||||
|
);
|
||||||
|
|
||||||
|
let stream = store.stream("events").await.unwrap();
|
||||||
|
stream.publish(json!({"n": 1})).await.unwrap();
|
||||||
|
stream.publish(json!({"n": 2})).await.unwrap();
|
||||||
|
stream.save_offset("c", 5).await.unwrap();
|
||||||
|
for limit in [0i64, -1, i64::MIN] {
|
||||||
|
let read = stream.read_since(0, limit).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
read.is_empty(),
|
||||||
|
"read_since(limit = {limit}) must read nothing"
|
||||||
|
);
|
||||||
|
let read = stream.read_from_consumer("c", limit).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
read.is_empty(),
|
||||||
|
"read_from_consumer(limit = {limit}) must read nothing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let whole = stream.read_since(0, 10).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
whole.len(),
|
||||||
|
2,
|
||||||
|
"the clamped reads never handed the stream out in bulk"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Boundary totality: a horizon below every surviving offset deletes
|
||||||
|
// nothing, idempotently, and survivors keep their offsets.
|
||||||
|
let deleted = stream.trim_to(-7).await.unwrap();
|
||||||
|
assert_eq!(deleted, 0, "a negative trim horizon deletes nothing");
|
||||||
|
let after = stream.read_since(0, 10).await.unwrap();
|
||||||
|
let survivor_offsets: Vec<i64> = after.iter().map(|e| e.offset).collect();
|
||||||
|
assert_eq!(
|
||||||
|
survivor_offsets,
|
||||||
|
whole.iter().map(|e| e.offset).collect::<Vec<_>>(),
|
||||||
|
"surviving offsets are immutable, never renumbered"
|
||||||
|
);
|
||||||
|
|
||||||
|
factory.teardown().await.expect("factory teardown");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Duration-refusal** — durations are the other half of the
|
||||||
|
/// numeric-domain rule: `try_lock(ttl <= 0)` and `renew(ttl <= 0)` are
|
||||||
|
/// opaque rejections (`Error::Database`), never a granted lock, never
|
||||||
|
/// a silent no-op grant, and not misreported as name errors. The lock
|
||||||
|
/// state is untouched by a guarded call: a guarded `try_lock` over an
|
||||||
|
/// untouched name creates no row and a later valid try succeeds — and
|
||||||
|
/// a valid holder's subsequent guarded renews leave its window
|
||||||
|
/// unshrunken.
|
||||||
|
///
|
||||||
|
/// The opaque shape is deliberate (no `InvalidArgument` variant was
|
||||||
|
/// minted — the caller's remedy is the same regardless: fix the
|
||||||
|
/// constant, ADR-008 §5's act-differently rule); the row pins the shape
|
||||||
|
/// engines must agree on, not a message text.
|
||||||
|
///
|
||||||
|
/// Contract stamp: ADR-023 §2 (durations reject); ADR-008 §5 (the
|
||||||
|
/// opaque fallback carries it, source chain in the detail); ADR-008 §7
|
||||||
|
/// (the exclusion guarantee the refusal protects).
|
||||||
|
pub async fn duration_refusal_on_non_positive_ttl(factory: &dyn StoreFactory) {
|
||||||
|
let store = factory.open().await.expect("factory opens a store");
|
||||||
|
|
||||||
|
for ttl in [0i64, -1] {
|
||||||
|
let out = store.try_lock("guarded", "a", ttl).await;
|
||||||
|
match out {
|
||||||
|
Ok(Some(_)) => panic!("try_lock(ttl = {ttl}) must not grant"),
|
||||||
|
Ok(None) => panic!("try_lock(ttl = {ttl}) must be a rejection, not a silent miss"),
|
||||||
|
Err(e) => assert!(
|
||||||
|
matches!(e, Error::Database(_)),
|
||||||
|
"opaque `Database` rejection shape, got: {e:?}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lock = store
|
||||||
|
.try_lock("guarded", "a", 300)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("the guarded calls fired no round trip: the name is free");
|
||||||
|
for ttl in [0i64, -1] {
|
||||||
|
match lock.renew(ttl).await {
|
||||||
|
Ok(landed) => panic!("renew(ttl = {ttl}) must not land: {landed}"),
|
||||||
|
Err(e) => assert!(
|
||||||
|
matches!(e, Error::Database(_)),
|
||||||
|
"opaque `Database` rejection shape, got: {e:?}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(lock.renew(600).await.unwrap(), "a positive ttl renews");
|
||||||
|
let _ = lock.release().await.unwrap();
|
||||||
|
|
||||||
|
factory.teardown().await.expect("factory teardown");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **`encode_payload` typed-failure round-trip** — enqueue and publish
|
||||||
|
/// store the exact serde_json serialization of the input `Value` and
|
||||||
|
/// decode back to `Value` equality; the helper's failure arm is typed
|
||||||
|
/// (`Error::Codec`), so no silent empty-byte store exists (which would
|
||||||
|
/// decode to `Null` — the corruption ADR-023 §1 removed).
|
||||||
|
///
|
||||||
|
/// Row-driven probe: the round-trip property pins the byte-level pin
|
||||||
|
/// through the real store paths (queue enqueue, keyed publish), both
|
||||||
|
/// auto-commit; the helper's typed shape is pinned directly by
|
||||||
|
/// exercising it against the same values (serialization of a `Value`
|
||||||
|
/// is infallible in practice — the assert is that what *is* stored is
|
||||||
|
/// exactly the helper's output bytes, and that the failure arm's type
|
||||||
|
/// exists for the seam to carry). Byte equality composes with the
|
||||||
|
/// cross-engine pin (the same `Value` serializes identically on both
|
||||||
|
/// engines).
|
||||||
|
///
|
||||||
|
/// Contract stamp: ADR-023 §1 (the fallible helper — `Result<Vec<u8>,
|
||||||
|
/// Error::Codec>`, no silent empty-Vec fallback); ADR-020 §4 (store
|
||||||
|
/// exactly the serialized bytes; decode back to `Value` equality).
|
||||||
|
pub async fn payload_round_trip_stores_exact_encoding(factory: &dyn StoreFactory) {
|
||||||
|
let store = factory.open().await.expect("factory opens a store");
|
||||||
|
|
||||||
|
let value = json!({"task": "resize", "deep": [1, 2, {"n": null, "ok": true}], "f": -0.5});
|
||||||
|
let exact = encode_payload(&value).expect("the serialization encodes");
|
||||||
|
|
||||||
|
let queue = store
|
||||||
|
.queue("work", alkstore::QueueOpts::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let id = queue
|
||||||
|
.enqueue(value.clone(), EnqueueOpts::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let job = queue.get_job(id).await.unwrap().expect("the row exists");
|
||||||
|
assert_eq!(
|
||||||
|
job.payload, exact,
|
||||||
|
"the stored job bytes are the helper's exact serialization"
|
||||||
|
);
|
||||||
|
let decoded: serde_json::Value =
|
||||||
|
alkstore::payload_as(&job.payload).expect("the stored bytes decode");
|
||||||
|
assert_eq!(decoded, value, "the decode round-trips to value equality");
|
||||||
|
|
||||||
|
let stream = store.stream("events").await.unwrap();
|
||||||
|
let offset = stream
|
||||||
|
.publish_with_key(Some("k".to_string()), value.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(offset > 0, "the publish got an offset");
|
||||||
|
let page = stream.read_since(0, 10).await.unwrap();
|
||||||
|
assert_eq!(page.len(), 1, "exactly one event landed");
|
||||||
|
assert_eq!(
|
||||||
|
page[0].payload, exact,
|
||||||
|
"the stored event bytes are the helper's exact serialization"
|
||||||
|
);
|
||||||
|
assert_eq!(page[0].key.as_deref(), Some("k"), "the key round-trips");
|
||||||
|
assert_eq!(
|
||||||
|
alkstore::payload_as::<serde_json::Value>(&page[0].payload).unwrap(),
|
||||||
|
value,
|
||||||
|
"the event decode round-trips to value equality"
|
||||||
|
);
|
||||||
|
|
||||||
|
factory.teardown().await.expect("factory teardown");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **`PayloadTooLarge` occurrence asymmetry** — the SQLite engine
|
||||||
|
/// never produces the variant at any payload size (no notification
|
||||||
|
/// limit); engine-agnostic code writes the same match on both engines
|
||||||
|
/// and the non-occurring arm simply never fires. The row drives an
|
||||||
|
/// oversized payload through the store's notify path and asserts
|
||||||
|
/// success.
|
||||||
|
///
|
||||||
|
/// This is the SQLite column's arm of the asymmetry row (the pg-side
|
||||||
|
/// rejection arm pins with wave 4's adoption — the same row text, the
|
||||||
|
/// engine-differing leg inside).
|
||||||
|
///
|
||||||
|
/// Contract stamp: ADR-016 §5 (the occurrence asymmetry — SQLite never
|
||||||
|
/// produces `PayloadTooLarge`); ADR-008 §5 (universal variant, no
|
||||||
|
/// engine-specific growth).
|
||||||
|
pub async fn payload_too_large_never_produced_on_sqlite(factory: &dyn StoreFactory) {
|
||||||
|
let store = factory.open().await.expect("factory opens a store");
|
||||||
|
|
||||||
|
let big = "x".repeat(2_000_000);
|
||||||
|
store
|
||||||
|
.notify("big", json!({"blob": big}))
|
||||||
|
.await
|
||||||
|
.expect("SQLite notify has no payload limit at any size");
|
||||||
|
|
||||||
|
factory.teardown().await.expect("factory teardown");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Drop = rollback, no-ghosts** — a transaction handle dropped
|
||||||
|
/// before commit rolls its writes back through RAII: job rows, stream
|
||||||
|
/// events, notifies, and offset saves all leave no residue the
|
||||||
|
/// transaction's own reads can no longer observe, and post-drop reads
|
||||||
|
/// (fresh tx and auto-commit handles alike) see the pre-transaction
|
||||||
|
/// state exactly.
|
||||||
|
///
|
||||||
|
/// The row drives one tx through the full no-ghosts list (every write
|
||||||
|
/// kind, ADR-021 §4's uniform list), drops without commit, then reads
|
||||||
|
/// the subjects through every surface that saw them mid-tx.
|
||||||
|
///
|
||||||
|
/// Contract stamp: ADR-021 §4 (drop = rollback; the uniform no-ghosts
|
||||||
|
/// list); ADR-007 (the caller-held-handle seam the RAII paths guard).
|
||||||
|
pub async fn drop_rollback_leaves_no_ghosts(factory: &dyn StoreFactory) {
|
||||||
|
let store = factory.open().await.expect("factory opens a store");
|
||||||
|
|
||||||
|
let mut tx = store.begin_tx().await.unwrap();
|
||||||
|
let ghost_id = tx
|
||||||
|
.enqueue_tx("work", EnqueueOpts::default(), json!({"ghost": true}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.publish_tx("events", json!({"ghost": true}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.save_offset_tx("events", "c", 7).await.unwrap();
|
||||||
|
|
||||||
|
// The ghost is real inside the tx (read-your-own-writes is what
|
||||||
|
// makes this drop's rollback meaningful).
|
||||||
|
assert!(
|
||||||
|
tx.get_job_tx("work", ghost_id).await.unwrap().is_some(),
|
||||||
|
"the tx sees its own enqueue pre-drop"
|
||||||
|
);
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
// Fresh reads: the subjects are gone through every surface.
|
||||||
|
let mut tx = store.begin_tx().await.unwrap();
|
||||||
|
let gone = tx.get_job_tx("work", ghost_id).await.unwrap();
|
||||||
|
let events = tx.read_since_tx("events", 0, 100).await.unwrap();
|
||||||
|
let checkpoint = tx.get_offset_tx("events", "c").await.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
assert_eq!(gone, None, "the dropped tx's job left no row");
|
||||||
|
assert_eq!(events, Vec::<StreamEvent>::new(), "no ghost events");
|
||||||
|
assert_eq!(checkpoint, 0, "the dropped tx's offset save never landed");
|
||||||
|
|
||||||
|
let queue = store
|
||||||
|
.queue("work", alkstore::QueueOpts::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let drained = queue.claim_batch("probe", 10).await.unwrap();
|
||||||
|
assert!(drained.is_empty(), "no ghost work became claimable");
|
||||||
|
|
||||||
|
let stream = store.stream("events").await.unwrap();
|
||||||
|
let events = stream.read_since(0, 100).await.unwrap();
|
||||||
|
assert!(events.is_empty(), "no ghost events on the auto-commit read");
|
||||||
|
assert_eq!(
|
||||||
|
stream.get_offset("c").await.unwrap(),
|
||||||
|
0,
|
||||||
|
"no ghost checkpoint"
|
||||||
|
);
|
||||||
|
|
||||||
|
factory.teardown().await.expect("factory teardown");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **In-tx read-your-own-writes** — reads issued on the tx handle see
|
||||||
|
/// the transaction's own writes: `get_job_tx` a row the same tx
|
||||||
|
/// enqueued, `read_since_tx` an event the same tx published, and the
|
||||||
|
/// consumer-twin forms see their own saves. (Another connection
|
||||||
|
/// seeing none of it pre-commit and the post-rollback no-ghosts leg
|
||||||
|
/// pin in the drop-rollback and commit-visibility rows; this row pins
|
||||||
|
/// the visibility itself.)
|
||||||
|
///
|
||||||
|
/// Contract stamp: ADR-021 §1 (tx-side read methods,
|
||||||
|
/// read-your-own-writes); ADR-007 (the commit-atomic seam the reads
|
||||||
|
/// ride).
|
||||||
|
pub async fn in_tx_reads_see_own_writes(factory: &dyn StoreFactory) {
|
||||||
|
let store = factory.open().await.expect("factory opens a store");
|
||||||
|
|
||||||
|
let mut tx = store.begin_tx().await.unwrap();
|
||||||
|
let id = tx
|
||||||
|
.enqueue_tx("work", EnqueueOpts::default(), json!({"n": 9}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let offset = tx.publish_tx("events", json!({"n": 9})).await.unwrap();
|
||||||
|
|
||||||
|
let job = tx
|
||||||
|
.get_job_tx("work", id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("own write visible");
|
||||||
|
assert_eq!(job.id, id);
|
||||||
|
assert_eq!(job.queue, "work");
|
||||||
|
assert_eq!(job.state, JobState::Pending);
|
||||||
|
assert_eq!(job.payload, encode_payload(&json!({"n": 9})).unwrap());
|
||||||
|
|
||||||
|
let page = tx.read_since_tx("events", 0, 10).await.unwrap();
|
||||||
|
assert_eq!(page.len(), 1, "the tx sees its own publish");
|
||||||
|
assert_eq!(page[0].offset, offset, "the assigned offset reads back");
|
||||||
|
assert_eq!(page[0].stream, "events");
|
||||||
|
|
||||||
|
// The twin forms see their own saves too.
|
||||||
|
tx.save_offset_tx("events", "c", offset).await.unwrap();
|
||||||
|
let from = tx.read_from_consumer_tx("events", "c", 10).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
from.is_empty(),
|
||||||
|
"everything is at or below the checkpoint just saved"
|
||||||
|
);
|
||||||
|
let checkpoint = tx.get_offset_tx("events", "c").await.unwrap();
|
||||||
|
assert_eq!(checkpoint, offset, "the tx sees its own save");
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
factory.teardown().await.expect("factory teardown");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Enqueue-opts resolution** — opts-to-row stamping is engine-side
|
||||||
|
/// arithmetic but contract-pinned identical across engines.
|
||||||
|
/// Delay-over-`run_at` precedence (the row shows the resolved ready
|
||||||
|
/// time only), relative `expires` resolution to the absolute row
|
||||||
|
/// expiry, the plain-queue derived stamps on the no-handle-open
|
||||||
|
/// tx shape (300/3/5/none), and the same defaults with
|
||||||
|
/// `EnqueueOpts::max_attempts` override riding over them — `get_job`
|
||||||
|
/// shows resolved values, never the raw opts.
|
||||||
|
///
|
||||||
|
/// The `run_at`-alone-literal and neither-field-resolves-to-now legs
|
||||||
|
/// are wall-clock-adjacent; they pin with wave 5's equivalence row
|
||||||
|
/// (this row's precedence/resolution legs are the engine-agnostic
|
||||||
|
/// claim).
|
||||||
|
///
|
||||||
|
/// Contract stamp: ADR-020 §1 (delay wins over `run_at`); §2
|
||||||
|
/// (relative `expires` → absolute row value); §3/§3a (the derived
|
||||||
|
/// default stamps — plain queue 300/3/5/none, per-job override);
|
||||||
|
/// ADR-012 §2 (engine-side arithmetic pinned equivalent).
|
||||||
|
pub async fn enqueue_opts_resolution(factory: &dyn StoreFactory) {
|
||||||
|
let store = factory.open().await.expect("factory opens a store");
|
||||||
|
|
||||||
|
const PLAIN_STAMPS: (i64, i64, i64, Option<i64>) = (300, 3, 5, None);
|
||||||
|
|
||||||
|
let mut tx = store.begin_tx().await.unwrap();
|
||||||
|
let plain = tx
|
||||||
|
.enqueue_tx("work", EnqueueOpts::default(), json!({"shape": "tx"}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let precedence = tx
|
||||||
|
.enqueue_tx(
|
||||||
|
"work",
|
||||||
|
EnqueueOpts {
|
||||||
|
delay: Some(60),
|
||||||
|
run_at: Some(1_500_000_000),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
json!({"shape": "precedence"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let bounded = tx
|
||||||
|
.enqueue_tx(
|
||||||
|
"work",
|
||||||
|
EnqueueOpts {
|
||||||
|
expires: Some(90),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
json!({"shape": "bounded"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let overridden = tx
|
||||||
|
.enqueue_tx(
|
||||||
|
"work",
|
||||||
|
EnqueueOpts {
|
||||||
|
max_attempts: Some(7),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
json!({"shape": "override"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let queue = store
|
||||||
|
.queue("work", alkstore::QueueOpts::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let plain_job = queue.get_job(plain).await.unwrap().unwrap();
|
||||||
|
let stamps = (
|
||||||
|
plain_job.visibility_timeout_s,
|
||||||
|
plain_job.max_attempts,
|
||||||
|
plain_job.backoff_base_s,
|
||||||
|
plain_job.dead_letter_retention_s,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stamps, PLAIN_STAMPS,
|
||||||
|
"the no-handle-open tx shape stamps the plain-queue derived defaults"
|
||||||
|
);
|
||||||
|
|
||||||
|
let precedence_job = queue.get_job(precedence).await.unwrap().unwrap();
|
||||||
|
assert_ne!(
|
||||||
|
precedence_job.run_at, 1_500_000_000,
|
||||||
|
"delay wins over run_at — run_at is not the row's ready time"
|
||||||
|
);
|
||||||
|
|
||||||
|
let bounded_job = queue.get_job(bounded).await.unwrap().unwrap();
|
||||||
|
let expires_at = bounded_job
|
||||||
|
.expires_at
|
||||||
|
.expect("relative expires resolves to an absolute row expiry");
|
||||||
|
let now = unix_now();
|
||||||
|
assert!(
|
||||||
|
expires_at > now + 60 && expires_at <= now + 90,
|
||||||
|
"expires is relative from enqueue (resolved, not raw): {expires_at} vs now {now}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let override_job = queue.get_job(overridden).await.unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
override_job.visibility_timeout_s,
|
||||||
|
override_job.max_attempts,
|
||||||
|
override_job.backoff_base_s,
|
||||||
|
override_job.dead_letter_retention_s
|
||||||
|
),
|
||||||
|
(PLAIN_STAMPS.0, 7, PLAIN_STAMPS.2, PLAIN_STAMPS.3),
|
||||||
|
"the per-job override rides over the derived defaults, others untouched"
|
||||||
|
);
|
||||||
|
|
||||||
|
factory.teardown().await.expect("factory teardown");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Receiver close and save arms** — the stream subscription
|
||||||
|
/// receiver's pinned arms (ADR-021 §5): `recv()`'s error arm carries
|
||||||
|
/// `Database` only, close is terminal `None` (this SQLite column:
|
||||||
|
/// store disposal closes the receiver — the cause asymmetry is
|
||||||
|
/// documented per engine), `try_recv` distinguishes idle (`Ok(None)`)
|
||||||
|
/// from closed (`Err(Error::Closed)`), the receiver-form save is a
|
||||||
|
/// no-op before any event yields, lands the last-yielded offset
|
||||||
|
/// exactly once yielded, and regressions are silent no-ops — direct
|
||||||
|
/// and receiver forms interleave without regressing one another
|
||||||
|
/// (ADR-019 §6).
|
||||||
|
///
|
||||||
|
/// Contract stamp: ADR-021 §5 (receiver error/close arms —
|
||||||
|
/// `Database`-only `Err`, terminal `None` close, SQLite closes on
|
||||||
|
/// watcher death); ADR-019 §6 (save composition — monotone, forms
|
||||||
|
/// interleave); ADR-008 §8 (explicit saves only).
|
||||||
|
pub async fn receiver_close_and_save_arms(factory: &dyn StoreFactory) {
|
||||||
|
let store = factory.open().await.expect("factory opens a store");
|
||||||
|
|
||||||
|
let stream = store.stream("events").await.unwrap();
|
||||||
|
let o1 = stream.publish(json!({"n": 1})).await.unwrap();
|
||||||
|
let o2 = stream.publish(json!({"n": 2})).await.unwrap();
|
||||||
|
|
||||||
|
let mut rx = stream.subscribe("c").await.unwrap();
|
||||||
|
assert_eq!(rx.offset(), 0, "no event yielded yet");
|
||||||
|
assert!(
|
||||||
|
rx.save_offset().is_ok(),
|
||||||
|
"the pre-yield save is a no-op: Ok on any engine"
|
||||||
|
);
|
||||||
|
// The no-op never wrote a checkpoint: a fresh consumer read-back
|
||||||
|
// (from 0) is empty and the direct form's fresh inspection is 0.
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.stream("events")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get_offset("c")
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
0,
|
||||||
|
"the pre-yield save touched no checkpoint"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut last = rx
|
||||||
|
.recv()
|
||||||
|
.await
|
||||||
|
.expect("the drained attach read yields o1")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(last.offset, o1, "the attach read yields offset ASC");
|
||||||
|
// Drain the rest of the attach read (o2 was published pre-attach —
|
||||||
|
// the attach read drains to the tail, so it is pending in the
|
||||||
|
// receiver's feed too; the idle assert below reads an empty tail).
|
||||||
|
while let Some(next) = rx.try_recv().unwrap() {
|
||||||
|
assert!(next.offset > last.offset, "the drain stays offset ASC");
|
||||||
|
last = next;
|
||||||
|
}
|
||||||
|
rx.save_offset().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rx.offset(),
|
||||||
|
last.offset,
|
||||||
|
"the save lands the last-yielded offset exactly"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.stream("events")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get_offset("c")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
> 0,
|
||||||
|
"the checkpoint is stored (the no-op save never wrote it; this one did)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Idle: the feed drained to the tail — try_recv is Ok(None) (not
|
||||||
|
// closed).
|
||||||
|
assert_eq!(
|
||||||
|
rx.try_recv().unwrap(),
|
||||||
|
None,
|
||||||
|
"an attached drained receiver idles, it is not closed"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Direct-then-receiver composition without regression: a direct
|
||||||
|
// save ahead moves the cursor; the receiver's subsequent save
|
||||||
|
// cannot rewind it (monotone).
|
||||||
|
stream.save_offset("c", o2).await.unwrap();
|
||||||
|
let direct = store
|
||||||
|
.stream("events")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get_offset("c")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(direct >= o2, "the direct save landed");
|
||||||
|
rx.save_offset().unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.stream("events")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get_offset("c")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
>= direct,
|
||||||
|
"the receiver form never regresses the direct form's save"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Terminal close on disposal (this engine's documented close
|
||||||
|
// cause: the watcher's death guard clears the feed — ADR-021 §5's
|
||||||
|
// SQLite arm; the store handle dropping is the disposal).
|
||||||
|
drop(store);
|
||||||
|
let closed = rx.recv().await;
|
||||||
|
assert!(
|
||||||
|
closed.is_none(),
|
||||||
|
"store disposal closes the receiver terminally: got {closed:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(rx.try_recv(), Err(Error::Closed)),
|
||||||
|
"post-close try_recv is Err(Closed), not idle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared clock helper for the suite's clock-adjacent rows: unix
|
||||||
|
/// seconds now (rows never assert tight timings — the tolerance is
|
||||||
|
/// the stamp's resolution).
|
||||||
|
fn unix_now() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
@@ -2,16 +2,22 @@
|
|||||||
//! on rusqlite over the forked honker-core substrate carried in-tree
|
//! on rusqlite over the forked honker-core substrate carried in-tree
|
||||||
//! (ADR-001, ADR-011, ADR-013).
|
//! (ADR-001, ADR-011, ADR-013).
|
||||||
//!
|
//!
|
||||||
//! Single-host by nature: file-backed, one machine; NFS
|
//! # Posture
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! Long transactions park the writer: the writer slot is a lease, and
|
//! **Single-host** (ADR-016): this engine is file-backed, one machine,
|
||||||
//! holding a caller-held [`alkstore::TxHandle`] across `await` points
|
//! no runtime capability surface, no multi-host mode — NFS
|
||||||
//! holds it (ADR-007's honest model, surfaced so consumers budget
|
//! two-writers-unsupported is the inherited lineage honesty, stated
|
||||||
//! transactions).
|
//! 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
|
//! Every engine call round-trips a blocking thread (the
|
||||||
//! `spawn_blocking` seam — rusqlite connections are not
|
//! `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)?))
|
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 config = opts.watcher_config()?;
|
||||||
|
|
||||||
let writer_conn = crate::substrate::open_conn_bootstrapped(path)
|
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
|
No unregistered divergence remains. The wave-2 review gate
|
||||||
(`review-wave-2`) re-ran the full-coverage lineage diff (clean —
|
(`review-wave-2`) re-ran the full-coverage lineage diff (clean —
|
||||||
everything register-addressable) and appended D-27/D-28 for its two
|
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 |
|
| 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-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-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-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
|
### Cherry-picks
|
||||||
|
|
||||||
|
|||||||
@@ -40,32 +40,32 @@
|
|||||||
//! ops (`fork-rederive-queue-ops`, register D-12) live in
|
//! ops (`fork-rederive-queue-ops`, register D-12) live in
|
||||||
//! `queue_ops.rs` — owned contract-v1 code, not lineage body.
|
//! `queue_ops.rs` — owned contract-v1 code, not lineage body.
|
||||||
//!
|
//!
|
||||||
//! Port state (wave 2): the machinery below is ported; wave 3 wires
|
//! Port state (wave 3): the machinery below is ported and wired — the
|
||||||
//! `open` and the trait/seam impl. A subset of the ported surface
|
//! engine layer reaches every substrate surface, so no dead-code
|
||||||
//! remains unreachable until the mechanism tasks wire it (the
|
//! posture remains; genuinely unreachable ported code is cut, not
|
||||||
//! `#![allow(dead_code)]` posture stays until the last wiring task).
|
//! suppressed (any cut is a register entry in `PROVENANCE.md`).
|
||||||
|
|
||||||
#![allow(dead_code)]
|
|
||||||
#![allow(unused_imports)]
|
|
||||||
|
|
||||||
mod ops;
|
mod ops;
|
||||||
mod queue_ops;
|
mod queue_ops;
|
||||||
mod schema;
|
mod schema;
|
||||||
mod watcher;
|
mod watcher;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) use watcher::DEFAULT_WATCHER_POLL_INTERVAL;
|
||||||
|
|
||||||
pub(crate) use ops::{
|
pub(crate) use ops::{
|
||||||
arg_i64, arg_opt_i64, attach_alkstore_functions, in_savepoint, lock_acquire, lock_release,
|
lock_acquire, lock_release, lock_renew, stream_get_offset, stream_publish, stream_read_since,
|
||||||
lock_renew, stream_get_offset, stream_publish, stream_read_since, stream_save_offset,
|
stream_save_offset,
|
||||||
};
|
};
|
||||||
pub(crate) use queue_ops::{
|
pub(crate) use queue_ops::{
|
||||||
FireOpts, Stamps, ack, ack_batch, cancel, claim_batch, enqueue, fail, get_job, heartbeat,
|
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,
|
parse_every_interval, retry, scheduler_register, scheduler_soonest, scheduler_tick,
|
||||||
scheduler_tick, scheduler_unregister, sweep_expired,
|
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,
|
|
||||||
};
|
};
|
||||||
|
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
|
/// Provision a fresh connection into the writer slot after a
|
||||||
/// cancellation-path connection was consumed. Private helper over the
|
/// 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),
|
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> {
|
fn real_to_i64(f: f64, idx: usize) -> rusqlite::Result<i64> {
|
||||||
// 2^63 exactly; i64::MAX as f64 rounds *up* to it, so compare
|
// 2^63 exactly; i64::MAX as f64 rounds *up* to it, so compare
|
||||||
// against the power of two and exclude the top end.
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now_unix(conn: &Connection) -> rusqlite::Result<i64> {
|
|
||||||
conn.query_row("SELECT unixepoch()", [], |r| r.get(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn stream_publish(
|
pub(crate) fn stream_publish(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
topic: &str,
|
topic: &str,
|
||||||
@@ -425,13 +413,16 @@ mod real_arg_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn optional_arg_helper_keeps_null_and_converts_whole_reals() {
|
fn optional_arg_probe_keeps_null_and_converts_whole_reals() {
|
||||||
let conn = db();
|
let conn = db();
|
||||||
conn.create_scalar_function(
|
conn.create_scalar_function(
|
||||||
"alkstore_opt_arg_probe",
|
"alkstore_opt_arg_probe",
|
||||||
1,
|
1,
|
||||||
FunctionFlags::SQLITE_UTF8,
|
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();
|
.unwrap();
|
||||||
|
|
||||||
@@ -515,12 +506,11 @@ mod real_arg_tests {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod pressure_tests {
|
mod pressure_tests {
|
||||||
use super::super::schema::{attach_notify, bootstrap_schema};
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::Barrier;
|
use std::sync::Barrier;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering as AO};
|
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 {
|
fn temp_db(name: &str) -> std::path::PathBuf {
|
||||||
let p = std::env::temp_dir().join(format!(
|
let p = std::env::temp_dir().join(format!(
|
||||||
@@ -849,7 +839,9 @@ mod optional_error_tests {
|
|||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let now: i64 = now_unix(&conn).unwrap();
|
let now: i64 = conn
|
||||||
|
.query_row("SELECT unixepoch()", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
expires > now + 300,
|
expires > now + 300,
|
||||||
"renew must actually extend: {expires} vs now {now}"
|
"renew must actually extend: {expires} vs now {now}"
|
||||||
|
|||||||
@@ -242,33 +242,6 @@ pub(crate) fn sweep_expired(
|
|||||||
Ok(moved)
|
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(
|
pub(crate) fn scheduler_register(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -1995,20 +1968,4 @@ mod queue_tests {
|
|||||||
let err = scheduler_tick(&conn, now).expect_err("a cron spec in storage is rejected");
|
let err = scheduler_tick(&conn, now).expect_err("a cron spec in storage is rejected");
|
||||||
assert!(err.to_string().contains("unsupported spec"), "got: {err}");
|
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 parking_lot::{Condvar, Mutex};
|
||||||
use rusqlite::functions::FunctionFlags;
|
use rusqlite::functions::FunctionFlags;
|
||||||
use rusqlite::{Connection, OpenFlags};
|
use rusqlite::{Connection, OpenFlags};
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
#[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) {
|
pub(crate) fn release(&self, conn: Connection) {
|
||||||
if self.closed.load(Ordering::Acquire) {
|
if self.closed.load(Ordering::Acquire) {
|
||||||
return;
|
return;
|
||||||
@@ -478,6 +470,7 @@ fn closed_err() -> Error {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod writer_reader_tests {
|
mod writer_reader_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
fn mem() -> Connection {
|
fn mem() -> Connection {
|
||||||
Connection::open_in_memory().unwrap()
|
Connection::open_in_memory().unwrap()
|
||||||
@@ -495,21 +488,11 @@ mod writer_reader_tests {
|
|||||||
p
|
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]
|
#[test]
|
||||||
fn writer_close_drops_idle_connection() {
|
fn writer_close_drops_idle_connection() {
|
||||||
let w = Writer::new(mem());
|
let w = Writer::new(mem());
|
||||||
w.close();
|
w.close();
|
||||||
assert!(w.acquire().is_none());
|
assert!(w.acquire().is_none());
|
||||||
assert!(w.try_acquire().is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -518,7 +501,7 @@ mod writer_reader_tests {
|
|||||||
let conn = w.acquire().unwrap();
|
let conn = w.acquire().unwrap();
|
||||||
w.close();
|
w.close();
|
||||||
w.release(conn);
|
w.release(conn);
|
||||||
assert!(w.try_acquire().is_none());
|
assert!(w.acquire().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -242,23 +242,16 @@ pub(crate) struct UpdateWatcher {
|
|||||||
const UPDATE_WATCHER_IDENTITY_INTERVAL: Duration = Duration::from_millis(100);
|
const UPDATE_WATCHER_IDENTITY_INTERVAL: Duration = Duration::from_millis(100);
|
||||||
|
|
||||||
impl UpdateWatcher {
|
impl UpdateWatcher {
|
||||||
/// Spawn a watcher thread on `db_path`. `on_change` is called once
|
/// Spawn a watcher thread on `db_path` with an explicit poll
|
||||||
/// per observed commit. The thread runs until [`UpdateWatcher`] is
|
/// cadence. `on_change` is called once per observed commit. The
|
||||||
/// dropped or [`stop`](Self::stop) is called.
|
/// thread runs until [`UpdateWatcher`] is dropped or
|
||||||
|
/// [`stop`](Self::stop) is called.
|
||||||
///
|
///
|
||||||
/// Fallible (W-2 — ADR-012 §4): errors if the thread cannot be
|
/// Fallible (W-2 — ADR-012 §4): errors if the thread cannot be
|
||||||
/// spawned; the engine surfaces the failure at store-open time.
|
/// spawned; the engine surfaces the failure at store-open time.
|
||||||
/// Baseline-capture failures inside the thread (open failures, a
|
/// Baseline-capture failures inside the thread (open failures, a
|
||||||
/// vanished db file) leave the sender dropped and the loop itself
|
/// vanished db file) leave the sender dropped and the loop itself
|
||||||
/// retrying per W-1's bounded backoff — not a spawn failure.
|
/// 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>(
|
pub(crate) fn spawn_with_config<F>(
|
||||||
db_path: PathBuf,
|
db_path: PathBuf,
|
||||||
on_change: F,
|
on_change: F,
|
||||||
@@ -356,15 +349,11 @@ pub(crate) struct SharedUpdateWatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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
|
/// Fallible (W-2 — ADR-012 §4): the engine surfaces the failure at
|
||||||
/// store-open time.
|
/// 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> {
|
pub(crate) fn new_with_config(db_path: PathBuf, config: WatcherConfig) -> Result<Self, String> {
|
||||||
let senders: Arc<Mutex<HashMap<u64, SyncSender<()>>>> =
|
let senders: Arc<Mutex<HashMap<u64, SyncSender<()>>>> =
|
||||||
Arc::new(Mutex::new(HashMap::new()));
|
Arc::new(Mutex::new(HashMap::new()));
|
||||||
@@ -413,6 +402,7 @@ impl SharedUpdateWatcher {
|
|||||||
self.senders.lock().remove(&id);
|
self.senders.lock().remove(&id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) fn subscriber_count(&self) -> usize {
|
pub(crate) fn subscriber_count(&self) -> usize {
|
||||||
self.senders.lock().len()
|
self.senders.lock().len()
|
||||||
}
|
}
|
||||||
@@ -501,7 +491,8 @@ mod watcher_tests {
|
|||||||
let tmp = temp_db("shared-fanout");
|
let tmp = temp_db("shared-fanout");
|
||||||
wal_db(&tmp);
|
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<()>)> =
|
let subs: Vec<(u64, std::sync::mpsc::Receiver<()>)> =
|
||||||
(0..50).map(|_| shared.subscribe()).collect();
|
(0..50).map(|_| shared.subscribe()).collect();
|
||||||
|
|
||||||
@@ -539,7 +530,8 @@ mod watcher_tests {
|
|||||||
let tmp = temp_db("unsub");
|
let tmp = temp_db("unsub");
|
||||||
wal_db(&tmp);
|
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 (id, rx) = shared.subscribe();
|
||||||
assert_eq!(shared.subscriber_count(), 1);
|
assert_eq!(shared.subscriber_count(), 1);
|
||||||
|
|
||||||
@@ -570,7 +562,8 @@ mod watcher_tests {
|
|||||||
let tmp = temp_db("death-signal");
|
let tmp = temp_db("death-signal");
|
||||||
wal_db(&tmp);
|
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 (_id, rx) = shared.subscribe();
|
||||||
|
|
||||||
// Let the watcher snapshot the initial identity.
|
// Let the watcher snapshot the initial identity.
|
||||||
@@ -610,7 +603,8 @@ mod watcher_tests {
|
|||||||
let tmp = temp_db("watcher-replace");
|
let tmp = temp_db("watcher-replace");
|
||||||
wal_db(&tmp);
|
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));
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
|
||||||
@@ -637,7 +631,8 @@ mod watcher_tests {
|
|||||||
let tmp = temp_db("prune");
|
let tmp = temp_db("prune");
|
||||||
wal_db(&tmp);
|
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();
|
let _subs: Vec<_> = (0..10).map(|_| shared.subscribe()).collect();
|
||||||
assert_eq!(shared.subscriber_count(), 10);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: sqlite-engine-integration
|
id: sqlite-engine-integration
|
||||||
name: SQLite engine — integration (lint removal, full-surface wiring, suite adoption)
|
name: SQLite engine — integration (lint removal, full-surface wiring, suite adoption)
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: [sqlite-engine-notify-listen, sqlite-engine-streams, sqlite-engine-queues, sqlite-engine-locks, sqlite-engine-scheduler-outbox]
|
depends_on: [sqlite-engine-notify-listen, sqlite-engine-streams, sqlite-engine-queues, sqlite-engine-locks, sqlite-engine-scheduler-outbox]
|
||||||
scope: moderate
|
scope: moderate
|
||||||
risk: medium
|
risk: medium
|
||||||
@@ -59,17 +59,17 @@ Close the wave's integration gaps once every mechanism is wired:
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] Both `#![allow]` lints removed from `substrate/mod.rs`; any
|
- [x] Both `#![allow]` lints removed from `substrate/mod.rs`; any
|
||||||
code the removal surfaces is cut (and registered) or wired —
|
code the removal surfaces is cut (and registered) or wired —
|
||||||
clippy `-D warnings` green without them
|
clippy `-D warnings` green without them
|
||||||
- [ ] No `todo!`/`unimplemented!` in the engine crate; full trait
|
- [x] No `todo!`/`unimplemented!` in the engine crate; full trait
|
||||||
surface implemented
|
surface implemented
|
||||||
- [ ] `StoreFactory` implemented for SQLite; the backlog-column rows
|
- [x] `StoreFactory` implemented for SQLite; the backlog-column rows
|
||||||
listed above exist, version-stamped, and run green against the
|
listed above exist, version-stamped, and run green against the
|
||||||
SQLite factory
|
SQLite factory
|
||||||
- [ ] Engine crate lib docs carry the single-host + writer-parking
|
- [x] Engine crate lib docs carry the single-host + writer-parking
|
||||||
posture statements
|
posture statements
|
||||||
- [ ] Workspace gates green: `cargo build`, `cargo test`, clippy
|
- [x] Workspace gates green: `cargo build`, `cargo test`, clippy
|
||||||
`-D warnings`, fmt clean
|
`-D warnings`, fmt clean
|
||||||
|
|
||||||
## References
|
## References
|
||||||
@@ -83,8 +83,118 @@ Close the wave's integration gaps once every mechanism is wired:
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
> To be filled by implementation agent
|
> Decisions of record the description didn't pin:
|
||||||
|
|
||||||
|
- **Lint removal cut six substrate items, registered D-32..D-36** —
|
||||||
|
lifting the two `#![allow]`s surfaced six genuinely dead surfaces,
|
||||||
|
all cut rather than wired, each an ADR-018 register entry with its
|
||||||
|
reason: `arg_opt_i64` (upstream consumed it at the optional-arg SQL
|
||||||
|
entry points whose queue functions D-04 dropped — the re-derivation
|
||||||
|
resolves opts engine-side), `ops::now_unix` (the fold D-17 gave
|
||||||
|
each module region its own clock read; `resolution.rs` owns the
|
||||||
|
engine layer's single clock), `queue_next_claim_at` (upstream's
|
||||||
|
external-poll-driver function; the v1 scheduler surface has no
|
||||||
|
next-wake query — the engine's leader loop derives the boundary
|
||||||
|
from `scheduler_tick` + `scheduler_soonest`), `Writer::try_acquire`
|
||||||
|
(a try-shaped begin doesn't exist on the contract — `begin_tx`
|
||||||
|
*parks* on the lease by design, ADR-007; a try-acquire would
|
||||||
|
contradict the posture), and `UpdateWatcher::spawn` /
|
||||||
|
`SharedUpdateWatcher::new` (the config-less convenience twins —
|
||||||
|
this engine's single open posture always constructs
|
||||||
|
`WatcherConfig` from `SqliteOpts`, ADR-023 §4; a second
|
||||||
|
config-less path would recreate the mixed-posture shape D-29
|
||||||
|
resolved). None changed kept-fidelity posture — all six are
|
||||||
|
`drop`-category entries against the *kept* set, with the affected
|
||||||
|
substrate tests re-expressing their properties through the
|
||||||
|
reachable surface (no assertion strength lost: the probe tests
|
||||||
|
test the same coercion/lease behavior through the surviving forms).
|
||||||
|
- **Two substrate items kept under `#[cfg(test)]`** rather than cut:
|
||||||
|
`SharedUpdateWatcher::subscriber_count` and the
|
||||||
|
`DEFAULT_WATCHER_POLL_INTERVAL` re-export — engine-side tests
|
||||||
|
reach them (the leak/no-leak pins, the 1 ms default pin); they are
|
||||||
|
test-observation surface, honestly gated `#[cfg(test)]`, not
|
||||||
|
suppressed. `open_store` was likewise promoted `pub(crate)` (the
|
||||||
|
store tests construct the concrete handle; `open` stays the
|
||||||
|
consumer surface).
|
||||||
|
- **The exemplar row had a real-engine sequencing defect — fixed
|
||||||
|
suite-side** (a suite-text change, ADR-017 §2 class 4): the
|
||||||
|
name-validation row held its `tx` handle alive across the whole
|
||||||
|
battery including the final `with_tx` leg. The mock harness never
|
||||||
|
saw this; a real engine's `begin_tx` parks a concurrent begin on
|
||||||
|
the writer slot (ADR-007's honest model), so the row deadlocked a
|
||||||
|
real factory. The tx battery is now scoped so the handle drops
|
||||||
|
before the `with_tx` leg. The row's *assertions* are unchanged —
|
||||||
|
only the sequencing fix — but the incident is the wave-5 lesson
|
||||||
|
the factory exists to surface: rows are mock-blind until an
|
||||||
|
executes them against a real single-writer engine.
|
||||||
|
- **Row set** (nine total — the exemplar + eight new, all in
|
||||||
|
`alkstore-contract-suite/src/properties.rs`, one owner,
|
||||||
|
version-stamped per the convention, each a public `pub async
|
||||||
|
fn(&dyn StoreFactory)`): the ADR-023-stamped rows (extent-clamp
|
||||||
|
including the boundary-totality legs, duration-refusal,
|
||||||
|
`encode_payload` round-trip), and the engine-scoped rows the task
|
||||||
|
names (PayloadTooLarge-never-produced — the SQLite arm, the pg
|
||||||
|
rejection arm rides wave 4's adoption of the same row text; the
|
||||||
|
drop=rollback no-ghosts; in-tx read-your-own-writes;
|
||||||
|
enqueue-opts resolution — ADR-020 §1–§3; receiver close/save arms
|
||||||
|
— ADR-021 §5). **Plain-path open (§3) and poll cadence (§4) pin
|
||||||
|
engine-side, not as suite rows**: both are constructor-level
|
||||||
|
SQLite-native facts not expressible over `StoreFactory::open()` —
|
||||||
|
`uri_shaped_open_path_is_a_literal_filename` and
|
||||||
|
`poll_interval_flows_to_the_watcher_config` (stamped,
|
||||||
|
`open_tests.rs`) are this engine's rows for them; wave 4's pg
|
||||||
|
column simply doesn't carry them (they're SQLite-scoped backlog
|
||||||
|
rows per core-contract.md).
|
||||||
|
- **The factory** (`alkstore-sqlite/tests/contract_suite.rs`): a fresh
|
||||||
|
never-before-used temp file per `open` under a per-instance
|
||||||
|
directory; teardown = directory delete, idempotent
|
||||||
|
(already-deleted ⇒ `Ok`). Its isolation/idempotence contract has
|
||||||
|
its own pin in the test target (`opens_are_isolated_and_teardown_is_idempotent`);
|
||||||
|
receiver-close rows drive the terminal-close arm by dropping the
|
||||||
|
store handle (the dispose carrier every engine has), not teardown —
|
||||||
|
deleting files under a live store is not a close mechanism.
|
||||||
|
- **Coverage spot-check** (cargo-llvm-cov, engine crate): 93.59%
|
||||||
|
lines / ~89% regions, every file ≥85% lines; the misses are error
|
||||||
|
arms and Windows-only `file_id` variants
|
||||||
|
(`watcher.rs`'s HighRes/LowRes halves) — no large uncovered regions
|
||||||
|
outside error arms, the gate the task pinned.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> To be filled on completion
|
> Landed: the two `#![allow]` lints removed from
|
||||||
|
> `substrate/mod.rs` (its module docs re-stated to the wave-3 wired
|
||||||
|
> posture); the removal surfaced six dead surfaces — cut, each
|
||||||
|
> registered D-32..D-36 in `PROVENANCE.md` (queue-side: `arg_opt_i64`,
|
||||||
|
> `now_unix`, `queue_next_claim_at`; writer/watcher-side:
|
||||||
|
> `Writer::try_acquire`, `UpdateWatcher::spawn`,
|
||||||
|
> `SharedUpdateWatcher::new`), their probe tests re-expressed through
|
||||||
|
> the reachable surface; two test-observation items honestly
|
||||||
|
> `#[cfg(test)]`-gated (`subscriber_count`, the
|
||||||
|
> `DEFAULT_WATCHER_POLL_INTERVAL` re-export); no allows re-added —
|
||||||
|
> clippy `-D warnings` green without them.
|
||||||
|
>
|
||||||
|
> Contract-suite adoption: eight new rows in
|
||||||
|
> `alkstore-contract-suite/src/properties.rs` (extent-clamp +
|
||||||
|
> boundary-totality, duration-refusal, 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) — all version-stamped, all re-exported
|
||||||
|
> from the suite lib. `store::SqliteFactory` implemented in the
|
||||||
|
> engine's new integration test target
|
||||||
|
> (`alkstore-sqlite/tests/contract_suite.rs`), running all nine rows
|
||||||
|
> (the exemplar + the eight) against the SQLite factory — 10 tests,
|
||||||
|
> green; the factory's isolation/idempotence contract pinned
|
||||||
|
> alongside. Found and fixed suite-side: the exemplar row's
|
||||||
|
> hold-the-tx-across-`with_tx` sequencing deadlocked any real
|
||||||
|
> single-writer factory (the mock never saw it).
|
||||||
|
>
|
||||||
|
> Engine crate lib docs: the posture statements surfaced under a
|
||||||
|
> `# Posture` heading — single-host (ADR-016) and writer-parking
|
||||||
|
> (ADR-007's negative consequence) spelled as the crate's identity
|
||||||
|
> statements.
|
||||||
|
>
|
||||||
|
> Verified: workspace `cargo build`/`cargo test` green (25 + 186 +
|
||||||
|
> 10 + 3 + harness), clippy `--workspace --all-targets -D warnings`
|
||||||
|
> clean, `cargo fmt --check` clean; engine-crate coverage 93.59%
|
||||||
|
> lines (cargo-llvm-cov), misses confined to error arms and
|
||||||
|
> Windows-only watcher variants.
|
||||||
Reference in new issue
Block a user