9.3 KiB
id, name, status, depends_on, scope, risk, impact, level, tags
| id | name | status | depends_on | scope | risk | impact | level | tags | |||
|---|---|---|---|---|---|---|---|---|---|---|---|
| pg-engine-locks | Postgres engine — named locks (`try_lock`, `Lock` handle, duration guards) | completed |
|
narrow | low | component | implementation |
|
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→ opaqueErr(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'slock_acquiresemantics, re-derived — the POC's probe shape).Ok(None)= held elsewhere (no-work is a value);Ok(Some(boxed Lock))on grant.Lockhandle (renew, consumingrelease):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<Self>)— 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/releasefail closed with opaqueDatabase(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
try_lock: validation (name + owner), duration guard (ttl <= 0→ opaqueErr, detail in the source chain), closed-store check, then acquire —Ok(None)on held-elsewhere- Exclusion + loser shape pinned (two owners, one wins); expiry re-acquisition pinned (the POC's property, engine-side)
renew: duration guard; full-window reset;falseon lost/expired-and-reacquiredrelease: consuming, owner-scoped, both boolean arms pinned (stale-row delete =true; foreign/row-less =false)- Same-owner re-acquire disposition pinned (and flagged if it differs from the SQLite arm's)
- Silent lapse posture documented (no revocation event; no general expiry sweeper)
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'sINSERT OR IGNOREshape exactly. Verified test-side by comparing the row'sexpires_atbefore 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'sON CONFLICT DO NOTHINGalready 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
BEGINframe) — aBEGIN/COMMITframe 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: noin_txframe 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 viaQualifiedTable,quote_identifierat the composition site — the schema task's injection boundary); the locks table's PK-on-name column is unquotedname(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 + ttlcomputed asi64binds, not a SQL-side clock function) — the tx-seam task's documented clock posture (onestd::timeread 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 secondopen'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 withdrop_schemaexplicitly (the queue tests' convention); the closed-store test's admin connection works after the store'sclose()(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 onttl <= 0(opaqueDatabase, 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) andPgLockHandle(Lockimpl:renewwith the same duration guard over the owner-scoped full-window update (0 rows = lost it); consumingrelease(self: Box<Self>)over the owner-scoped delete with both boolean arms — no handle-local held-flag caching).store.rs'stry_lockstub replaced with the wiring; the wiring-stubs test updated (the lock acquire grants + releases, the stub assertion moved tooutbox/schedule/unschedule/run_schedules). Eight acceptance tests instore/lock_tests.rspin: exclusive grant + the loser'sOk(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-trueand foreign/row-less -falsearms (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 secondopenre-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); workspacecargo testgreen server-less (332 tests, pg tests skipping per convention);cargo clippy --all- targets -- -D warningsandcargo fmt --checkclean workspace-wide.