Skip to content

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

Merged
dimitri merged 6 commits into
mainfrom
fix/774-failover-race-condition
Jul 27, 2026
Merged

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

Conversation

@dimitri

@dimitri dimitri commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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.c could race during a manual failover (pg_autoctl perform failover). Rule 1 (ProceedGroupStateForPrimaryNode, "no healthy standby in the quorum" block) 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. Without a guard, the primary gets reassigned wait_primary right as its own candidate is converging — then a separate rule (assuming the primary is still in one of its normal pre-demote states) assigns it demote_timeout, which has no KeeperFSM[] edge from wait_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 in prepare_promotion or later counts). This prevents the race from happening at all — the primary goes primary → draining → demote_timeout cleanly, with no detour through wait_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 one KeeperFSM[] actually has an edge from. That turned out to be unnecessary and harmful: main already has a working, purely time-based escape hatch (NodeIsDrainTimeExpired) that reassigns a primary stuck unresponsive at wait_primary to demoted — a state genuinely reachable from wait_primary (added in #707, before this issue was filed). The restriction prevented the primary's goal from ever becoming demote_timeout in 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 unmodified origin/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_fatal here 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, a pg_regress test isolating Rule 1's guard via direct state manufacture (the same technique stale_primary_report.sql uses for otherwise hard-to-reach states).

Verified via:

  • make installcheck (pg_regress in Docker) — all 13 monitor tests + isolation tests pass.
  • Full multi-async pgaftest schedule run against this exact code and against an unmodified origin/main baseline, 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:

@dimitri dimitri self-assigned this Jul 27, 2026
@dimitri dimitri added the bug Something isn't working label Jul 27, 2026
@tboevil

tboevil commented Jul 27, 2026

Copy link
Copy Markdown

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 wait_primary without Rule 1 ever being involved, so Fix 1 cannot help, and the damage happens before Fix 2 and Fix 3 get a chance to.

Production timeline

Two data nodes (A, B) plus a witness/monitor. Times are offsets from the start of an operator-initiated failover. PGDATA is ~8.5 GB.

00:00   perform_failover(): A -> draining, B -> prepare_promotion
00:00   B -> stop_replication, A -> demote_timeout
01:14   demote timeout expires: B -> wait_primary, A -> demoted
01:15   A -> catchingup, starts pg_rewind (8.6 GB)

        ... B sits at wait_primary for the next 6 minutes ...
        wait_primary -> primary needs a healthy secondary in the quorum,
        and A is catchingup the whole time.

06:18   A finishes pg_rewind, Postgres starts
06:43   A converges to secondary
        ^ B should now be advanced wait_primary -> primary

06:47   B's keeper starts timing out to the monitor (node_active fails)
        ^ 4 seconds later. That rule lives in
          ProceedGroupStateForPrimaryNode(), which only runs when the
          primary itself calls node_active() -- so B never gets advanced
          and stays pinned at wait_primary.

07:04.34  monitor marks B unhealthy
07:04.38  SECOND FAILOVER, 42 ms later:
            A -> prepare_promotion
            B -> draining          <-- B is at wait_primary
07:06     B's keeper receives wait_primary -> draining
07:06     B's keeper receives wait_primary -> demote_timeout
07:14.69  B is healthy again (the outage lasted 10 seconds)
07:20-35  FATAL x5 over 15s:
            "does not know how to reach state demote_timeout from wait_primary"
07:35     demote_timeout expires monitor-side, B -> demoted
07:44     B -> catchingup, second pg_rewind (8.7 GB)
11:21     second pg_rewind completes
11:49     A -> primary, cluster stable

The real outage was 10 seconds. It cost a second full 8.7 GB resync.

This is a different entry into wait_primary

In the scenario this PR reproduces, the primary is put into wait_primary by Rule 1 mid-failover, and Fix 1 stops that from happening.

In ours, B was already at wait_primary — it was promoted there by the first (operator-initiated) failover at 01:14 and got stuck because the window to advance it to primary opened at 06:43 and its keeper lost the monitor at 06:47. secondaryQuorumNodesCount never entered into it.

Rule 1 in fact cannot fire here: its first condition is already !IsCurrentState(primaryNode, REPLICATION_STATE_WAIT_PRIMARY), and B is at wait_primary. So Fix 1 is a no-op for this timeline.

What fires at 07:04.38 is the two-node auto-failover trigger (group_state_machine.c:631-635):

	if (IsCurrentState(activeNode, REPLICATION_STATE_SECONDARY) &&
		IsInPrimaryState(primaryNode) &&
		NodeIsUnhealthy(primaryNode, ctx) && NodeIsHealthy(activeNode, ctx) &&
		activeNode->candidatePriority > 0 &&
		WalDifferenceWithin(activeNode, primaryNode, PromoteXlogThreshold))

IsInPrimaryState() goes through CanTakeWritesInState(), which includes WAIT_PRIMARY. So this rule assigns draining directly to a node sitting at wait_primary — and KeeperFSM[] has no wait_primary -> draining edge. The assignment is unreachable at the moment it is made, before any of the rules this PR touches are reached.

Worth noting the multi-standby path already guards against exactly this (group_state_machine.c:382-383):

		if (IsInPrimaryState(primaryNode) &&
			!IsCurrentState(primaryNode, REPLICATION_STATE_WAIT_PRIMARY) &&
			candidatesCount >= 1)

Both rules assign the primary draining. The multi-standby one excludes wait_primary; the two-node one does not.

What #1165 changes for this timeline — and what it doesn't

Replaying the above with this PR applied, as best I can tell from the diff:

  • 07:04.38 — the trigger still fires. B still gets draining, still unreachable, still fatals. Not covered.
  • 07:06 — Fix 2's whitelist correctly blocks the follow-on demote_timeout (B's reportedState is wait_primary). Covered.
  • Fix 3's complement rule promotes A from prepare_promotion directly, so the cluster no longer waits out demote_timeout. It would recover around 07:06 instead of 07:35. Real improvement.

