Skip to content

Fix #774: monitor can assign a primary an unreachable goal state during failover#1165

Open
dimitri wants to merge 3 commits into
mainfrom
fix/774-failover-race-condition
Open

Fix #774: monitor can assign a primary an unreachable goal state during failover#1165
dimitri wants to merge 3 commits into
mainfrom
fix/774-failover-race-condition

Conversation

@dimitri

@dimitri dimitri commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Problem

Two rules in group_state_machine.c could combine to assign the primary a goal state its own KeeperFSM[] (src/bin/pg_autoctl/fsm.c) has no transition path to reach from its current state, fataling the keeper forever with "does not know how to reach state ... from ...".

  • Rule 1 (ProceedGroupStateForPrimaryNode, "no healthy standby in the quorum"): reassigns the primary to wait_primary whenever secondaryQuorumNodesCount == 0. That count drops to zero the instant a failover candidate leaves SECONDARY (e.g. converges to prepare_promotion) — even though the candidate is the failover in progress, not a lost standby.
  • Rule 2 ("prepare_promotion -> stop_replication"): assigns the primary demote_timeout as soon as the candidate reports prepare_promotion, unconditionally. demote_timeout has a real KeeperFSM[] edge from primary/join_primary/apply_settings/draining — but not from wait_primary. If Rule 1 already reassigned the primary to wait_primary, Rule 2 hands out an unreachable demote_timeout.

Fix

  • Rule 1 now skips its reassignment when IsFailoverInProgress() is true (the candidate already being in prepare_promotion counts).
  • Rule 2 now only assigns demote_timeout when the primary's reported state is actually one KeeperFSM[] has an edge to (primary, join_primary, apply_settings, draining), deferring otherwise — the next node_active() round re-evaluates.

Testing

Added src/monitor/sql/failover_candidate_leaves_secondary.sql, a pg_regress test with two scenarios, each isolating one of the two guards via direct state manufacture (the same technique stale_primary_report.sql already uses for otherwise hard-to-reach states). Neither needs the isolation tester — both are pure sequencing issues inside ProceedGroupStateFromContext.

Verified via:

  • Authoritative make installcheck (pg_regress in Docker) — all 13 monitor tests + isolation tests pass.
  • Empirically reproduced both bugs pre-fix (temporarily reverted each guard, rebuilt, confirmed the unreachable goal state is assigned and the test fails), then confirmed both fixes independently load-bearing.

Issues

Fixes #774.

Also looked at #883 and #369 as candidates for the same root cause:

…ng failover

Two rules in group_state_machine.c could combine to assign the primary
goal state demote_timeout while it is sitting at wait_primary. There is
no KeeperFSM[] edge from wait_primary to demote_timeout, so the keeper
fatals forever with "does not know how to reach state ... from ...".

Rule 1 ("no healthy standby in the quorum"): reassigns the primary to
wait_primary whenever secondaryQuorumNodesCount == 0. That count drops
to zero the instant a failover candidate leaves SECONDARY (e.g.
converges to prepare_promotion) -- even though the candidate is the
failover in progress, not a lost standby. Guarded with
!IsFailoverInProgress().

Rule 2 ("prepare_promotion -> stop_replication"): assigns the primary
demote_timeout as soon as the candidate reports prepare_promotion,
unconditionally. demote_timeout has a real KeeperFSM[] edge from
primary/join_primary/apply_settings/draining -- but not from
wait_primary. Guarded to only fire when the primary's reported state is
actually one of those four.

Adds a pg_regress test (failover_candidate_leaves_secondary) isolating
each rule via direct state manufacture, following the same technique
stale_primary_report.sql already uses for hard-to-reach states.

Verified via authoritative `make installcheck` (pg_regress in Docker,
all 13 monitor tests + isolation tests pass) and by empirically
reproducing both bugs pre-fix and confirming both fixes independently
load-bearing (temporarily reverting each and observing the failure
reproduce).
@dimitri dimitri self-assigned this Jul 27, 2026
@dimitri dimitri added the bug Something isn't working label Jul 27, 2026
dimitri added 2 commits July 27, 2026 03:17
Pre-existing missing blank line from #1164, unrelated to the #774 fix
in this branch but blocking this PR's style-check CI job since
citus_indent lints the whole tree. Verified with `make docker-check`
(clean) and `sh ci/banned.h.sh` (clean).
before ever reaching PRIMARY

Found via the actual CI failure (pgaftest multi-async, test_014_003_
restart_node1): when a primary is killed while still at wait_primary
(never got a healthy secondary counted before being promoted the rest
of the way), GetPrimaryOrDemotedNodeInGroupFromList still resolves it
as primaryNode (its goal state, wait_primary, is writable). The
previous fix's Rule 2 guard correctly refuses to assign it
demote_timeout (no KeeperFSM[] edge from wait_primary), but the
primaryNode == NULL fallback never fires either, since primaryNode is
non-NULL -- the candidate is left stuck at prepare_promotion forever.

Broadened the fallback rule to also fire whenever primaryNode exists
but is not in a state with a real demote_timeout edge (mirroring the
guard's own restricted set): there is no live primary to hand off to
either way, so promote the candidate directly, exactly as when
primaryNode is NULL.

Updated failover_candidate_leaves_secondary.sql's Scenario B to assert
the corrected behavior: the candidate now reaches wait_primary directly
instead of deferring.

Verified via:
- `make installcheck` (pg_regress in Docker): all 13 monitor tests +
  isolation tests pass.
- Reproduced the CI hang locally with own-tagged Docker images
  (pgaf774dbg:pg17 / pgaf774dbg:pgaftest) running the actual
  multi-async pgaftest schedule; confirmed test_014_003_restart_node1
  now passes (previously hung for the full 180s timeout).
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.

How to recover from a current/assigned state combination without a possible state transition path?

1 participant