Fix 3-DC split-brain: monitor assigns wait_primary on replication stall (issue #997) - #1149
Conversation
243af2b to
3218785
Compare
pg_regress runs an unscheduled (bare) REGRESS list as a single parallel group. None of src/monitor/'s regression tests were written to tolerate that: they all register nodes via pgautofailover.register_node() against the one pgautofailover.node table (and its one shared nodeid sequence) in the whole contrib_regression database, and their expected/*.out files hardcode small literal node ids on the assumption that they have that sequence -- and the table -- to themselves. dummy_update.sql and upgrade.sql additionally run their own CREATE/ALTER/DROP EXTENSION pgautofailover against the single shared extension object, a second, separate way for two tests to corrupt each other if run concurrently. Observed in CI as sporadic "not ok - upgrade" and "not ok - lock_and_fetch_migration" failures (most recently on PR #1149's "Build run image (PG18)" job), depending on which pair of tests the scheduler happened to interleave that run. Confirmed locally: running the old flat REGRESS list surfaced a *second*, previously-undiagnosed instance of the same class of bug -- monitor/workers/etc. leaking node-id and formation rows into each other when run in a naive "everything but the three extension-owning tests" parallel group. Fix: add src/monitor/regress_schedule, a real pg_regress --schedule file that puts every test in the original REGRESS list on its own serial "test:" line, in dependency order (create_extension first, then the functional tests, then dummy_update -> drop_extension -> upgrade last). Wire it in via REGRESS_OPTS += --schedule=..., and override installcheck to invoke pg_regress without also passing the bare $(REGRESS) list -- pg_regress treats trailing bare test-name arguments as extra tests run one at a time *after* the schedule finishes (pg_regress.c's extra_tests/run_single_test()), so passing both would silently re-run every test a second time. REGRESS itself is left in place, still listing every test: PGXS's `ifdef REGRESS` gates the installcheck/clean recipes entirely (verified: an empty-valued REGRESS makes `ifdef REGRESS` false), and it still documents the full test set. Verified: `make build-pg18` (the exact Dockerfile/pg_virtualenv path CI uses) -- all 12 REGRESS tests and all 6 ISOLATION tests pass, in the expected serialized order.
When the primary and standby lose connectivity while both remain reachable
from the monitor (the 3-DC split-brain scenario), synchronous_standby_names
stays set on the primary, causing every COMMIT to hang indefinitely.
Fix
---
Monitor-side FSM rule: when a PRIMARY node reports an empty
pg_stat_replication sync_state for longer than
pgautofailover.replication_stall_timeout (default 10 s), the monitor
assigns wait_primary. This clears synchronous_standby_names and unblocks
writes without initiating a failover; the standby rejoins as secondary once
the link is restored.
Implementation details
----------------------
* pgautofailover.node gains two new columns:
region text NOT NULL DEFAULT 'default'
replication_stall_since timestamptz (NULL = not stalled)
* Extension version bumped 2.2 → 2.3; upgrade script
pgautofailover--2.2--2.3.sql added.
* register_node() gains a node_region parameter (default 'default').
* current_state() now returns noderegion so pg_autoctl watch can display
the region column in verbose+ policies.
* ReportAutoFailoverNodeState() manages replication_stall_since: it is set
to COALESCE(replication_stall_since, now()) when the node is in PRIMARY
state and reportedrepstate is empty, and cleared (set to NULL) otherwise.
* New GUC: pgautofailover.replication_stall_timeout (10 s, PGC_SIGHUP).
* FSM rule in ProceedGroupStateFromContext(): if the primary is healthy
(monitor can reach it) but replication_stall_since is set and
now - replication_stall_since > replication_stall_timeout, assign
wait_primary with a diagnostic log message.
* pg_autoctl watch: added COLUMN_TYPE_REGION, shown in verbose/almost-full/
full/fully-verbose policies.
Test
----
tests/tap/specs/replication_stall_3dc.pgaf — a new regression spec that:
1. Confirms sync replication is active.
2. Severs the node1↔node2 Docker network link (simulating DC1↔DC2 failure).
3. Waits for the primary to reach wait_primary (stall timeout = 3 s here).
4. Verifies writes no longer hang (synchronous_standby_names is empty).
5. Restores the link and verifies the standby rejoins and replication is
healthy again.
Closes #997
- Thread --region option (-G) through all three pg_autoctl create postgres long_options arrays in cli_create_node.c, central option parsing in cli_common.c (case 'G'), keeper_config KeeperConfig.region field, nodespec.c ini/argv builder, and monitor_register_node() SQL call (). - Bump extension version to 2.3 consistently: Makefile EXTVERSION, metadata.h AUTO_FAILOVER_EXTENSION_VERSION, and pgautofailover.control already at 2.3. Add pgautofailover--2.3--dummy.sql upgrade path for dummy_update regress test. Update dummy_update.out expected DETAIL line to say '2.3'. - Fix monitor regress test ordering: add ORDER BY nodeid to the unordered SELECT from pgautofailover.node so the result is deterministic regardless of heap page layout (wider rows from new region/replication_stall_since columns changed physical scan order). - Update watch_colspecs.h MAX_COL_SPECS 12→14 to accommodate the new COLUMN_TYPE_REGION entry plus COLUMN_TYPE_LAST sentinel in the fully-verbose column policy (13 entries total). - Add pgaftest DSL support for 'region <name>' node option: T_REGION token in lexer, grammar rule in parser, TestNode.region field, compose_gen writes --region flag, cli_indent pretty-prints it. - Add tests/tap/specs/replication_stall_3dc.pgaf: 2-node cluster across dc1/dc2 regions; cuts network to node2, waits for node1 to reach wait_primary (FSM rule fires after replication_stall_timeout), then restores connectivity and waits for secondary recovery.
Rebasing issue #997's replication-stall fix onto current main (which bumps the extension to 2.3) shifts tests/upgrade/Makefile's PREV_TAG auto-detection: it excludes whatever tag matches the *current* in-development version, so with current=2.3 it now resolves to v2.2 (the latest actual release) instead of v2.1. upgrade.pgaf hardcoded '2.1'/'2.2' version strings that no longer match: - baseline `installed_version` check: 2.1 -> 2.2 - `ALTER EXTENSION ... UPDATE TO`: '2.2' -> '2.3' - final `installed_version` check: 2.2 -> 2.3 Also updated the header comment: the documented "v2.1 supervisor bug" (no monitor_init before the dropped-node check, fixed in v2.2) no longer applies to a v2.2 -> v2.3 upgrade, so the "compose start" calls in test_003b_wait_keeper_restart are now expected no-ops against still-running containers rather than a real crash recovery -- kept as-is so the spec still exercises the restart path correctly whenever PREV_TAG is pinned back to a pre-v2.2 release (PREV_TAG=v2.1 make ...). Verified: `pgaftest run tests/tap/specs/upgrade.pgaf` against PG16, 10/10 steps passing, PREV_TAG correctly auto-detected as v2.2.
3218785 to
5294ec5
Compare
pg_regress's "upgrade" test tries expected/upgrade.out first, then falls back to expected/upgrade_1.out (the variant matching psql versions that show a "Default version" column in \dx output) -- CI runners hit the second one. That file still hardcoded "2.2" as the control file's default_version, left over from before this branch bumped the extension to 2.3; the 2.2 -> 2.3 bump touched pgautofailover.control and defaults.h but missed this expected-output file, so every \dx assertion after CREATE/ALTER EXTENSION (1.0, 1.1, 1.2, 1.3) failed on the "Default version" column. src/monitor/expected/pg19/expected/upgrade_1.out is a symlink to this same file, so no separate edit needed there. Verified: `make build-pg18` (the exact Dockerfile/pg_virtualenv path CI uses) -- all 12 REGRESS tests pass, including upgrade.
6a8be97 ("Wire --region through CLI, fix extension versioning, add 3-DC stall test") added "order by nodeid" to the unordered SELECT from pgautofailover.node in sql/monitor.sql, to make the result deterministic now that wider rows (new region/replication_stall_since columns) can change physical heap scan order. It updated the base expected/monitor.out to match, but missed updating the PG19-specific copy at expected/pg19/expected/monitor.out (kept as a real file, not a symlink, because of PG19's differing pg_lsn display format elsewhere in the same test) -- so the "monitor" test failed under PG19 CI with the query missing its ORDER BY clause. Verified: `make build-pg19` -- clean build, all 12 REGRESS tests pass.
6a8be97 ("Wire --region through CLI, fix extension versioning, add 3-DC stall test") added a noderegion column to the monitor_get_current_state query at index 14, shifting healthlag/reportlag to indices 15/16 (17 columns total, 0-16) -- and updated the per-row parser and its own documentation comment to match. It missed the PQnfields() guard a few lines above, which still required exactly 16 columns, so every call through this path failed immediately with "Query returned 17 columns, expected 16" before the (already-correct) row parsing ever ran. This is the single root cause behind widespread CI failures across every PG version: pytest single/ssl (pg_autoctl show state), pgaftest quick's config_get_set spec (same path, via `pg_autoctl set node metadata` + `show state`), and both citus-1/citus-2 specs (coordinator fetching the node list from the monitor during formation setup). Verified: rebuilt the run + pgaftest images and re-ran the exact previously-failing `quick` schedule against them -- config_get_set now passes cleanly, no more column-count error anywhere in the run.
|
Would checking |
|
Thanks for taking a close look at this — both good questions, and worth walking through in detail. On replication_stall_since = CASE
WHEN $1 = 'primary' AND $3 = '' -- $3 is pgsrSyncState
THEN COALESCE(replication_stall_since, now())
ELSE NULL
END
So checking That said, digging into this did surface a real gap: On |
…1156) * docs: document the region column/option and node.ini gap from PR #1149 PR #1149 (issue #997, 3-DC split-brain fix) added a --region option to pg_autoctl create postgres/coordinator/worker, a matching [settings] region key in pg_autoctl_node.ini, a REGION column in `pg_autoctl watch`, and a new pgautofailover.replication_stall_timeout GUC -- none of it made it into the docs. - docs/ref/pg_autoctl_create_postgres.rst: add --region to the usage synopsis and options list (create_coordinator.rst/create_worker.rst already defer common options here, matching the existing pattern for --candidate-priority etc., so no change needed there). - docs/ref/pg_autoctl_node.rst: add region to the [settings] section reference. Verified in the source (keeper.c, nodespec.c, and the absence of a "region" case in cli_get_set_properties.c) that, unlike its section-mates candidate_priority/replication_quorum, region has no live "pg_autoctl set node region" path -- it's read from [settings] but only takes effect at node creation time. Documented that distinction explicitly and corrected the section's blanket "applied live" claim. - docs/ref/pg_autoctl_watch.rst: add the Region column to the column list. Confirmed via cli_show.c that `pg_autoctl show state` has its own, separate, unaffected column set -- only `watch` gained this column. - docs/ref/configuration.rst: add pgautofailover.replication_stall_timeout to the sample pg_settings query output and give it its own subsection (matching the existing guard_data_loss subsection), explaining the 3-DC split-brain scenario it addresses. - docs/ref/pgaftest.rst: add the `region <name>` node modifier to the DSL reference table (verified against the actual grammar rule and the replication_stall_3dc.pgaf spec's real usage: `node1 region dc1`). Also documents the manual docker-compose trick asked about separately: `compose start`/`compose stop` are DSL keywords usable only inside a spec's step{} block, with no standalone `pgaftest compose ...` command for ad hoc use from the `pgaftest tmux` bottom pane. Added a "Manually starting/ stopping a node" section showing the equivalent raw `docker compose -f $PGAFTEST_HOST_WORK_DIR/docker-compose.yml start/stop <node>` invocation, cross-referenced from the failure-simulation semantics section. Verified: `make -C docs html` builds clean; also checked with `sphinx-build -E -n` (full rebuild, nitpicky mode) -- no new warnings. * Add region mutability: monitor SQL/C, pg_autoctl CLI, docs PR #1149 introduced `region` (data-centre/availability-zone label) as create-time only: a `--region` flag and a `[settings] region` key in node.ini, stored on `pgautofailover.node`, but with no way to change it on an already-registered, running node. Monitor side: - ReportAutoFailoverNodeRegion() (node_metadata.c/.h): UPDATE pgautofailover.node SET region = ... for a given node. - set_node_region() SQL function (node_active_protocol.c + pgautofailover.sql): validates the node exists and the new region is non-empty, updates it, and notifies listeners. No quorum/FSM impact, unlike candidate_priority/replication_quorum -- a straight passthrough. No extension version bump: 2.3 is still unreleased on origin/main, so this is incremental development on the current version. - Monitor regression coverage added to the existing node_active_protocol.sql/.out (not a new test file): direct set/get, unknown-node error, empty-value error. pg_autoctl CLI side: - monitor_set_node_region()/monitor_get_node_region() (monitor.c/.h). - `pg_autoctl set node region <value>` / `pg_autoctl get node region` (cli_get_set_properties.c), mirroring the existing candidate-priority/ replication-quorum commands but without their FSM-convergence wait, since region has none. Docs: pg_autoctl_node.rst flips region to "Mutable" under [settings]; new pg_autoctl_set_node_region.rst / pg_autoctl_get_node_region.rst reference pages, registered in pg_autoctl_set.rst/pg_autoctl_get.rst and pg_autoctl.rst's command listings. * pgaftest: nodeini set/get DSL primitive + interactive nodeini/compose commands Testing region's node.ini live-apply path required editing a node's host-side .ini file directly (it's bind-mounted read-only inside the node's own container, so this can't go through exec/compose) -- there was no DSL primitive for that. - New `nodeini set <node> <key> <value>` / `nodeini get <node> <key> <value>` DSL commands (test_spec.h/.l/.y), sharing one file-I/O implementation (nodeini_write_value/nodeini_read_value in test_runner.c) between the DSL command execution and the new interactive commands below. - New interactive `pgaftest nodeini get|set` command, mirroring the existing `pgaftest network`/`pgaftest sql` interactive commands. - New interactive `pgaftest compose start|stop|kill|down|exec` command set, wrapping `docker compose ...` directly so the -p/-f flags don't need to be typed by hand from the tmux bottom pane -- previously documented as a manual docker compose invocation only. - docs/ref/pgaftest.rst updated: synopsis, new `pgaftest_nodeini`/ `pgaftest_compose` sub-command sections, the "Node .ini file access" DSL reference entry (with a note on Docker Desktop for macOS's virtiofs sometimes not relaying host-originated inotify events -- see the nodespec.c commit for how that's handled), and the "Manually starting/stopping a node" section now leads with the built-in command. - tests/tap/specs/config_get_set.pgaf: CLI round-trip (test_003_region_cli_roundtrip) and node.ini automated file-watch coverage (test_004_region_nodeini_live_apply) using the new `nodeini set`/`nodeini get` commands. * nodespec: live-apply region; make file-watch detection CRC-based nodespec_apply() gains a region block matching the existing candidate_priority/replication_quorum pattern exactly: shells out to `pg_autoctl set node region --pgdata <dir> <value>` via run_program() when node.ini's region entry changes -- i.e. node.ini editing was never a second, parallel implementation for these settings, just automation that types the same CLI command a human would. Separately, hardened the watcher itself: change detection is now always CRC32C-hash based (nodespec_file_crc / NodeSpecWatcher.last_crc), with inotify (Linux only) used purely as a low-latency hint to check sooner, never trusted as the sole signal. inotify has been observed to silently miss host-originated writes to a bind-mounted node.ini under Docker Desktop for macOS's virtiofs -- the write's content lands in the container immediately, but no IN_CLOSE_WRITE event is ever raised. The hash poll (NODESPEC_HASH_POLL_INTERVAL_SECS, currently 1s) guarantees a change is picked up regardless of whether inotify fires, on any platform/filesystem. * region: full parity with candidate_priority/replication_quorum region previously lived on KeeperConfig as a plain INI-backed option (OPTION_AUTOCTL_REGION), meaning it was read from and written to pg_autoctl.cfg and reachable via the generic `pg_autoctl config get/set pg_autoctl.region` -- a second, disconnected mutation path that never notified the monitor and (a pre-existing bug) didn't even persist reliably. candidate_priority/replication_quorum never had this generic exposure: they live on PostgresSetup.settings, merged into the config via a single direct struct-copy in keeper_config_merge_options() (config->pgSetup = pgSetup), entirely bypassing the INI option array, config file, and config get/set. region now follows the exact same path: - Moved from KeeperConfig.region to a new PostgresSetup.settings.region field (pgsetup.h), alongside candidatePriority/replicationQuorum. - OPTION_AUTOCTL_REGION and its SET_INI_OPTIONS_ARRAY entry removed entirely (keeper_config.c) -- region is no longer read from, written to, or merged through pg_autoctl.cfg in any way, and no longer reachable via keeper_config_to_json/config get/set at all. - --region at creation time now writes into LocalOptionConfig.pgSetup.settings.region (cli_common.c), riding the same direct struct-copy that already carries candidate_priority/ replication_quorum through keeper_config_merge_options. - Both monitor_register_node() call sites (keeper.c) updated to read config->pgSetup.settings.region. - `pg_autoctl config get/set pg_autoctl.region` now given a clear, dedicated error pointing to `pg_autoctl set/get node region` instead of falling through to a bare "unknown option" lookup failure (cli_config.c). The monitor remains the sole source of truth after registration, same as before. Verified with a full local Docker image rebuild (the generated docker-compose.yml has no `build:` section -- pg_autoctl:pgNN must be rebuilt manually for local source changes to take effect): config_get_set.pgaf passes reliably across repeated runs, and basic_operation.pgaf's full 27-step create/failover/maintenance/ network-partition lifecycle is unaffected.
|
@dimitri thanks for your reply, I has another question. Maybe I missed something. we've now added more checks for downgrading (primary -> wait_primary), but we haven't done the corresponding checks for upgrading(like primaryNode->pgsrSyncState != SYNC_STATE_UNKNOWN). Could this lead to repeated oscillations? |
There seems to be another problem; reportedrepstate is 'unknown', but it's compared to '', so the condition is never true. BTW: If you need help (it seems there are a lot of bug fixes lately), I'd be happy to submit a PR after we've finalized a fix. |
Problem
In a 3-DC topology — primary in dc1, standby in dc2, monitor in dc3 — cutting the dc1↔dc2 link while both nodes remain reachable from the monitor leaves
synchronous_standby_namesset on the primary. EveryCOMMITthen hangs indefinitely waiting for an acknowledgement from a standby that can no longer be reached.Closes #997.
Root cause
The FSM had no rule to transition the primary out of the
primarystate when synchronous replication was stalled. The monitor could see that the standby was unreachable from the primary (viapg_stat_replicationreportingsync_state = 'unknown') but took no action.Fix
Monitor side (
src/monitor/):replication_stall_sincecolumn topgautofailover.node. The monitor sets it tonow()the first time a primary reportssync_state = unknown(replication partner unreachable) and clears it when replication recovers.regioncolumn topgautofailover.node(populated from--regiononpg_autoctl create postgres). This lets operators label nodes with their DC location for observability.pgautofailover.replication_stall_timeout(default 10 s). Whennow() - replication_stall_since > timeout, the FSM assignswait_primary, which clearssynchronous_standby_namesand unblocks writes.Client side (
src/bin/pg_autoctl/):--region/-Gflag added topg_autoctl create postgresand wired throughkeeper_config,monitor_register_node, andnodespec.pg_autoctl watchupdated to display theregioncolumn.Test (
tests/tap/specs/replication_stall_3dc.pgaf):New end-to-end regression test using the
pgaftestframework. The test models the 3-DC scenario in a single Docker Compose stack: the monitor is always reachable from both nodes, butdocker network disconnectsevers the dc1↔dc2 replication link. Six steps verify:synchronous_standby_names = 'ANY 1 (…)')wait_primarywithin ~20 s (well within the 60 s timeout)synchronous_standby_namesis cleared; writes no longer hangVerified locally, 6/6 steps passing against PG17.
Watch the fix work, interactively
Bring the exact topology up and drive it step by step (pulls/builds images on first run, ~30s):
This opens a 3-pane tmux session:
docker compose logs -f(top),pg_autoctl watch— the live FSM state table (middle), an interactive shell (bottom). From the shell:Each
pgaftest step(no argument) auto-advances to the next pending step in the spec'ssequence;pgaftest show stepsat any point lists all six with*marking which one runs next.To see the bug rather than the fix: the spec file itself doesn't exist before this PR, but the FSM rule it exercises is a single, self-contained
ifblock inProceedGroupStateFromContext()(src/monitor/group_state_machine.c). Comment that block out, rebuild the monitor image, and rerun the spec — step 3 times out instead of passing (node1stays atprimarywithsynchronous_standby_namesstill set), and step 4's write hangs forever (interrupt it andpgaftest cluster downto clean up).Reusing this for future bug reports
The spec above didn't start as a regression test — it started as the smallest
.pgaffile that reliably reproduced the reported bug. That's a generally useful pattern for triaging issues in this repo, not just for this one:.pgaffile describing just the topology and the manual steps that trigger the problem (acluster{}block, asetup{}that gets to the interesting state, andnetwork/sql/execcommands reproducing the failure — nostep{}/sequenceneeded yet).pgaftest show compose <spec.pgaf>lets the reporter sanity-check the generated Docker Compose file without needing to actually understand pgaftest's internals.pgaftest tmux <attached-spec.pgaf>and gets the exact reported topology live in under a minute, withpg_autoctl watchshowing FSM state in real time anddocker compose logs -fshowing every node's logs — no need to describe the bug in prose or guess at timing from a text report, and no need to reconstruct the environment from scratch.pgaftest sql <node> { ... },pgaftest network disconnect/connect <node>, andpgaftest show statelet the maintainer poke at the live cluster interactively — inject the same fault differently, check intermediate state, confirm a hypothesis — without editing the spec or restarting the stack.step{}s withwait until/assert/expectassertions and asequence(exactly what happened between the original issue-2 nodes + witness = 3 data centers (problem case detected) #997 repro andreplication_stall_3dc.pgafabove), andpgaftest run <spec.pgaf>gives the same scenario a permanent, deterministic, TAP-emitting place in CI — so the exact bug that was reported can never silently come back.The whole loop — reporter's repro spec straight through to CI regression test — uses the same file and the same tool the whole way, which is the main advantage over a prose bug report: nobody has to translate "here's what I did" into "here's a test" by hand.
Rebase notes
Rebased onto current
main(post-#1150) on 2026-07-22; the earlier squashed pgaftest-development commits this branch used to carry (CLI restructure,pg_autoctl watchcolumn-count fix, installcheck expected-output fixes, etc.) are already onmainvia #1150 and are no longer part of this PR's diff. Two pre-existing bugs were fixed while rebasing: a stray trailing backslash in theDockerfilethat broke every Docker build, and an incomplete 2.2→2.3 version bump (PG_AUTOCTL_EXTENSION_VERSIONindefaults.hwas left at"2.2", which made everypg_autoctlstart try to downgrade the extension).tests/tap/specs/upgrade.pgaf's hardcoded version strings were also updated to match (itsPREV_TAGauto-detection now resolves tov2.2instead ofv2.1, since it always targets the newest released tag below the current in-development version).