Skip to content

fix(cluster): GRD recovery liveness — same-episode remaster retry + proof-carrying WAIT_EPOCH + dead-node PCM cleanup (spec-4.6a, BUG-B/C2)#26

Merged
sqlrush merged 13 commits into
mainfrom
stage7-p0-reconfig-liveness
Jul 9, 2026
Merged

fix(cluster): GRD recovery liveness — same-episode remaster retry + proof-carrying WAIT_EPOCH + dead-node PCM cleanup (spec-4.6a, BUG-B/C2)#26
sqlrush merged 13 commits into
mainfrom
stage7-p0-reconfig-liveness

Conversation

@sqlrush

@sqlrush sqlrush commented Jul 8, 2026

Copy link
Copy Markdown
Owner

What

A fail-stop reconfig could wedge a settled cluster permanently (observed 56min+, zero self-heal) on three stacked liveness holes:

  1. A BLOCKED HW remaster worker was never relaunched within the same episode (BGW_NEVER_RESTART + launched-latch) — silent BLOCKED causes included an unconfigured cluster.wal_threads_dir.
  2. WAIT_CLUSTER had no watchdog while its cluster/HW gates were held — 8.A fail-closed, but invisible.
  3. WAIT_EPOCH's strict progress gate wedged forever when an IC-piggybacked epoch bump landed before the local reconfig event (baseline re-captured post-bump → cur == old permanently).

Fixes: bounded same-episode HW remaster retry (backoff + SIGHUP-raisable cap, BLOCKED_STRUCTURAL never retried + WARNING-once + multi-node shared_catalog startup fail-fast), observational WAIT_CLUSTER watchdog (never unfreezes), three-layer proof-carrying WAIT_EPOCH escape (event-scoped REDECLARE_DONE witness — stale-event DONEs dropped at accounting; no timeout-only advance exists), dead-node PCM holder/pending-X cleanup on the dead-sweep (post-kill DDL recovers instead of timing out), serve-gate scope alignment, and full observability (retry/exhausted/watchdog/escape/cleanup counters).

Tests

  • New t/362: 4-node shared_catalog formation → kill -9 → remaster convergence + post-kill DDL + cleanup counter + 0-wedge invariant (no SKIP after the kill).
  • t/293: same-episode self-heal (hide/restore snapshot → BLOCKED → retry → DONE → unfreeze-extend) + retry-exhaustion (frozen + watchdog WARNING, no unfreeze).
  • t/337: level-2 fail-fast negative leg.
  • Unit: relaunch-decision table (8 rows), event-scoped DONE rejection + witness full-condition advance, PCM dead-cleanup forms incl. pending-X.

Gates (local, cassert build)

cluster_unit 158 binaries · t/293 + t/337 + t/362 + smoke subset · cluster_regress 13/13 · PG regress 219/219 · clang-format 0 violations · headers/scn-cmp/no-clog-overlay/cppcheck clean.

Spec: spec-4.6a-grd-recovery-liveness.md

SqlRush added 2 commits July 8, 2026 18:24
… WAIT_CLUSTER watchdog, event-scoped WAIT_EPOCH escape, dead-node PCM cleanup

A fail-stop reconfig could wedge a settled cluster forever on three stacked
liveness holes: a BLOCKED HW remaster worker was never relaunched within the
same episode (BGW_NEVER_RESTART + launched-latch), WAIT_CLUSTER had no
watchdog while its cluster/HW gates were held, and WAIT_EPOCH's strict
progress gate wedged permanently when an IC-piggybacked epoch bump landed
before the local reconfig event (baseline re-captured as the post-bump
value, cur == old forever).

- HW remaster: per-dead-node result/attempts/next_attempt shmem state; the
  FSM relaunches BLOCKED workers with exponential backoff (cap 60s) up to
  cluster.hw_remaster_retry_max_attempts (SIGHUP-raisable); abnormal worker
  exit marks BLOCKED via before_shmem_exit; the three silent BLOCKED
  returns now LOG their cause.  wal_threads_dir-unset is detected up front
  as BLOCKED_STRUCTURAL: never retried, WARNING-once with the config hint,
  and (level 2) a multi-node shared_catalog=on boot without it now fails
  fast at startup.
- WAIT_CLUSTER: observational watchdog on the rebuild-timeout cadence —
  WARNING with the missing-survivor list, gate states and per-dead-node
  retry state, plus a counter; it never unfreezes anything.
- WAIT_EPOCH: three proof-carrying layers — abort-to-idle on a new event id,
  durable observed-epoch adoption, and an event-scoped coordinator witness
  (REDECLARE_DONE carries the event id; stale-event DONEs are dropped at
  accounting; the equal-epoch escape additionally requires the coordinator's
  DONE to have been accounted after the accept snapshot).  No timeout-only
  advance exists; without proof the shards stay frozen.
