Files
alkstore/tasks/pg-fix-tx-wake.md
T

159 lines
7.9 KiB
Markdown

---
id: pg-fix-tx-wake
name: Fix tx enqueue/publish paths issuing no pg_notify wake (review 002 Finding 2 — F-1's root cause)
status: completed
depends_on: []
scope: narrow
risk: low
impact: component
level: implementation
tags: [wave-4-fixes, postgres-engine, tx, wake]
---
## Description
Fix the HIGH finding from the wave-4 general review
(`docs/reviews/002-wave-4-general-review.md` §Finding 2, live-proven):
none of the tx-side enqueue/publish paths run `pg_notify` in the
caller's transaction. Sites: `alkstore-postgres/src/tx.rs:290-329`
(`publish_tx`/`publish_with_key_tx`), `tx.rs:272-288` (`enqueue_tx`),
`tx.rs:545-562` (`outbox_enqueue_tx`). `notify_tx` proves `pg_notify`
inside a tx is native commit-atomic (delivers at the caller's commit)
— the exact mechanism these paths have available and don't use. A
*committed* `publish_tx`/`enqueue_tx` never wakes a registered
listener (deterministic silence, live-proven), while the auto-commit
`publish`/`Queue::enqueue` wake immediately.
**This is F-1's root cause, engine-side** — `tasks/review-wave-4.md`
F-1's "root cause unresolved" and the gate's "the tx INSERT's commit
*is* the NOTIFY carrier" conclusion were both wrong. Mechanism: the
subscriber bridge attach-read + drain race the commit; when both beat
the commit the bridge drains empty and parks on `wait_wake` — and no
wake ever comes from a tx-committed publish, so the 15 s deadline
exhausts (~2% under the 8-thread suite).
**Conformance angle**: engine-postgres.md's mapping names `pg_notify`
as the streams wake trigger; core-contract.md pins "events never
require polling to become visible" and the tx/no-ghosts rows pin
rollback dropping the event row *with its wake*. SQLite's watcher
fires on any committed write, so its tx path wakes — the asymmetry
would also have failed wave 5's cross-engine equivalence rows.
**Fix shape** (the review's):
- `publish_with_key_tx`: after the INSERT, one
`SELECT pg_notify($1, '')` inside the caller's tx — the channel is
the stream name (the mechanism-name-is-the-channel realization,
already pinned in `stream.rs`'s docs). Empty payload: the wake
carries `Wake { channel }` only, never payloads (ADR-008 §3). This
is the **contract obligation** — the load-bearing fix.
- `enqueue_tx` and `outbox_enqueue_tx`: add the same wake (channel =
queue name; the outbox's derived `__alkstore_outbox:{name}`) for
**posture parity** with SQLite's watcher — a weaker obligation (the
queues row's pinned re-poll safety net covers correctness; the wake
is latency posture), but the asymmetry is free to close and the
review recommends it.
- `run_once` is a pull op, consumer-driven — no wake needed there
(do not add one).
**Record updates**: this fix retires F-1's engine arm. Update
`tasks/review-wave-4.md`'s F-1 note (engine arm retired by this fix;
the wave-5 suite-hardening candidates — parked-recv deadline, lag
diagnostics — remain valid as defense-in-depth but are no longer
load-bearing for this flake) and `docs/plans/implementation.md`'s
review-rounds line for the general review.
## Acceptance Criteria
- [ ] `publish_with_key_tx` issues the `pg_notify` wake commit-atomically
(channel = stream name, empty payload) — pinned by test: a
registered listener receives the wake at/after the caller's
commit, deterministically
- [ ] The no-ghosts arm pinned: a *rolled-back* `publish_tx` produces
no wake (the wake is commit-atomic, not statement-atomic)
- [ ] `enqueue_tx` and `outbox_enqueue_tx` issue their wakes (channel =
queue name / derived backing queue) — pinned by test
- [ ] `tx_publishes_compose_with_the_handle` is deterministic: repeated
solo runs (≥ 20) green with zero deadline misses
- [ ] Auto-commit `publish`/`enqueue` behavior unchanged (still wake;
no double-wake regression pinned where the suite observes it)
- [ ] `tasks/review-wave-4.md` F-1 note + `docs/plans/implementation.md`
review-rounds line updated to record the retirement
- [ ] `cargo test -p alkstore-postgres` (harness server), clippy
`-D warnings`, fmt clean; gates green server-less
## References
- docs/reviews/002-wave-4-general-review.md §Finding 2 (the finding,
the probe, the fix shape, the F-1 mechanism)
- docs/architecture/engine-postgres.md (pg_notify as the streams wake
trigger)
- docs/architecture/core-contract.md (streams visibility; tx/no-ghosts
rows)
- docs/architecture/decisions/008-contract-v1-pinning.md §3 (Wake
carries channel only)
- tasks/review-wave-4.md (F-1's record — updated by this task)
- alkstore-postgres/src/tx.rs (`notify_tx` — the proven commit-atomic
mechanics these paths adopt)
## Notes
Fix-shape decisions of record (what the description left open):
- **One-owner wake statement**: all three producer paths call a
shared `tx::wake_tx(client, channel)` — `SELECT pg_notify($1, '')`
(empty payload, `Wake { channel }` only per ADR-008 §3) issued on
the caller's tx client after the INSERT. Channel = the stream name
(`publish_tx` rides `publish_with_key_tx`, so one site), the queue
name (`enqueue_tx`), and the outbox's derived `__alkstore_outbox:{name}`
(`outbox_enqueue_tx`).
- **Wake error posture**: best-effort log-and-swallow (the engine-wide
stance — a wake must never fail a write), matching the auto-commit
publish/enqueue twins. Documented hazard: a failed statement aborts
the pg tx server-side, so a swallowed wake failure still surfaces
typed at COMMIT — no silent half-state; a rolled-back tx's queued
notifies are discarded server-side with the row (the no-ghosts arm
is native).
- **No double-wake**: the wake is applied at the tx call sites, NOT
inside the shared `enqueue_row`/INSERT — the auto-commit
`Queue::enqueue` and the scheduler's fire-wake keep their own single
statements on their pool connections, untouched.
- **`run_once` untouched** (pull op, consumer-driven — no wake, per
the task).
- **Test shape**: one harness test in `tx_tests.rs`
(`tx_producer_wakes_are_commit_atomic_and_rollback_discards_them`)
pins all four arms — pre-commit silence (commit-atomic, not
statement-atomic: 300 ms statement window + 100 ms listen, then
commit), deterministic wake delivery at commit on all three
registered channels, rollback silence, committed row-count truth.
Uses `forwarder().register` + the raw fanout (`listener_hears`);
`register` does no name validation, so the reserved derived outbox
channel registers directly.
- **Record updates went slightly beyond the strict line**: besides
the review-rounds line, `docs/plans/implementation.md`'s frontmatter
`last_updated` and the wave-4 gate bullet's "root cause unresolved"
text got dated supersede/correction pointers (the stale-reference
discipline — those conclusions were factually wrong, not just
unresolved).
## Summary
**Landed**: the tx producer paths' commit-atomic `pg_notify` wakes
(`tx.rs`: `wake_tx` helper + the three call sites + the "The tx wakes"
module-doc section pinning the semantics) — `publish_with_key_tx`/
`publish_tx` (the contract obligation: streams never require polling;
rollback drops the row with its wake) and the posture-parity
`enqueue_tx`/`outbox_enqueue_tx` twins (channel = queue name / derived
backing queue); the new pinned tx-wake test; the F-1 retirement
recorded in `tasks/review-wave-4.md` (including the correction that
the gate's "the tx INSERT's commit *is* the NOTIFY carrier" conclusion
was wrong) and `docs/plans/implementation.md`.
**Verified**: full pg lib suite 116/116 green against the harness
server (two consecutive full runs, ~70 s each); the new wake test
green; `tx_publishes_compose_with_the_handle` deterministic — 20
consecutive solo runs green with zero deadline misses; workspace
`cargo test` server-less green (harness tests skip cleanly);
`cargo clippy --all-targets -- -D warnings` clean; `cargo fmt --check`
clean. Auto-commit publish/enqueue unchanged (their wake tests ride
the suite untouched).