Fix #774: monitor can assign a primary an unreachable goal state during failover - #1165
Conversation
|
We hit #774 in production, and I think there is a second, independent way into it that this PR does not close. Our path reaches Production timelineTwo data nodes (A, B) plus a witness/monitor. Times are offsets from the start of an operator-initiated failover. PGDATA is ~8.5 GB. The real outage was 10 seconds. It cost a second full 8.7 GB resync. This is a different entry into
|
…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).
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).
(converged or unhealthy), not just its reportedState The two rules that decide what to do with a candidate converged at prepare_promotion both act on primaryNode's reportedState to choose between the demote_timeout path and the direct-promotion fallback. Neither previously checked whether primaryNode was actually done changing: a primary that is healthy and legitimately still converging toward a real "primary" (goalState already PRIMARY, e.g. after a replication-quorum change, but reportedState still lagging one step behind at a real predecessor like apply_settings) could have its lagging reportedState misread as "no live primary", triggering the fallback and promoting a second candidate straight to wait_primary while the first primary was seconds away from completing its own promotion on its own -- two simultaneously-writable nodes, which is worse than the unreachable-goal-state bug (#774) these rules exist to prevent. Both rules now require primaryNode to be "settled" before acting on its reportedState at all: either primaryNode == NULL, or it has fully converged to its own currently assigned goal (reportedState == goalState), or it is confirmed unhealthy (NodeIsUnhealthy) and therefore cannot make further progress toward that goal regardless. If primaryNode is healthy but still mid-transition, neither rule fires this tick; the next node_active() round re-evaluates once it either converges or is confirmed unhealthy. Also fixed a maintenance-handling regression introduced while merging the two rules' guards: a primaryNode in maintenance must defer entirely (as before), not fall through to the direct-promotion fallback just because it doesn't match the demote_timeout-reachable state set. Added Scenario C to failover_candidate_leaves_secondary.sql, manufacturing exactly the healthy-but-unconverged primary case and asserting the candidate's goal is left untouched at prepare_promotion (deferred) rather than hastily promoted. Verified via `make installcheck` (pg_regress in Docker): all 13 monitor tests (including the new Scenario C) + isolation tests pass. Ran citus_indent (docker run citus/stylechecker:no-py) and reformatted accordingly; re-verified full suite passes after reformatting.
everything built on it were solving a self-inflicted regression, not the real #774 bug Built and ran the exact CI reproduction against a fresh, completely unmodified origin/main worktree: all 27 multi-async tests pass, including the "primary dies at wait_primary" scenario this whole series of fixes was chasing (test_014_003_restart_node1 takes ~35s via the pre-existing drain-timeout escape hatch, then test_014_004 resolves cleanly with no split-brain). Root cause of the confusion: main already has a working, purely-time-based mechanism for this exact case (NodeIsDrainTimeExpired + the "demote timeout expired" rule) that reassigns a stuck-at-wait_primary primary to demoted -- a state genuinely reachable from wait_primary in KeeperFSM[] (added in #707, before #774 was even filed). The second commit in this branch ("Fix #774: monitor can assign a primary an unreachable goal state") restricted the demote_timeout-assigning rule to require the primary's reportedState be in a specific reachable set. That inadvertently prevented the primary's goal from ever becoming demote_timeout in the wait_primary case at all -- which is the precondition NodeIsDrainTimeExpired checks (node->goalState != DEMOTE_TIMEOUT_STATE => false). That broke the escape hatch's trigger, producing the "candidate hangs at prepare_promotion forever" bug the next two commits in this branch were built to fix -- via a new fallback path that promotes the candidate directly but never reconciles the old primary's own row, producing an actual split-brain (two simultaneously-writable nodes) that does not exist on main. Re-reading the original #774 issue thread confirms the actual reported bug is a *race*, not a permanent unreachable-state hang: a manual perform_failover can have the "no healthy standby in the quorum" rule (Rule 1) reassign the primary to wait_primary in the same window a separate rule assigns it demote_timeout, exactly matching the issue reporter's own diagnosis ("the monitor should not continue with the usual failover transition" when it just reassigned wait_primary). Fix 1 (IsFailoverInProgress guard on Rule 1, kept) directly prevents this race. The keeper's FATAL-and-retry behavior for a genuinely unreachable assignment is not itself a crash (log_fatal is a severity label only, per src/bin/lib/log/src/log.h -- the keeper loops and retries harmlessly) and should stay as-is: the monitor must never hand out an unreachable transition, full stop, and the keeper is right to refuse loudly if it ever does. Reverted group_state_machine.c's prepare_promotion handling back to origin/main's version, then reapplied only Fix 1 (the IsFailoverInProgress guard in ProceedGroupStateForPrimaryNode) -- diffed against origin/main to confirm this is now the only change. Trimmed failover_candidate_leaves_secondary.sql back to Scenario A (Fix 1's test) only; removed Scenarios B/C, which tested the now-reverted code. Verified via: - `make installcheck` (pg_regress in Docker): all 13 monitor tests + isolation tests pass. - citus_indent (docker run citus/stylechecker:no-py): clean. - Full multi-async pgaftest schedule against this exact code (own local image tags): all 27 tests pass, matching origin/main's baseline behavior exactly (including the ~35s drain-timeout path for test_014_003, and a clean test_014_004 with no split-brain).
- has_needed_replication_slots(): bump retry_timeout 5s -> 15s, the existing 0.5s-backoff retry loop wasn't always covering a keeper- triggered Postgres restart under a loaded CI runner (test_multi_ifdown.py::test_003_add_standby saw a bare Connection refused). - test_citus_multi_standbys.py: use the existing run_sql_query_retry() helper for worker2c instead of a bare run_sql_query(), which had no retry at all (test_003_002_all_workers_have_data saw the same).
477f6b6 to
654e88c
Compare
… rebase origin/main merged PR #1161 (timeline_fork_detection), which now runs before this test in regress_schedule and consumes 6 more ids from the shared node-id sequence. This test's expected.out hardcoded the literal assigned_node_id values (24/25) from register_node's return row, per the schedule file's own documented caveat about every test needing the whole node-id sequence to itself in a fixed order. Regenerated against the actual node ids now assigned (31/32); no other part of the test changed. Verified: all 14 pg_regress + 6 isolation tests pass locally via 'make installcheck'.
|
Thanks for the detailed write-up and the production timeline — that's a really useful trace, and you're right that it's a real gap. I confirmed your read against the current state of this branch: Rule 1's own guard ( Given your own note that a similar internal fix broke To pin down the exact behavior (and give the fix something concrete to converge against), I also put together a minimal end-to-end repro as a pgaftest spec — #1169. It's a 3-node cluster (primary + sync secondary + async secondary), where the sync secondary fails first (async standbys never count toward quorum, so this alone reassigns the primary to The fix itself will happen on top of #1169 once it's merged, tracked under #1168. Thanks again for catching this — it's a good find. |
…eachable goal state The two-node/general auto-failover trigger in ProceedGroupStateFromContext unconditionally assigned a healthy candidate's primary goal state "draining" (and, one tick later, an unconditional follow-on rule re-assigned it "demote_timeout"), neither of which KeeperFSM[] has an edge for from wait_primary -- only PRIMARY/JOIN_PRIMARY/APPLY_SETTINGS have those edges. The primary's keeper would fatal and retry forever on that specific assignment; the cluster recovered anyway, but only via the unrelated, purely time-based drain-timeout self-heal, drain_timeout_ms late. Root issue was reported against PR #1165 (the #774 fix): #1165 (comment) Fix, in three coordinated parts: 1. The trigger no longer reassigns primaryNode's goal at all when it's already converged to wait_primary -- there is nothing reachable to reassign it to, and no live standby to gracefully drain in the first place (wait_primary never had synchronous quorum). 2. The unconditional prepare_promotion -> stop_replication follow-on rule likewise skips reassigning primaryNode's goal in that case (still unconditionally moves the candidate to stop_replication). 3. A new NodeIsWaitPrimaryPresumedDead() applies the exact same drain_timeout_ms safety margin as NodeIsDrainTimeExpired before the completion rule commits the one real, reachable wait_primary -> demoted transition -- deliberately not shortened to the trigger's own (shorter) unhealthyTimeoutMs, and deliberately not gated on pgIsRunning (the common crash/partition case leaves a stale "running" self-report, so requiring it to be false first would make the wait permanent for exactly that case). Anchoring that safety margin took two attempts: the first version measured elapsed time from primaryNode's own reportTime, which seemed right by analogy to NodeIsDrainTimeExpired -- but reportTime keeps getting refreshed by the primary's own keeper the moment it reconnects and resumes polling, even if it never actually converges on anything, turning the bounded wait into a permanent stall. Caught this by re-running the #1169 repro spec with the primary reconnecting mid-flight (exactly the scenario a real network blip would produce) and watching promotion never complete. Fixed by anchoring on the *candidate's* own stateChangeTime instead, which only moves when the monitor reassigns the candidate's own goal -- untouched by the old primary's unrelated activity, and fixed in place the moment the candidate converges to stop_replication. Verified: pg_regress + isolation suites (13+6 tests, unchanged), multi_async.pgaf (27 tests, including the existing drain-timeout self-heal path at the same ~35s timing), demote_timeout_wait_primary_deadlock.pgaf (3 tests), and the #1169 repro spec updated to assert the fix (no fatal log, cluster still converges within the same safety margin) -- all green, run twice for stability.
…eachable goal state The two-node/general auto-failover trigger in ProceedGroupStateFromContext unconditionally assigned a healthy candidate's primary goal state "draining" (and, one tick later, an unconditional follow-on rule re-assigned it "demote_timeout"), neither of which KeeperFSM[] has an edge for from wait_primary -- only PRIMARY/JOIN_PRIMARY/APPLY_SETTINGS have those edges. The primary's keeper would fatal and retry forever on that specific assignment; the cluster recovered anyway, but only via the unrelated, purely time-based drain-timeout self-heal, drain_timeout_ms late. Root issue was reported against PR #1165 (the #774 fix): #1165 (comment) Fix, in three coordinated parts: 1. The trigger no longer reassigns primaryNode's goal at all when it's already converged to wait_primary -- there is nothing reachable to reassign it to, and no live standby to gracefully drain in the first place (wait_primary never had synchronous quorum). 2. The unconditional prepare_promotion -> stop_replication follow-on rule likewise skips reassigning primaryNode's goal in that case (still unconditionally moves the candidate to stop_replication). 3. A new NodeIsWaitPrimaryPresumedDead() applies the exact same drain_timeout_ms safety margin as NodeIsDrainTimeExpired before the completion rule commits the one real, reachable wait_primary -> demoted transition -- deliberately not shortened to the trigger's own (shorter) unhealthyTimeoutMs, and deliberately not gated on pgIsRunning (the common crash/partition case leaves a stale "running" self-report, so requiring it to be false first would make the wait permanent for exactly that case). Anchoring that safety margin took two attempts: the first version measured elapsed time from primaryNode's own reportTime, which seemed right by analogy to NodeIsDrainTimeExpired -- but reportTime keeps getting refreshed by the primary's own keeper the moment it reconnects and resumes polling, even if it never actually converges on anything, turning the bounded wait into a permanent stall. Caught this by re-running the #1169 repro spec with the primary reconnecting mid-flight (exactly the scenario a real network blip would produce) and watching promotion never complete. Fixed by anchoring on the *candidate's* own stateChangeTime instead, which only moves when the monitor reassigns the candidate's own goal -- untouched by the old primary's unrelated activity, and fixed in place the moment the candidate converges to stop_replication. Verified: pg_regress + isolation suites (13+6 tests, unchanged), multi_async.pgaf (27 tests, including the existing drain-timeout self-heal path at the same ~35s timing), demote_timeout_wait_primary_deadlock.pgaf (3 tests), and the #1169 repro spec updated to assert the fix (no fatal log, cluster still converges within the same safety margin) -- all green, run twice for stability.
…KeeperFSM edge (#1169) * Add pgaftest spec reproducing issue #1168 3-node cluster (node1 primary, node2 sync secondary, node3 async secondary with candidate-priority 50 > 0). node2 fails, zeroing secondaryQuorumNodesCount even though node3 stays healthy (async standbys never count toward it), so node1 gets reassigned wait_primary. node1 then itself goes unhealthy while at that converged wait_primary: the auto-failover trigger in ProceedGroupStateFromContext fires against node3 and hands node1 a goal (draining, then demote_timeout within the same polling cycle) that has no KeeperFSM[] edge from wait_primary. Verified against current origin/main: node1's keeper fatals/retries with "does not know how to reach state" before self-healing via the unrelated, purely time-based drain-timeout mechanism (goal re-targeted straight to demoted). Ran twice locally via 'pgaftest run' against a freshly built pg17 image, both green -- this spec documents the current (buggy) recovery path and will need its assertions updated once #1168 is actually fixed (drop the log-contains check, assert node1 goes straight to demoted without the intermediate fatal). * Fix #1168: monitor no longer assigns a primary at wait_primary an unreachable goal state The two-node/general auto-failover trigger in ProceedGroupStateFromContext unconditionally assigned a healthy candidate's primary goal state "draining" (and, one tick later, an unconditional follow-on rule re-assigned it "demote_timeout"), neither of which KeeperFSM[] has an edge for from wait_primary -- only PRIMARY/JOIN_PRIMARY/APPLY_SETTINGS have those edges. The primary's keeper would fatal and retry forever on that specific assignment; the cluster recovered anyway, but only via the unrelated, purely time-based drain-timeout self-heal, drain_timeout_ms late. Root issue was reported against PR #1165 (the #774 fix): #1165 (comment) Fix, in three coordinated parts: 1. The trigger no longer reassigns primaryNode's goal at all when it's already converged to wait_primary -- there is nothing reachable to reassign it to, and no live standby to gracefully drain in the first place (wait_primary never had synchronous quorum). 2. The unconditional prepare_promotion -> stop_replication follow-on rule likewise skips reassigning primaryNode's goal in that case (still unconditionally moves the candidate to stop_replication). 3. A new NodeIsWaitPrimaryPresumedDead() applies the exact same drain_timeout_ms safety margin as NodeIsDrainTimeExpired before the completion rule commits the one real, reachable wait_primary -> demoted transition -- deliberately not shortened to the trigger's own (shorter) unhealthyTimeoutMs, and deliberately not gated on pgIsRunning (the common crash/partition case leaves a stale "running" self-report, so requiring it to be false first would make the wait permanent for exactly that case). Anchoring that safety margin took two attempts: the first version measured elapsed time from primaryNode's own reportTime, which seemed right by analogy to NodeIsDrainTimeExpired -- but reportTime keeps getting refreshed by the primary's own keeper the moment it reconnects and resumes polling, even if it never actually converges on anything, turning the bounded wait into a permanent stall. Caught this by re-running the #1169 repro spec with the primary reconnecting mid-flight (exactly the scenario a real network blip would produce) and watching promotion never complete. Fixed by anchoring on the *candidate's* own stateChangeTime instead, which only moves when the monitor reassigns the candidate's own goal -- untouched by the old primary's unrelated activity, and fixed in place the moment the candidate converges to stop_replication. Verified: pg_regress + isolation suites (13+6 tests, unchanged), multi_async.pgaf (27 tests, including the existing drain-timeout self-heal path at the same ~35s timing), demote_timeout_wait_primary_deadlock.pgaf (3 tests), and the #1169 repro spec updated to assert the fix (no fatal log, cluster still converges within the same safety margin) -- all green, run twice for stability.
Problem
A monitor rule could assign a node a goal state its own
KeeperFSM[](src/bin/pg_autoctl/fsm.c) has no transition path to reach from that node's current state.Root cause: two independent rules in
group_state_machine.ccould race during a manual failover (pg_autoctl perform failover). Rule 1 (ProceedGroupStateForPrimaryNode, "no healthy standby in the quorum" block) reassigns the primary towait_primarywheneversecondaryQuorumNodesCount == 0. That count drops to zero the instant a failover candidate leavesSECONDARY(e.g. converges toprepare_promotion) — even though the candidate is the failover in progress, not a lost standby. Without a guard, the primary gets reassignedwait_primaryright as its own candidate is converging — then a separate rule (assuming the primary is still in one of its normal pre-demote states) assigns itdemote_timeout, which has noKeeperFSM[]edge fromwait_primary.This matches the original issue reporter's own diagnosis in the thread: "the monitor should not continue with the usual failover transition" once it has reassigned the primary to
wait_primary.Fix
Rule 1 now skips its reassignment when
IsFailoverInProgress()is true (the candidate already being inprepare_promotionor later counts). This prevents the race from happening at all — the primary goesprimary → draining → demote_timeoutcleanly, with no detour throughwait_primary.What this PR deliberately does not change
An earlier version of this PR also restricted the rule that assigns
demote_timeout, to require the primary's reported state be oneKeeperFSM[]actually has an edge from. That turned out to be unnecessary and harmful:mainalready has a working, purely time-based escape hatch (NodeIsDrainTimeExpired) that reassigns a primary stuck unresponsive atwait_primarytodemoted— a state genuinely reachable fromwait_primary(added in #707, before this issue was filed). The restriction prevented the primary's goal from ever becomingdemote_timeoutin that case, which is the precondition that escape hatch checks, breaking it and requiring an increasingly complex set of follow-up rules to compensate. Verified this by running the full CI reproduction against an unmodifiedorigin/main: it already resolves the "primary dies at wait_primary" scenario cleanly (~35s via the drain timeout), no changes needed there.The keeper's
log_fatal-and-retry behavior for a genuinely unreachable assignment is also left as-is:log_fatalhere is a severity label only (not a crash — the keeper loops and retries harmlessly), and the monitor should simply never hand out an unreachable transition in the first place, rather than have the keeper silently tolerate one.Testing
Added
src/monitor/sql/failover_candidate_leaves_secondary.sql, apg_regresstest isolating Rule 1's guard via direct state manufacture (the same techniquestale_primary_report.sqluses for otherwise hard-to-reach states).Verified via:
make installcheck(pg_regress in Docker) — all 13 monitor tests + isolation tests pass.multi-asyncpgaftest schedule run against this exact code and against an unmodifiedorigin/mainbaseline, using own local Docker image tags — both pass all 27 tests with matching behavior.Issues
Fixes #774.
Also looked at #883 and #369 as candidates for the same root cause:
node_metadata.c/node_active_protocol.c).