- Dead-node PCM cleanup: the GRD dead-sweep clears the dead node's
  x_holder/s_holders/pi_holders/master_holder and pending-X residue (LOG
  summary + pcm/dead_cleanup_entries counter), and a local-master S state
  with no local residency can now upgrade S->X through the standard
  invalidate/ACK path instead of failing closed forever — post-kill DDL
  recovers instead of timing out on cluster locks.
- Serve-gate scope alignment: the merged-materialization check in
  gcs_block phase_for_tag now uses the same thread-recovery scope policy as
  the unfreeze gate, removing the permanent-RECOVERING mismatch outside the
  gate's applicable scope (in-scope behavior unchanged).
- Observability: hw retry/exhausted + grd cluster_gate_timeout /
  wait_epoch_escape + pcm dead_cleanup_entries dump keys; S4-reject and HW
  fail-closed diagnostics.

Tests: unit relaunch-decision table (8 rows) + event-scoped DONE rejection/
witness advance + PCM dead-cleanup forms incl. pending-X; t/293 gains the
same-episode self-heal and retry-exhaustion legs (unfreeze-extend positive
+ watchdog WARNING greps); t/337 gains the level-2 fail-fast negative leg;
new t/362 runs a 4-node shared_catalog formation, kill -9, and asserts
remaster convergence, post-kill DDL, cleanup counters and the 0-wedge
invariant end to end.  Local gates (cassert): unit 158 binaries, t/293 +
t/337 + t/362 + smoke subset, cluster_regress 13/13, PG regress 219/219.

Spec: spec-4.6a-grd-recovery-liveness.md
t/358-361 are reserved by parallel lanes (spec-7.2 / S-xid), t/363 by the
S-dead lane; occupancy verified against origin branches.  The 4-node
formation + kill -9 + convergence run gets its own shard for wall-clock
isolation.

Spec: spec-4.6a-grd-recovery-liveness.md
@sqlrush sqlrush force-pushed the stage7-p0-reconfig-liveness branch from e8fc3b9 to e7ca433 Compare July 8, 2026 10:24
SqlRush added 11 commits July 8, 2026 18:25
…adow unit

Same inline suppression + reason as the other cluster_unit main()
signatures (test_cluster_grd.c precedent); a newer local cppcheck
flags it, the CI baseline version does not.
…hash)

The P6 all-done gate and the WAIT_EPOCH coordinator witness keyed the
cross-node DONE on event_id, but event_id folds the sender-local
cssd_dead_generation, which drifts with each survivor's private flap
observation history and never converges across nodes: any flap asymmetry
made the survivors compute different ids for the same episode, drop each
other's DONEs, and wedge P6 forever — nondeterministically reintroducing
the permanent-freeze shape this branch exists to remove.

The quorum-accepted dead SET is what actually converges, so the DONE
payload now carries a hash over the dead bitmap alone (same kernel as
the event_id hash, riding the same request-id field pair — no envelope
change), stamped per episode at P0 accept.  Accounting, the P6 gate and
the witness compare against the stamp; event_id stays a purely local
accept-dedup scope.  A late DONE from a previous episode still cannot
back a new one: a coordinator re-election changes the dead set (hash
mismatch) and a same-node re-death rides a higher epoch through the
interposed JOIN bump (epoch conjunct).  Pre-accept frames now mismatch
the previous stamp and are dropped; senders re-announce every tick, so
accounting always lands after the accept snapshot — closing the
first-event pre-accept window as a side effect.

Spec: spec-4.6a-grd-recovery-liveness.md (Amendment v1.2 R2/R4)
Revert the scope short-circuit that returned NORMAL for a dead static
master's blocks wherever online thread recovery cannot run (the default
configuration and every >2-node deployment): it skipped both the
is_materialized cold-block door and the redo-coverage lost-write door,
so a committed write only the dead node saw could be silently read
stale from shared storage.  No other guard sits on that read path — the
GRD freeze ends with the episode, the HW gate covers only extend
high-water marks, and the page-SCN checks ride the ship path.

Out of scope the posture is now an explicit bounded retryable error
(53R9L, hint updated to name the way out: restart the failed node, or
enable online thread recovery in a supported scope) for every tag whose
static master is dead — including never-written blocks, which the
cold-block door cannot distinguish from not-yet-replayed ones — and the
door reopens the moment the failed node stops being DEAD.  In-scope
behavior is byte-identical.  The now-orphaned scope helper is removed.

