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

174 lines
9.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<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
- [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<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.