Skip to content

Track connected Postgres server version and Citus extension version#1157

Merged
dimitri merged 1 commit into
mainfrom
feature/node-pg-citus-version-tracking
Jul 25, 2026
Merged

Track connected Postgres server version and Citus extension version#1157
dimitri merged 1 commit into
mainfrom
feature/node-pg-citus-version-tracking

Conversation

@dimitri

@dimitri dimitri commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

pgautofailover.node gains four nullable columns: pg_versionnum (int), pg_version (text), pg_versionstring (text), citus_version (text). These capture the connected node's server_version_num/server_version/version(), and its installed Citus extension version when present (NULL otherwise).

Design

  • Separate SQL API, not routed through node_active(). pgautofailover.report_postgres_version() is a standalone, non-STRICT function (fields can be NULL) called once per Postgres restart, not on every periodic report — this data can't change without a restart, so it stays off node_active()'s hot path.
  • Edge detection uses a dedicated tracker, not LocalPostgresServer.pgIsRunning directly. pgIsRunning is written from multiple places (fsm_transition.c, primary_standby.c, keeper_update_pg_state() itself), so comparing it to its own previous value inside keeper_update_pg_state() misses the first, most important transition — Postgres coming up for the first time. PostgresVersionInfo.lastKnownRunning is written only by keeper_update_pg_state(), giving it a reliable not-running → running signal independent of every other writer.

Implementation

  • pgsql_get_postgres_version() (src/bin/common/pgsql.c): one query fetching version info plus Citus's extversion via a scalar subquery.
  • monitor_report_postgres_version() (src/bin/pg_autoctl/monitor.c): sends it to the monitor.
  • ReportAutoFailoverNodeVersion() (src/monitor/node_metadata.c): plain SPI UPDATE by nodeid, no lock or existence check — a keeper self-report, same posture as ReportAutoFailoverNodeState() (silent no-op on an unknown node, unlike operator-facing commands such as set_node_region).
  • pgautofailover.report_postgres_version() (src/monitor/pgautofailover.sql / node_active_protocol.c): the SQL-callable entry point.

Testing

  • Extended node_active_protocol.sql/.out with direct SQL coverage of report_postgres_version() (NULL handling, unknown/null node_id).
  • New postgres_version_tracking.pgaf pgaftest spec, added to the quick schedule, exercises the keeper-side edge detection end to end against a live node.
  • make docker-check and ci/banned.h.sh: clean. Full monitor regression (12 regress + 6 isolation) passes. config_get_set.pgaf and the full basic_operation.pgaf pass, confirming the keeper_update_pg_state() change doesn't affect the core FSM lifecycle.

