Skip to content

Fix 3-DC split-brain: monitor assigns wait_primary on replication stall (issue #997) - #1149

Merged
dimitri merged 6 commits into
mainfrom
fix/issue-997-replication-stall
Jul 22, 2026
Merged

Fix 3-DC split-brain: monitor assigns wait_primary on replication stall (issue #997)#1149
dimitri merged 6 commits into
mainfrom
fix/issue-997-replication-stall

Conversation

@dimitri

@dimitri dimitri commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

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_names set on the primary. Every COMMIT then 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 primary state when synchronous replication was stalled. The monitor could see that the standby was unreachable from the primary (via pg_stat_replication reporting sync_state = 'unknown') but took no action.

Fix

Monitor side (src/monitor/):

  • Add a replication_stall_since column to pgautofailover.node. The monitor sets it to now() the first time a primary reports sync_state = unknown (replication partner unreachable) and clears it when replication recovers.
  • Add a region column to pgautofailover.node (populated from --region on pg_autoctl create postgres). This lets operators label nodes with their DC location for observability.
  • Add a GUC pgautofailover.replication_stall_timeout (default 10 s). When now() - replication_stall_since > timeout, the FSM assigns wait_primary, which clears synchronous_standby_names and unblocks writes.
  • Extension bumped to 2.3 with an upgrade path from 2.2.

Client side (src/bin/pg_autoctl/):

  • --region / -G flag added to pg_autoctl create postgres and wired through keeper_config, monitor_register_node, and nodespec.
  • pg_autoctl watch updated to display the region column.

Test (tests/tap/specs/replication_stall_3dc.pgaf):

New end-to-end regression test using the pgaftest framework. The test models the 3-DC scenario in a single Docker Compose stack: the monitor is always reachable from both nodes, but docker network disconnect severs the dc1↔dc2 replication link. Six steps verify:

  1. Sync replication is active (synchronous_standby_names = 'ANY 1 (…)')
  2. The dc1↔dc2 link is cut
  3. The monitor assigns wait_primary within ~20 s (well within the 60 s timeout)
  4. synchronous_standby_names is cleared; writes no longer hang
  5. Link restored; standby re-joins
  6. Row written during the stall has replicated

Verified 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):

pgaftest tmux tests/tap/specs/replication_stall_3dc.pgaf

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:

# See the six steps and where you are
pgaftest show steps

# Preview the DSL commands the next step will run before running it
pgaftest show step

# Confirm sync replication is active: synchronous_standby_names = 'ANY 1 (...)'
pgaftest step

# Sever the dc1<->dc2 link -- watch the middle pane: node1 stays "primary"
# reporting an empty sync_state
pgaftest step
pgaftest show state

# Within ~10-20s (replication_stall_timeout), node1 flips to wait_primary
# in the live watch pane -- this step just asserts what you can already see
pgaftest step

# Confirm writes actually unblocked: synchronous_standby_names is now empty
pgaftest step
pgaftest sql node1 { SHOW synchronous_standby_names; }

# Restore the link; node2 rejoins as secondary, node1 confirms primary
pgaftest step

# Confirm replication caught back up, including the row written during the stall
pgaftest step

# Tear down
pgaftest cluster down

Each pgaftest step (no argument) auto-advances to the next pending step in the spec's sequence; pgaftest show steps at 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 if block in ProceedGroupStateFromContext() (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 (node1 stays at primary with synchronous_standby_names still set), and step 4's write hangs forever (interrupt it and pgaftest cluster down to clean up).

Reusing this for future bug reports

The spec above didn't start as a regression test — it started as the smallest .pgaf file that reliably reproduced the reported bug. That's a generally useful pattern for triaging issues in this repo, not just for this one:

  1. When opening an issue, attach a minimal .pgaf file describing just the topology and the manual steps that trigger the problem (a cluster{} block, a setup{} that gets to the interesting state, and network/sql/exec commands reproducing the failure — no step{}/sequence needed yet). pgaftest show compose <spec.pgaf> lets the reporter sanity-check the generated Docker Compose file without needing to actually understand pgaftest's internals.
  2. When triaging, a maintainer runs pgaftest tmux <attached-spec.pgaf> and gets the exact reported topology live in under a minute, with pg_autoctl watch showing FSM state in real time and docker compose logs -f showing 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.
  3. While diagnosing, pgaftest sql <node> { ... }, pgaftest network disconnect/connect <node>, and pgaftest show state let 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.
  4. Once the root cause is understood and fixed, the reporter's manual steps get turned into named step{}s with wait until/assert/expect assertions and a sequence (exactly what happened between the original issue-2 nodes + witness = 3 data centers (problem case detected) #997 repro and replication_stall_3dc.pgaf above), and pgaftest 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 watch column-count fix, installcheck expected-output fixes, etc.) are already on main via #1150 and are no longer part of this PR's diff. Two pre-existing bugs were fixed while rebasing: a stray trailing backslash in the Dockerfile that broke every Docker build, and an incomplete 2.2→2.3 version bump (PG_AUTOCTL_EXTENSION_VERSION in defaults.h was left at "2.2", which made every pg_autoctl start try to downgrade the extension). tests/tap/specs/upgrade.pgaf's hardcoded version strings were also updated to match (its PREV_TAG auto-detection now resolves to v2.2 instead of v2.1, since it always targets the newest released tag below the current in-development version).

@dimitri dimitri self-assigned this Jul 15, 2026
@dimitri dimitri added the bug Something isn't working label Jul 15, 2026
@dimitri
dimitri force-pushed the fix/issue-997-replication-stall branch from 243af2b to 3218785 Compare July 21, 2026 23:35
dimitri added a commit that referenced this pull request Jul 22, 2026
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.
dimitri added 3 commits July 22, 2026 13:32
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.
@dimitri
dimitri force-pushed the fix/issue-997-replication-stall branch from 3218785 to 5294ec5 Compare July 22, 2026 11:33
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.
dimitri added 2 commits July 22, 2026 15:07
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.
@dimitri
dimitri merged commit 92acc7f into main Jul 22, 2026
142 of 144 checks passed
@dimitri
dimitri deleted the fix/issue-997-replication-stall branch July 22, 2026 23:50
@tboevil

tboevil commented Jul 23, 2026

Copy link
Copy Markdown

Would checking primaryNode->pgsrSyncState be more accurate and timely? And does secondaryQuorumNodesCount need to be updated?

@dimitri

dimitri commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for taking a close look at this — both good questions, and worth walking through in detail.

On pgsrSyncState: this is actually already the signal driving the whole mechanism, not an alternative to it. ReportAutoFailoverNodeState()'s update query sets replication_stall_since with:

replication_stall_since = CASE
  WHEN $1 = 'primary' AND $3 = ''   -- $3 is pgsrSyncState
  THEN COALESCE(replication_stall_since, now())
  ELSE NULL
END

pgsrSyncState reported as ''/"unknown" both map to SYNC_STATE_UNKNOWN, so primaryNode->pgsrSyncState == SYNC_STATE_UNKNOWN is exactly the condition that sets replication_stall_since — on the very first report where it's empty, and cleared the instant a standby reappears. On the keeper side, keeper_update_pg_state() re-derives pgsrSyncState from pg_stat_replication.sync_state on every single local report cycle, so it already goes empty immediately when a standby drops out — a walreceiver restart, a brief network blip, anything.

So checking primaryNode->pgsrSyncState directly, as you suggest, would be timelier — but what it actually removes is the debounce: right now we wait for that condition to hold continuously for replication_stall_timeout (10s default) before acting, specifically so a single bad report doesn't flip the primary into wait_primary (and clear synchronous_standby_names) over a hiccup that resolves itself a second later. Reacting on the first empty report would reintroduce exactly the flapping risk the timeout exists to avoid. So: more immediate, yes; more accurate, we'd argue no — same signal, just without the hysteresis.

That said, digging into this did surface a real gap: replication_stall_3dc.pgaf only exercises the sustained-stall path (disconnect, wait out the full timeout, confirm wait_primary) — there's no test confirming a brief disconnect, shorter than replication_stall_timeout, does not trigger it. That's precisely the case the debounce is supposed to handle, and it's currently unverified.

On secondaryQuorumNodesCount: no change needed there. It's computed later in the same function, but the stall check returns early (return true) before that code path runs, and once the primary transitions to wait_primary, IsInPrimaryState(primaryNode) goes false — so the stall check itself stops firing on subsequent ticks. No same-tick or cross-tick interaction with stale data that we could find.

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

tboevil commented Jul 29, 2026

Copy link
Copy Markdown

@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?

@tboevil

tboevil commented Jul 29, 2026

Copy link
Copy Markdown
cluster {
    monitor
    formation {
        node1
        node2
    }
}

setup {
    wait until node1 state = primary    timeout 120s
    wait until node2 state = secondary  timeout 120s
}

teardown {
    sql node2 { ALTER SYSTEM RESET primary_conninfo; }
    sql node2 { SELECT pg_reload_conf(); }
    compose down
}

step repro {
    # disable replication connect(192.0.2.0/24 = RFC 5737)
    sql node2 { ALTER SYSTEM SET primary_conninfo = 'host=192.0.2.1 port=5432 user=nobody connect_timeout=2'; }
    sql node2 { SELECT pg_reload_conf(); }

    sleep 30s

    sql monitor {
        SELECT reportedstate || '/' || goalstate
          FROM pgautofailover.node WHERE nodename = 'node1';
    }
    expect { wait_primary/wait_primary }
}

sequence
    repro

There seems to be another problem; reportedrepstate is 'unknown', but it's compared to '', so the condition is never true.

06:05:55 1 INFO  --- end container logs ---
06:05:55 1 INFO  Running teardown block
06:05:55 1 INFO   [01] sql node2 { ALTER SYSTEM RESET primary_conninfo; }
06:05:55 1 INFO   [02] sql node2 { SELECT pg_reload_conf(); }
06:05:56 1 INFO   [03] compose down
06:05:56 1 INFO  Tearing down compose stack (project: replication_stall_standby_healthy)
 Container replication_stall_standby_healthy-node2-1 Stopping
 Container replication_stall_standby_healthy-node2-1 Stopped
 Container replication_stall_standby_healthy-node2-1 Removing
 Container replication_stall_standby_healthy-node2-1 Removed
 Container replication_stall_standby_healthy-node1-1 Stopping
 Container replication_stall_standby_healthy-node1-1 Stopped
 Container replication_stall_standby_healthy-node1-1 Removing
 Container replication_stall_standby_healthy-node1-1 Removed
 Container replication_stall_standby_healthy-monitor-1 Stopping
 Container replication_stall_standby_healthy-monitor-1 Stopped
 Container replication_stall_standby_healthy-monitor-1 Removing
 Container replication_stall_standby_healthy-monitor-1 Removed
 Volume replication_stall_standby_healthy_node1_data Removing
 Network replication_stall_standby_healthy_pgafnet Removing
 Volume replication_stall_standby_healthy_node1_data Removed
 Volume replication_stall_standby_healthy_node2_data Removing
 Volume replication_stall_standby_healthy_monitor_data Removing
 Volume replication_stall_standby_healthy_monitor_data Removed
 Volume replication_stall_standby_healthy_node2_data Removed
 Network replication_stall_standby_healthy_pgafnet Removed
1..1
not ok 1 - repro
# expected "wait_primary/wait_primary", got "primary/primary"
# replication_stall_standby_healthy.pgaf
not ok1        - repro                   30155 ms
1..1
# 1 test failed.

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.

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.

2 nodes + witness = 3 data centers (problem case detected)

2 participants