Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
1530e93
Checkpoint: three-array FSM dispatch with looping continuation
dimitri Jul 28, 2026
ec4516f
Align FSM dispatch with the design doc: one array, single-shot dispatch
dimitri Jul 28, 2026
392961c
Fix stale comment: two top-level lookups, not three
dimitri Jul 28, 2026
62646c3
Rename MonitorFSM_MSFailoverClusterStart -> MonitorFSM_FromContextRes…
dimitri Jul 28, 2026
e0385e9
Finish MonitorFSM[] data-driven refactor: API triggers, MS-failover, …
dimitri Jul 29, 2026
a78dfcf
Add monitor/keeper FSM reachability check (pg_autoctl inspect fsm check)
dimitri Jul 29, 2026
cc647a0
Make ActionRunMultiStandbyFailoverCascade's goal assignments declarative
dimitri Jul 29, 2026
a170820
Add keeper_fsm_edges.json fixture and two-step regress cross-check
dimitri Jul 29, 2026
a6d6342
Fix pgautofailover.last_events() (broken) and its rule_pos comment
dimitri Jul 29, 2026
6e075f1
Show rule_pos in "pg_autoctl show events" and "pg_autoctl watch"
dimitri Jul 29, 2026
a18595d
Terminate every FSM-relevant regress test with a last_events() summary
dimitri Jul 29, 2026
81e6e90
Add otherNode as a role distinct from primaryNode in MonitorFSM[]
dimitri Jul 29, 2026
dd82a26
monitor: replace MonitorFSM[] index-range boundaries with section paths
dimitri Jul 29, 2026
73478fe
monitor: sectionPath one-per-line formatting; keeper edge reverse-che…
dimitri Jul 30, 2026
67678af
monitor: remove dead AssignGoalState fallbacks, replace with hard errors
dimitri Jul 30, 2026
0d7fb46
monitor: reflow group_state_machine.c's own comments to 80 columns
dimitri Jul 30, 2026
8a405c5
monitor: exclude api_triggered-only dropped edges from keeper_fsm_edg…
dimitri Jul 30, 2026
7b6b096
monitor+keeper: keep ANY_STATE as a literal 'any' sentinel, match it …
dimitri Jul 30, 2026
3b12634
monitor: add per-rule subtotal rows to keeper_fsm_edges.sql Step 2a
dimitri Jul 30, 2026
be4d163
monitor: document Step 2a's 10-rule historical/precedent investigation
dimitri Jul 30, 2026
660b00a
monitor: move keeper_fsm_edges Step 2a's n column right after rule
dimitri Jul 30, 2026
147f216
monitor: add pgaftest coverage for pos 211's keeper gap, document sha…
dimitri Jul 30, 2026
dcb1e46
monitor: make dump_fsm_edges() detect first-match-wins shadowing betw…
dimitri Jul 30, 2026
39a771c
monitor: document why cross-section shadowing detection was tried and…
dimitri Jul 30, 2026
abb47d4
monitor: filter dump_fsm_edges() candidates by isInPrimaryState too; …
dimitri Jul 30, 2026
9f75fbf
monitor FSM: fix lone priority-zero primary self-demotion, remove Mon…
dimitri Jul 30, 2026
08e7dbe
tests: schedule keeper_fsm_gap_209/211 specs into CI (node.sch)
dimitri Jul 30, 2026
506c2dc
monitor FSM: narrow pos 211 so dump_fsm_edges() reflects the pos 210 fix
dimitri Jul 30, 2026
2f00802
keeper: fix wait_maintenance keeper gap; monitor: exclude wait_standb…
dimitri Jul 31, 2026
c0b9b07
tests: schedule the 3 new keeper_fsm_gap specs into CI (node.sch)
dimitri Jul 31, 2026
7ca2562
Fix keeper FSM gap: fast_forward left alone in group
dimitri Jul 31, 2026
c0f9277
Fix monitor FSM rule 209: exclude join_secondary and prepare_maintenance
dimitri Jul 31, 2026
c63f2be
Fix keeper FSM gap: pos 211's prepare_promotion/demote_timeout/join_s…
dimitri Jul 31, 2026
075b80e
Add live pgaftest coverage for pos 211's three previously-static-only…
dimitri Aug 1, 2026
881d0f4
Fix monitor FSM rule 325: exclude SINGLE from primaryNode's reachable…
dimitri Aug 1, 2026
d90c1ba
Fix dump_fsm_edges() blindness to primaryNode's resolver-derived DROP…
dimitri Aug 1, 2026
21d0eea
Fix dump_fsm_edges() blindness to primaryNode's WAIT_STANDBY/JOIN_SEC…
dimitri Aug 1, 2026
d07b8ed
Close remaining keeper/monitor FSM edge gaps; add manual fsm step rep…
dimitri Aug 1, 2026
67f60b9
citus_indent: fix group_state_machine.c formatting
dimitri Aug 1, 2026
2a1e61c
Close the last Step 2a gap: rule 211's stop_replication -> report_lsn
dimitri Aug 2, 2026
c8fb371
keeper_fsm_edges.sql: trim comments to mechanics, drop fix history
dimitri Aug 2, 2026
6ada84e
ci: refresh stale node-schedule comment in the pgaftest matrix
dimitri Aug 2, 2026
c1fd50a
group_state_machine.c: replace banned memcpy/snprintf calls
dimitri Aug 2, 2026
445ca67
docs: document pgautofailover.fsm, the reachability cross-check, and …
dimitri Aug 2, 2026
1033f43
regress: regenerate stale PG19 expected-output overrides
dimitri Aug 2, 2026
975a233
ci: split node-fsm-gaps out of node.sch (PG17-only), fixing CI timeouts
dimitri Aug 2, 2026
5ad901e
upgrade: bootstrap ltree before ALTER EXTENSION UPDATE TO 2.3, port n…
dimitri Aug 2, 2026
b8532f3
tests: fix stale no-autopilot keyword in 5 node-fsm-gaps specs
dimitri Aug 2, 2026
d177574
tests: rebuild both fast_forward gap specs on a suspended node, elimi…
dimitri Aug 2, 2026
deae2b9
keeper: repair groupId/replication-slot drift in step mode too
dimitri Aug 2, 2026
286a543
keeper: narrow group/slot self-heal to a config-file update only
dimitri Aug 2, 2026
92836a2
monitor: rewrite group_state_machine.c comments to describe current code
dimitri Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,9 @@ jobs:
- { PGVERSION: 19, schedule: quick }
# node: create_standby_with_pgdata, maintenance_and_drop, auth,
# monitor_disabled, replace_monitor, extension_update,
# debian_clusters, tablespaces
# debian_clusters, tablespaces, fsm_step_report_advance,
# replication_stall/demote_timeout/timeline_fork deadlocks
# — see tests/tap/schedules/node.sch for the full list
- { PGVERSION: 14, schedule: node }
- { PGVERSION: 15, schedule: node }
- { PGVERSION: 16, schedule: node }
Expand All @@ -332,6 +334,7 @@ jobs:
- { PGVERSION: 17, schedule: multi-alternate }
- { PGVERSION: 17, schedule: multi-misc }
- { PGVERSION: 17, schedule: multi-async }
- { PGVERSION: 17, schedule: node-fsm-gaps }
- { PGVERSION: 17, schedule: citus-1 }
- { PGVERSION: 17, schedule: citus-2 }
# citus on PG18 (supported); allow failure until officially validated
Expand Down
154 changes: 154 additions & 0 deletions docs/failover-state-machine.rst
Original file line number Diff line number Diff line change
Expand Up @@ -630,3 +630,157 @@ node reacting to the other side of that removal.
the full graph, ``join_primary`` included, as one Graphviz file (e.g.
to pipe into their own
tooling), but it is no longer the documented way to visualize the FSM.

