--- id: pg-engine-locks name: Postgres engine — named locks (`try_lock`, `Lock` handle, duration guards) status: completed depends_on: [pg-engine-seam-tx] scope: narrow risk: low impact: component level: implementation tags: [wave-4, postgres-engine] --- ## Description Implement the named-lock mechanism's pg arm — the smallest mechanism, and the one with the strongest POC ground (the pg POC's lock probe pinned acquire/release/renew with TTL and expiry re-acquisition directly). Over the schema task's locks table: - **`try_lock(name, owner, ttl)`** — shared-namespace + non-empty owner validation (`InvalidName`); the **duration guard** (`ttl <= 0` → opaque `Err(Database)`, detail in the source chain — ADR-023 §2's duration kind; an exclusion mechanism must not silently hand back a lease the caller believes it holds); the closed-store check; then the acquire op on a pool connection: opportunistic-expiry- delete + insert-or-reacquire + read-back (the substrate's `lock_acquire` semantics, re-derived — the POC's probe shape). `Ok(None)` = held elsewhere (no-work is a value); `Ok(Some(boxed Lock))` on grant. - **`Lock` handle** (`renew`, consuming `release`): - `renew(ttl)` — the same duration guard; the new **full TTL window from now** (not additive); `false` = lost it (expired and re-acquired by another owner — the owner-scoped update's 0-rows). - `release(self: Box)` — consuming; the owner-scoped delete; `true` = was held (row gone by this owner's hand), `false` = row-less or foreign (the substrate's row-scoped truth-teller posture — no handle-local "held" flag caching). - **Guarantee row posture** (ADR-008 §7): mutual exclusion bounded by TTL + renewal; expiry lapses *silently* (no revocation event — expired rows are deleted opportunistically by the next acquire's expiry-delete, no general expiry sweeper — the inherited posture, documented); re-acquirable after expiry (the POC-pinned property — pin it engine-side here; the contract-suite row remains for the suite itself). - **Same-owner re-acquire**: pin the inherited disposition (the substrate's shape: the same owner's re-acquire keeps the original row, TTL *not* refreshed, reports granted — the SQLite task's pinned posture; engine-sqlite.md's mapping row calls it inherited deliberately, and the pg arm should match it for cross-engine equivalence — flag in Notes if the pg realization differs). - **Post-lapse release arms**: pin both (the stale holder's release deletes its own stale row → `true`; after a foreign re-acquire → `false`) — the SQLite task's pinned shape, matched here. - **Closed-store posture**: `try_lock`/`renew`/`release` fail closed with opaque `Database` (the engine-wide shape). - **No `lock_tx`** — deliberate absence (core-contract.md's named- locks section); nothing to build, just don't invent one. ## Acceptance Criteria - [x] `try_lock`: validation (name + owner), duration guard (`ttl <= 0` → opaque `Err`, detail in the source chain), closed-store check, then acquire — `Ok(None)` on held-elsewhere - [x] Exclusion + loser shape pinned (two owners, one wins); expiry re-acquisition pinned (the POC's property, engine-side) - [x] `renew`: duration guard; full-window reset; `false` on lost/expired-and-reacquired - [x] `release`: consuming, owner-scoped, both boolean arms pinned (stale-row delete = `true`; foreign/row-less = `false`) - [x] Same-owner re-acquire disposition pinned (and flagged if it differs from the SQLite arm's) - [x] Silent lapse posture documented (no revocation event; no general expiry sweeper) - [x] `cargo test -p alkstore-postgres` (harness server), clippy `-D warnings`, fmt clean; gates green server-less ## References - docs/architecture/core-contract.md (named locks section) - docs/architecture/decisions/008-contract-v1-pinning.md §7 (guarantee row) - docs/architecture/decisions/023-fourth-review-round.md §2 (duration kind) - docs/architecture/engine-postgres.md (Mapping the contract: named locks) - docs/research/poc-pg-posture-findings.md (the lock probe row) - alkstore-sqlite/src/lock.rs (the structural twin — match its pinned dispositions) - alkstore/src/lock.rs (the trait) ## Notes Decisions of record made while implementing (the description didn't pin them): - **Same-owner re-acquire matches the SQLite arm deliberately — no difference to flag.** The pg acquire deletes the *expired* row for this name, then `INSERT … ON CONFLICT (name) DO NOTHING`, then a read-back of the holder column: an unexpired row (the same owner's or anyone's) survives the DO NOTHING untouched, TTL included — the substrate's `INSERT OR IGNORE` shape exactly. Verified test-side by comparing the row's `expires_at` before and after the same-owner re-acquire (`expired == grant`). - **Ownership decided by the holder-column read-back, not the INSERT's affected-row count** — D-4's lesson (the substrate's owner read-back ends `.ok()`; the forked-fork removed the swallow but the lesson is the discipline): the insert's `ON CONFLICT DO NOTHING` already yields an accurate affected count, but the read-back is the explicit ownership statement (and re-checks expiry races in one frame). - **The acquire op's three statements ride one pool connection's implicit auto-commit (no explicit `BEGIN` frame)** — a `BEGIN`/ `COMMIT` frame adds two round trips to a path whose correctness rests on the PK constraint anyway: a concurrent same-name grant either commits first (the read-back sees the foreign owner → `Ok(None)`) or lands after (the PK rejects ours, the row stays theirs). The expiry-delete + insert + read-back race window is closed by the PK, not by the frame; every other engine op that relies on single-statement atomicity uses the same posture. Read differently: no `in_tx` frame was needed (the queue ops' use is about multi-statement *moves*, not handout decisions). - **The lock ops' SQL rides the pool path's `table()` helper shape** (schema-qualified via `QualifiedTable`, `quote_identifier` at the composition site — the schema task's injection boundary); the locks table's PK-on-name column is unquoted `name` (not a reserved word — the offsets' `"offset"` / events' `"key"` hazard pair does not apply here). - **Second-precision `now_unix()` stamps the expiry from Rust** (`expires_at = now + ttl` computed as `i64` binds, not a SQL-side clock function) — the tx-seam task's documented clock posture (one `std::time` read per op, the SQL layer never reads a clock). - **Test lapse windows are backdated through the raw admin connection** (the queue tests' cross-connection probe shape): a short TTL's window is stamped via `UPDATE … SET expires_at = now-1`, so no test sleeps out a multi-second TTL (the SQLite arm's bounded-wait posture's shape, adapted — the backdate makes the wait unnecessary). The cross-instance re-acquire test keeps the bounded poll loop (a second `open`'s acquire is the assertion, the loop is the honest re-acquisition probe). - **No explicit schema teardown on the closed-store test's store** — the fixture closes the store (its `close()` is the drop path) and every test tears its schema down with `drop_schema` explicitly (the queue tests' convention); the closed-store test's admin connection works after the store's `close()` (the pool close and the listener shutdown don't touch the admin's independent connection). ## Summary > `alkstore-postgres/src/lock.rs`: the acquire path (`try_lock` — > shared-namespace + non-empty owner validation, the ADR-023 §2 > duration guard on `ttl <= 0` (opaque `Database`, detail in the > source chain) before any round trip, the closed-store check, then > the acquire op on a pool connection — opportunistic expiry-delete + > insert-or-reacquire + holder read-back, `Ok(None)` = held > elsewhere) and `PgLockHandle` (`Lock` impl: `renew` with the same > duration guard over the owner-scoped full-window update (0 rows = > lost it); consuming `release(self: Box)` over the owner-scoped > delete with both boolean arms — no handle-local held-flag caching). > `store.rs`'s `try_lock` stub replaced with the wiring; the > wiring-stubs test updated (the lock acquire grants + releases, the > stub assertion moved to `outbox`/`schedule`/`unschedule`/ > `run_schedules`). Eight acceptance tests in `store/lock_tests.rs` > pin: exclusive grant + the loser's `Ok(None)` + the no-refresh > same-owner re-acquire (matched to the SQLite arm), both duration > guards with the source-chain detail and the guard-before-acquire > no-row pin, full-window renew and the lapsed-holder refusals, > owner-scoped release with the stale-row-`true` and foreign/row-less > -`false` arms (no sweeper — the stale row persists until an acquire > or the owner's own delete), the silent lapse + foreign re-acquire, > the engine-external lapse probe (a second `open` re-acquires — the > verification backlog's pg re-acquisition pin at engine level), the > entry-point validation posture (empty/whitespace/ReservedName, > residue-free), and the closed-store fail-closed shape on all three > ops. Verified against the harness server (8 new tests; 97 lib tests > green, ×2 runs); workspace `cargo test` green server-less (332 > tests, pg tests skipping per convention); `cargo clippy --all- > targets -- -D warnings` and `cargo fmt --check` clean > workspace-wide.