Skip to content

pgaftest: DSL/runner improvements + monitor fixes#1150

Open
dimitri wants to merge 88 commits into
mainfrom
pgaftest-improvements
Open

pgaftest: DSL/runner improvements + monitor fixes#1150
dimitri wants to merge 88 commits into
mainfrom
pgaftest-improvements

Conversation

@dimitri

@dimitri dimitri commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Continues pgaftest (the Docker-based DSL test runner merged in #1137) with
new runner functionality and DSL commands, and bundles a set of
monitor/pg_autoctl correctness fixes found while using the tool to
reproduce and diagnose flaky/failing CI runs. Also migrates most of the
Python test suite to .pgaf specs and rewrites the pgaftest docs.

pgaftest tool

  • Root user / DooD fix: drop the docker user, sudo, and
    entrypoint.sh; run the setup container as root so the Docker-socket
    bind mount works without GID-matching games on macOS/CI
  • Direct libpq paths: replace several docker exec monitor pg_autoctl ... round-trips with direct calls over the existing LISTEN connection
    (monitor_get_node_state, runner_promote_one, new
    runner_perform_failover, exec_sql_on_service)
  • New perform failover DSL command (untargeted failover; monitor
    picks the best secondary), distinct from promote nodeX
  • CLI restructure: cluster/tmux subcommands, show state|step,
    pgaftest indent (canonical spec formatter, plus a couple of real
    formatting-corruption fixes in it), help
  • UX polish: interactive quick-start block, trailing-newline fix, reliable
    teardown for the setup container, periodic SQL poll inside the LISTEN
    loop

Monitor / pg_autoctl fixes found via pgaftest

Several of these came directly out of using pgaftest/pytest to chase down
CI flakes:

  • NodeActive()/RemoveNode() stale-read races — new
    concurrent_remove_node / concurrent_remove_standby_and_* isolation
    suite added to reproduce and pin down the race, then fixed
  • Connection leak in coordinator_update_node_prepare
  • Refuse failover/switchover onto a candidate-priority-0 standby
  • current_state column-count guard fixed (16, not 17)
  • lock_timeout/statement_timeout set before master_update_node
  • waitpid() after pg_ctl stop to reap the postgres zombie
  • Priority-induced failover now detected in wait-for-apply-settings
  • 3x timeout when demoting the primary during
    enable maintenance --allow-failover
  • keeper_pg_init: init the monitor connection before checking whether
    the node was dropped

New regression/isolation coverage for these:
src/monitor/sql/drop_node.sql, src/monitor/sql/stale_primary_report.sql,
and three new isolation specs
(concurrent_remove_node, concurrent_remove_standby_and_primary_report,
concurrent_remove_standby_and_standby_report), all passing locally
(11/11 REGRESS, 3/3 ISOLATION).

Test spec migration

  • Ported the bulk of the pytest suite to tests/tap/specs/*.pgaf
  • Fixed a semantic inversion across ~20 specs: promote nodeX (targets
    nodeX) was being confused with perform switchover (hands off from
    the current primary) — corrected throughout
  • Renamed specs for naming consistency (basic_citus_operation.pgaf,
    citus_nonha_operation.pgaf)
  • Bumped test_003_002_stop_primary's timeout (90s → 120s): the PG16 CI
    flake there was a timing budget issue, not a promotion-safety bug
    (confirmed by the new stale_primary_report.sql regression test)
  • Dropped redundant explicit sequence{} blocks from 6 specs where the
    order already matched step-declaration order (the tool's default)

Docs

Rewrote docs/ref/pgaftest.rst, added docs/testing.rst and
docs/reporting-bugs.rst, expanded the pgaftest README.md.

Testing

  • basic_operation.pgaf 27/27 PASS, multi_standbys.pgaf 27/27 PASS
  • src/monitor regression + isolation suites: 11/11 + 3/3 PASS locally
  • test_multi_alternate_primary_failures.py reproduced locally via
    make run-test (Docker, matches CI) — 15/15 PASS
  • Style check (citus_indent --check) and banned-API check
    (ci/banned.h.sh) both clean

Follow-up

PR #1149 (issue #997 monitor stall fix) will be rebased on top of this
once merged.

Note on diff size

A large share of the reported diff (~3,600 lines, ~34%) is the
bison/flex-generated test_spec_parse.c/.h and test_spec_scan.c, which
fully regenerate — including internal line/state numbering — from small
edits to the 51-line test_spec_parse.y grammar and 6-line
test_spec_scan.l lexer. These are committed so the tool builds without
bison/flex installed; the actual hand-written diff is much smaller than
the raw line count suggests.

@dimitri
dimitri force-pushed the pgaftest-improvements branch 2 times, most recently from e7e89d4 to d9e27ac Compare July 16, 2026 16:19
dimitri added 28 commits July 16, 2026 18:37
- Dockerfile: trailing backslash from debug edit made the next FROM
  line get appended to the RUN command, breaking make build entirely.
  Remove the stray continuation backslash.

- expected/upgrade_1.out: PG18 added a 'Default version' column to
  \dx output; upgrade_1.out is the PG18+ variant. Still showed '2.2'
  as the default version; update all four rows to '2.3'.

- expected/pg19/expected/monitor.out: mirror of monitor.out for PG19
  (different pg_lsn display format). Did not include the ORDER BY
  nodeid clause added to monitor.sql; add it to keep the echoed SQL
  in the expected output in sync with the query file.
- test_runner.c (runner_compose_generate): after writing docker-compose.yml
  and node ini files, copy the spec file to <workdir>/spec.pgaf.  This lets
  'pgaftest step' reload the spec without the user supplying the original
  path again.

- test_runner.c (runner_init): derive the Docker Compose project name from
  the work-directory basename (e.g. replication_stall_3dc) rather than the
  spec filename basename.  When the spec is loaded from the in-workdir copy
  spec.pgaf the old code yielded 'spec', which did not match the running
  stack and caused 'no such container: spec-node2-1' errors.
  COMPOSE_PROJECT_NAME env var still takes precedence (used inside the
  compose network).

- replication_stall_3dc.pgaf: add 'ssl off' to the cluster block.  Without
  it pgaftest defaults to self-signed SSL, which requires certificate
  generation before Postgres starts; node1 failed with
  'could not load server certificate file server.crt'.
parseCurrentNodeState already parsed 17 columns (0-16), including
noderegion at column 14 added with the --region / issue-997 work.
The guard check above it still said 16, causing pg_autoctl watch to
log 'Query returned 17 columns, expected 16' in a tight loop and
refuse to display any node state.
…ries

The old Makefile had a phony 'common' target that ran make -C common,
then both pg_autoctl and pgaftest depended on it.  However, because
'common' was .PHONY, make always considered it out of date and rebuilt
it on every invocation — and a parallel make -j could still race the
two sub-makes against each other's common/*.o output files since both
include Makefile.common and carry the same compile rules.

Fix: make  a real file target.  Both pg_autoctl and
pgaftest depend on it, so make builds the archive once serially first,
then the two binaries in parallel without contention.
In --tmux mode the bottom pane previously exec'd into the first data
node container, which had no pgaftest binary, no docker CLI, and no
knowledge of the spec file.

New approach: the pgaftest service (already emitted by compose_gen for
CI) is reused as the interactive shell target.  That container has:
  - the pgaftest binary
  - docker + docker compose (DooD via /var/run/docker.sock)
  - /spec.pgaf bind-mounted from the host workDir
  - COMPOSE_PROJECT_NAME, PGAFTEST_HOST_WORK_DIR, PG_AUTOCTL_MONITOR set

compose_gen_write gains an 'interactive' parameter: when true the
pgaftest service uses 'sleep infinity' instead of 'pgaftest run
/spec.pgaf', keeping it alive for a shell.  The interactive flag is
set when runner_setup is called with withTmux=true.

The bottom pane command changes from:
  docker compose exec -it <node1> bash
to:
  docker compose exec -it pgaftest sh -c 'pgaftest _setup_ ... && exec bash'

Four new sub-commands mirror the DSL keywords, callable directly from
the interactive shell (context resolved from PGAFTEST_SPEC /
PGAFTEST_HOST_WORK_DIR env vars injected by the compose stack):

  pgaftest wait until <node> state = <state> [timeout <N>s]
  pgaftest sql <node> "<query>"
  pgaftest network connect|disconnect <node>
  pgaftest assert <node> state = <state>

After setup the bottom pane prints a hint listing available steps and
example commands so the user knows immediately what they can type.
Two bugs in the previous commit:

1. hintCmd[256] was smaller than the literal format string (292 bytes)
   before even inserting the step list, triggering sformat's BUG assert.

2. The bottom pane command used sh -c "..." with single-quote delimiters
   inside; step names or other text containing apostrophes would break
   the shell syntax, causing 'unexpected EOF' on startup.

Fix: drop hintCmd and the sh -c wrapper entirely.  The bottom pane now
runs pgaftest _setup_ directly via docker compose exec (no sh -c), and
cli_run_setup_only prints the hint to stdout (the pane tty) after the
setup block completes, then execlp("bash") to hand the pane over to an
interactive shell.  No string embedding, no quoting concerns.
…TEST_SPEC

- pgaftest help (and bare pgaftest) now prints usage instead of "unknown command"
- pgaftest show is now a sub-command set: show compose|spec|steps|services
- pgaftest step accepts optional <spec.pgaf> second arg; when omitted it falls
  back to PGAFTEST_SPEC env var, /spec.pgaf in CWD, or <workDir>/spec.pgaf
- compose_gen emits PGAFTEST_SPEC=/spec.pgaf into the pgaftest service env
  so all interactive sub-commands (step, wait, assert, network, sql) can
  discover the spec without explicit path arguments inside the container
- resolve_interactive_context() helper centralises the env fallback logic
- pgaftest show step: lists sequence steps with:
    *  next step to run
    !  last failed step (will retry)
    (space) completed steps

- pgaftest step (no args): auto-advances to the next step using a state
  file at <workDir>/pgaftest.state.  On failure current stays at the
  failed step so the next no-arg invocation retries it.

- pgaftest step <name>: still works; also updates the state file and
  advances current when the named step matches the cursor position.

- TestRunnerState struct + runner_state_read / runner_state_write /
  runner_step_next added to test_runner.c/h; state is JSON for easy
  inspection with a text editor.

- show_resolve_spec() now derives workDir when it is not set, so
  pgaftest show step <spec.pgaf> picks up the state file automatically.
getopt permutes argv to move flags before positionals when optind is
reset to 0 (full re-initialisation).  Without this, the library's first
getopt pass at the root level stopped at the first non-option (the
subcommand name), leaving any flags that appeared before the spec file —
e.g. "setup --tmux spec.pgaf" — unprocessed by the sub-command's
getopt, which then saw an empty positional list and printed the usage
error.

Setting optind=0 (as every pg_autoctl getopts function does) tells
getopt to reorder the argument vector so all options are seen before
non-option positionals, regardless of the order the user typed them.

Also simplify cli_setup back to its original form now that ordering is
handled at the getopt level instead of by hand.
Replaces commandline_help() (one-level flat listing) with
commandline_print_command_tree() so that pgaftest help renders the full
command tree with sub-command groups expanded, matching the output style
of pg_autoctl help.
The workDir bind-mount was :ro, preventing runner_state_write from
creating pgaftest.state inside the container.  The state file belongs in
workDir alongside the generated compose files, so drop the :ro flag.
The pgaftest image already creates a 'docker' user with HOME=/var/lib/postgres.
Switch the pgaftest compose service from root to that user:

  user: docker
  working_dir: /var/lib/postgres
  spec mounted at /var/lib/postgres/spec.pgaf

This lands the interactive shell in the docker user's HOME with the spec
file right there, and aligns the SSL cert paths with ~/.postgresql/.
…file

Adds:
- Host vs. container command table with descriptions
- Typical interactive session walkthrough
- DooD architecture section explaining the pgaftest service container
- Step state file section
- Updated synopsis and sub-command reference to match current CLI
  (show compose|spec|step|services, step auto-advance, etc.)
runner_state_path() picks the state file location based on whether
PGAFTEST_COMPOSE_SERVICE is set:

  - inside container  →  $HOME/pgaftest.state  (always writable by docker user)
  - on host           →  <workDir>/pgaftest.state  (alongside compose files)

The bind-mounted workDir may not be writable by the container's docker
user on Linux hosts where UIDs don't align.  $HOME (/var/lib/postgres) is
owned by the docker user in the image, so it is unconditionally writable.

Update docs/ref/pgaftest.rst to reflect the new state file locations.
…ate/step

Command table changes:
- setup / prepare / down moved under new 'cluster' subcommand group
  (pgaftest cluster setup|prepare|down)
- pgaftest tmux <spec.pgaf> replaces pgaftest setup --tmux; --tmux option
  removed from pgaftest_getopts entirely
- pgaftest wait and pgaftest assert removed (not useful interactively)

show subcommand changes:
- show steps (plural, restored) — sequence list with */ ! progress markers
- show step (singular, new) — prints the DSL commands of the next step to run
- show state (new) — 'Step X/N: name' header then pg_autoctl show state output
- show services removed

New functions in test_runner.c:
- test_cmd_print(): prints a TestCmd in DSL form to a FILE*
- runner_show_state(): prints step progress header then pg_autoctl show state
The old name implied the variable held a service name.  It is actually a
boolean flag (set to "1") that tells the binary it is running inside the
pgaftest container — which determines where to write the step state file
(container $HOME vs host workDir).

Also complete the docs/ref/pgaftest.rst update: Synopsis and Sub-commands
sections now reflect the restructured CLI (cluster setup/prepare/down,
tmux, show steps/step/state, removal of wait/assert/show services).
…eady

When the monitor container is still starting up, 'pg_autoctl watch'
fails on its first connection attempt and exits, closing the tmux pane.

Add --wait to pg_autoctl watch: it calls
pgsql_set_monitor_interactive_retry_policy() before entering the main
loop, which makes the internal pgsql_open_connection() retry for up to
15 minutes with exponential back-off (same policy used by the demo app
and enable/disable maintenance commands).

pgaftest now passes --wait in the tmux middle pane so the watch pane
stays alive while the monitor container initialises.
…r startup

Makefile.common: replace individual -Werror=implicit-* flags with a
single -Werror so all -Wall warnings are fatal on both macOS and Linux.
The generated bison/flex files already carry -Wno-error in the pgaftest
Makefile so those remain unaffected.

test_runner.c: the tmux bottom pane (docker compose exec -it pgaftest)
exited immediately when the container wasn't yet running, closing the
pane before the user could see it.  Fix: probe with a no-TTY
'docker compose exec -T pgaftest true' loop until the container accepts
exec, then hand off to the interactive command.  Same timing issue as the
watch pane (fixed earlier with --wait).

Also log the manual exec command on startup so the user can reattach to
the pgaftest shell from the host tmux pane if needed:
  docker compose -p <project> -f <workdir>/docker-compose.yml exec -it pgaftest bash
Convenience command for opening a new interactive shell in the running
pgaftest container from a host tmux pane:

  pgaftest cluster sh [--work-dir <dir>]

Equivalent to:
  docker compose -p <project> -f <workdir>/docker-compose.yml exec -it pgaftest bash

Resolves the project name and workdir the same way as the other cluster
subcommands (--work-dir flag, PGAFTEST_HOST_WORK_DIR env, or derived
from the spec file path).
The 'sleep infinity + exec' model caused a timing race: tmux opens the
pane before the pgaftest container is in 'running' state, so exec fails
and the pane closes immediately.  The probe-loop workaround was a band-
aid; the real fix is to not require a background service at all.

docker compose run starts a fresh container on demand from the service
definition (same image, bind-mounts, env, network) without needing the
service to already be running.  No timing race, no probe loop.

Changes:
- runner_compose_up: pass --scale pgaftest=0 in interactive mode so the
  image is built but no background container is started
- tmux session now has 4 panes when the spec has a setup{} block:
    pane 0  docker compose logs -f
    pane 1  pg_autoctl watch --wait
    pane 2  pgaftest _setup_ via docker compose run --rm  (closes when done)
    pane 3  interactive bash via docker compose run --rm -it
  Without setup{}: 3 panes (pane 2 is the interactive shell directly)
- cluster sh: updated to use docker compose run --rm -it as well
…est/.last)