The monitor's FSM: ``pgautofailover.fsm``
------------------------------------------

The diagrams above are rendered from the *keeper's* side of the FSM
(``KeeperFSM[]``, ``src/bin/pg_autoctl/fsm.c``): the transitions a node
knows how to perform. The *monitor's* side is a separate, matching
declarative table, ``MonitorFSM[]`` (``src/monitor/group_state_machine.c``):
the rules deciding, for every combination of reported states and cluster
conditions, which goal state to assign next. It's exposed read-only via the
``pgautofailover.fsm`` view, one row per rule, ordered by ``pos``:

::

=# SELECT pos, section, comment FROM pgautofailover.fsm
WHERE pos BETWEEN 301 AND 305;
pos | section | comment
-----+--------------------+---------------------------------------------------------------------------------
301 | reporting_node | converged secondary, reportedTLI not an ancestor of reference -> catchingup
303 | reporting_node | converged secondary/catchingup, primary reachable, primary not in the primary
| | states -> catchingup
305 | reporting_node | multi-standby cascade resume point (see MonitorFSM_MultiStandbyCascadeResumeAfterPos)
(3 rows)

Every column of a single rule, expanded (``\x on``):

::

=# \x on
=# SELECT * FROM pgautofailover.fsm WHERE pos = 301;
-[ RECORD 1 ]-----------------+----------------------------------------------------------------
pos | 301
section | reporting_node
comment | converged secondary, reportedTLI not an ancestor of reference -> catchingup
active_node_current_state | secondary
other_node_current_state |
candidate_node_current_state |
active_node_conditions | isComparableToReferenceTli=false
other_node_conditions |
candidate_node_conditions |
group_conditions |
active_node_assigned_state | catchingup
other_node_assigned_state |
has_extra_action | f
section_path | reporting_node.from_context

