Repository navigation
Fix residual synchronized_standby_slots race during primary promotion - #1200
Merged
Merged
Conversation
#1199 forces the primary through apply_settings when a standby catches up after the primary has already converged to steady "primary" (pos 302, group_state_machine.c). A narrower window was left open: a standby finishing catch-up *while the primary is still mid-promotion* (reportedState=wait_primary, goalState already primary) never gets the same treatment. fsm_enable_sync_rep()'s WAIT_PRIMARY -> PRIMARY keeper transition (fsm_transition.c) retries every heartbeat until primary_standby_has_caught_up() succeeds, re-fetching synchronized_standby_slots fresh on each retry -- driven by LOCAL pg_stat_replication catch-up, not the monitor's own reportedState column. That local check can succeed off a single caught-up standby before a second standby's own catchingup -> secondary transition has landed on the monitor. Since that transition is a one-time event per standby (gated on previouslyReportedState=catchingup), pos 302's original FSM_STATE(PRIMARY) gate -- requiring reportedState == goalState == primary -- could never retroactively fire for it afterwards: the primary's own goalState (and, in the real keeper, its locally-applied synchronized_standby_slots GUC) stayed stuck on whatever shorter list had already been fetched, for three nodes started together roughly once every ten startups. Widen pos 302's primaryNode gate to FSM_PRIMARY_CONVERGED_OR_PROMOTING: a new TRANSITIONING pattern matching reportedState in {primary, wait_primary} *with goalState already primary* -- catching the primary in that exact mid-promotion window without also matching a primary merely sitting at steady wait_primary with no promotion pending (goalState still wait_primary too), which must keep resolving straight to primary undisturbed. Deliberately excludes join_primary: no (join_primary, apply_settings) transition exists in the keeper's own FSM table, so admitting it would assign a goalState the keeper has no transition function for. Regression test: synchronized_standby_slots_promoting_primary.sql drives both standbys through a concurrent catchingup assignment, lets one finish first (triggering the primary's promotion), and has the second finish while the primary is still mid-promotion -- asserting the primary's goalState becomes apply_settings rather than staying primary. Confirmed failing before this fix and passing after.
…e one real diff My previous update to this expected file was regenerated wholesale from a local run, which also baked in a collation-dependent demote_timeout/ demoted row reordering unrelated to this fix (this environment's locale sorts '_' before 'd' differently than CI's) -- breaking keeper_fsm_edges on every PG version in CI. Restore the original row order and apply only the genuine semantic change: (wait_primary, apply_settings) drops out of the 'keeper edge with no monitor counterpart' list (23 -> 22 rows), since pos 302 can now actually produce that edge.
current_state there is plain text (the 'any' sentinel isn't a legal pgautofailover.replication_state value, forcing the CASE expression to resolve to text), so its ORDER BY position depended on whatever collation initdb picked -- unlike assigned_state (the enum, sorted by declaration order regardless of locale) and unlike the GROUPING SETS query's own e.current_state (dump_fsm_edges()'s enum return column, despite the same column name). That's exactly what broke CI: this file's expected output was committed under one locale, and regenerating it from a local run under a different one silently reordered an unrelated section. Explicit COLLATE "C" on both plain-text current_state columns pins the sort order independent of locale. Confirmed identical output (query text aside) under both en_US.UTF-8 and C locally.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
#1199 fixed
synchronized_standby_slotsstaying stale when a standby catches up after the primary has already converged to steadyprimary:MonitorFSM[]'s pos 302 forces that primary throughapply_settingsthe moment the standby'sreportedStategoescatchingup -> secondary, but only when the primary's own row already readsreportedState == goalState == primary.A narrower window was left open: a standby finishing catch-up while the primary itself is still mid-promotion (
reportedState=wait_primary,goalStatealreadyprimary) never gets the same treatment. In a three-node group started together, this produced a primary stuck applying a one-slotsynchronized_standby_slotslist while the monitor's own view already listed two standbys, observed in roughly 1 in 10 concurrent three-node startups.Root cause
fsm_enable_sync_rep()'sWAIT_PRIMARY -> PRIMARYkeeper transition (fsm_transition.c) is retried on every heartbeat untilprimary_standby_has_caught_up()returns true, and each retry re-fetchessynchronized_standby_slotsfresh viafsm_apply_settings(). Crucially,primary_standby_has_caught_up()checks localpg_stat_replication, not the monitor's ownreportedStatecolumn — so this retry loop can succeed off a single caught-up standby's WAL position before that other standby's owncatchingup -> secondarytransition has actually landed on the monitor.Since that transition is a one-time event per standby (
pos 302is gated onpreviouslyReportedState == catchingup, via theonDiskSnapshotmechanism from #1199), if it's processed while the primary's row still readsreportedState=wait_primary(mid-promotion),pos 302's original strictFSM_STATE(PRIMARY)gate simply doesn't match — and it can never retroactively fire again for that standby. The primary's owngoalState(and, in the real keeper, its locally-appliedsynchronized_standby_slotsGUC) is left stuck on whatever shorter list had already been fetched, indefinitely.Fix
Widen
pos 302'sprimaryNodegate fromFSM_STATE(REPLICATION_STATE_PRIMARY)to a newFSM_PRIMARY_CONVERGED_OR_PROMOTINGpattern: aNODE_STATE_TRANSITIONINGpattern matchingreportedStatein{primary, wait_primary}withgoalStatealreadyprimary.This specifically catches the primary in that exact mid-promotion window, without also matching a primary merely sitting at steady
wait_primarywith no promotion pending (goalStatestillwait_primarytoo — e.g. waiting on its first quorum secondary), which must keep resolving straight toprimaryon its own next heartbeat, undisturbed. Confirmed vianode_active_protocol.sql's own ordinary one-standby startup scenario, which initially regressed with a naivereportedState-only widening and is now unaffected.Deliberately excludes
join_primaryfrom the pattern'sreportedStates, unlike the existingFSM_WAIT_OR_JOIN_PRIMARY_TRANSITIONING_TO_PRIMARY: no(JOIN_PRIMARY_STATE, APPLY_SETTINGS_STATE)transition exists in the keeper's own FSM table (fsm.c), so admitting it would assign agoalStatethe keeper has no transition function for. The(WAIT_PRIMARY_STATE, APPLY_SETTINGS_STATE)edge this fix now exercises already existed in the keeper's FSM table (a no-op transition) but was previously unreachable from the monitor side —check_fsm_reachability's own edge-count assertions confirm this (178 -> 179total edges, and the fixture-basedkeeper_fsm_edgestest shows that edge disappearing from its "keeper edge with no monitor counterpart" list).Test
New regression test
synchronized_standby_slots_promoting_primary.sqldrives the exact interleaving deterministically throughpgautofailover.node_active()calls (no timing/Docker dependency):catchingupconcurrently while the primary is still steadywait_primary(an ordinary two-standby startup).s1) finishes catching up first, which is what nudges the primary into promoting (goalState=primaryassigned,reportedStatestillwait_primary— the mid-promotion window).s2) only reports finishing (catchingup -> secondary) while the primary is in that exact window.goalStatebecomesapply_settingsimmediately afterwards, not left atprimary.Confirmed failing before this fix (
goalStatestaysprimary) and passing after (goalStatebecomesapply_settings).Also re-ran the full existing suite (regress + isolation, under
USE_ASSERT_CHECKINGsoAssertMonitorFSMWellFormed()'s reachability cross-check actually runs) to confirm no regression, includingnode_active_protocol.sql,guard_data_loss.sql, and the originalsynchronized_standby_slots.sqlfrom #1199 — all pass.Style:
citus_indent --checkpasses;ci/banned.h.shreports no banned API usage.