But the failover still happens. A becomes the new primary, so B must demote and rejoin — and the 8.7 GB pg_rewind at 07:44 happens anyway. This PR makes an unnecessary failover complete cleanly; it doesn't stop it from being started. For this incident the entire cost was in that resync, so the saving would be about 30 seconds out of roughly 4.5 minutes.

I have not tested this against your branch — the above is from reading the diff, and the trigger at :631 being untouched is the part I'm confident about. Happy to actually run it if that's useful.

Suggested addition

One line, mirroring the multi-standby guard:

	if (IsCurrentState(activeNode, REPLICATION_STATE_SECONDARY) &&
		IsInPrimaryState(primaryNode) &&
		!IsCurrentState(primaryNode, REPLICATION_STATE_WAIT_PRIMARY) &&
		NodeIsUnhealthy(primaryNode, ctx) && NodeIsHealthy(activeNode, ctx) &&
		...

This is also the semantically right call on its own terms: CanInitiateFailover() already refuses manual failover from wait_primary, with the comment "we're not sure if the secondary has done catching-up yet" — which applies just as much to an automatic one. Our current internal fix is ​​this, but after further consideration, we found that it blocks some normal paths, such as single -> wait_primary or apply_settings -> draining.

dimitri added 5 commits July 27, 2026 17:54
…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).
… 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'.
@dimitri
dimitri merged commit 93e877d into main Jul 27, 2026
73 checks passed
@dimitri
dimitri deleted the fix/774-failover-race-condition branch July 27, 2026 16:56
@dimitri

dimitri commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

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 (!IsCurrentState(primaryNode, WAIT_PRIMARY)) means it can't fire once the primary is already at wait_primary, so this PR's fix is a no-op here — it's a genuinely separate path into the same class of bug (monitor assigns a goal state with no KeeperFSM[] edge from the node's current state), not something this PR happens to cover.