``section_path`` is an ``ltree`` column, so the table's rows can be queried
hierarchically instead of by exact section name -- for example, every rule
belonging to the multi-standby candidate-election machinery, regardless of
how deep its own sub-leaf goes::

=# SELECT pos, comment FROM pgautofailover.fsm
WHERE section_path <@ 'reporting_node.ms_failover'::ltree
ORDER BY pos;

Like the keeper diagrams above, this view is generated straight from the
compiled-in ``MonitorFSM[]`` table -- it's the same on every fresh monitor of
a given pg_auto_failover version, unaffected by any node or formation state,
and changes only when a rule is added, removed, or edited in a new release.

Cross-checking the monitor and keeper FSMs
-------------------------------------------

The monitor and the keeper are two different programs (the monitor extension
runs inside Postgres, the keeper is the ``pg_autoctl run`` process on each
node) with two independently-maintained tables: ``MonitorFSM[]`` decides
*what* goal state to assign, ``KeeperFSM[]`` decides whether a node *can
execute* the transition it's just been assigned. If a monitor rule is
changed or added without a matching keeper edge, the keeper has no way to
perform what it's told and fails at runtime with an error like
``pg_autoctl does not know how to reach state "X" from "Y"`` -- historically
only ever discovered when an operator's cluster actually reached that
specific combination of states in production.

Three building blocks turn that from a runtime surprise into something
checked ahead of time:

``pgautofailover.dump_fsm_edges()``
Resolves every ``MonitorFSM[]`` row's state pattern into its concrete
``(pos, current_state, assigned_state)`` edges -- the same information
``pgautofailover.fsm`` shows as a pattern, fully expanded one row per
reachable current state. Reflexive edges (current state == assigned
state) and the ``api_triggered`` section are deliberately excluded: the
former are no-ops, and the latter resolves which node plays which role
via hand-written C ahead of dispatch, so its own state pattern was never
meant to double as a full reachability precondition.

``pgautofailover.check_fsm_reachability(keeper_edges jsonb)``
Takes a JSON array of ``{"current": ..., "assigned": ...}`` edges -- the
transitions *some* keeper knows how to perform -- and returns every
``dump_fsm_edges()`` edge missing from it: every transition the monitor
could assign that this particular keeper has no edge for.

``pg_autoctl inspect fsm check``
The live, end-to-end version of the same check, run from a node against
its own monitor: it serializes the *real*, compiled-in ``KeeperFSM[]``
(``KeeperFSMToJSON()``) and passes it straight to
``check_fsm_reachability()`` above -- no synthetic input, no assumptions
about what the keeper can do. A clean cluster reports::

$ pg_autoctl inspect fsm check
12:00:00 1 INFO OK: every monitor FSM transition has a matching keeper edge

A gap reports one line per missing edge and exits non-zero, so it can be
used as a build gate. For illustration, here is what running an
*older* keeper binary against a *newer* monitor -- one that has since
learned a transition the old keeper predates -- would report (this is a
hypothetical mismatch for illustration, not a gap that exists in the
current tables)::

$ pg_autoctl inspect fsm check
12:00:00 1 ERROR pos 381: draining -> single has no matching keeper edge
(other node was forcibly removed, now single)
$ echo $?
1

``--json`` is also available, returning ``{"ok": false, "mismatches": [...]}``
for scripting.

How this is tested in CI
^^^^^^^^^^^^^^^^^^^^^^^^^

Three regress tests exercise this mechanism on every build, for every
supported Postgres version, as part of the ``make -C src/monitor
installcheck`` step run while building each ``pgaf:run-pgN`` Docker image
(see the ``build_run_images`` job) -- so a gap fails CI directly, without
needing a live two-process cluster:

