10 KiB
id, name, status, depends_on, scope, risk, impact, level, tags
| id | name | status | depends_on | scope | risk | impact | level | tags | |||
|---|---|---|---|---|---|---|---|---|---|---|---|
| pg-fix-forwarder-reconnect | Fix forwarder permanent death after one failed reconnect (review 002 Finding 1) | completed | moderate | medium | component | implementation |
|
Description
Fix the HIGH finding from the wave-4 general review
(docs/reviews/002-wave-4-general-review.md §Finding 1, live-proven):
the forwarder loop dies permanently after one failed reconnect
connect. Site: alkstore-postgres/src/forwarder.rs:498-509 →
:369. On a failed reconnect_config.connect(NoTls) the loop's Err
arm continues — but the loop top is
let Some(poll_connection) = connection.take() else { return; } and
connection is still None (the old connection was consumed by
take() when its generation started). One failed connect ⇒ the loop
takes the else arm and the forwarder task exits forever. Consequences
(all live-proven): every later listen() fails with a spurious
"mid-reconnect" Database for the store's remaining lifetime; wake
delivery stops permanently (no re-LISTEN, no reconnect-wake); stream
subscribers parked on wait_wake stay parked forever (the fanout's
senders stay alive — handle-held — so the broadcast never closes and
the consumer never even sees a terminal arm), contradicting the
streams row's pinned posture that events never require polling to
become visible (core-contract.md streams section).
Fix shape (the review's): retry the connect with the live backoff
inside the reconnect arm — loop sleep(backoff) → connect until
success or shutdown — never falling back into the take()'s
else return. The loop top's None arm must become unreachable by
construction (or restructured away entirely: e.g. carry the connection
slot through the reconnect arm so a failed connect retries in place
with the slot still empty but the loop alive).
Hardening (the review's second point): make every forwarder_loop
exit path release the Forwarder struct's fanout sender (the
shutdown-take machinery already exists — Forwarder::shutdown takes
it) so any loop exit — not just the shutdown flip — surfaces the
terminal Closed/None arm to subscribers instead of a
forever-silence. Invariant to establish and pin: receivers go
terminal iff the forwarder loop is gone. With the retry fix the loop
only exits at shutdown, so this is defense-in-depth against the next
abnormal-exit bug, not a behavior change.
Testability — the gap that hid the bug: the gate's reconnect test
(open_tests.rs:244) kills the backend via pg_terminate_backend
while the server stays up — the 50 ms reconnect attempt connects
successfully every time. The unreachable-server window (connect
fails) is the one case the machinery exists for and the one case
untested. The fix must come with a test that exercises it. Recommended
shape: a cfg(test) seam on the reconnect config (the loop ctx's
reconnect_config behind a swappable slot, or the connect step behind
an injectable closure) so a test can point reconnects at an
unreachable endpoint after open, force a reconnect (backend kill),
verify the loop survives repeated failed connects across several
backoff cycles, then swap the real config back and verify full
recovery. Alternative: extract the connect step behind a connector
trait/closure and unit-test the retry loop with a failing connector —
implementer's choice, but the failed-connect arm must be
test-exercised, not reasoned-about.
Acceptance Criteria
- A failed reconnect connect no longer exits the loop: the reconnect arm retries with the live backoff (50 ms → 2 s cap) until success or shutdown — pinned by a test that forces repeated failed connects (unreachable-endpoint seam or connector injection) across multiple backoff cycles
- After the server becomes reachable again: the synthetic
reconnect-wake broadcasts, post-reconnect
NOTIFYdelivers on registered channels, andlisten()succeeds (no spurious "mid-reconnect"Database) — recovery pinned by test - Every
forwarder_loopexit path releases the fanout sender — an abnormal loop exit surfaces the terminal arm to subscribers (the receivers-terminal-iff-loop-gone invariant; the shutdown case's existing pin stays green) - The existing backend-kill reconnect tests
(
forwarder_reconnects_after_backend_kill_and_delivers,receiver_stays_open_across_reconnects_closes_at_shutdown, the no-replay pin) stay green unchanged 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 1 (the finding, the probe, the fix shape)
- docs/architecture/decisions/004-postgres-driver.md (forwarder decision, the hand-rolled posture's owned failure modes)
- docs/architecture/decisions/006-wake-and-delivery-contract.md (reconnect-recovery semantics)
- docs/architecture/core-contract.md (streams: events never require polling to become visible)
- tasks/pg-engine-notify-listen.md (the forwarder's task of record; Notes carry the shutdown sender-take decision this hardening builds on)
Notes
Decisions of record the implementation made that the description didn't pin:
- The retry core is an extracted helper (
reconnect_with_retry) rather than an inline restructure: sleep → shutdown-select → attempt → repeat, generic over anFnMut() -> Future<Output = Option<GenerationPair>>attempt closure. It is only ever exited with a fresh generation pair (Some) or at the shutdown flip (None) — the loop top's empty-slot arm is restructured away entirely:LoopCtx.connectionis no longerOption; the loop re-binds a plain loop-carried connection from the successful connect at the arm's bottom, so there is no fall-through path left to die on. - The attempt closure is the connector injection (the
implementer's-choice seam, one mechanism for both shapes): the real
closure clones
tokio_postgres::Configout of a sharedReconnectConfigSlot(Arc<Mutex<Config>>) and callsConfig::connect(NoTls), mapping errors toNone; the server-less unit tests substitute their own always-failing closures. The slot is also the config-swap seam:Forwarder::swap_reconnect_config/reconnect_config_snapshot(#[cfg(test)]) let a test point reconnects at an unreachable endpoint post-open and restore the real one. The field is#[cfg(test)]onForwarder(dead in prod otherwise — the loop reads its own slot clone); the shutdown watch'schanged()is selected with the backoff sleep so a close surfaces the terminal arm mid-outage instead of waiting out a cycle. - The fanout hardening rides a shared slot + drop guard: the
handle's sender became the shared
FanoutSlot(Arc<Mutex<Option< Sender>>>) the loop also receives, and the loop holds aFanoutReleaseguard whoseDropempties the slot — Drop runs on return, unwind, and task abort alike, so any loop exit (plusForwarder::shutdown's take, unchanged) releases the fanout.subscribereads the shared slot — a subscribe arriving past any loop exit fails closed (None) rather than parking on a forever-silent broadcast. receivers-terminal-iff-loop-gone is now enforced by construction, not just the shutdown path. - Backoff reset semantics unchanged: the cap/growth live in the
helper (doubled per attempt,
saturating_mul(2)→min(2000)); the reset to the 50 ms base still fires on the verified-alive generation (successful LISTEN/probe), so a connect that succeeds but dies before its LISTEN reconnects at the carried backoff. - The success path of the retry helper has no server-less unit pin
(a real
(Client, Connection)pair cannot be fabricated); it is pinned end-to-end by the harness recovery test. The unit pins carry: repeated failed connects across six backoff cycles with the cap, pre-flipped shutdown aborts with zero attempts, and the guard's drop-releases-slot mechanism. tokiogained atest-utilfeature on the crate's[dev-dependencies](feature-unified into test builds only) for the paused-time retry unit test — the gate battery runs deterministic and instant.- The bug was replay-proofed: with the old one-shot
Err → returnshape temporarily reintroduced, the new harness test (forwarder_survives_failed_reconnects_and_recovers_fully) fails as expected; with the fix it passes (verified both ways live).
Summary
What landed, verified how:
alkstore-postgres/src/forwarder.rs: the forwarder loop restructured — the reconnect arm now carriesreconnect_with_retry(sleep with live backoff → shutdown-arms → connect attempt, repeating until success or shutdown; failed connects never leave the arm), the loop-carried connection is a plain non-Optionrebinding (the empty-slottake() else returnthat killed the loop permanently after one failed connect is gone by construction), and the sharedFanoutSlot+FanoutReleaseguard make every loop exit path release the fanout sender (receivers-terminal-iff-loop-gone).LoopCtx/Forwarder::spawnadjusted; thecfg(test)reconnect-config seam (swap/snapshot) added; subscribe/shutdown doc comments updated for the invariant.alkstore-postgres/src/store/open_tests.rs(4 new tests):forwarder_survives_failed_reconnects_and_recovers_fully(harness: config swapped to unreachable → backend kill → repeated failed connects across ~4 backoff cycles mid-outage with the honest transientregistererror pinned → config restored → reconnect-wake- post-recovery NOTIFY delivery + working
listen()), two server-less paused-time unit pins of the retry core (six failed attempts across six backoff cycles capped at 2 s, shutdown-abort before any attempt), and the fanout release guard's drop mechanism.
- post-recovery NOTIFY delivery + working
alkstore-postgres/Cargo.toml:tokiotest-utiladded to[dev-dependencies]only.- Gates:
cargo test -p alkstore-postgreswith the harness server green (115 lib + 10 contract-suite + 9 schema, run twice full); every pre-existing forwarder/reconnect/no-replay/shutdown pin green unchanged; workspacecargo build,cargo test(server-less — new tests skip or run server-less cleanly),cargo clippy --all-targets -- -D warnings,cargo fmt --checkall green.