Given your own note that a similar internal fix broke single -> wait_primary and apply_settings -> draining, this needs its own careful design rather than a quick patch on top of this PR, so I've opened it as a separate issue: #1168.

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 wait_primary even with a healthy async standby still around), and then the primary itself fails while sitting at that wait_primary. On the current code that confirms exactly what you described: the primary gets handed an unreachable goal (draining, then demote_timeout), the keeper fatals and retries on it, and it only recovers via the unrelated, purely time-based drain-timeout mechanism — not a permanent hang, but a real unnecessary delay plus an assignment that should never have happened.

# Reproduces https://github.com/hapostgres/pg_auto_failover/issues/1168.
#
# node1 (primary) loses its only quorum-counted standby (node2) and gets
# reassigned wait_primary, even though node3 -- an async standby
# (candidate-priority 50, replication-quorum false) -- stays healthy
# throughout: async standbys never count toward secondaryQuorumNodesCount,
# so losing node2 alone is enough to zero it out.
#
# node1 then itself becomes unhealthy while sitting at (converged)
# wait_primary. The auto-failover trigger in ProceedGroupStateFromContext
# (`IsCurrentState(activeNode, SECONDARY) && IsInPrimaryState(primaryNode)
# && NodeIsUnhealthy(primaryNode) && ...`) fires against node3 and assigns
# node1 "draining" -- a goal state with no KeeperFSM[] edge from
# wait_primary (only PRIMARY/JOIN_PRIMARY/APPLY_SETTINGS -> DRAINING
# exist). node1's keeper fatals and retries forever on that specific
# assignment. The cluster still recovers, but only via the unrelated,
# purely time-based drain-timeout self-heal (goal jumps straight to
# demoted once drain_timeout_ms elapses) -- not because the assignment was
# ever reachable.

cluster {
    monitor
    ssl off
    formation {
        node1
        node2
        node3  async
    }
}

setup {
    wait until node1 state is primary
        and node2 state is secondary
        and node3 state is secondary
        timeout 120s
}

teardown {
    compose down
}

step test_001_losing_the_only_sync_standby_reassigns_wait_primary {
    network disconnect node2
    wait until node1 assigned-state is wait_primary  timeout 60s
    assert node3 state is secondary
}

step test_002_primary_failure_at_wait_primary_hits_1168 {
    network disconnect node1

    # node3 is the only remaining healthy candidate (async, candidate
    # priority 50 > 0); the buggy trigger targets it once node1 is marked
    # unhealthy by the monitor's own health checks. node1's goal moves
    # draining -> demote_timeout within the same polling cycle (neither
    # reachable from wait_primary), so we don't probe that intermediate
    # value directly -- the FATAL log below is the stable signature.
    wait until node3 assigned-state is prepare_promotion  timeout 90s

    network connect node1

    # Recovers, but only via the unrelated drain-timeout self-heal (goal
    # re-targeted straight to demoted once drain_timeout_ms elapses), not
    # because "draining"/"demote_timeout" ever became reachable from
    # wait_primary.
    wait until node1 state is demoted  timeout 90s

    # node1 reported back in still at wait_primary along the way, was
    # handed one of those unreachable goals, and fataled/retried -- the
    # bug this spec documents.
    logs node1 contains "does not know how to reach state"
}

step test_003_cluster_converges_back_after_recovery {
    network connect node2
    wait until node3 state is primary
        and node1 state is secondary
        and node2 state is secondary
        timeout 120s
}

sequence
    test_001_losing_the_only_sync_standby_reassigns_wait_primary
    test_002_primary_failure_at_wait_primary_hits_1168
    test_003_cluster_converges_back_after_recovery

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.

dimitri added a commit that referenced this pull request Jul 27, 2026
…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.
dimitri added a commit that referenced this pull request Jul 27, 2026
…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.
dimitri added a commit that referenced this pull request Jul 27, 2026
…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.
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?

2 participants