t/362 asserts the honest two-outcome contract on fresh connections
(success or explicit SQLSTATE — 53R9L or the TT-unknown visibility
door — never a hang), rides pre-warmed sessions for convergence
observability, and pins the D12 cleanup counter to a positive delta;
t/293's post-kill extend leg follows the same posture.

Spec: spec-4.6a-grd-recovery-liveness.md (Amendment v1.2 R1/R3)
…grade cleanup

- Order the worker's terminal-result store after its backoff-deadline
  store (pg_memory_barrier) so the relaunch decider never pairs a fresh
  BLOCKED with a stale deadline and skips one backoff wait.
- A structural cause discovered only by the WORKER (SIGHUP race) now
  leaves the retry deadline unset so the FSM's next tick takes the
  MARK_STRUCTURAL branch and emits the once-per-episode operator
  WARNING with the configuration hint.
- Wrap the local S->X upgrade in PG_TRY and release the temporary S
  claim when the ACK wait throws (cancel via CHECK_FOR_INTERRUPTS),
  not only on the false return; document why the completed upgrade
  hard-resets the local S refcount (the X grant subsumes all local S
  declarations).  Linker-only exception stubs for the units that link
  cluster_pcm_lock.o.

Spec: spec-4.6a-grd-recovery-liveness.md (Amendment v1.2 R5/R6/R7/R9)
WAIT_BARRIER / WAIT_CLUSTER carried only epoch-coherence guards, so a
survivor whose CSSD detected a coordinator-folded multi-death late kept
announcing REDECLARE_DONE under its stale dead-bitmap hash: peers that
stamped the full set dropped those frames on the composite key and P6 —
which has no timeout by design — wedged permanently (survivor-behind
detection skew).  Add a mid-episode guard that aborts to IDLE on a newer
local event whose dead-bitmap hash differs: the next tick re-consumes
the event, re-stamps recovery_event_bitmap_hash, re-runs recovery
against the fuller dead set and re-announces DONE under the new
composite key.  Same-set dead_generation drift is absorbed without
episode churn (the event_id keeps its local ABA-scoping role).  The
coordinator-behind direction needs no handling: the coordinator's own
later detection bumps the epoch again and the epoch guards abort.

Correct the stamp-site / accounting / hash-helper comments that
overstated the bitmap as quorum-ratified (each node stamps its OWN
accepted event's bitmap; convergence is eventual via the CSSD deadband
plus this guard), and assert a stamped episode hash is never 0 — 0 is
reserved as unstamped in the mark_peer_done drop test, and a JOIN
episode's all-zero bitmap hashes to a fixed nonzero constant.

Unit: survivor-behind growth leg (H({1}) stamped at the folded epoch,
grown {1,2} event arrives -> abort, re-stamp, full-set remaster, DONE
accounting converges) plus a same-set drift no-churn leg; both go red
with the guard disabled.

Spec: spec-4.6a-grd-recovery-liveness.md (r3-P2-2, r3-P3a)
…arrier

hw_remaster_record_terminal stores the backoff deadline before the
terminal result with a release fence between the stores, but the LMON
relaunch decider loaded result-then-deadline with no read-side pairing:
on a weakly-ordered CPU it could still pair a fresh BLOCKED with a stale
zero deadline and skip one backoff wait.  Insert pg_read_barrier()
between the result and deadline loads (attempts needs no fence — it is
written only by the single-writer LMON FSM) and cross-reference the
pairing at the write site.

Spec: spec-4.6a-grd-recovery-liveness.md (r3-P3b)
The serve-gate unseal (static master no longer CSSD-DEAD -> NORMAL) is
heartbeat liveness, not a direct recovery-completion signal.  Document
the traced proof of why returning NORMAL is nevertheless safe: CSSD —
the only heartbeat sender — is spawned by the phase-4 driver at the
PM_RUN transition, after the startup process completed the node's crash
recovery against shared storage; and even under a stale-ALIVE view a
fetch cannot complete against a still-recovering node, because the
master-side handler default-denies until the node is an in-quorum
MEMBER and only the (equally post-PM_RUN) QVOTEC process can establish
the quorum lease after a restart wiped shmem.  Deny replies surface as
bounded 53R9L, endpoint unavailability as bounded 53R90, and no path
falls back to a silent local storage read.  Replaces the previous
wording that asserted recovery-completion semantics without grounding.

Spec: spec-4.6a-grd-recovery-liveness.md (r3-P2-1 prove-safe)
… leg

The Amendment v1.2 (R1) serve-gate fix had no test lock: the t/362
two-outcome legs stay green under a reverted gate (their success arm
accepts a NORMAL serve of a dead-master page) and no unit links the real
phase decision.  Add L4h: after the kill, with the dead node never
restarted, once the observed survivor's own recovery episode reached
IDLE, a FRESH backend on a cold survivor runs a wide
pg_class/pg_attribute scan that MUST fail with exactly 53R9L — no
success arm, bounded return.  An out-of-scope formation never publishes
the dead node's materialization proof, so every dead-master page stays
RECOVERING while the node is down; the scan spans dozens of catalog
pages hashed ~1/4 to the dead node and cold-reads them past the 16MB
test pool, and it touches no dead-node-written rows (heap INSERTs only,
no DDL), so 53R9L is the only legal outcome.  Verified red-first: with
the gate reverted the suite fails at exactly this leg while every other
leg stays green.

The episode-IDLE wait polls the pre-warmed session with the exact query
shape it ran pre-kill and compares in Perl: a cast or operator added to
the SQL performs fresh syscache lookups that themselves cold-read a
dead-master catalog page and trip 53R9L (the very mechanism under
test).  Also accept the 53R51 write-fence rejection in the pre-existing
two-outcome write legs: a transient lease/epoch fence right after the
reconfig is an explicit, retryable fail-closed rejection inside the
same honest contract.

Spec: spec-4.6a-grd-recovery-liveness.md (r3-P1-1 TAP hard leg)
The spec-4.6a Amendment v1.2 (R2) composite convergence key gated both
recovery_done_epoch[] and recovery_done_bitmap_hash[] behind one drop gate
in cluster_grd_recovery_mark_peer_done().  A rejoining node never P0-accepts
the JOIN episode as its own FSM episode (JOIN_COMMITTED is published
coordinator-side only), so its local episode-hash stamp stays 0 forever; the
composite gate then dropped every survivor REDECLARE_DONE, the join fence
(cluster_grd_block_view_rebuilt) never lifted, and every joiner-home block
access fail-closed 53R9L permanently.  Nightly t/347 L4-iii (rejoin with
xid_striping) and t/353 L5 (fresh insert on reborn node0) pinned the wedge.

Split the two accounting axes:

  * done_epoch[] accounts unconditionally (monotonic-max), feeding the
    epoch-only join fence.  Sound because REDECLARE_DONE(epoch=E) is
    broadcast only after grd_block_redeclare_scan_complete(E), so a DONE at
    E proves the sender finished its WAIT_BARRIER re-declare at E -- the
    fence's safety condition, independent of the sender's episode dead set.

  * done_bitmap_hash[] stays composite-gated for the P6 cluster gate and the
    WAIT_EPOCH coordinator witness, which still AND both axes and stay
    fail-closed on the withheld hash half (r3-P2-2 multi-death skew
    protection unchanged).

New unit leg test_recovery_idle_joiner_accounts_done_epoch_for_fence asserts
the fence lifts once survivor DONEs land while the composite half stays
withheld; the stale-bitmap and dead-set-growth legs gain a negative
assertion that the epoch axis alone never fires the composite escape.

Spec: spec-4.6a-grd-recovery-liveness.md
The same S-reconfig nightly that pinned the join-fence wedge also flagged
two stale dump-count baselines that lagged already-shipped counters:

  * t/108 pg_cluster_state pcm category 22 -> 23 (spec-4.6a D12
    dead_cleanup_entries).
  * t/249 grd_recovery dump roster 18 -> 31 keys plus the exact sorted key
    list (spec-4.6 2.4 base + spec-5.16 D5 join direction + spec-4.6a
    liveness/episode surface).

Both stay exact-count / exact-roster assertions; the code already emits the
31-key grd_recovery set (verified against cluster_debug.c).

Spec: spec-4.6a-grd-recovery-liveness.md
t/353 L5 crashes node0 and has the reborn node take a FRESH HW space lease,
which requires the survivor to complete HW remaster of the dead node's
shards.  HW remaster reads the dead node's per-thread WAL, so on this
shared_data 2-node pair it is only recoverable when cluster.wal_threads_dir
is configured (cluster_hw_remaster_recoverable).  The pair was created with
shared_data but WITHOUT wal_threads_root, so recoverable() returned false,
the remaster was marked BLOCKED_STRUCTURAL, and the reborn node's fresh-lease
DDL/insert/count fail-closed (L5 subtests 28-31, nightly crossnode-b).

Opt the pair into wal_threads_root, matching every other HW-remaster-
dependent kill test (t/247/248/274/293).  This completes the per-thread WAL
harness config that the RACvsRAC/ClusterQuad harness already carries but this
ClusterPair test was missing.

Spec: spec-4.6a-grd-recovery-liveness.md
@sqlrush sqlrush merged commit 2d95cce into main Jul 9, 2026
24 of 25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant