Skip to content

Fix #1025: self-fenced primary deadlocks forever on an unreachable goal state#1158

Merged
dimitri merged 4 commits into
mainfrom
fix/1025-demote-timeout-wait-primary-deadlock
Jul 25, 2026
Merged

Fix #1025: self-fenced primary deadlocks forever on an unreachable goal state#1158
dimitri merged 4 commits into
mainfrom
fix/1025-demote-timeout-wait-primary-deadlock

Conversation

@dimitri

@dimitri dimitri commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Fixes #1025.

Bug

check_for_network_partitions() in service_keeper.c self-fences a node into demote_timeout based purely on its own local current_role, independent of whatever goalState the monitor last assigned. If that goal state (e.g. wait_primary, assigned for an unrelated benign reason such as a peer entering maintenance) has no FSM edge reachable from demote_timeout, the keeper fatals forever:

FATAL pg_autoctl does not know how to reach state "wait_primary" from "demote_timeout"

Fix

  • group_state_machine.c: a guard in ProceedGroupStateFromContext re-targets a node reporting demote_timeout with an unreachable goal to demoted — a real demote_timeout -> demoted FSM edge, and the safe, conservative choice.
  • node_metadata.c: GetPrimaryOrDemotedNodeInGroupFromList() didn't recognize a node that had fully converged to demoted (only the transitional states leading up to a demotion), a prerequisite gap the guard above exposed. Extended it to match IsDemotedPrimary()'s existing idiom.
  • node_active_protocol.c: stop_maintenance() assigned catchingup (stream from the current primary) even when the only "primary" found is fully demoted — Postgres stopped, nothing to stream from — so a node coming out of maintenance retried a doomed connection forever with no way back. It now assigns report_lsn in that case, which seeds the existing candidate-election machinery and lets the formation actually recover a working primary.

Test

tests/tap/specs/demote_timeout_wait_primary_deadlock.pgaf reproduces the deadlock end to end and verifies the fix in three steps: the self-fence recovers to demoted instead of crash-looping, the secondary is confirmed unaffected while the primary is down, and disabling maintenance afterwards converges the formation back to a working primary/secondary pair.

dimitri added 4 commits July 25, 2026 04:16
check_for_network_partitions() in service_keeper.c self-fences a node into
demote_timeout based purely on its own local current_role, independent of
whatever goalState the monitor last assigned. If that goalState (e.g.
wait_primary, assigned for an unrelated benign reason like a peer entering
maintenance) has no FSM edge reachable from demote_timeout, the keeper
fatals forever with "does not know how to reach state X from
demote_timeout", and the rest of the formation stays stuck waiting on it.

Add a guard in ProceedGroupStateFromContext: when a node reports
demote_timeout but its assigned goal isn't one demote_timeout can actually
reach, re-target it to demoted -- a real demote_timeout -> demoted FSM edge,
and the safe, conservative choice.

That guard exposed a second, pre-existing gap in
GetPrimaryOrDemotedNodeInGroupFromList(): its fallback loop only recognized
the transitional states leading up to a demotion (draining, demote_timeout,
prepare_maintenance) via StateBelongsToPrimary(), never the terminal
demoted state itself, unlike IsDemotedPrimary() right below it which already
had to special-case this. Previously unreachable because every existing
failover path already has a new primary by the time the old one reports
fully demoted; this fix's self-fence recovery path is the first place a
node can reach demoted with no promotion ever having happened, which
surfaced the gap as a monitor-side ERROR crash-looping the node's
node_active() calls.

demoted is a safe landing spot but not a self-healing one: with no failover
ever triggered, the peer never becomes a promotion candidate, so there's no
existing mechanism (perform_failover included) to elect a new primary from
here. Recovering the formation to a working primary again is a separate,
pre-existing gap and out of scope for this fix.
…ad end

Two more questions worth capturing alongside the #1025 deadlock fix:

- What happens to the former secondary while the primary is disconnected
  and self-fencing, and afterwards? Nothing: entering maintenance is fully
  independent of the primary, and the MAINTENANCE early-exit in
  ProceedGroupStateFromContext means the monitor won't touch it again until
  an explicit stop_maintenance() call, regardless of what the primary does
  in the meantime. test_002 asserts this directly.

- How do we repair the cluster and converge back to a working primary?
  With today's tools, we can't. test_003 demonstrates the dead end:
  stop_maintenance() assigns catchingup (not report_lsn), since
  IsFailoverInProgress() is false at that point -- so the secondary retries
  a replication connection to the demoted, stopped primary forever.
  perform_failover() and perform_promotion() both refuse too, since neither
  a demoted primary nor a catchingup secondary satisfies their
  preconditions. Recovering formations stuck in this shape is a separate,
  pre-existing gap from #1025 and remains unsolved.
stop_maintenance() found the demoted primary via
GetPrimaryOrDemotedNodeInGroupFromList() (now correctly recognizing it,
per the previous commit's fix) but still assigned the rejoining node
"catchingup" -- meaning "stream from the primary". A demoted primary's
Postgres is stopped, so that retried a doomed replication connection
forever, with no automatic or documented manual path back (verified:
perform_failover() and perform_promotion() both refuse too, since neither
a demoted primary nor a catchingup secondary satisfies their
preconditions).

When the found primary is IsDemotedPrimary() -- fully converged to
demoted, nothing left running to stream from -- assign report_lsn
instead. That's what seeds the recovery: once the node reports its LSN,
ProceedGroupStateForMSFailover()'s candidate scan (already existing code)
picks up the demoted primary too, both report their LSN, and the normal
election promotes the most advanced one. The demoted -> report_lsn FSM
edge (fsm.c:854, fsm_report_lsn_and_drop_replication_slots) was already
built for exactly this: "used when a former primary node has been
demoted and gets back online during the secondary election" -- it just
had no way to get seeded in a 2-node group where the only other node was
parked in maintenance the whole time.

Extended demote_timeout_wait_primary_deadlock.pgaf: test_002 confirms
the secondary is unaffected by the primary's self-fence (and pins down
exactly which monitor-side rule releases it from wait_maintenance),
test_003 confirms disable maintenance now converges the formation back
to a working primary/secondary pair end to end.
Comments had grown to the point of drowning out the actual scenario.
Cut the per-line source citations and restated rationale down to what's
needed to follow the steps; the commit messages carry the full
investigation. Also label the two bare `sql monitor {}` blocks (no
`expect`) as DEBUG -- they're just state snapshots printed for a human
reading the test output, not assertions.
@dimitri dimitri self-assigned this Jul 25, 2026
@dimitri dimitri added the bug Something isn't working label Jul 25, 2026
@dimitri
dimitri merged commit 10864fa into main Jul 25, 2026
72 checks passed
@dimitri
dimitri deleted the fix/1025-demote-timeout-wait-primary-deadlock branch July 25, 2026 14:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Possible FAILURE STATE in State Machine

1 participant