174 lines
9.3 KiB
Markdown
174 lines
9.3 KiB
Markdown
---
|
||
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. |