pgautofailover.node had no version column at all -- only the keeper's
local state file tracked control-data version info (pg_control_version/
catalog_version_no), never the running server's own server_version_num/
server_version/version(), and Citus's installed version wasn't tracked
anywhere in this codebase. Adds four nullable columns: pg_versionnum
(int), pg_version (text), pg_versionstring (text), citus_version (text,
NULL whenever Citus isn't installed).

Design choices, each a deliberate deviation from the initial plan after
digging into the actual code:

- Three plain scalar columns, not a composite type. No precedent exists
  anywhere in this codebase for a composite CREATE TYPE used as a table
  column or SPI parameter (the only CREATE TYPE is the replication_state
  enum) -- would have been a first, for no real benefit over matching
  the existing all-scalar convention (region, sysidentifier, ...).

- A dedicated, separate SQL API (pgautofailover.report_postgres_version),
  not threaded through node_active(). Postgres/Citus version can't change
  without a Postgres restart, so there's no reason to carry it on every
  few-second periodic report; it's fetched and reported once per restart
  instead, keeping node_active()'s hot path untouched.

- Fired at both keeper start and any later detected Postgres restart, not
  just keeper start. This surfaced a real bug worth documenting: a naive
  "compare pgIsRunning to its previous value" edge check is unreliable,
  because LocalPostgresServer.pgIsRunning is also written directly by
  fsm_transition.c/primary_standby.c during FSM transitions (e.g. the
  very first init -> single transition), outside of
  keeper_update_pg_state() entirely. By the time keeper_update_pg_state()
  next ran, pgIsRunning could already read true from one of those other
  writers, making the most important edge -- Postgres coming up for the
  first time -- invisible. Fixed with a dedicated tracker
  (PostgresVersionInfo.lastKnownRunning) that only
  keeper_update_pg_state() itself ever writes, decoupled from every other
  writer of pgIsRunning.

Monitor side: pgsql_get_postgres_version() (src/bin/common/pgsql.c) runs
one query (server_version_num/server_version/version(), plus Citus's
extversion via a scalar subquery -- NULL when Citus isn't installed) on
the not-running -> running edge; monitor_report_postgres_version()
(monitor.c) sends it via the new SQL function, deliberately not STRICT
so the four fields can be NULL; ReportAutoFailoverNodeVersion()
(node_metadata.c) does the plain SPI UPDATE, no lock or existence check
needed -- a keeper self-report on its own already-known nodeid, same
posture as ReportAutoFailoverNodeState()'s own report path (silent no-op
on an unknown node, unlike operator-facing commands like
set_node_region).

Tests: extended the existing node_active_protocol.sql/.out with direct
SQL coverage of report_postgres_version() (NULL handling, unknown/null
node_id); new postgres_version_tracking.pgaf pgaftest spec exercises the
real keeper-side edge detection end to end against a live node, added to
the quick schedule. Verified config_get_set.pgaf and the full 27-step
basic_operation.pgaf are unaffected by the keeper_update_pg_state()
change.
@dimitri dimitri self-assigned this Jul 25, 2026
@dimitri dimitri added the enhancement New feature or request label Jul 25, 2026
@dimitri
dimitri merged commit e156a5f into main Jul 25, 2026
213 of 216 checks passed
@dimitri
dimitri deleted the feature/node-pg-citus-version-tracking branch July 25, 2026 01:27
dimitri added a commit that referenced this pull request Jul 26, 2026
CI's upgrade.pgaf job failed: an in-place ALTER EXTENSION UPDATE TO '2.3'
left several functions this branch (and two other already-merged PRs)
added to the fresh-install script completely absent from the incremental
2.2--2.3 upgrade script, so any node calling them post-upgrade got
'function does not exist'.

Ports over, matching the fresh-install definitions in pgautofailover.sql
exactly:
- set_node_region() (#1156)
- the four pg_version*/citus_version columns and report_postgres_version()
  (#1157)
- node_timeline_history / accepted_timeline tables and
  report_timeline_history() / accept_timeline() /
  resolve_accepted_timeline() / node_timeline_status() (this branch)

Verified with make -C tests/upgrade all (builds pgaf:current from the v2.2
tag and pgaf:next from this branch, then runs tests/tap/specs/upgrade.pgaf
end to end against a live 3-node cluster) -- all 10 steps pass, including
the convergence step that previously failed.
dimitri added a commit that referenced this pull request Jul 27, 2026
CI's upgrade.pgaf job failed: an in-place ALTER EXTENSION UPDATE TO '2.3'
left several functions this branch (and two other already-merged PRs)
added to the fresh-install script completely absent from the incremental
2.2--2.3 upgrade script, so any node calling them post-upgrade got
'function does not exist'.

Ports over, matching the fresh-install definitions in pgautofailover.sql
exactly:
- set_node_region() (#1156)
- the four pg_version*/citus_version columns and report_postgres_version()
  (#1157)
- node_timeline_history / accepted_timeline tables and
  report_timeline_history() / accept_timeline() /
  resolve_accepted_timeline() / node_timeline_status() (this branch)

Verified with make -C tests/upgrade all (builds pgaf:current from the v2.2
tag and pgaf:next from this branch, then runs tests/tap/specs/upgrade.pgaf
end to end against a live 3-node cluster) -- all 10 steps pass, including
the convergence step that previously failed.
dimitri added a commit that referenced this pull request Jul 27, 2026
…itchover (#1161)

* Part 1: verify genuine timeline ancestry at the secondary gate, not just numbers

Fixes #683. standby_check_timeline_with_upstream() only ever compared
timeline numbers (upstreamTimeline >= localTimeline): it cannot tell
"genuine ancestor, just behind" apart from "diverged, but the timeline
number still happens to be lower" -- a standby whose own WAL replay has
gone past the point where its timeline was actually superseded looks
identical to one that's simply still catching up, as long as its number
is lower. The former can never resolve through ordinary streaming
replication and needs pg_rewind; the latter just needs to wait.

Add standby_verify_timeline_ancestry(), which walks the upstream's own
TIMELINE_HISTORY data -- already fetched into
replicationSource.system.timelines by the pgctl_identify_system() call
standby_check_timeline_with_upstream() already makes, so this needs no
new network round trip -- and confirms the local checkpoint LSN lies
strictly before the point where the local timeline was superseded in the
upstream's lineage. When it doesn't (genuine divergence), invoke the
existing primary_rewind_to_standby() and retry the metadata fetch, rather
than retrying the same doomed comparison forever.

* Part 2 (schema + publishing): node_timeline_history table and reporting

Adds the shared infrastructure a report_lsn election needs to reason about
timeline ancestry across nodes, since there is no elected primary at that
point to check against -- that has to live on the monitor.

- pgautofailover.node_timeline_history: append-only (nodeid, tli,
  parenttli, switchpoint_lsn) facts, unioned across every node's own
  history. pgautofailover.report_timeline_history(node_id, history jsonb)
  upserts a batch via ON CONFLICT DO NOTHING (immutable facts, safe to
  resend). Written as plpgsql rather than C specifically to lean on
  jsonb_array_elements() instead of hand-rolled JSONB C API code.
- pgautofailover.accepted_timeline: the (empty by default) operator pin
  table part 3 will use; added now since it's part of the same migration.
- timeline_history.c/.h (new): reads a node's own pg_wal/<tli>.history
  file directly (no replication connection needed) and encodes it as the
  JSON array report_timeline_history expects.
- Keeper wiring, two call sites per the design: every tick in the main
  loop, unconditionally of FSM state (an uneventful secondary picks up a
  timeline switch through ordinary streaming replication with no fsm_*
  transition ever running, so this can't be hooked off transitions alone),
  gated by an in-memory watermark so it's a no-op once caught up; and once
  more, synchronously, immediately before fsm_report_lsn()'s standalone
  restart -- the riskiest moment, since a genuine divergence can hard
  FATAL there and the node never gets to report anything again.

The election itself doesn't consult this table yet -- that's next.

* Part 2 (election): filter report_lsn candidates by timeline ancestry

The election never actually used timeline at all, despite a comment on
WalDifferenceWithin() claiming it does: ListMostAdvancedStandbyNodes() and
SelectFailoverCandidateNode() both sort/pick purely by raw reportedLSN. A
node on a genuinely forked, diverged timeline compares as "more advanced"
exactly like a node that's simply further along the same lineage -- there
is no way to tell them apart from LSN alone.

timeline_history.c/.h (new, monitor-side): walks
pgautofailover.node_timeline_history to answer "is candidate tli a genuine
ancestor of the group's reference tli", where the reference is either an
operator's pin in pgautofailover.accepted_timeline (part 3) or, in the
normal case, auto-detected as the branch containing the highest reported
tli among the group's own nodes.

ProceedGroupStateForMSFailover now filters the group's node list through
FilterNodesByTimelineAncestry() before it reaches BuildCandidateList() and
ListMostAdvancedStandbyNodes() -- the two places that assemble and rank
the candidate pool. Nodes outside the reference lineage are excluded
entirely, not deprioritized: a diverged node can never become comparable
no matter how long the election waits for it, so counting it as "missing"
would block the whole group forever (see #683, #1113); excluding it lets
the remaining, genuinely comparable candidates proceed normally, and if
exclusion happens to leave zero candidates, the existing "not enough
candidates yet" handling already does the right, graceful thing with no
new plumbing needed.

The already-selected-candidate fast path (a failover already in progress)
is untouched: filtering only applies to the "pick a new candidate" path.

* Part 3: operator-pinned accepted_timeline for genuine forks

candidate_priority doesn't work for this: it's consulted inside
SelectFailoverCandidateNode, which runs strictly after BuildCandidateList
has already assembled the comparable pool -- the ancestry filter lives at
or below that assembly step, deciding whether nodes are comparable at all,
before priority ever enters the picture. Zeroing a node's priority changes
how it's ranked once it's in a comparable set; it does nothing to tell the
ancestry filter "ignore this node's incomparable position". What's missing
is a fact the operator asserts explicitly, stored somewhere the filter
itself consults -- not inferred from a side effect of an unrelated field.

- pgautofailover.accept_timeline(formation_id, group_id, tli, decided_by):
  validates the tli actually appears in node_timeline_history for the
  group (refuses to pin a timeline nobody has ever reported), then inserts
  the pin. GetAcceptedTimeline() (already wired into
  FilterNodesByTimelineAncestry in the previous commit) picks it up ahead
  of auto-detection.
- pgautofailover.resolve_accepted_timeline(formation_id, group_id): marks
  the pin resolved. Wired into PromoteSelectedNode() so it fires
  automatically once a candidate is actually selected for promotion --
  kept as a permanent audit record rather than deleted.
- pg_autoctl accept timeline --formation --group --tli [--reason]: new CLI
  command calling accept_timeline() via monitor_accept_timeline().

* Part 4: pg_autoctl show timeline

Add node_timeline_status() SQL function (recursive ancestry CTE, per
node current tli/lsn against the group's reference lineage) and wire
it up to a new `pg_autoctl show timeline` command that prints the
group's known timeline history plus each node's position against it,
flagging any node that has diverged.

* Add monitor regression tests for timeline-fork detection

Covers report_timeline_history()/node_timeline_history idempotency,
node_timeline_status() ancestry computation (unpinned auto-detect,
operator pin, and post-resolve revert), accept_timeline()'s rejection
of unreported timelines, and an end-to-end election test: a 4-node
group with two diverging branches where the operator's pin makes the
election promote the pinned lineage and exclude the higher-numbered
sibling branch, auto-resolving the pin on promotion.

Also fixes two bugs the test caught:

  - accept_timeline()'s target_tli parameter shadowed the tli column
    it was compared against, so every call raised 'column reference
    "tli" is ambiguous' instead of doing anything.

  - timeline_history.c was missing from pgaftest's SHARED_SRCS list,
    so the pgaftest binary failed to link as soon as fsm_transition.c
    and service_keeper.c started calling into it (Part 2).

* Fix node_timeline_history FK to cascade on node removal

Without ON DELETE CASCADE, dropping any node that had ever published
timeline history left pg_autoctl node drop / node_active('dropped')
failing with 'update or delete on table node violates foreign key
constraint node_timeline_history_nodeid_fkey' -- caught by running
basic_operation.pgaf (test_016_drop_secondary) against a rebuilt
docker image after the timeline-fork-detection changes.

node_timeline_history rows are provenance (who reported this fact),
not the fact itself -- other nodes' rows for the same tli are
identical and unaffected, so cascading the delete loses nothing an
election or ancestry check depends on.

* Fix #683: standby ahead of upstream retried a doomed reconnect forever

standby_check_timeline_with_upstream() only knew how to rewind a
standby that was BEHIND its upstream's timeline (Part 1's genuine-
divergence check). When local tli > upstream tli -- a standby that
generated WAL past a fork point, e.g. after being promoted outside of
pg_autoctl's control -- it just logged an error and returned false,
forever: Postgres itself refuses to stream ("requested timeline N is
not a child of this server's history" / "highest timeline N of the
primary is behind recovery timeline M"), and nothing ever called
pg_rewind to get out of it. That's the literal "unrecoverable state"
from #683. Fixed by rewinding symmetrically in that direction too.

Also broadens the ancestry check itself: fsm_follow_new_primary() is
used for both REPORT_LSN/JOIN_SECONDARY -> SECONDARY (already checked)
and SECONDARY -> CATCHINGUP (an ordinary health-check-driven resync,
previously skipped entirely since the check was gated on
assigned_role == SECONDARY_STATE). Manual reproduction showed this is
exactly the path a forked standby takes when the health checker
notices it and cycles it back through catchingup -- without the fix
here it hit the identical doomed-reconnect loop one gate over.

Finally, adds a defensive, synchronous timeline-history publish right
before both ancestry checks (refactored out of fsm_report_lsn's
existing one into keeper_defensive_publish_timeline_history()): the
periodic per-tick publish and FSM transition processing share a
cadence, so a fork that both appears and gets rewound within a single
tick could otherwise never make it into node_timeline_history at all,
losing the only record it happened.

Root-caused and verified via manual reproduction against a live
Docker cluster (network-partition the standby, promote it directly at
the Postgres level to fork it, reconnect, force it back through
catchingup): pre-fix, Postgres logs "waiting for WAL to become
available" in an infinite retry loop; post-fix, it rewinds (or falls
back to pg_basebackup when pg_rewind can't itself connect) and rejoins
cleanly. tests/tap/specs/timeline_fork_report_lsn_deadlock.pgaf turns
that reproduction into an automated, repeatable end-to-end spec.

* timeline_fork_report_lsn_deadlock.pgaf: add live-cluster show/accept timeline coverage

pg_autoctl show timeline and pg_autoctl accept timeline (Part 3/4)
previously had monitor-SQL-level coverage only
(timeline_fork_detection.sql) -- nothing exercised the actual CLI
against a live cluster (cli_show.c/cli_accept.c, monitor_print_timeline()).

New test_003_show_and_accept_timeline_cli step, inserted into the
existing #683 fork scenario: unpinned, node2's fork reads clean (the
known 2-node limitation from the header comment -- it IS the highest
reported tli, and nobody else is arguing); accept_timeline rejects an
unreported tli and pins the correct one; show timeline then correctly
flags node2 as FORK. Orthogonal to the maintenance-cycle recovery
step that follows it, since the standby-side #683 fix never consults
the pin -- only a monitor-side election does.

* ci: add every .pgaf spec to a CI schedule

Audited tests/tap/specs/*.pgaf against the .sch shards actually wired
into ci.yml's pgaftest matrix (the flat tests/tap/schedule file is a
manual/local reference, not read by CI). Found 5 specs that existed
but never ran in CI:

  - guard_data_loss, fast_forward: real specs (issues #1113, #1060),
    present in the flat schedule file but dropped when the .sch
    shards were split out. Added to multi-misc.sch (3-node scenarios,
    same family as the existing multi_standbys/multi_maintenance).
  - demote_timeout_wait_primary_deadlock (#1025) and
    timeline_fork_report_lsn_deadlock (#683): added this session,
    never wired up. Added to node.sch alongside replication_stall_3dc,
    which had the same gap.

Left alone, by design: debug_citus_worker_switchover.pgaf and
debug_failover_pg19.pgaf are explicitly documented as interactive-only
diagnostic templates; upgrade.pgaf has its own dedicated CI job
(needs pgaf:current/pgaf:next images the standard matrix doesn't
build).

Also brought the flat tests/tap/schedule file up to date with specs
it was missing entirely (launch_deferred_set_metadata,
postgres_version_tracking, tablespaces) plus the two new ones, so it
stays a genuine full-suite reference for local runs.

Verified locally: multi-misc.sch runs 6/7 specs clean; the 7th
(fast_forward test_003) fails identically on unmodified origin/main
(confirmed via a throwaway worktree build) -- a pre-existing,
branch-unrelated flake, not a regression from this branch's changes.
node.sch's new entries were each verified individually before being
added; a full sequential node.sch run is still in progress.

* fast_forward.pgaf: fix test_003 deadlock by calling perform_failover via SQL

Root-caused via a throwaway origin/main worktree: this deadlock is
pre-existing, not caused by the timeline-fork-detection changes, and
was previously masked simply by never running in CI (see the prior
commit adding this spec to multi-misc.sch).

exec pg_autoctl perform failover --allow-data-loss blocks waiting for
a node to reach exact PRIMARY_STATE (both goalState and reportedState)
within listen_notifications_timeout (60s default). In this scenario
node2 can only reach wait_primary during step 3 -- number_sync_standbys=1
needs a second live quorum standby, and node3 doesn't come back until
step 4 -- so that wait can never succeed and the exec always times out.

guard_data_loss.pgaf's near-identical scenario (primary + one standby
killed, survivor promoted with --allow-data-loss) already documented
and worked around this exact deadlock by calling
pgautofailover.perform_failover() directly over SQL instead of
through the CLI. Apply the same fix here.

Verified stable across two full runs (5/5).

* Drop fast_forward from this branch's CI schedule and revert its spec

Reverts fast_forward.pgaf back to its pristine origin/main content
(undoing commit cf9ba18) and removes it from multi-misc.sch.

While making the spec actually exercise the real fast_forward FSM
state (per review feedback that the CLI-deadlock fix alone left it
functionally duplicating guard_data_loss.pgaf), it surfaced a genuine,
pre-existing, unrelated bug: standby_fetch_missing_wal()
(primary_standby.c) blocks in an unbounded loop with no timeout or WAL
source liveness check, so once a fast_forward candidate's keeper
commits to fetching, the monitor's own issue #1060 stuck-detection
guard can never reach it -- the keeper simply never calls node_active()
again to receive the reset. That's a real fix (a timeout/liveness
check on the fetch loop), not a test-file fix, and out of scope here.

fast_forward.pgaf and its stuck-detection bug will be picked up on a
separate branch/PR. guard_data_loss stays in multi-misc.sch; it does
not depend on fast_forward and was independently verified passing.

* Fix citus_indent formatting in timeline_history.c/.h

My earlier local style-check invocation (docker run citus/stylechecker
without an explicit citus_indent argument) wasn't actually running the
check -- it silently did nothing, so this drift went unnoticed until
CI's style_checker job (which runs citus_indent --check directly
inside the container) caught it on push. The Makefile already has the
right invocation (make docker-check / make docker-indent); using that
going forward instead of hand-rolling the docker command.

* monitor.c: fix show timeline fork message to not assert election behavior

monitor_print_timeline() previously predicted that 'automatic election
is refused' for diverged nodes. That's a server-side election policy
statement made from client-side CLI code, with nothing tying the two
together, and it's imprecise: the design excludes the diverged node
from candidacy, it doesn't refuse the whole election. Reword to state
only the observed fact and point at the resolution command.

* docs: document timeline-fork detection (accept/show timeline, FSM, fault-tolerance, architecture, FAQ, operations)

New manual pages for pg_autoctl accept timeline and pg_autoctl show
timeline, modeled on the existing perform-failover and show-standby-names
pages, with example output captured from a live 2-node cluster forked the
same way tests/tap/specs/timeline_fork_report_lsn_deadlock.pgaf does it.

Wires both into the existing indexes (pg_autoctl_show.rst, manual.rst,
pg_autoctl.rst).

Extends the existing prose docs rather than introducing new structure:
- failover-state-machine.rst: ancestry-check paragraphs in the Catchingup
  and Report_LSN sections, alongside the existing guard_data_loss paragraph.
- fault-tolerance.rst: new "Timeline Forks" subsection with a 6th tikz
  sequence diagram (seq-timeline-fork.tex/.svg), following the same
  prose+diagram pattern as the other four scenarios.
- architecture.rst: extends the existing "Node recovery" section.
- faq.rst: new entry alongside the existing report_lsn-stuck entry.
- operations.rst: new "Resolving a detected timeline fork" subsection
  under Cluster Management and Operations, plus a Trouble-Shooting Guide
  pointer, using the same captured-transcript style as the other
  operations subsections.

No new operations manual needed -- docs/operations.rst already exists and
is the right place for this.

Verified with a full sphinx html build (no warnings) and a full tikz svg
rebuild.

* monitor: add missing SQL objects to the 2.2->2.3 upgrade script

CI's upgrade.pgaf job failed: an in-place ALTER EXTENSION UPDATE TO '2.3'
left several functions this branch (and two other already-merged PRs)
added to the fresh-install script completely absent from the incremental
2.2--2.3 upgrade script, so any node calling them post-upgrade got
'function does not exist'.

Ports over, matching the fresh-install definitions in pgautofailover.sql
exactly:
- set_node_region() (#1156)
- the four pg_version*/citus_version columns and report_postgres_version()
  (#1157)
- node_timeline_history / accepted_timeline tables and
  report_timeline_history() / accept_timeline() /
  resolve_accepted_timeline() / node_timeline_status() (this branch)

Verified with make -C tests/upgrade all (builds pgaf:current from the v2.2
tag and pgaf:next from this branch, then runs tests/tap/specs/upgrade.pgaf
end to end against a live 3-node cluster) -- all 10 steps pass, including
the convergence step that previously failed.

* docs+tests: address review feedback on timeline-fork-detection docs

- pg_autoctl_accept_timeline.rst: drop the --tli 99 validation-error
  example (that's a unit-test concern, not documentation) and replace it
  with a pg_autoctl show timeline capture that makes the actual case for
  needing to pin a lineage.
- fault-tolerance.rst: rephrase the Timeline Forks section to describe
  only current behavior, not implementation history ("pre-fix, ...").
- pg_autoctl_show_timeline.rst: new worked example correcting an
  assumption the docs didn't previously address -- a 3-node formation
  does NOT by itself close the 2-node auto-detection blind spot.
  FilterNodesByTimelineAncestry()'s heuristic only excludes a candidate
  when a genuinely competing branch is reported; a sibling that simply
  stays behind isn't competing with anything, so the fork still reads as
  clean and pg_autoctl accept timeline is still required. Backed by a new
  pgaf spec, tests/tap/specs/timeline_fork_3node_auto_detect.pgaf (added
  to tests/tap/schedules/node.sch), verified locally end to end -- all 6
  steps pass, including the corrected assertions after the initial design
  wrongly assumed 3 nodes would auto-detect.

Both new/changed CLI examples use real output captured from live 3-node
(and, for the accept_timeline page, 2-node) clusters, same convention as
the rest of this branch's docs.

(The two new Unhealthy-Nodes sequence diagrams originally drafted
alongside this work -- Primary node monitored unhealthy, Monitor node
has failed -- document pre-existing, unrelated behavior and were split
out to PR #1162 instead.)

* pgaftest: size expect-mismatch error buffers to fit multi-row output

runner_exec_step()'s errBuf was declared char[512] at all 8 call sites,
while r->lastSqlOutput (the actual command output an 'expect' step
compares against) is char[4096]. Any expect mismatch against output
bigger than ~450 bytes -- e.g. pg_autoctl show timeline against a 3+
node formation -- overflowed the format buffer, and sformat()'s own
overflow guard silently swallowed the real diagnostic, printing only
'BUG: sformat needs N bytes...' instead of the actual 'expected X, got
Y' message. Bumped all 8 err/tdErr declarations to 8192 bytes (2x
lastSqlOutput's cap plus room for the template and cmd->expected).

Verified by forcing a real expect mismatch against a multi-row show
timeline table (tests/tap/specs/timeline_fork_3node_auto_detect.pgaf
with one expect deliberately broken): previously printed only the BUG
warning, now prints the full untruncated 'expected ..., got ...'
message with the complete table.

* docs+tests: show concurrent application traffic during a 3-node fork

The previous 3-node example forked node3 while node1/node2 sat
completely idle -- unrealistic, since a fork developing on one standby
elsewhere in the formation is no reason for the main system to pause.
Extended timeline_fork_3node_auto_detect.pgaf so the application keeps
writing to node1 (and replicating to node2) throughout every step of the
scenario -- before the fork, during node3's isolation, after it
reconnects, after the operator pins the lineage, and confirms all 200
rows survive node3's eventual recovery untouched.

Recaptured the docs example from this updated spec: node1/node2's LSN is
now visibly ahead of node3's diverged tip throughout, showing the
ancestry-check blind spot alongside ordinary, uninterrupted application
traffic rather than against a frozen cluster.

Verified: all 6 pgaf steps pass end to end against a live 3-node
cluster.

* monitor: detect a timeline fork on a secondary as soon as it's reported

Adds a new guard in ProceedGroupStateFromContext(), alongside the
existing SECONDARY-state checks: when a node currently in SECONDARY_STATE
reports a timeline that FilterNodesByTimelineAncestry() excludes from
the group's reference lineage, it's pushed to CATCHINGUP immediately --
on its very next node_active() call -- instead of waiting for an
incidental health-check cycle or an explicit maintenance toggle to
eventually drive it there.

This only changes *when* a diverged secondary gets pushed through the
existing, already-tested ancestry-check-and-rewind path (the #683 fix);
it reuses the same FilterNodesByTimelineAncestry() the report_lsn
election path already calls, so the two can't drift apart. Scoped to
SECONDARY_STATE specifically, matching the exact guard the pre-existing
"unhealthy secondary" transition uses for the identical goal-state
assignment.

Verified: monitor rebuilt, docker image rebuilt, both live-cluster specs
(2-node and 3-node) still pass end to end. Directly confirmed via a
manual repro and via a new dedicated step
(test_005_automatic_catchingup_without_maintenance in
timeline_fork_3node_auto_detect.pgaf) that the monitor now assigns
catchingup within ~1s of an operator's accept_timeline pin, with no
maintenance toggle involved -- log evidence: "Setting goal state of
node3 to catchingup: its reported timeline 2 does not appear to be an
ancestor of the group's reference timeline 1 -- forcing a resync...".

Kept the original maintenance-cycle-based full recovery check as a
separate, subsequent step: reaching CATCHINGUP is now immediate, but
completing the actual pg_rewind still depends on node1's pg_hba.conf
hostname resolution being current against node3's address, a
pre-existing dependency this change doesn't add or remove. That
dependency's own staleness-after-reconnect behavior is a separate,
already-flagged issue, unrelated to this fix's correctness.

* pgctl: pg_rewind must connect to the formation's own database, not a hardcoded postgres

Root-caused a permanent, 100%-reproducible pg_rewind failure hit while
testing the new automatic timeline-fork detection: any formation whose
--dbname isn't literally postgres (e.g. this branch's own
timeline_fork_3node_auto_detect.pgaf, using demo) has pg_rewind's
connection rejected by every peer's pg_hba.conf, every single time --
not a race, not a DNS caching issue (both directions of the hostname
resolution were confirmed fine via the Postgres log's own DETAIL lines:
forward lookup matches). pg_autoctl's own pghba_ensure_host_rules_exist()
only ever writes HBA rules for the formation's configured database name
plus the replication pseudo-database -- never a bare literal postgres
unless that also happens to be the configured name. But pg_rewind()'s
--source-server connection string hardcoded dbname=postgres regardless,
so it could never match any rule pg_autoctl itself had written.

ReplicationSource already carries the correct configured dbname (populated
by standby_init_replication_source() from postgresSetup.dbname, the exact
same value passed to pghba_ensure_host_rules_exist()) -- pg_rewind() just
wasn't using it. Falls back to postgres only if that field is somehow
empty, preserving prior behavior for that edge case.

This masked itself in most existing coverage because fsm_rewind_or_init()
(the pre-existing MAINTENANCE-to-CATCHINGUP path) already has a
pg_basebackup fallback for any pg_rewind failure, so recovery would
silently succeed via a full base backup instead of an incremental rewind
-- slower and more wasteful, but not visibly broken. The #683 ancestry-check
path (standby_check_timeline_with_upstream(), this branch's own new code)
has no such fallback: a pg_rewind failure there just returns false and
retries forever, with no escape valve, which is what actually exposed this.

Verified directly: a manual replicator connection to the configured
database now succeeds where it previously failed with 'no pg_hba.conf
entry'; the full automatic-detection scenario
(timeline_fork_3node_auto_detect.pgaf) now completes via a genuine
incremental pg_rewind (pg_rewind: Done!, 85MB copied in under a
second) instead of the pg_basebackup fallback it was hitting before.
All 7 steps in that spec still pass end to end.

* docs: describe the new automatic, near-instant catchingup push on a forked secondary

Once a diverged secondary's timeline mismatch is visible to the monitor
(via an explicit accept_timeline pin, or a genuinely competing branch
auto-detected with no pin at all), it is now pushed to catchingup and
rewound with pg_rewind within about a second -- no maintenance cycle or
incidental health-check-driven resync needed to trigger it, as the docs
previously described.

- failover-state-machine.rst: new paragraph under Secondary describing
  the monitor-side ancestry check on every report.
- fault-tolerance.rst: rewritten Timeline Forks prose + updated
  seq-timeline-fork.tex/.svg diagram (operator pin -> immediate
  goal=catchingup, no maintenance step depicted).
- architecture.rst: Node recovery section mentions the proactive check.
- faq.rst: timeline-mismatch entry no longer frames recovery as waiting
  on the next health-check cycle.
- ref/pg_autoctl_accept_timeline.rst, ref/pg_autoctl_show_timeline.rst,
  operations.rst: replace "cycle the node through maintenance to force
  it" language with the new automatic, immediate behavior, verified
  against a live docker cluster (accept timeline -> catchingup -> rewound
  secondary within ~1s, confirmed via monitor logs).

* timeline_fork_report_lsn_deadlock.pgaf: fix race between the new auto-catchingup guard and the maintenance-cycle step

CI (logs_81878989213) showed test_004_maintenance_cycle_recovers failing
intermittently (3/6 PG versions) with: monitor ERROR cannot start
maintenance: ... current state for primary node 1 is "wait_primary" ->
"wait_primary".

Root cause: node2 is this formation's only standby, so once the new
monitor-side ancestry guard (added earlier this branch) excludes it
right after test_003's accept_timeline pin, node1 must also drop
synchronous replication -- it transitions primary -> wait_primary at the
same moment. The old test_004 called "enable maintenance" on node2
immediately after test_003 with nothing in between, racing node1's own
transition back to primary about half the time.

The 3-node companion spec never hit this: excluding one of two
standbys there doesn't force the primary to touch synchronous
replication at all, since a second real secondary is still available.

Fix: add test_004_automatic_catchingup_without_maintenance (mirroring
the 3-node spec's test_005) that both asserts the new automatic
detection directly and waits for node1/node2 to fully settle back to
primary/secondary before the next step touches maintenance --
renumbering the old test_004/test_005 to test_005/test_006. Updated the
header comment and test_003's own comment, both of which described the
pre-guard behavior ("goes unnoticed until forced") that's no longer
accurate.

Verified: 4/4 clean runs locally (previously ~50% failure), plus the
3-node spec re-run once more to confirm no regression there.

* fast_forward: fix silent promotion corruption in multi-node failover

tests/tap/specs/multi_ifdown.pgaf's failover to a lagging standby caught
up via fast_forward (peer-to-peer WAL fetch, not from the primary) could
silently fail to promote while reporting success, leaving the candidate
stuck on its old timeline -- indistinguishable by timeline number from a
demoted former primary, and a genuine risk of electing a node that never
actually advanced.

Root causes, found by live reproduction against real container logs
rather than guesswork:

* fast_forward configured Postgres's own recovery_target_lsn to an
  arbitrary snapshot of a peer standby's current replay position. That
  position isn't guaranteed to land on a genuine WAL record boundary, so
  Postgres could get stuck permanently 'waiting for WAL to become
  available' at that exact LSN, or FATAL-crash if promoted while still in
  that state ('recovery ended before configured recovery target was
  reached'). fast_forward already had its own application-level polling
  loop (pgsql_has_reached_target_lsn) to decide when enough WAL had been
  fetched; asking Postgres to also independently enforce the same
  threshold via recovery_target_lsn was both redundant and unsafe. The
  new ReplicationSource.pauseAtRecoveryTarget flag makes that opt-in,
  defaulting to off; fast_forward now just streams normally and decides
  for itself when to promote, exactly like the non-fast_forward
  promotion path already does. The one legitimate existing use
  (crash-recovery's 'immediate' target, always a well-defined,
  reachable point) opts back in explicitly.

* Independently, the FAST_FORWARD_STATE -> PREP_PROMOTION_STATE
  transition (fsm_cleanup_as_primary) removed standby.signal from disk
  well before the real pg_ctl promote (issued later, from
  PREP_PROMOTION_STATE -> STOP_REPLICATION_STATE). Postgres's own
  promotion-completion logic removes standby.signal itself as part of
  processing the promote request, and FATAL-crashes if it's already
  gone ('could not remove file "standby.signal": No such file or
  directory'). fsm_cleanup_as_primary now leaves the on-disk standby
  setup untouched; the already-correct post-promotion cleanup in
  fsm_promote_standby (via standby_cleanup_as_primary) is the only place
  that needs to run it.

* standby_cleanup_as_primary itself no longer restarts Postgres: restarting
  after standby.signal has been removed, but before a genuine promotion
  took place, makes Postgres boot as an ordinary (non-standby) server and
  complete plain crash recovery on its current timeline -- reaching a
  not-in-recovery state without ever going through the real promotion code
  path, which is exactly the silent-corruption failure mode this commit
  closes.

Defense in depth, in case a future change reintroduces a similar race:

* standby_promote() now calls the new standby_promotion_advanced_timeline
  to verify, via the local pg_wal/<TLI>.history file, that a claimed
  promotion genuinely advanced to a new timeline with a recorded
  ancestor -- not just a bare, unverified 'not in recovery' state -- both
  on its idempotent early-return path and after a real pg_ctl promote.
* fsm_init_primary and fsm_promote_standby now publish the node's fresh
  timeline history to the monitor immediately after promotion
  (keeper_defensive_publish_timeline_history), rather than waiting for
  the next periodic tick, so other nodes and 'pg_autoctl show timeline'
  observe it as soon as it's genuinely confirmed.
* standby_fetch_missing_wal and standby_promote now tell the separate
  Postgres controller service (service_postgres_ctl.c) to stand down
  (keeper_set_postgres_state_unknown/running) for the duration of their
  own waits, so its independent 100ms liveness poll can't race a
  transient unavailability during recovery/promotion into an unplanned
  restart of its own.

Verified with repeated clean runs of multi_ifdown.pgaf (previously a
deterministic 300s timeout on test_008_failover), confirming node3's
genuine on-disk timeline advance (pg_walfile_name, pg_wal/*.history) and
correct 'pg_autoctl show timeline' reporting across all nodes, plus
timeline_fork_report_lsn_deadlock.pgaf and timeline_fork_3node_auto_detect.pgaf
regressions.

* primary_standby: detect same-timeline LSN divergence in standby_check_timeline_with_upstream

standby_check_timeline_with_upstream's same-timeline branch previously
treated 'upstream reports the same timeline number we're on' as
sufficient proof of a shared history and returned success immediately.
That mirrors pg_rewind's own well-documented limitation (and a matching,
still-open Patroni issue for the identical scenario): a bare
timeline-number match doesn't rule out genuine divergence when a node
was promoted or written to outside of pg_autoctl's control while
carrying the same timeline number as its nominal upstream.

A genuine, unbroken standby can never get ahead of its own upstream in
LSN terms while remaining on the exact same timeline -- ordinary
streaming replication only ever replays WAL the upstream itself
produced. So this adds one more check: when the timelines match, also
compare the node's local LSN against the upstream's own reported
current LSN. If local is ahead, that's the same genuine divergence #683
already handles for differing timeline numbers, just expressed here as
same-number-but-diverged instead -- so it's resolved the same way, via
primary_rewind_to_standby, followed by a metadata refresh and a
re-check that the rewind actually landed us on the upstream's timeline.

* fsm_transition_citus: fix same fast_forward promotion corruption for Citus workers

fsm_citus_cleanup_and_resume_as_primary() is the Citus counterpart of
fsm_cleanup_as_primary(), both firing at the FAST_FORWARD_STATE ->
PREP_PROMOTION_STATE transition, i.e. before the real pg_ctl promote
(issued later, from standby_promote() via fsm_promote_standby). The
non-Citus path was already fixed in f375443/6462149 to leave the on-disk
standby setup untouched at that point, but the Citus counterpart still
called standby_cleanup_as_primary() (and, until just now, additionally
restarted Postgres) at this same early call site.

Live reproduction against a new debug spec (tests/tap/specs/
debug_citus_worker_fast_forward.pgaf, a 3-node Citus worker group mirroring
multi_ifdown.pgaf's setup) showed Postgres FATAL-crashing during the actual
promote:

  LOG:  selected new timeline ID: 2
  FATAL:  could not remove file "standby.signal": No such file or directory
  LOG:  shutting down due to startup process failure

Postgres's own promotion-completion logic removes standby.signal itself
while processing the promote request, and crashes if it's already gone.
The crash-recovery restart that follows finds no standby.signal (already
removed) and silently completes ordinary crash recovery of what looks like
a primary, without ever genuinely promoting -- indistinguishable by
timeline number from a demoted former primary. standby_promote()'s
standby_promotion_advanced_timeline guard (from f375443) caught the
resulting corruption and refused to proceed, but the underlying crash kept
recurring on every retry.

Fix: fsm_citus_cleanup_and_resume_as_primary() no longer touches the
on-disk standby setup at all; it only prepares the coordinator-side
master_update_node() call, exactly mirroring fsm_cleanup_as_primary()'s
current no-op behavior. The already-correct post-promotion cleanup in
fsm_promote_standby (shared with the non-Citus path) remains the only
place that runs standby_cleanup_as_primary().

Verified with a clean run of debug_citus_worker_fast_forward.pgaf
(previously a deterministic crash/300s timeout on this same repro),
confirming worker1c's genuine promotion to timeline 2, plus
citus_multi_standbys.pgaf (14/14) and citus_basic_operation.pgaf (14/14)
regressions.

* fsm_mermaid.c: fix citus_indent formatting (missing blank line)

Pre-existing citus_indent violation in main's fsm_mermaid.c (added by
#1164), surfaced by rebasing this branch onto origin/main: citus_indent
wants a second blank line between FsmMermaidColorForState() and the
following function comment, matching the two-blank-line convention used
throughout the rest of the codebase.

Caught by CI's 'Style check' job on the PR-merge ref (pull/1161/merge),
which combines this branch with current main -- this file doesn't exist
on this branch's pre-rebase history, so `make docker-check` only started
failing locally once the rebase brought it in.

* tests: use SIGINT for cluster teardown, stop leaking orphaned nodes

Root-caused from CI: pytest / citus (PG16) and (PG17) both timed out at
the 15-minute job-step ceiling in this branch's CI, despite every actual
test assertion passing (76 passed). The failure was entirely in
teardown_module()'s cluster.destroy().

Datanode.destroy() called self.stop_pg_autoctl() -- default SIGTERM --
which triggers pg_autoctl's graceful-shutdown maintenance handoff
(keeper_graceful_shutdown() in service_keeper.c): for a primary with a
viable standby, up to KEEPER_MAINTENANCE_SHUTDOWN_LOOP_MAX_SECS (30s)
trying to reach maintenance, falling back to up to
KEEPER_SHUTDOWN_LOOP_MAX_SECS (30s) reporting state to the monitor while
Postgres stops -- 60s worst case, defined in defaults.h. That's exactly
this module's own COMMAND_TIMEOUT (network.py), with zero margin for
process/network/sudo overhead. test_citus_skip_pg_hba.py's coordinator
(coord0a, primary, with standby coord0b) hit that ceiling, and
stop_pg_autoctl() raised subprocess.TimeoutExpired with a margin of
literal microseconds -- not random flakiness, two equal constants racing.

Worse, that call ran *before* the try/except in Datanode.destroy() that
respects ignore_failure, so the exception propagated straight out of
Cluster.destroy()'s reversed-order loop and aborted it mid-iteration.
Since coord0a is last in that order, every node destroyed cleanly except
it -- and the abort meant self.monitor.destroy() and self.vlan.destroy()
(the test network namespace teardown) never ran at all. The monitor's
pg_autoctl process was left running as an orphan, holding the test
container open until GitHub's 15-minute step timeout force-killed it,
long after pytest itself had already printed its own summary and exited.

There's no standby to protect when tearing down an entire test cluster,
so there's no reason to pay for the graceful maintenance handoff here.
SIGINT skips it entirely (keeper_graceful_shutdown() only runs on a plain
SIGTERM -- service_keeper.c checks `asked_to_stop && !asked_to_stop_fast
&& !asked_to_quit`) while still stopping Postgres cleanly: for anything
other than SIGTERM, supervisor_stop_subprocesses() forwards the signal to
every service directly (not just the keeper), so the Postgres-controller
sibling gets it too and calls ensure_postgres_status_stopped() on its own,
same as it always does. The top-level supervisor process itself uses
exitOnQuit=false (supervisor_init()), so SIGINT/SIGQUIT there still goes
through the ordinary loop and properly reaps its children -- no new
orphans introduced. SIGINT specifically (not SIGQUIT) because destroy()
is a deliberate teardown, not a crash simulation -- that distinction is
already documented on DataNode.fail()'s own default.

Datanode.destroy() and MonitorNode.destroy() now both pass sig=SIGINT
explicitly (stop_pg_autoctl()'s own default stays SIGTERM, still needed
by tests wanting a plain graceful restart), and both wrap their stop call
in the try/except that already existed for the rest of destroy() -- so a
future timeout for an unrelated reason can no longer abort the rest of
cluster teardown either.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant