--- id: sqlite-engine-locks name: SQLite engine — named locks (`try_lock`, `Lock` handle, duration guards) status: completed depends_on: [sqlite-engine-seam-tx] scope: narrow risk: low impact: component level: implementation tags: [wave-3, sqlite-engine] --- ## Description Implement `Store::try_lock` and the `Lock` trait over the substrate's lock ops (`lock_acquire`, `lock_release`, `lock_renew`): - `try_lock(name, owner, ttl)` — validated entry point (name: shared-namespace rules; owner: non-empty only); `None` = someone holds it (no-work is a value). Returns the boxed `Lock` handle. - **Duration guard (ADR-023 §2)**: `ttl <= 0` is a rejection — `Err`, opaque `Database` shape — enforced at the trait-impl entry, before the substrate. The substrate's `lock_renew` already guards `ttl_s <= 0` (its message preserved in the source chain where it fires); `lock_acquire` does not — the engine guards both call sites uniformly so the rule is contract-side, not substrate-side (the pg engine implements the same rule with no inherited guard at all). - `Lock::renew(ttl)` — same duration guard; a new full TTL window from now; `false` = lost it (expired and re-acquired elsewhere). - `Lock::release(self)` — consuming; `true` = was held; `false` = already expired/re-acquired (no-op, not an error). - Exclusion lapses silently at TTL expiry (no revocation event) — the substrate's expired-row delete on next acquire is the mechanism; pin the lapse-then-reacquire shape in tests. ## Acceptance Criteria - [x] `try_lock` grants exclusively (second acquirer gets `None`); release makes it acquirable again - [x] Duration guards: `try_lock`/`renew` with `ttl <= 0` return opaque `Err` (`Database`) — never a granted lock, never a panic; substrate guard message preserved in the source chain on `renew` — pinned per ADR-023 §2's backlog row - [x] `renew` extends from now (full window, not additive); foreign owner cannot renew; `false` on lapsed/re-acquired - [x] `release` removes only the owner's row; `false` = no-op shape - [x] TTL lapse: after expiry, another owner acquires (silent lapse) - [x] `cargo test -p alkstore-sqlite`, clippy `-D warnings`, fmt clean ## References - docs/architecture/core-contract.md (locks section, numeric-domain rules) - docs/architecture/decisions/008-contract-v1-pinning.md §7 - docs/architecture/decisions/019-mechanism-handle-surfaces.md §1 - docs/architecture/decisions/023-fourth-review-round.md §2 - alkstore-sqlite/src/substrate/ops.rs (lock_* fns) ## Notes > Decisions of record the description didn't pin: - **Same-owner re-acquire returns `Some`, not `None`** — the substrate's `lock_acquire` is opportunistic-expiry-delete + `INSERT OR IGNORE` + read-back: the same owner's re-acquire keeps the original row (TTL *not* refreshed) and reports granted. Pinned as the inherited shape (engine-sqlite.md's mapping row: "re-acquire does not refresh TTL — inherited deliberately"; the research register's D-4 kept `lock_acquire` verbatim). The handle returned is a fresh boxed `Lock`; `renew` remains the only refresh path. - **Post-lapse release returns `true` while the stale row survives, `false` only once the row is gone/foreign** — the substrate's `lock_release` is a blind `(name, owner)` delete and expired rows persist until the next acquire's opportunistic delete (no general lock-expiry sweep, upstream-inherited). So immediately after a lapse the stale holder's release deletes its own stale row (`true`); after a foreign re-acquire (or any row-less state) it is the no-op `false`. Tests pin both arms; the contract text's "true = was held" reads as row-scoped, which the substrate defines. - **The duration-guard detail rides the source chain, not `Display`** — the engine guard's message ("ttl must be a positive duration (seconds), got {ttl}") is the `#[source]` inner error (ADR-008 §5's chain discipline; the `Database` display is `database error` opaque). The substrate's direct-caller message `ttl_s must be positive` is asserted verbatim at the substrate call site (A-2 above). - **Closed-store posture**: `try_lock`/`renew`/`release` check the engine's closed flag (or the writer slot's unavailability) and fail closed with opaque `Database` — the engine-wide shape every wave-3 mechanism carries; release preserves the substrate row delete as the truth-teller rather than caching a handle-local "held" flag. - `store/lock_tests.rs` follows the wave-3 bounded-wait posture for TTL expiry (second-precision `unixepoch()`; no tight-race sleeps); the short-window lapse steps stamp short expiries directly through a separate connection (the queue tests' honest cross-connection probe shape) instead of sleeping out multi-second windows. ## Summary > `alkstore-sqlite/src/lock.rs`: the acquire path (`try_lock` — > shared-namespace + non-empty owner validation, the ADR-023 §2 > duration guard on `ttl <= 0` engine-side before the substrate, the > closed-store check, then `lock_acquire` through the writer slot, > `Ok(None)` = held elsewhere) and `SqliteLockHandle` (`Lock` impl: > `renew` with the same duration guard over `lock_renew`'s full-window > update; consuming `release(self: Box)` over `lock_release`'s > owner-scoped delete). `store.rs`'s `try_lock` stub replaced with the > wiring. Six acceptance tests in `store/lock_tests.rs` pin the > exclusion/loser shapes, both duration guards + the substrate's > verbatim message, full-window renew and stale-holder refusals, > owner-scoped release with both boolean arms, the silent > lapse-then-reacquire shape, and the cross-instance re-acquire (the > verification backlog's SQLite re-acquisition pin at engine level; > the contract-suite row remains for the suite itself). Verified: > `cargo build`, `cargo test` (workspace; 177 + 25 + 3 pass), > `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` > — all clean.