pgaftest cluster setup / tmux write the active work directory to
$TMPDIR/pgaftest/.last on startup.  resolve_interactive_context() reads
it as the final fallback when neither --work-dir nor PGAFTEST_HOST_WORK_DIR
nor a spec file is available.

Effect: after 'pgaftest tmux tests/.../foo.pgaf', all subsequent host
commands work without arguments:

  pgaftest cluster sh
  pgaftest cluster down
  pgaftest show steps
  pgaftest show state
runner_down() now kills the tmux session (named after the project) after
compose down, so a subsequent 'pgaftest tmux' for the same spec does not
hit 'duplicate session: <name>'.  The kill is best-effort (2>/dev/null ||
true) so it is harmless when no tmux session exists.
The pgaftest container runs as the 'docker' user but /var/run/docker.sock
is owned by a host group (GID 1 on macOS Docker Desktop, typically 999 on
Linux) that does not match any group inside the container.  This caused:

  permission denied while trying to connect to the docker API at
  unix:///var/run/docker.sock

Fix: stat /var/run/docker.sock at compose-generation time and emit
'group_add: [<gid>]' in the pgaftest service so the container user
gains the socket's group as a supplementary group, regardless of what
numeric GID the host assigns to it.
- runner_setup: check 'tmux -V' before doing anything when withTmux is
  true; log the version at NOTICE level, error out cleanly if tmux is
  not on PATH.  Uses run_cmd_capture, the same helper used everywhere
  else in the runner.

