Files
alkstore/tasks/pg-engine-locks.md

9.3 KiB
Raw Permalink Blame History

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
pg-engine-seam-tx
narrow low component implementation
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<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/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

  • 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
  • Exclusion + loser shape pinned (two owners, one wins); expiry re-acquisition pinned (the POC's property, engine-side)
  • renew: duration guard; full-window reset; false on lost/expired-and-reacquired
  • release: 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'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<Self>) 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.