- ``fsm.sql`` -- a plain dump of the whole ``pgautofailover.fsm`` view.
Since the table is compile-time-fixed, its expected output changes only
when a rule is added, removed, or edited, giving that change an explicit,
reviewable diff.
- ``check_fsm_reachability.sql`` -- exercises the SQL-side mechanism itself
against small, synthetic keeper-edge inputs (an edge present drops out of
the mismatch list, an edge absent stays in, an unrecognized state name
fails loudly). It doesn't touch the real ``KeeperFSM[]``, which lives in
the ``pg_autoctl`` binary, not the database -- it only proves the
comparison logic itself is correct.
- ``keeper_fsm_edges.sql`` -- the real end-to-end static check, without
needing a live cluster. It loads ``keeper_fsm_edges.json``, a fixture
generated from the actual ``KeeperFSM[]`` via ``pg_autoctl inspect fsm
list --json`` and committed alongside the test (regenerated by hand
whenever ``KeeperFSM[]`` changes), then cross-references it against
``dump_fsm_edges()`` in both directions: every monitor edge with no
matching keeper row (a real, actionable gap), and every keeper row the
monitor never actually dispatches to (dead weight worth a second look,
not a build failure).

``pg_autoctl inspect fsm check`` itself -- talking to a real monitor over
the network -- is exercised live rather than in the regress suite: it's
part of the ``fsm_step_report_advance`` pgaftest spec (see
:ref:`pg_autoctl_manual_fsm_step`) and is also the tool to reach for by hand
after any manual edit to either FSM table, or when investigating a report
that looks like a reachability gap.
15 changes: 12 additions & 3 deletions docs/ref/pg_autoctl_inspect.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,18 @@ variable is required. All commands in this group are safe to run while
tune Compute and log some Postgres tuning options

pg_autoctl inspect fsm
state Read the keeper's state from disk and display it
list List reachable FSM states from current state
gv Output the FSM as a .gv program suitable for graphviz/dot
state Read the keeper's state from disk and display it
list List reachable FSM states from current state
check Check that every monitor FSM transition has a matching keeper edge
gv Output the FSM as a .gv program suitable for graphviz/dot
mermaid Output the FSM as Mermaid stateDiagram-v2 programs, split by phase for readability

pg_autoctl inspect fsm mermaid
init Mermaid diagram: how a node comes into existence or rejoins
steady-state Mermaid diagram: normal operation, no failure
failover Mermaid diagram: primary failover/promotion, including multi-standby candidate election
maintenance Mermaid diagram: planned maintenance
removal Mermaid diagram: node removal/drop

pg_autoctl inspect show
ipaddr Print this node's IP address information
Expand Down
38 changes: 37 additions & 1 deletion src/bin/common/pgsql.c
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,15 @@ pgsql_execute(PGSQL *pgsql, const char *sql)
}


/*
* Cap on how much of a single query parameter's own value gets printed in
* pgsql_execute_with_params' debug trace (see its own comment at the one
* call site that truncates). Keeps that trace's fixed BUFSIZE buffer from
* overflowing on a large parameter, without limiting what's actually sent
* to Postgres (PQexecParams always gets the real, untruncated value).
*/
#define DEBUG_PARAM_VALUE_MAX_LEN 200

/*
* pgsql_execute_with_params opens a connection, runs a given SQL command,
* and closes the connection again.
Expand Down Expand Up @@ -1081,7 +1090,9 @@ pgsql_execute_with_params(PGSQL *pgsql, const char *sql, int paramCount,
int remainingBytes = BUFSIZE;
char *writePointer = (char *) debugParameters;

for (paramIndex = 0; paramIndex < paramCount; paramIndex++)
for (paramIndex = 0;
paramIndex < paramCount && remainingBytes > 0;
paramIndex++)
{
int bytesWritten = 0;
const char *value = paramValues[paramIndex];
Expand All @@ -1091,12 +1102,37 @@ pgsql_execute_with_params(PGSQL *pgsql, const char *sql, int paramCount,
bytesWritten = sformat(writePointer, remainingBytes, ", ");
remainingBytes -= bytesWritten;
writePointer += bytesWritten;

if (remainingBytes <= 0)
{
break;
}
}

if (value == NULL)
{
bytesWritten = sformat(writePointer, remainingBytes, "NULL");
}
else if (strlen(value) > DEBUG_PARAM_VALUE_MAX_LEN)
{
/*
* A parameter can be arbitrarily large (e.g. the JSON payload
* "pg_autoctl inspect fsm check" sends to
* pgautofailover.check_fsm_reachability(), several KB) --
* printing it here in full would overflow debugParameters'
* own fixed BUFSIZE and make sformat() itself log a "BUG:"
* about it on every single call, which is exactly what
* happened the first time that command ran for real. This is
* a debug-only trace, not the actual query (PQexecParams
* below always gets the real, untruncated paramValues), so
* truncating what gets logged here changes nothing about
* query correctness.
*/
bytesWritten = sformat(writePointer, remainingBytes,
"'%.*s...' (%zu bytes total)",
DEBUG_PARAM_VALUE_MAX_LEN, value,
strlen(value));
}
else
{
bytesWritten =
Expand Down
Loading