- Setup container renamed from <project>-pgaftest-run-<hash> to
  <project>-setup so compose logs show a short, readable name.
  Interactive shell container named <project>-sh for the same reason.
  Both still carry --rm so they are removed when they exit.

docker socket permission history
---------------------------------
Previously the pgaftest service ran as 'user: root'.  Root bypasses the
socket's group-ownership check, so DooD worked without any group
membership.

We changed to 'user: docker' (an unprivileged UID) to avoid running
test commands as root inside the container.  /var/run/docker.sock is
typically owned root:docker with mode 0660 (or root:<N> where N is the
host docker GID, which varies: 1 on macOS Docker Desktop, ~999 on most
Linux distros).  The container's 'docker' user is not in that group, so
every docker CLI call got EACCES.

The previous commit added 'group_add: ["<gid>"]' by statting the
socket at compose-generation time.  'group_add' is honoured by both
'docker compose up' and 'docker compose run', so it applies to the
setup and shell containers started via 'run --rm' as well.  The compose
file must be regenerated ('pgaftest cluster down && pgaftest tmux') for
the fix to take effect on an existing cluster.
…ude from up

Root cause of monitor not running
-----------------------------------
In interactive mode, runner_compose_up passed --scale pgaftest=0 to
prevent the sleep-infinity container from starting.  Docker Compose v2
still rebuilds ALL service images when --build is present, even for
scaled-to-zero services.  On a re-run (stack already up from a previous
pgaftest tmux invocation), 'up --build -d --scale pgaftest=0' rebuilds
and recreates the monitor/node containers, causing a brief downtime
window during which runner_wait_for_monitor could time out, tear the
stack down, and leave the cluster gone — hence 'monitor not running'.

Fix
---
In interactive mode the service is now named 'setup' and carries
'profiles: [setup]'.  Services with a profile are completely invisible
to 'docker compose up' (no build, no start, no scale interaction).
'docker compose run setup' still works and starts the container on
demand.  This means:

  - 'docker compose up --build -d' only builds and starts cluster
    services (monitor, nodes) — never touches the setup image.
  - Subsequent pgaftest tmux runs on an already-running stack are
    non-destructive: up is a no-op for running healthy containers.
  - --scale pgaftest=0 removed; no longer needed.

Service naming
--------------
Interactive mode: service named 'setup' — appears as '<project>-setup'
in logs and docker ps.  The --name flag on docker compose run gives
containers the stable names '<project>-setup' (setup pane) and
'<project>-sh' (interactive shell pane).

CI mode: service still named 'pgaftest', no profile, command set to
'pgaftest run <spec.pgaf>' so 'docker compose up --exit-code-from
pgaftest' continues to work unchanged.
…y ensures readiness)

In tmux mode the host process does not use the monitor LISTEN connection
at all — it just starts the stack and hands off to the tmux session.
docker compose up -d already waits for the full depends_on:
service_healthy chain before returning, so the monitor is provably
ready when up exits.

runner_wait_for_monitor from the host is both unnecessary and the source
of the intermittent 'monitor not running' failure: on Docker Desktop for
Mac, published-port connections appear as 192.168.65.1, which is outside
the monitor's pg_hba trust CIDR.  The fallback pg_hba patch and the
120s timeout loop both ran on the critical path before tmux launched,
creating a wide window for races.

runner_apply_formation_settings only needs docker compose exec (internal
network) — no host->monitor libpq — so it works fine without the wait.

runner_wait_for_monitor is still called in CI mode (runner_run) where
the host process genuinely drives steps via LISTEN/NOTIFY.
…ke build

The setup container runs Docker-out-of-Docker (DooD) by bind-mounting
/var/run/docker.sock. Running as root eliminates GID mismatch between
macOS Docker Desktop and Linux CI, where the docker socket group ID
varies by host.

Changes:
- Dockerfile: remove docker user, sudo, entrypoint.sh; add WORKDIR /root
- Makefile.docker: add build-pgaftest and force-build-pgaftest targets,
  include build-pgaftest in the default 'make build' target
dimitri added 29 commits July 19, 2026 02:44
…ECONDARY

The postmaster writes PM_STATUS_READY / PM_STATUS_STANDBY to postmaster.pid
a few milliseconds before the TCP listener is actually bound.
pg_setup_wait_until_is_ready() (called via ensure_postgres_service_is_running)
returns as soon as the PID-file flag is set, leaving a narrow window where
pgIsRunning is reported as true to the monitor but connections are still
refused.

This causes two distinct CI failures:
 1. Connection refused: test queries a standby immediately after it reaches
    SECONDARY state, hitting the window before the TCP socket is open.
 2. start_maintenance '0 candidate nodes available': CountHealthyCandidates()
    called IsHealthy() on the secondary just as the health-check worker ran
    and marked it BAD (because TCP was not yet open).

Fix: in the two FSM transitions that promote a standby to SECONDARY
(CATCHINGUP->SECONDARY via fsm_prepare_for_secondary, and
JOIN_SECONDARY->SECONDARY via fsm_follow_new_primary), switch the local sql
client to the init retry policy (up to 15 minutes, exponential back-off from
5 ms to 2 s) and call pgsql_get_postgres_metadata() before declaring success.
pgsql_get_postgres_metadata calls pgsql_open_connection, which falls through
to pgsql_retry_open_connection (PQping loop) when the initial PQconnectdb
fails, so no new mechanism is needed.  The function also captures the current
LSN, sync state and control-file data as a useful side-effect.

tests/test_multi_alternate_primary_failures.py: test_005_002 was checking for
the transient assigned state 'report_lsn' which lasts only milliseconds; the
monitor advances through it to 'prepare_promotion' before the poll can see it.
Wait for the stable end state 'primary' instead (300s timeout).

tests/tap/specs/multi_alternate.pgaf: same fix for the pgaftest path.
…est spec

The 'wait until node3 assigned-state = report_lsn' used
runner_wait_assigned_goal which does NOT pre-drain the libpq notification
buffer on entry.  Under load on GHA the state can arrive and be consumed
during the preceding compose-kill exec, making it invisible to the
assigned-state poll loop.

Switch to:

    wait until node3 state is primary passing through report_lsn  timeout 300s

This routes through runner_wait_notify_goal which:
1. Drains the entire buffered notification queue on entry via
   runner_drain_notify(), catching states that arrived while the
   previous exec was blocked.
2. Tracks report_lsn as a required pass-through goalState — fails if
   the monitor skipped it.
3. Waits for the stable end-state (primary reported-state) rather than
   a transient millisecond assignment.

