Contract-suite lock rows (task suite-lock-rows): two version-stamped rows discharging the lock backlog rows — lock_ttl_expiry_and_reacquisition (the ADR-008 §7 guarantee row: a held lock excludes a second acquirer (contender None); after a 1 s TTL the exclusion lapses silently — no revocation event, no error — and a second owner acquires; the original holder's post-expiry renew is refused (false, the lost-it arm); the second owner's release frees the name for a third acquirer, which consumes cleanly; tolerance-sleep posture, state outcomes only; ADR-008 §7 + ADR-019 §1, duration-guard legs cross-referenced to duration_refusal_on_non_positive_ttl) and concurrent_try_lock_loser_is_a_value (the contention posture: four sequential contenders — two owners, repeated — against a held lock all land the clean None value, never Database, never a busy-throw; the backlog row's SQLite busy-path open question answered: no divergence from the pg reference; release-then-contend cycle proves the loser path leaves no state blocking a later acquire, granted to a former loser and consumed cleanly; ADR-008 §5 + §7 + ADR-019 §1) — wired into both engines' suite targets (SQLite tokio tests, pg harness_row!s), 23 rows per column up from 21. Dispositions in Notes: no divergence found (no fix/ADR call needed), ADR-023 §2 excluded from stamps (its guard property is the duration-refusal row's, cross-referenced), the backlog parenthetical re-acquire-does-not-refresh-TTL stays engine-pinned, renew-refusal asserted after the second owner's re-acquisition (strongest form), contenders distinct-owner only (same-owner re-acquire grants per the inherited substrate shape), and one unreproducible first-cold-run pg failure recorded (message lost to truncation; 13 subsequent clean runs incl. concurrent SQLite+pg — no timing hazard identified, the lapse assertions are post-sleep state outcomes). Drive-by: alkstore-contract-suite's tokio dep gained the macros feature — a pre-existing compile break in the crate's own tests/suite_harness.rs (since 7f749ac) failed the harness target and the workspace test gate on HEAD; test-side non-event (ADR-017 §2 class 4). Verified: cargo test -p alkstore-sqlite -p alkstore-postgres green (SQLite 121+23+9, pg 189+23 vs harness server on :15432, server-less pg skips clean), workspace cargo test green (12 binaries), clippy -D warnings, fmt clean
This commit is contained in:
1 parent
1d05df200e
commit
98de4a49cd
6 files changed
+285
-17
No files matched your search
@@ -9,4 +9,4 @@ publish = false
|
||||
[dependencies]
|
||||
alkstore = { version = "0.1", path = "../alkstore" }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
tokio = { version = "1", features = ["rt", "time", "macros"] }
|
||||
@@ -38,8 +38,9 @@ pub mod version_stamp;
|
||||
|
||||
pub use factory::StoreFactory;
|
||||
pub use properties::{
|
||||
duration_refusal_on_non_positive_ttl, enqueue_opts_resolution, extent_clamp_semantics,
|
||||
in_tx_reads_see_own_writes, job_handle_validity_predicate,
|
||||
concurrent_try_lock_loser_is_a_value, duration_refusal_on_non_positive_ttl,
|
||||
enqueue_opts_resolution, extent_clamp_semantics, in_tx_reads_see_own_writes,
|
||||
job_handle_validity_predicate, lock_ttl_expiry_and_reacquisition,
|
||||
name_validation_rejects_empty_and_reserved, outbox_enqueue_tx_commit_atomicity,
|
||||
payload_round_trip_stores_exact_encoding, payload_too_large_never_produced_on_sqlite,
|
||||
payload_too_large_produced_on_pg, publish_with_key_tx_commit_atomicity,
|
||||
|
||||
@@ -525,6 +525,163 @@ pub async fn duration_refusal_on_non_positive_ttl(factory: &dyn StoreFactory) {
|
||||
factory.teardown().await.expect("factory teardown");
|
||||
}
|
||||
|
||||
/// **Lock TTL/expiry re-acquisition** — the ADR-008 §7 guarantee row:
|
||||
/// a held lock excludes a second acquirer (`None` — the loser value,
|
||||
/// never an error); after the TTL elapses exclusion lapses *silently*
|
||||
/// — no revocation event, no error anywhere — and a second owner's
|
||||
/// acquire succeeds; the original holder's post-expiry `renew` is
|
||||
/// refused (`false` — the deadline-lapse refusal of the handle's
|
||||
/// uniform predicate shape); and the second owner's `release` frees
|
||||
/// the name for a third acquirer. Short-TTL (1 s) stamps with the
|
||||
/// tolerance-sleep posture (state outcomes asserted, never durations;
|
||||
/// sequential single-store drive).
|
||||
///
|
||||
/// Cross-reference: the duration-guard legs (`ttl <= 0` rejecting on
|
||||
/// `try_lock`/`renew`) pin in `duration_refusal_on_non_positive_ttl`
|
||||
/// (ADR-023 §2) — this row owns the TTL *lifecycle* (lapse,
|
||||
/// silent-exclusion loss, re-acquisition, post-expiry refusal, and
|
||||
/// name-freedom after release).
|
||||
///
|
||||
/// Contract stamp: ADR-008 §7 (the locks guarantee row — mutual
|
||||
/// exclusion bounded by TTL + renewal; expiry is a silent loss of
|
||||
/// exclusivity, not an error; exclusivity resumes with
|
||||
/// re-acquisition); ADR-019 §1 (the `Lock` handle surface the
|
||||
/// refusals ride — repeatable `renew` with the lost-it `false`, the
|
||||
/// consuming `release`).
|
||||
pub async fn lock_ttl_expiry_and_reacquisition(factory: &dyn StoreFactory) {
|
||||
let store = factory.open().await.expect("factory opens a store");
|
||||
|
||||
let holder = store
|
||||
.try_lock("lapse", "a", 1)
|
||||
.await
|
||||
.expect("try_lock on a free name must not error")
|
||||
.expect("the first acquire holds the free name");
|
||||
let contender = store
|
||||
.try_lock("lapse", "b", 300)
|
||||
.await
|
||||
.expect("a contender's try_lock must not error");
|
||||
assert!(
|
||||
contender.is_none(),
|
||||
"a held lock excludes a second acquirer"
|
||||
);
|
||||
|
||||
// TTL expiry (a 1 s stamp; the sleep is bounded well past the
|
||||
// second-precision stamp's resolution): exclusion lapses silently
|
||||
// — the second owner's acquire is a clean Ok(Some) with no error
|
||||
// preceding it and no revocation event to observe.
|
||||
past_stamp_sleep(2_500);
|
||||
let second = store
|
||||
.try_lock("lapse", "b", 300)
|
||||
.await
|
||||
.expect("the post-expiry acquire must not error (silent lapse, no revocation event)")
|
||||
.expect("after TTL expiry exclusion has silently lapsed: a second owner acquires");
|
||||
|
||||
assert!(
|
||||
!holder
|
||||
.renew(300)
|
||||
.await
|
||||
.expect("renew errs only on storage failure, never on lapse"),
|
||||
"the original holder's post-expiry renew is refused: the lost-it arm"
|
||||
);
|
||||
|
||||
assert!(
|
||||
second
|
||||
.release()
|
||||
.await
|
||||
.expect("the second owner's release errs only on storage failure"),
|
||||
"the second owner's release deletes its own row"
|
||||
);
|
||||
let third = store
|
||||
.try_lock("lapse", "c", 300)
|
||||
.await
|
||||
.expect("try_lock on the freed name must not error")
|
||||
.expect("the released name is acquirable by a third owner");
|
||||
assert!(
|
||||
third
|
||||
.release()
|
||||
.await
|
||||
.expect("the third release errs only on storage failure"),
|
||||
"the third owner releases its own row (no residue for teardown)"
|
||||
);
|
||||
|
||||
factory.teardown().await.expect("factory teardown");
|
||||
}
|
||||
|
||||
/// **Concurrent `try_lock` loser is a value** — the contention
|
||||
/// posture: with a lock held, contenders' `try_lock` calls return the
|
||||
/// clean no-work value (`None`) on both engines — never a `Database`
|
||||
/// error, never a busy-throw surfacing (the SQLite substrate's
|
||||
/// busy-path is the risk site this row pins; the pg side is the
|
||||
/// reference behavior the suite drives first). Contenders are driven
|
||||
/// sequentially against one held lock (several distinct owners,
|
||||
/// including a repeat contender), and a release-then-contend cycle
|
||||
/// proves the loser path leaves no state behind that blocks a later
|
||||
/// acquire — the former loser acquires cleanly on an empty field.
|
||||
///
|
||||
/// Cross-reference: the TTL-lapse arms of the loser value pin in
|
||||
/// `lock_ttl_expiry_and_reacquisition` — this row owns the
|
||||
/// *held-lock* loser path (the substrate's busy-path behavior is the
|
||||
/// thing under test, the backlog row's open question).
|
||||
///
|
||||
/// Contract stamp: ADR-008 §5 (the value-not-error rule — `try_lock`
|
||||
/// returning `Option<Lock>` is explicitly not a taxonomy item; a
|
||||
/// loser `Database` error would be a contract violation); ADR-008 §7
|
||||
/// (the exclusion row the contention composes with); ADR-019 §1 (the
|
||||
/// handle surface the winner's consuming `release` rides).
|
||||
pub async fn concurrent_try_lock_loser_is_a_value(factory: &dyn StoreFactory) {
|
||||
let store = factory.open().await.expect("factory opens a store");
|
||||
|
||||
let holder = store
|
||||
.try_lock("contended", "holder", 300)
|
||||
.await
|
||||
.expect("try_lock on a free name must not error")
|
||||
.expect("the first acquire holds the free name");
|
||||
|
||||
// Sequential contenders against the held lock: every loser sees
|
||||
// the clean `None` value — a `Database` error on any of them is
|
||||
// the busy-path finding this row exists to catch.
|
||||
for attempt in 0..4u32 {
|
||||
let owner = if attempt.is_multiple_of(2) {
|
||||
"contender-1"
|
||||
} else {
|
||||
"contender-2"
|
||||
};
|
||||
let out = store
|
||||
.try_lock("contended", owner, 300)
|
||||
.await
|
||||
.expect("a contended try_lock must not error (no busy-throw surfacing)");
|
||||
assert!(
|
||||
out.is_none(),
|
||||
"a held lock must lose contenders the None value, not a granted lock"
|
||||
);
|
||||
}
|
||||
|
||||
// Release-then-contend: the loser path left no state — an
|
||||
// immediate re-contend after release grants, to a former loser's
|
||||
// owner, and consumes cleanly.
|
||||
assert!(
|
||||
holder
|
||||
.release()
|
||||
.await
|
||||
.expect("the winner's release errs only on storage failure"),
|
||||
"the winner's release deletes its own row"
|
||||
);
|
||||
let winner = store
|
||||
.try_lock("contended", "contender-1", 300)
|
||||
.await
|
||||
.expect("the post-release contend must not error")
|
||||
.expect("the loser path left no state blocking a later acquire");
|
||||
assert!(
|
||||
winner
|
||||
.release()
|
||||
.await
|
||||
.expect("release errs only on storage failure"),
|
||||
"the new winner releases cleanly (no residue for teardown)"
|
||||
);
|
||||
|
||||
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
|
||||
|
||||
@@ -163,9 +163,10 @@ async fn harness_ready() -> bool {
|
||||
|
||||
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,
|
||||
job_handle_validity_predicate, name_validation_rejects_empty_and_reserved,
|
||||
concurrent_try_lock_loser_is_a_value, drop_rollback_leaves_no_ghosts,
|
||||
duration_refusal_on_non_positive_ttl, enqueue_opts_resolution, extent_clamp_semantics,
|
||||
in_tx_reads_see_own_writes, job_handle_validity_predicate,
|
||||
lock_ttl_expiry_and_reacquisition, name_validation_rejects_empty_and_reserved,
|
||||
outbox_enqueue_tx_commit_atomicity, payload_round_trip_stores_exact_encoding,
|
||||
payload_too_large_produced_on_pg, publish_with_key_tx_commit_atomicity,
|
||||
queue_depth_reclaim_and_dead_letter, receiver_close_and_save_arms,
|
||||
@@ -207,6 +208,16 @@ mod rows {
|
||||
duration_refusal_on_non_positive_ttl,
|
||||
"row-duration-refusal"
|
||||
);
|
||||
harness_row!(
|
||||
row_lock_ttl_expiry_and_reacquisition,
|
||||
lock_ttl_expiry_and_reacquisition,
|
||||
"row-lock-ttl-expiry"
|
||||
);
|
||||
harness_row!(
|
||||
row_concurrent_try_lock_loser_is_a_value,
|
||||
concurrent_try_lock_loser_is_a_value,
|
||||
"row-lock-loser-value"
|
||||
);
|
||||
harness_row!(
|
||||
row_payload_round_trip_stores_exact_encoding,
|
||||
payload_round_trip_stores_exact_encoding,
|
||||
|
||||
@@ -67,9 +67,10 @@ impl StoreFactory for SqliteFactory {
|
||||
|
||||
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,
|
||||
job_handle_validity_predicate, name_validation_rejects_empty_and_reserved,
|
||||
concurrent_try_lock_loser_is_a_value, drop_rollback_leaves_no_ghosts,
|
||||
duration_refusal_on_non_positive_ttl, enqueue_opts_resolution, extent_clamp_semantics,
|
||||
in_tx_reads_see_own_writes, job_handle_validity_predicate,
|
||||
lock_ttl_expiry_and_reacquisition, name_validation_rejects_empty_and_reserved,
|
||||
outbox_enqueue_tx_commit_atomicity, payload_round_trip_stores_exact_encoding,
|
||||
payload_too_large_never_produced_on_sqlite, publish_with_key_tx_commit_atomicity,
|
||||
queue_depth_reclaim_and_dead_letter, receiver_close_and_save_arms,
|
||||
@@ -99,6 +100,16 @@ mod rows {
|
||||
duration_refusal_on_non_positive_ttl(&factory("row-duration-refusal")).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn row_lock_ttl_expiry_and_reacquisition() {
|
||||
lock_ttl_expiry_and_reacquisition(&factory("row-lock-ttl-expiry")).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn row_concurrent_try_lock_loser_is_a_value() {
|
||||
concurrent_try_lock_loser_is_a_value(&factory("row-lock-loser-value")).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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: suite-lock-rows
|
||||
name: Contract-suite rows — lock TTL/expiry re-acquisition + concurrent try_lock loser
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
risk: medium
|
||||
@@ -51,14 +51,14 @@ divergence silently.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Two rows exist, version-stamped (ADR-008 §7, ADR-019 §1,
|
||||
- [x] Two rows exist, version-stamped (ADR-008 §7, ADR-019 §1,
|
||||
ADR-023 §2 as applicable)
|
||||
- [ ] Rows wired into both engines' `contract_suite.rs` targets
|
||||
- [ ] SQLite column green server-less; pg column green against the
|
||||
- [x] Rows wired into both engines' `contract_suite.rs` targets
|
||||
- [x] SQLite column green server-less; pg column green against the
|
||||
harness server
|
||||
- [ ] Any engine divergence found is recorded in Notes with its
|
||||
- [x] Any engine divergence found is recorded in Notes with its
|
||||
disposition (fix or documented asymmetry) — not pinned silently
|
||||
- [ ] `cargo test -p alkstore-sqlite -p alkstore-postgres` green
|
||||
- [x] `cargo test -p alkstore-sqlite -p alkstore-postgres` green
|
||||
(pg rows skip cleanly server-less); clippy `-D warnings`; fmt clean
|
||||
|
||||
## References
|
||||
@@ -71,8 +71,96 @@ divergence silently.
|
||||
|
||||
## Notes
|
||||
|
||||
> To be filled by implementation agent
|
||||
> Decisions of record the description didn't pin:
|
||||
|
||||
- **The open question is answered: no divergence** — the SQLite
|
||||
substrate's busy-path under lock contention never surfaces
|
||||
`Database`: contended `try_lock` returns the same clean
|
||||
`Ok(None)` value as pg (the engines route lock ops through the
|
||||
writer-slot-serialized surface / pool acquire respectively, and
|
||||
both acquire ops are opportunistic-expiry-delete +
|
||||
insert-or-ignore + read-back, so a held row reads as foreign —
|
||||
the loser value — on both). No engine fix and no ADR-level
|
||||
asymmetry call was needed; the row pins the convergence rather
|
||||
than recording one.
|
||||
- **Stamp scope** — ADR-023 §2 is *not* on either row's stamp list:
|
||||
the duration-guard behavior it pins (`ttl <= 0` rejecting on
|
||||
`try_lock`/`renew`) is `duration_refusal_on_non_positive_ttl`'s
|
||||
property (cross-referenced in the TTL row's doc text, as are
|
||||
that row's legs from this one). The rows carry stamps for the
|
||||
behavior they test: ADR-008 §7 (the guarantee row / contention
|
||||
composition), ADR-008 §5 (the loser-value-not-error rule the
|
||||
contention row pins), ADR-019 §1 (the `Lock` handle surface).
|
||||
- **Backlog-row parenthetical stays engine-side** — the backlog
|
||||
row's "re-acquire-does-not-refresh-TTL behavior is upstream's,
|
||||
inherited deliberately" note remains pinned by the SQLite
|
||||
engine tests (`store/lock_tests.rs`'s mapping-row pin); the
|
||||
suite row pins the task description's five lifecycle legs only
|
||||
and does not re-pin the same-owner-re-acquire-returns-Some /
|
||||
TTL-not-refreshed shape (deliberate scope: that shape is the
|
||||
acquisition semantics' *non-lapse* twin, both engine tests pin
|
||||
it, and neither task text asked the suite to own it).
|
||||
- **renew-refusal ordering** — the original holder's `renew` is
|
||||
asserted *after* the second owner's re-acquisition (the
|
||||
strongest form: the row the refuser targets belongs to another
|
||||
owner), rather than immediately post-lapse while the stale row
|
||||
survives; both forms land `false` on both engines, so only the
|
||||
strongest is pinned.
|
||||
- **Contenders are distinct-owner only** — the contention row
|
||||
never drives a same-owner `try_lock` against a held lock: the
|
||||
inherited substrate shape means a same-owner re-acquire grants
|
||||
(`Some`, original row kept) rather than losing, so including it
|
||||
in a "loser is a value" row would misdescribe the property.
|
||||
- **One unreproducible pg failure, first cold run** — the very
|
||||
first pg full-suite run failed `row_lock_ttl_expiry_and_
|
||||
reacquisition`; the failure message was lost to output
|
||||
truncation and the row passed on every subsequent run (5
|
||||
isolated full-suite runs, 3 concurrent SQLite+pg full-suite
|
||||
runs, 5 focused `--test-threads=16` runs — 13 clean total). No
|
||||
divergence observed, no repro found; recorded per the stream-
|
||||
rows task's precedent. The TTL legs' assertions are
|
||||
post-sleep state outcomes (a slower clock can only *delay* the
|
||||
lapse, never un-lapse it), so no timing hazard is identified.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
> What landed, verified how:
|
||||
|
||||
Two rows added to `alkstore-contract-suite/src/properties.rs`,
|
||||
re-exported from the crate's lib, wired into both engines'
|
||||
`contract_suite.rs` targets (SQLite: direct test fns; pg: the
|
||||
`harness_row!` macro with unique factory tags) — 23 rows in each
|
||||
engine's column, up from 21:
|
||||
|
||||
- `lock_ttl_expiry_and_reacquisition` — the ADR-008 §7 guarantee
|
||||
row: held → contender `None`; post-1 s-TTL silent lapse (second
|
||||
owner acquires, no error, no revocation event); original
|
||||
holder's post-expiry `renew` refused (`false`); second owner's
|
||||
`release` frees the name for a third acquirer. Stamped ADR-008
|
||||
§7 + ADR-019 §1; duration-guard legs cross-referenced to
|
||||
`duration_refusal_on_non_positive_ttl` (ADR-023 §2).
|
||||
- `concurrent_try_lock_loser_is_a_value` — the contention
|
||||
posture: four sequential contenders (two distinct owners,
|
||||
repeated) against a held lock all get the clean `None` value —
|
||||
never `Database`, never a busy-throw (the backlog row's SQLite
|
||||
busy-path open question, answered: no divergence from the pg
|
||||
reference) — plus a release-then-contend cycle granting a
|
||||
former loser (no blocking state left) and consuming cleanly.
|
||||
Stamped ADR-008 §5 + §7 + ADR-019 §1.
|
||||
|
||||
Drive-by fix: `alkstore-contract-suite/Cargo.toml`'s tokio dep
|
||||
gained the `macros` feature — a pre-existing break (since 7f749ac
|
||||
added the dep without it) left the crate's own
|
||||
`tests/suite_harness.rs` target failing to compile, which failed
|
||||
`cargo test -p alkstore-contract-suite` and the workspace gate on
|
||||
HEAD before this task. Test-side non-event (ADR-017 §2 class 4);
|
||||
harness target green (3 tests).
|
||||
|
||||
Verification: `cargo test -p alkstore-sqlite -p
|
||||
alkstore-postgres` green (SQLite lib 121 + suite 23/23 +
|
||||
harness-shape; pg lib 189 + suite 23/23 against the harness
|
||||
server on :15432), server-less pg rows skip cleanly, full
|
||||
workspace `cargo test` green (12 binaries), `cargo clippy
|
||||
--workspace --all-targets -- -D warnings` clean, `cargo fmt
|
||||
--check` clean. Stability: 13 clean runs of the pg suite's lock
|
||||
rows/full suite as recorded in Notes.
|
||||
Reference in new issue
Block a user