The pytest counterpart (test_multi_alternate_primary_failures.py) was
already fixed in the prior commit to poll wait_until_state('primary')
since SQL polling cannot catch transient states at all.
write_legacy_node_command() generated 'pg_autoctl create postgres' without
--name, causing the monitor to auto-assign node_N names from the sequence
counter.  Those names are derived from the insertion order, not from the
service names in the compose file, so they diverge when nodes restart in
a different order — for example in test_008_failover_post_upgrade where
the upgrade process brings nodes up sequentially.

The registered name mismatch showed up as nodes not being found by service
name in subsequent steps.

Fix: pass the service name (n->name) as --name so the monitor always
registers the node under the same identifier as the compose service.
Replace the inline 'alpine:3 + apk add dnsmasq' command in the generated
docker-compose.yml with a pre-built pgaf:dnsmasq image.

Problems with the previous approach:
- apk add runs at container start, fetching from Alpine CDN on every test
  run: slow (~2-5s) and fragile if the network is slow on GHA runners.
- No healthcheck: other containers could start before dnsmasq was ready,
  causing initial hostname resolution failures.
- Extra network failure mode independent of the test under execution.

Changes:
  Dockerfile       — new 'dnsmasq' target: FROM alpine:3, apk add dnsmasq,
                     HEALTHCHECK with nslookup, ENTRYPOINT that reads
                     /etc/pgaf-hosts via --addn-hosts and forwards to
                     configurable upstream DNS servers (DNS1/DNS2 env vars).

  compose_gen.c    — _dns service uses PGAF_DNS_IMAGE env var (default
                     pgaf:dnsmasq) instead of alpine:3 with inline install.
                     Healthcheck is emitted in the compose YAML so depends_on
                     can use service_healthy.
                     monitor, second monitor, and all data nodes gain
                       depends_on: _dns: condition: service_healthy
                     so no node starts before DNS is available.

Build:
  docker build --target dnsmasq -t pgaf:dnsmasq .

Override image:
  PGAF_DNS_IMAGE=my-registry/dnsmasq:latest pgaftest run spec.pgaf
Add a build_dnsmasq job that builds the new Dockerfile 'dnsmasq' target
once per workflow run and passes the image to every pgaftest consumer:

  build_dnsmasq:
  - FROM alpine:3 target, no PGVERSION dependency
  - GHA layer cache (scope=pgaf-dnsmasq) — fast cache hits on re-runs
  - Saves pgaf:dnsmasq to a tarball artifact (retention-days: 1)

Wire-up:
  test_pgaftest — needs: [build_run_images, build_pgaftest, build_dnsmasq]
                  downloads + loads dnsmasq-image artifact
                  passes -e PGAF_DNS_IMAGE=pgaf:dnsmasq to docker run
  upgrade       — same: needs build_dnsmasq, downloads + loads, passes env var

The generated docker-compose.yml already uses PGAF_DNS_IMAGE (default
pgaf:dnsmasq) for the _dns service, so once the image is loaded on the
runner the variable just confirms the name.  Previously the compose file
used 'alpine:3' with 'apk add dnsmasq' at startup, which fetched from
Alpine CDN on every test run and had no healthcheck, causing a race where
data nodes could start before DNS was ready.
…node

master_update_node() on the Citus coordinator takes a distributed lock
that conflicts with active queries touching the old worker's shards.
Without a timeout the call blocks indefinitely.  This was observed as a
20-minute CI hang: the citus_cluster_name spec performs three switchovers
in sequence; the coordinator switchover completed in ~6s but the
immediately-following worker1a switchover blocked in coordinator_update_node_prepare
because coordinator1b (the newly-promoted coordinator) had in-flight
distributed transactions from before its own failover.

Fix: after pgsql_begin() and before the master_update_node() call, issue:

    SET LOCAL lock_timeout = '<cooldown>ms';
    SET LOCAL statement_timeout = '<cooldown*2>ms';

lock_timeout fires if the distributed lock isn't acquired within the
cooldown window (default 10 000 ms).  statement_timeout is a backstop for
the whole call in case something else inside master_update_node stalls
after the lock is acquired.  Both are SET LOCAL so they are cleared
automatically at transaction end and do not affect other uses of the
connection.

On timeout the transaction is aborted with an error; the FSM logs the
failure and retries the transition on the next keeper tick instead of
hanging forever.  The no-force path (older Citus without the force
argument) was previously completely unbounded; it now also benefits from
this guard.
When pg_autoctl set node candidate-priority raises a node's priority
above the current primary, the monitor may trigger a full failover
instead of the usual apply_settings cycle.  In that case the old
primary is assigned report_lsn (not apply_settings) as its first FSM
step, so the apply_settings wait loop never saw the new primary reach
primary/primary and timed out after 60 s.

Fix: add failoverInProgress to ApplySettingsNotificationContext.  When
the known primary node is assigned report_lsn we set this flag and
switch to waiting for ANY node in the formation to reach primary/primary
instead of continuing to track the apply_settings cycle for the original
primary node.
The grammar comment and test spec files use 'passing through' as the
natural English phrasing:

  wait until node3 state is primary passing through report_lsn

but the scanner only recognised the single keyword 'through'.  Add a
two-word flex rule that matches 'passing' followed by whitespace and
'through', returning a single T_THROUGH token — so both forms work
with no grammar changes.  Update test_spec_scan.c accordingly.
compose_gen.c: compose_network_name() was returning '<project>_default'
but the generated compose YAML defines the network as 'pgafnet', so
'docker network disconnect/connect' were failing with 'network not found'.
Fix: return '<project>_pgafnet' to match the compose YAML.

test_runner.c: 'docker network connect' without --ip lets Docker assign a
fresh DHCP address.  The dnsmasq pgaf-hosts file maps each node to its
original static IP; PostgreSQL resolves HBA hostnames through that file.
When the container gets a different IP after reconnect, the HBA rule no
longer matches and streaming replication fails with 'no pg_hba.conf entry'.
Fix: read the static IP from workDir/pgaf-hosts and pass --ip to
'docker network connect' so the container re-uses its original address.

cli_enable_disable.c: align ternary operator indentation to pass citus_indent.

multi_alternate.pgaf: test_005_002 starts node2 before killing node1 so that
node2 participates in the LSN quorum.  node2 was SIGKILLed in test_005_001
and never reported 'demoted'; it remained registered as a sync standby with
replication_quorum=true.  With number_sync_standbys=1 the monitor needs 2
LSN reporters; without node2 back online only node3 would report, stalling
the failover indefinitely.  test_005_003 no longer needs compose start node2.

multi_standbys.pgaf: increase test_013_restart_node2 timeout from 90s to 180s.
After network disconnect/reconnect, node2 must go through: contact monitor ->
monitor notifies node1 -> node1 updates HBA -> pg_rewind/basebackup from node1
-> streaming replication -> secondary.  90s was insufficient.
The previous commit's runner_hosts_lookup() used single-statement
if/while bodies without braces, which citus_indent (run via 'make
docker-check', matching CI) rejects. Run 'make docker-indent' to
apply the required brace style.
…pgaf

Align with the naming convention used by every other Citus spec
(citus_cluster_name, citus_force_failover, citus_multi_standbys,
citus_skip_pg_hba) so all Citus tests are prefixed the same way.
Updates the master tests/tap/schedule list and citus-2.sch.
print_cmd() had no case for CMD_RUN ("run <svc> <args>") or CMD_FAILOVER
("perform failover [in formation F] [group G]"), so pgaftest indent
silently rewrote any spec using either into "(unknown cmd N)" — a real
data-corruption bug for a tool meant to be a safe formatter. Both commands
are in active use: drop_node_destroy.pgaf uses run, several multi-node
specs use perform failover.

CMD_RUN mirrors the existing CMD_EXEC case. CMD_FAILOVER reconstructs
whichever of the four grammar forms matches from the stored service
(formation, "default" when omitted) and waitGroups[0] (group, 0 when
omitted).

Verified: indenting multi_standbys.pgaf and drop_node_destroy.pgaf now
round-trips perform failover / run correctly (only expected diffs remain:
dropped inline step-body comments and a redundant sequence block, both
pre-existing, documented limitations of the tool).
PG_AUTOCTL_TEST_DELAY previously derived each node's registration delay
by parsing a trailing numeric suffix off its name (node1 -> 2s, node2 ->
4s, ...), so that node IDs get assigned in a predictable order across
the depends_on-guaranteed-concurrent startup of every node after the
first. That parsing only works for names ending in a digit -- Citus-style
names (worker1a, coordinator1b, ...) end in a letter and silently got
zero delay, leaving their relative registration order to a race.

compose_gen_write() now assigns each node a 0-based ordinal by its
position in cluster{} declaration order across every formation (the same
order already used to pick the depends_on anchor node) and passes it
directly via PG_AUTOCTL_TEST_DELAY, instead of a node name. cli_node_run()
just reads that number -- it never parses the node's name at all, so this
works identically for any naming convention.

Also: read the value with stringToInt() instead of atoi(), matching
convention used elsewhere in this codebase (atoi silently treats garbage
as 0; stringToInt distinguishes the two and lets a malformed value log a
warning instead of misbehaving quietly). Guard the get_env_copy() call
with env_exists() first -- the monitor service (which also runs through
cli_node_run(), and never has PG_AUTOCTL_TEST_DELAY set) would otherwise
hit get_env_copy()'s unconditional log_error() for a var that is
legitimately absent.

Verified: dry-run compose generation shows correct sequential ordinals
(0,1,2,3 for a 4-node plain formation; 0-5 for a 6-node coordinator/worker
Citus formation). Full runs of multi_standbys.pgaf (27/27),
citus_multi_standbys.pgaf (14/14), and quick.sch (4/4) all pass with zero
PG_AUTOCTL_TEST_DELAY errors.
Audited every .pgaf spec's network disconnect / compose stop / stop
postgres steps against the Python method they were ported from
(node.fail(), node.stop_pg_autoctl(), node.stop_postgres(),
node.ifdown()/ifup()) and fixed two categories of drift.

1. network disconnect substituted for a graceful process stop:

   node.fail() sends SIGTERM to pg_autoctl (a graceful stop), not a
   network partition -- but basic_operation.pgaf's test_010_fail_primary
   and multi_standbys.pgaf's test_012_fail_primary /
   test_015_002_fail_two_standby_nodes ported it as 'network disconnect'.
   In basic_operation.pgaf this duplicated the file's own dedicated
   partition test (test_021-023, correctly ported from ifdown()/ifup()),
   silently losing coverage of the graceful-stop path. In multi_standbys
   the fix also removes an internal inconsistency: test_014_002 (same
   scenario, different formation config) already correctly used
   'compose stop'. Fixed all three to 'compose stop' / 'compose start',
   matching the sibling steps that were already correct.

2. 'stop postgres <node>' used where a real external stop was needed:

   'stop postgres <node>' (pg_autoctl manual service pgctl off) writes a
   persistent "expected: stopped" flag that the already-running keeper
   honors -- it cannot exercise the keeper's own "ensure" self-healing
   logic, because it explicitly tells the keeper not to restart. Python's
   node.stop_postgres() bypasses pg_autoctl entirely (raw pg_ctl --mode
   fast stop), so the keeper still believes Postgres should be running
   and its own polling loop notices and restarts it -- that's the actual
   behaviour under test. Switched to "exec <node> pg_ctl --wait --mode
   fast stop" in:
     - ensure.pgaf test_003_init_secondary (the file's whole purpose is
       testing this self-healing behaviour)
     - basic_operation.pgaf test_024_stop_postgres_monitor (whose own
       comment already claimed to bypass pg_autoctl, but the
       implementation didn't)
     - basic_operation.pgaf test_008_001 and multi_maintenance.pgaf
       test_006a (stop-during-maintenance checks that previously
       couldn't distinguish 'maintenance suppressed the restart' from
       'pgctl off already guaranteed no restart regardless')

   Python's test_002_stop_postgres (stop the primary directly while it's
   still a lone 'single' node, verify self-heal) is intentionally not
   ported into basic_operation.pgaf: that spec's setup{} brings up all
   three nodes upfront, so node1 already has a synchronous secondary by
   the time any step runs, and stopping its Postgres reliably triggers a
   real failover instead of a quiet self-heal -- duplicating
   test_010/012 rather than adding coverage. See the comment left in
   place of the step for the full reasoning.

Verified with full local runs: basic_operation.pgaf 27/27,
multi_standbys.pgaf 27/27, ensure.pgaf 5/5, multi_maintenance.pgaf 22/22.
Unify exec/run macro syntax under one $name(args) call convention
regardless of how a macro resolves -- $cidr() shells out to the monitor
at expansion time, $ip(<node>) is a pure file lookup, but a spec author
shouldn't need two different-looking syntaxes to tell them apart.

$ip(<node>) resolves to the static IP dnsmasq's pgaf-hosts assigns to
<node> (via the existing runner_hosts_lookup() helper, also used by
runner_network_on()'s --ip fix). Neither macro is cached: every
occurrence re-resolves independently.
docs/ref/pgaftest.rst:
- Replace the dated Python-porting-mapping content (audit-style, tied to
  a specific pass over the specs) with a permanent test catalog: every
  .pgaf file under tests/tap/specs/ with a short description drawn from
  its own header comment. The Python method -> .pgaf command mapping now
  lives once, in README.md, rather than duplicated in both places.
- Document the five distinct ways to make a node stop responding
  (network disconnect, compose stop, compose kill, stop postgres,
  exec pg_ctl fast stop) and what each actually exercises -- converted
  from a fragile hand-aligned RST simple-table to a definition list
  after a column-width edit silently broke the table.
- Document $cidr()/$ip() macro syntax and semantics.
- New "Node registration order" section explaining the depends_on +
  PG_AUTOCTL_TEST_DELAY mechanism and why it applies equally to Citus
  naming.

src/bin/pgaftest/README.md:
- New "Porting failure-simulation semantics from the Python suite"
  section: the Python mechanism table, the .pgaf equivalents, and the
  specific audit findings (which specs were checked, which were fixed
  and why, which were left as an intentional simplification).
- New "Deterministic node registration order" section: the
  depends_on / PG_AUTOCTL_TEST_DELAY mechanism in full, including why an
  earlier name-suffix-parsing version didn't work for Citus node names.
- "Static IP on network connect" section for the runner_network_on()
  --ip fix.

Verified: rst2html reports zero real errors (only pre-existing Sphinx
:ref: roles docutils doesn't know about), and make -C docs html builds
clean with zero warnings.
Neither macro has a real caller, and the reasons behind each are gone:

- %CIDR% (this macro's predecessor) existed for exactly one caller,
  installcheck.pgaf's HBA-widening step, so pg_regress could reach the
  monitor over the Docker bridge subnet. That file was already deleted
  before this branch started (installcheck now runs inside the Docker
  build stage via pg_virtualenv, connecting over a local Unix socket --
  the whole "reach the monitor over some subnet" problem it solved no
  longer exists). Nothing else ever called it.

- $ip(<node>) was added speculatively earlier in this same branch, while
  chasing an unrelated network-reconnect bug, on a "might be useful"
  basis. It was never adopted by any spec.

Removing both: runner_expand_macros() and its two call sites in
runner_exec_cmd() (CMD_EXEC, CMD_RUN -- now just copy cmd->args
verbatim), and the macro reference table in docs/ref/pgaftest.rst.

runner_hosts_lookup() itself is untouched -- it's also used by
runner_network_on()'s --ip fix, which is real and load-bearing.

Verified: make docker-check and bash ci/banned.h.sh both clean,
make -C docs html builds with zero warnings and zero remaining mentions
of either macro, and multi_standbys.pgaf still runs 27/27.
Reproduces 5/6 pytest/monitor CI failures (PG14, PG15, PG17, PG18, PG19),
identical on every run: "node3 failed to reach primary after 300 seconds".

Root cause: NOT pgautofailover.guard_data_loss. That GUC (added in #1142)
only changes behavior when explicitly set to false (--allow-data-loss);
its default (true) reproduces byte-for-byte the pre-#1142 blocking
behavior in ProceedGroupStateForMSFailover -- confirmed by diffing ca7834b:
every "return false;" guarding on missingNodesCount/quorumCandidateCount
already existed unconditionally before that commit, the commit only wraps
it in "if (GuardDataLoss)" and adds an unblocking else-branch that only
runs when the GUC is off. Default-on behavior is unchanged.

The real regression is commit a7974f8 ("fsm: wait for local Postgres to
accept connections before reporting SECONDARY"), landed earlier in this
branch. While fixing an unrelated connection-refused race, it also
"fixed" this test's assertion:

    assert node1.wait_until_assigned_state(target_state="draining")
    assert node3.wait_until_assigned_state(target_state="report_lsn")
        -->
    assert node3.wait_until_state(target_state="primary", timeout=300)

reasoning that 'report_lsn' is too transient to reliably observe. That's
true, but the replacement assertion is unreachable as written: at this
point in the test node2 is still down (killed in test_005_001 and not
restarted until test_005_003), so with number_sync_standbys=1 the monitor
needs 2 LSN reports before it will elect a new primary and node3 cannot
reach "primary" here -- only node2's restart in test_005_003 can unblock
that, and origin/main's version of this test correctly waits for
"report_lsn" here and defers the "primary" assertion to test_005_003.
a7974f8 turned a transient-state observability problem into a guaranteed
300s timeout by moving the wrong assertion to the wrong test function.

The .pgaf port of this same test (multi_alternate.pgaf) hit the identical
issue and was already fixed correctly in 3c9ca49, right after a7974f8:
it both moves "compose start node2" earlier (into test_005_002) *and*
adds a "passing through report_lsn" wait primitive to robustly observe
the transient state via LISTEN. This commit applies the equivalent fix to
the plain pytest side, which 3c9ca49 didn't touch: start node2 before
killing node1, so quorum is satisfiable and "wait for primary" is
reachable within test_005_002 itself. test_005_003_bring_up_first_failed_primary
no longer needs to start node2 itself, and its wait for the transient
'demoted' state (which may have already come and gone by the time the
test runs, since node2 restarts earlier now) is replaced with a wait for
the stable end state, matching the same fix pattern already used
elsewhere in this file.

test_003_002_stop_primary (the 1/6 PG16-specific failure) was not touched:
its assertions already tolerate node3 staying stuck at report_lsn (it
explicitly checks node3 does NOT reach wait_primary), so it isn't exposed
to this bug the same way -- its single failure is more likely independent
CI timing flakiness and should be monitored separately.

Reproduced locally before and after: fails identically to CI before this
change (333s runtime, node3 timeout after 300s); after the fix, all 15
tests in the file pass in 173s.
coordinator_update_node_prepare() runs on a single connection wrapped in
pgsql_begin(), which sets PGSQL_CONNECTION_MULTI_STATEMENT. In that mode
pgsql_execute()/pgsql_execute_with_params() deliberately keep the
connection open on failure ("Multi statements might want to ROLLBACK and
hold to the open connection for a retry step" -- pgsql.c) so the caller
can decide whether to retry or roll back.

Every failure branch in this function except one (the
transactionHasAlreadyBeenPrepared case) just did "return false;" without
calling pgsql_rollback(), so the connection was never closed. On a
formation where the coordinator keeps failing the same operation on every
FSM tick -- e.g. once per second while a worker is "draining" during a
switchover -- this leaks roughly one coordinator connection per retry.

Confirmed locally: reproduced citus_cluster_name.pgaf's worker1a
switchover hang (matches CI jobs 32/33, PG17/PG18) and polled
coordinator1b's pg_stat_activity directly. Connection count grew
26->41->56->70->85->99 across successive polls, all of them
"idle in transaction (aborted)" backends that had run
"SELECT master_update_node(nodeid, $2, $3, force => tru...", until
coordinator1b hit "FATAL: sorry, too many clients already" -- a full
outage, not just a stuck switchover.

Fix: call pgsql_rollback() before every "return false;" that follows a
successful pgsql_begin(), mirroring the pattern already used for the
transactionHasAlreadyBeenPrepared case. pgsql_rollback() issues ROLLBACK
and unconditionally closes the connection itself, so this is safe to call
even when the failing statement already left the connection in an aborted
transaction state.

Verified locally after the fix, driving citus_cluster_name.pgaf's
worker1a switchover by hand and polling coordinator1b every ~20s for over
3 minutes: connection count stays flat (7-8, 0 aborted) instead of
growing.

Note: fixing the leak does not by itself resolve the switchover hang.
Once the leak stops masking it, coordinator1b's keeper log shows the real
error on every retry: "Postgres ERROR: there is already another node
with the specified hostname and port". This is a separate, still-open
bug -- pg_dist_node already has a row for worker1b as the citus-secondary
"readonly" cluster node at the same host:port that master_update_node()
is trying to move the primary's row to, and Citus's (nodename, nodeport)
uniqueness constraint rejects the update. That collision, not lock
contention, is the actual reason the switchover in citus_cluster_name.pgaf
never completes. Tracked separately.
Reusable diagnostic spec for the citus_cluster_name.pgaf worker1a
switchover hang (CI jobs 32/33, PG17/PG18), following the same pattern
as debug_failover_pg19.pgaf: a minimal topology plus a background-exec
and timed polling steps to capture internal state instead of blocking
on the hanging command.

This spec is what led to root-causing both halves of that hang: the
coordinator.c connection leak fixed in 318f67a, and the still-open
pg_dist_node (nodename, nodeport) collision with citus-secondary
"readonly" cluster nodes, documented in the spec's header comment.
perform_failover()'s two-node fast path (used both for automatic failover
and for "pg_autoctl perform switchover" in a two-node group) never checked
the standby's candidatePriority before promoting it. Every other place in
the codebase treats candidatePriority == 0 as "never eligible as a
failover target":

  - BuildCandidateList/CountHealthyCandidates skip priority-0 nodes for
    groups with more than two nodes.
  - set_node_candidate_priority refuses to let a citus-secondary
    "read-replica" node's priority be set to anything but 0, and refuses
    to drop a node's priority to 0 if it would leave the group without a
    non-zero-priority candidate.

The two-node path was the one place this invariant wasn't enforced, so a
manual switchover against a two-node group whose only standby is a
citus-secondary read-replica (candidate-priority is pinned at 0 for those
nodes) would go ahead and promote it anyway.

Reproduced locally: citus_cluster_name.pgaf sets up exactly this shape
(each group's non-primary member is registered citus-secondary with
citus-cluster-name=readonly, candidate-priority 0) and its setup{} called
"pg_autoctl perform switchover" on the primary of each group. The FSM
happily assigned prepare_promotion to the priority-0 standby, which then
retried master_update_node() forever: pg_dist_node already had a row for
that node's hostname:port from its citus-secondary registration, so
Citus's (nodename, nodeport) uniqueness constraint rejected every retry
with "there is already another node with the specified hostname and
port". That's what produced the 20-minute CI hang in citus-1 (PG17/PG18)
-- not lock contention, and (once the separate connection leak fixed in
318f67a stopped masking it) not a resource exhaustion issue either: the
promotion was simply never going to succeed.

Fix: apply the same candidatePriority == 0 check the multi-node path
already has, in the two-node path, with an error message that explains
why up front instead of an indefinite retry loop discovered only via the
coordinator's Postgres log.

tests/tap/specs/citus_cluster_name.pgaf: drop the switchover calls from
setup{}. They were attempting exactly the now-refused operation, and
weren't attempting anything the test itself needs -- its own header
describes it as a read-routing test, none of its steps depend on a
switchover having happened, and the original Python predecessor
(test_citus_cluster_name.py) never called switchover or promote at all.

Verified locally: citus_cluster_name.pgaf passes on both PG17 and PG18
after this fix (previously hung to the 20-minute CI timeout on both).
Groups it with the other citus_*.pgaf specs alphabetically and by name;
"nonha_citus" read as "non-HA Citus" but sorted away from its siblings.
Updates tests/tap/schedule, tests/tap/schedules/citus-2.sch,
docs/ref/pgaftest.rst, and src/bin/pgaftest/README.md accordingly.

Verified the renamed spec still runs under the new path/schedule entry.
Every pgaftest compose stack previously ran a dedicated dnsmasq container
(_dns) that served a static hosts file over DNS, with every other service
pointed at it via `dns: [...]`, specifically to work around Docker's
embedded 127.0.0.11 resolver dropping queries under container churn on
GitHub Actions runners.

That workaround is no longer needed: since every node's IP is already
static (hashed from the project name at compose-gen time), the same
name-to-IP mapping can be written directly into each service's
`extra_hosts:` list in the generated docker-compose.yml. That resolves
names via a local /etc/hosts read (NSS "files") instead of a DNS query,
which can't drop a packet or time out the way a UDP lookup can, and
removes an entire container plus the depends_on/healthiness dependency
every other service had on it being up first.

compose_gen.c changes:
  - compose_dns_ip() removed; IP offsets renumbered now that there's no
    _dns container to reserve .2 for (monitor=.2, secondMonitor=.3, data
    nodes=.4+, was .3/.4/.5+).
  - New compose_write_extra_hosts() writes the same mapping as an
    `extra_hosts:` block instead of compose_gen_write_hosts()'s file.
  - compose_gen_write_hosts() (the pgaf-hosts file) is kept: it's no
    longer read by any container, but pgaftest itself still reads it back
    (runner_hosts_lookup in test_runner.c) to reconnect a node to its
    original static IP after `network disconnect`/`connect`, independent
    of how name resolution works.
  - The containerized "pgaftest"/"setup" compose service (used for
    Docker-in-Docker CI wrapper invocations and `docker compose run
    setup`) previously had neither `dns:` nor `networks:` set despite
    needing to resolve "monitor" via PG_AUTOCTL_MONITOR -- a latent gap,
    unrelated to dnsmasq specifically. It now gets extra_hosts and joins
    pgafnet like every other service.

Removed entirely: the `dnsmasq` Dockerfile target, the `build_dnsmasq` CI
job and its artifact upload/download/load steps in test_pgaftest and
upgrade, and PGAF_DNS_IMAGE.

Verified locally: citus_nonha_operation.pgaf and citus_cluster_name.pgaf
both pass end-to-end, including the network disconnect/reconnect steps
(coordinator and worker failovers) that depend on the static-IP lookup
still working correctly with extra_hosts instead of dnsmasq.
New src/monitor/sql/drop_node.sql, driven directly via SQL (register_node /
node_active / remove_node), to check the monitor-side half of Bug C: the
citus_nonha_operation.pgaf test_008_remove_old_primaries stall where, after
dropping a two-node group's last standby, the primary is intermittently
observed stuck at "wait_primary" instead of moving to "single", and
"pg_autoctl drop node" times out after 60s waiting for the standby's row to
disappear (root-caused via ~13 local Docker reproductions this session, at
roughly a 15% hit rate).

This test isolates the monitor's own bookkeeping from the real keeper
processes and proves it correct on every run:

  - remove_node() on the standby sets its goal to "dropped" and, in the
    same call, moves the primary straight to "single" -- never
    "wait_primary". AutoFailoverNodeGroup()'s "goalstate <> 'dropped'"
    filter means RemoveNode()'s inline ProceedGroupState(primaryNode)
    already sees a one-node group by the time it runs.

  - A stale/racing node_active() report from the about-to-be-dropped node
    (simulating a keeper report that hasn't caught up with the new
    "dropped" goal) does not corrupt the primary or resurrect the
    standby: ProceedGroupState()'s early-return for goalState=DROPPED
    means no new goal gets assigned, though reportedstate is still
    updated as unconditional bookkeeping -- this is the exact shape of
    the "New state for this node ... : single -> single" line seen in
    CI, where the about-to-be-dropped node reports something other than
    "dropped" and the row is left pending until it complies.

  - remove_node() called again before the standby complies is a safe,
    idempotent no-op (goal already "dropped", number_sync_standbys not
    decremented twice).

  - Once the standby reports "dropped", its row is actually removed and
    the primary is left alone, unaffected.

Net effect: this rules out a monitor-side sequencing bug in RemoveNode()
itself as the explanation for the observed "wait_primary" symptom -- that
path is correct on every call. The remaining, narrower suspect is a real
concurrency window between RemoveNode()'s transaction and the primary's
own independent node_active() call: NodeActive() (node_active_protocol.c)
reads the node via GetAutoFailoverNodeById() before acquiring
LockFormation/LockNodeGroup, so a primary's own concurrent poll could in
principle observe pre-drop state before blocking on the lock. Confirming
that requires true multi-session concurrency (e.g. an isolation-tester
spec), which this codebase doesn't currently have infrastructure for; not
pursued further here.

Placed last in REGRESS, after fast_forward: pgautofailover.node has a
global (nodehost, nodeport) unique constraint (not scoped per formation),
so node names must be unique across every test file in the same
database -- used dn_p/dn_s rather than the bare p/s that collided with
guard_data_loss.sql's own 'p' node on first attempt. Running after every
existing test avoids shifting their hardcoded assigned_node_id
expectations, which are global-sequence values.
Wires PGXS's ISOLATION variable into src/monitor/Makefile alongside the
existing REGRESS variable -- `make installcheck` now runs both. Isolation
specs live in src/monitor/specs/*.spec; their accepted output lives in
the same expected/ directory REGRESS already uses (pg_isolation_regress's
--expecteddir defaults to --inputdir, so no new directory convention is
needed). No Dockerfile change: `make -C src/monitor/ installcheck` already
runs both once ISOLATION is set, and pg_isolation_regress/isolationtester
ship in the same postgresql-server-dev package pg_regress already comes
from.

Two specs, in order of what they're building toward:

  - concurrent_remove_node.spec: two sessions call remove_node() on the
    same standby concurrently. This finds a real bug: remove_node_by_nodeid()
    reads the target node via GetAutoFailoverNodeById() *before*
    RemoveNode() acquires LockFormation(ExclusiveLock). The second,
    blocked session resumes with a pre-lock snapshot once the first
    commits, so RemoveNode()'s "goalState == DROPPED -> return early"
    idempotency check doesn't fire and it redundantly reruns the whole
    removal. The standby side is a harmless no-op re-write, but on the
    primary side RemoveNode()'s "if ProceedGroupState(primary) didn't
    change its goal, force APPLY_SETTINGS" fallback fires on this second,
    redundant call and pushes a primary that had already correctly
    settled at "single" into "apply_settings" for no reason. Not fixed
    here -- the fix is moving the node lookup to happen after the lock
    (inside RemoveNode(), taking a nodeId instead of a resolved
    AutoFailoverNode*) -- documented in the spec's header for whoever
    picks it up.

  - concurrent_remove_standby_and_primary_report.spec: the Bug C spec.
    Tests whether the primary's own node_active() call -- what its real
    keeper sends on every ~1s poll, independent of any drop -- can be
    tricked into the wrong decision by blocking behind a concurrent
    remove_node() on the standby and resuming after it commits.
    NodeActive() has the same "read before lock" shape as
    remove_node_by_nodeid(). Result: ruled out. The primary's node_active()
    correctly returns "single" every time, because the decision depends
    on AutoFailoverNodeGroup()'s nodesCount, a fresh SPI query issued
    after LockFormation is acquired, not on the stale pre-lock read (which
    only affects the calling node's own previously-known fields, not
    group membership). This is different from remove_node()'s bug, which
    trusts a stale field for its idempotency check specifically.

Net effect on the Bug C investigation (see drop_node.sql and this
session's ~13 Docker reproductions, ~15% hit rate): RemoveNode()'s own
synchronous sequencing is proven correct (drop_node.sql), and this rules
out the primary's own concurrent poll as the trigger for the observed
"wait_primary" hang. The next concurrency shape to test is whatever could
cause more than one remove_node() call for the same node from a single
"pg_autoctl drop node" invocation -- the only shape proven so far to
produce a wrong primary goal state, even though the wrong state it
produces (apply_settings) isn't yet a proven match for the "wait_primary"
symptom from CI.
RemoveNode() used to take a pre-resolved AutoFailoverNode*, looked up by
its caller (remove_node_by_nodeid/remove_node_by_host) via
GetAutoFailoverNodeById()/GetAutoFailoverNode() *before* RemoveNode()
acquired LockFormation(ExclusiveLock). Two concurrent remove_node() calls
on the same node would serialize on that lock correctly, but the second
call, once unblocked by the first's commit, was still working from the
snapshot it read before it ever tried to acquire the lock -- stale by
exactly the transaction it had just waited on.

That broke the "goalState == DROPPED -> return early" idempotency check:
the second call's stale currentNode still showed the pre-drop goalState,
so it skipped the early return and redundantly redid the entire removal.
The standby side was a harmless no-op re-write, but the primary side was
not: RemoveNode()'s "if ProceedGroupState(primary) didn't change its
goal, force APPLY_SETTINGS" fallback fired on that second, redundant
call -- the primary was already correctly "single" by then, so its goal
genuinely didn't change on the re-run -- and pushed a primary that had
already settled at "single" into "apply_settings" for no reason.

Fix: RemoveNode() now takes a nodeId instead of a pre-resolved pointer.
It does an initial unlocked lookup only to learn which formation to
lock (a node's formationId is immutable, so that read being racy is
harmless), acquires LockFormation, and only then does the authoritative
re-read of the node that all decision-making is based on. A caller
blocked behind a concurrent RemoveNode() call on the same node now
resumes with fresh, post-commit state instead of whatever it read before
it ever tried to acquire the lock. remove_node_by_nodeid()/
remove_node_by_host() keep their own unlocked existence checks (for a
precise "node not found" error message); a benign TOCTOU race there is
already handled by RemoveNode() itself, which now returns true (already
gone) if its own fresh lookup finds nothing.

concurrent_remove_node.spec, which caught this, is updated in place to
document and guard the fixed behavior instead of the bug: crn_p now
stays "single" instead of drifting to "apply_settings".

Verified: all 10 REGRESS + both ISOLATION specs pass via `make
installcheck` against a fresh local instance.
concurrent_remove_standby_and_standby_report.spec (new) asks whether the
node being dropped's OWN concurrent node_active() call -- its real
keeper's regular ~1s poll, re-reporting its last known role, unaware a
drop is in flight -- sees the fresh goalState = DROPPED once it blocks
behind a concurrent remove_node() and resumes after that transaction
commits. It does not, and this is the actual root cause of Bug C
(citus_nonha_operation.pgaf's intermittent test_008_remove_old_primaries
stall, ~15% hit rate across this session's Docker reproductions).

NodeActive() reads the node via GetAutoFailoverNodeById() before
acquiring LockFormation()/LockNodeGroup(), the same pre-lock-read shape
already fixed in RemoveNode() (previous commit). A node's own
node_active() call that blocks behind a concurrent remove_node() on
itself resumes, once unblocked, still using the struct it read before
ever trying to acquire the lock -- stale by exactly the transaction it
was waiting on. ProceedGroupState()'s "already dropped, do nothing"
checks compare against that stale goalState/reportedState and never
fire. Execution falls through to ordinary FSM logic, where
AutoFailoverNodeGroup()'s nodesCount -- a fresh SPI query, unaffected by
the staleness -- correctly sees a one-node group (the dropped node's own
row already excluded by its just-committed goalState = 'dropped'), and
the nodesCount == 1 case reassigns the node's OWN goal to SINGLE: its own
concurrent report undoes the drop it had just been given. This exactly
reproduces the original CI log line for the node being dropped, "New
state for this node ...: single -> single", instead of ever reaching
"dropped -> dropped" -- which is why "pg_autoctl drop node" timed out
waiting for a row that could never disappear.

(concurrent_remove_standby_and_primary_report.spec, committed earlier
this session, asked the same question about the PRIMARY's concurrent
poll instead and ruled it out: the primary's decision depends only on
the fresh nodesCount, not on any of its own stale fields, so it isn't
susceptible to this. Its header comment is updated to point at this
spec's result instead of leaving the "still open" note stale.)

Fix: NodeActive() now re-reads the node after acquiring
LockFormation()/LockNodeGroup(), mirroring the RemoveNode() fix -- same
root defect (decide-before-lock instead of decide-after-lock), same
remedy, second place it was hiding.

Verified: all 10 REGRESS + all 3 ISOLATION specs pass via `make
installcheck` against a fresh local instance.

Docker-level re-verification of citus_nonha_operation.pgaf with this fix
was attempted but the run was confounded by a transient, self-healing
local Docker networking blip (a live TCP timeout connecting to the
monitor, gone seconds later) unrelated to this fix, matching the same
class of environmental flakiness flagged earlier this session before a
Docker Desktop restart -- not treated as a real data point either way.
The isolation-test reproduction above is deterministic and doesn't
depend on Docker networking at all, which is why it's the basis for this
fix rather than the Docker run.
…ess test

Investigates the 6th CI failure: pytest/monitor PG16,
test_003_002_stop_primary, "node3 failed to reach prepare_promotion or
report_lsn after 90 seconds".

Initial hypothesis was a BuildCandidateList() bug: a primary hard-killed
via DataNode.fail() never reports its own demotion, so its reportedstate
stays frozen at "primary" while only its goalstate advances to "demoted"
via the monitor's own timeout. The "skip this node, it's a primary (old
or new)" branch in BuildCandidateList() only excludes a node from the skip
when its *reportedstate* (not goalstate) is draining/demoted, so a node
frozen at reportedstate=primary/goalstate=demoted matches the skip and is
dropped from missingNodesCount entirely -- suspected to let the sole
remaining standby be promoted without real quorum.

Added src/monitor/sql/stale_primary_report.sql to test this directly:
manufacture two consecutive dead primaries in exactly that state
(reportedstate frozen at primary, goalstate=demoted) and check whether the
sole surviving standby gets incorrectly promoted. It does not: the
separate quorumCandidateCount < minCandidates guard in
ProceedGroupStateForMSFailover (minCandidates = number_sync_standbys + 1)
still correctly blocks promotion regardless of the missingNodesCount
bookkeeping gap. Kept as permanent regression coverage for this edge case.

Rereading the actual pytest exception clarifies the real bug: node3
"failed to reach" report_lsn/prepare_promotion within 90 seconds -- it
never reached further than expected, it was simply slow to get there. The
monitor events log confirms the FSM did eventually reach the correct
state (report_lsn -> prepare_promotion -> stop_replication -> wait_primary)
after the pytest assertion had already timed out. This matches a
timing budget problem, not a promotion-safety bug: reaching this point
requires health-check detection of node2's death
(node_considered_unhealthy_timeout, 20s default) plus a full
primary_demote_timeout (30s default), and test_003_001 immediately before
this test already drove node1 through the identical sequence, so a loaded
CI runner can push the combined wait past the default 90s
STATE_CHANGE_TIMEOUT. Bumped the three affected waits in
test_003_002_stop_primary to timeout=120, matching the margin already used
for the equivalent waits in test_005_001/test_005_002 elsewhere in this
file.

Locally reproduced via `make -C tests run-test PGVERSION=16
TEST=test_multi_alternate_primary_failures` (Docker, same as CI): both
runs passed 15/15, consistent with this being a low-probability CI-only
timing flake rather than a deterministic failure.
pgaftest indent already detects and strips a sequence{} block when it
exactly matches step declaration order (cli_indent.c: "Emit the sequence
block only when it differs from definition order... the block is
redundant noise; drop it"), and test_runner.c falls back to declaration
order whenever no sequence block is present at all. Most specs already
rely on this default and never write an explicit sequence.

Six specs still carried an explicit sequence block added when they were
authored, none of which was ever actually needed: in every case the listed
order is identical, step for step, to the step declaration order already
in the file. Verified with `pgaftest indent`, which independently confirms
each file's sequence is redundant and drops it when re-serializing.

Removed the redundant sequence{} block from:
  - debug_citus_worker_switchover.pgaf
  - debug_failover_pg19.pgaf
  - drop_node_destroy.pgaf
  - fast_forward.pgaf
  - guard_data_loss.pgaf
  - launch_deferred_set_metadata.pgaf

No behavior change: all six now rely on the same declaration-order default
already used elsewhere. Confirmed each file still parses cleanly via
`pgaftest indent` after the edit.
@dimitri dimitri changed the title pgaftest: root user DooD, direct libpq, perform failover DSL, UX polish pgaftest: DSL/runner improvements + monitor fixes Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant