From 83e405b7dff848f145fc254f8e46bd89cb9ab83c Mon Sep 17 00:00:00 2001 From: Dimitri Fontaine Date: Sat, 11 Jul 2026 19:16:57 +0200 Subject: [PATCH 1/4] Add pgautofailover.guard_data_loss GUC and --allow-data-loss option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When all quorum standbys are in REPORT_LSN and one of them is unreachable, ProceedGroupStateForMSFailover refuses to promote any candidate — forever — because the missing node may have acknowledged a synchronous commit that no surviving standby has replicated. This is the correct conservative default. The problem is there is no operator-visible escape. The only recovery path until now was raw SQL surgery on the monitor catalog. This commit adds two escape hatches: 1. pgautofailover.guard_data_loss GUC (PGC_SUSET, default true) When false, ProceedGroupStateForMSFailover bypasses both the missingNodesCount guard and the quorumCandidateCount guard, allowing failover to proceed with whatever candidates have reported their LSN. A LOG message is emitted for each bypassed guard so the decision is visible in the server log. 2. pg_autoctl perform failover --allow-data-loss Opens a transaction, executes SET LOCAL pgautofailover.guard_data_loss TO false, then calls the node_active protocol as normal. The GUC is local to the transaction so it cannot leak to concurrent sessions. The option is documented in the help text with a data-loss warning. Also adds perform_failover rescue path for stuck-in-report_lsn scenarios: when GetNodeToFailoverFromInGroup returns NULL (no primary to promote from), perform_failover now searches for a node in REPLICATION_STATE_REPORT_LSN and calls ProceedGroupState on it directly. Regression test: src/monitor/sql/guard_data_loss.sql - Bootstraps a 3-node formation (p primary, s1/s2 secondaries, number_sync_standbys=1) - Manufactures the stuck state: p draining, s2 dead-and-unreported, s1 in report_lsn/report_lsn - Test 1: guard_data_loss=true → perform_failover returns, s1 stays in report_lsn (no candidate selected) - Test 2: guard_data_loss=false → perform_failover selects s1, s1.goalstate becomes prepare_promotion Fixes: #1113, #1060, #1059, #1014 Closes: #1055 --- src/bin/pg_autoctl/cli_perform.c | 62 ++++- src/bin/pg_autoctl/keeper_config.h | 3 + src/bin/pg_autoctl/monitor.c | 49 ++++ src/bin/pg_autoctl/monitor.h | 3 + src/monitor/Makefile | 2 +- src/monitor/expected/guard_data_loss.out | 310 +++++++++++++++++++++++ src/monitor/group_state_machine.c | 66 +++-- src/monitor/metadata.c | 1 + src/monitor/metadata.h | 3 + src/monitor/node_active_protocol.c | 26 ++ src/monitor/pg_auto_failover.c | 13 + src/monitor/sql/guard_data_loss.sql | 222 ++++++++++++++++ 12 files changed, 728 insertions(+), 32 deletions(-) create mode 100644 src/monitor/expected/guard_data_loss.out create mode 100644 src/monitor/sql/guard_data_loss.sql diff --git a/src/bin/pg_autoctl/cli_perform.c b/src/bin/pg_autoctl/cli_perform.c index f3fd6415e..50c990bbb 100644 --- a/src/bin/pg_autoctl/cli_perform.c +++ b/src/bin/pg_autoctl/cli_perform.c @@ -29,10 +29,14 @@ CommandLine perform_failover_command = make_command("failover", "Perform a failover for given formation and group", " [ --pgdata --formation --group ] ", - " --pgdata path to data directory\n" - " --formation formation to target, defaults to 'default'\n" - " --group group to target, defaults to 0\n" - " --wait how many seconds to wait, default to 60 \n", + " --pgdata path to data directory\n" + " --formation formation to target, defaults to 'default'\n" + " --group group to target, defaults to 0\n" + " --wait how many seconds to wait, default to 60 \n" + " --allow-data-loss Proceed even when quorum nodes have not reported " + "their\n" + " LSN; committed transactions on missing nodes may " + "be lost\n", cli_perform_failover_getopts, cli_perform_failover); @@ -40,10 +44,14 @@ CommandLine perform_switchover_command = make_command("switchover", "Perform a switchover for given formation and group", " [ --pgdata --formation --group ] ", - " --pgdata path to data directory\n" - " --formation formation to target, defaults to 'default'\n" - " --group group to target, defaults to 0\n" - " --wait how many seconds to wait, default to 60 \n", + " --pgdata path to data directory\n" + " --formation formation to target, defaults to 'default'\n" + " --group group to target, defaults to 0\n" + " --wait how many seconds to wait, default to 60 \n" + " --allow-data-loss Proceed even when quorum nodes have not reported " + "their\n" + " LSN; committed transactions on missing nodes may " + "be lost\n", cli_perform_failover_getopts, cli_perform_failover); @@ -87,6 +95,7 @@ cli_perform_failover_getopts(int argc, char **argv) { "formation", required_argument, NULL, 'f' }, { "group", required_argument, NULL, 'g' }, { "wait", required_argument, NULL, 'w' }, + { "allow-data-loss", no_argument, NULL, 'A' }, { "version", no_argument, NULL, 'V' }, { "verbose", no_argument, NULL, 'v' }, { "quiet", no_argument, NULL, 'q' }, @@ -108,7 +117,7 @@ cli_perform_failover_getopts(int argc, char **argv) optind = 0; - while ((c = getopt_long(argc, argv, "D:f:g:n:Vvqh", + while ((c = getopt_long(argc, argv, "D:f:g:n:AVvqh", long_options, &option_index)) != -1) { switch (c) @@ -164,6 +173,13 @@ cli_perform_failover_getopts(int argc, char **argv) break; } + case 'A': + { + options.allowDataLoss = true; + log_trace("--allow-data-loss"); + break; + } + case 'V': { /* keeper_cli_print_version prints version and exits. */ @@ -284,11 +300,31 @@ cli_perform_failover(int argc, char **argv) exit(EXIT_CODE_MONITOR); } - if (!monitor_perform_failover(&monitor, config.formation, config.groupId)) + bool performOk; + + if (keeperOptions.allowDataLoss) { - log_fatal("Failed to perform failover/switchover, " - "see above for details"); - exit(EXIT_CODE_MONITOR); + performOk = monitor_perform_failover_allow_data_loss( + &monitor, config.formation, config.groupId); + + if (!performOk) + { + log_fatal("Failed to perform failover with --allow-data-loss, " + "see above for details"); + exit(EXIT_CODE_MONITOR); + } + } + else + { + performOk = monitor_perform_failover( + &monitor, config.formation, config.groupId); + + if (!performOk) + { + log_fatal("Failed to perform failover/switchover, " + "see above for details"); + exit(EXIT_CODE_MONITOR); + } } /* process state changes notification until we have a new primary */ diff --git a/src/bin/pg_autoctl/keeper_config.h b/src/bin/pg_autoctl/keeper_config.h index 73e6cbd62..0c71a65e0 100644 --- a/src/bin/pg_autoctl/keeper_config.h +++ b/src/bin/pg_autoctl/keeper_config.h @@ -72,6 +72,9 @@ typedef struct KeeperConfig int citus_coordinator_wait_timeout; int citus_coordinator_wait_max_retries; int listen_notifications_timeout; + + /* allow data loss during a perform failover operation */ + bool allowDataLoss; } KeeperConfig; #define PG_AUTOCTL_MONITOR_IS_DISABLED(config) \ diff --git a/src/bin/pg_autoctl/monitor.c b/src/bin/pg_autoctl/monitor.c index a781800c6..7ceec151e 100644 --- a/src/bin/pg_autoctl/monitor.c +++ b/src/bin/pg_autoctl/monitor.c @@ -1534,6 +1534,55 @@ monitor_perform_failover(Monitor *monitor, char *formation, int group) } +/* + * monitor_perform_failover_allow_data_loss runs perform_failover in a + * transaction with guard_data_loss disabled, accepting the risk of data + * loss when quorum nodes have not reported their LSN. + */ +bool +monitor_perform_failover_allow_data_loss(Monitor *monitor, + char *formation, + int group) +{ + PGSQL *pgsql = &monitor->pgsql; + const char *sql = "SELECT pgautofailover.perform_failover($1, $2)"; + int paramCount = 2; + Oid paramTypes[2] = { TEXTOID, INT4OID }; + const char *paramValues[2]; + IntString groupString = intToString(group); + + paramValues[0] = formation; + paramValues[1] = groupString.strValue; + + if (!pgsql_begin(pgsql)) + { + log_error("Failed to open transaction on monitor"); + return false; + } + + if (!pgsql_execute(pgsql, + "SET LOCAL pgautofailover.guard_data_loss TO false")) + { + log_error("Failed to disable guard_data_loss on monitor"); + (void) pgsql_rollback(pgsql); + return false; + } + + if (!pgsql_execute_with_params(pgsql, sql, + paramCount, paramTypes, paramValues, + NULL, NULL)) + { + log_error("Failed to perform failover with --allow-data-loss " + "for formation %s and group %d", + formation, group); + (void) pgsql_rollback(pgsql); + return false; + } + + return pgsql_commit(pgsql); +} + + /* * monitor_perform_promotion calls the pgautofailover.perform_promotion * function on the monitor. diff --git a/src/bin/pg_autoctl/monitor.h b/src/bin/pg_autoctl/monitor.h index 6c6a95477..aea12ae2e 100644 --- a/src/bin/pg_autoctl/monitor.h +++ b/src/bin/pg_autoctl/monitor.h @@ -173,6 +173,9 @@ bool monitor_get_groupId_from_name(Monitor *monitor, int *groupId); bool monitor_perform_failover(Monitor *monitor, char *formation, int group); +bool monitor_perform_failover_allow_data_loss(Monitor *monitor, + char *formation, + int group); bool monitor_perform_promotion(Monitor *monitor, char *formation, char *name); bool monitor_get_current_state(Monitor *monitor, char *formation, int group, diff --git a/src/monitor/Makefile b/src/monitor/Makefile index 39606a2e4..eca708ccf 100644 --- a/src/monitor/Makefile +++ b/src/monitor/Makefile @@ -14,7 +14,7 @@ MODULE_big = $(EXTENSION) OBJS = $(patsubst ${SRC_DIR}%.c,%.o,$(wildcard ${SRC_DIR}*.c)) PG_CPPFLAGS = -std=c99 -Wall -Werror -Wno-unused-parameter -Iinclude -I$(libpq_srcdir) -g SHLIB_LINK = $(libpq) -REGRESS = create_extension monitor workers node_active_protocol dummy_update drop_extension upgrade +REGRESS = create_extension monitor workers node_active_protocol guard_data_loss dummy_update drop_extension upgrade PG_CONFIG ?= pg_config PGXS = $(shell $(PG_CONFIG) --pgxs) diff --git a/src/monitor/expected/guard_data_loss.out b/src/monitor/expected/guard_data_loss.out new file mode 100644 index 000000000..98b07b2f7 --- /dev/null +++ b/src/monitor/expected/guard_data_loss.out @@ -0,0 +1,310 @@ +-- Copyright (c) Microsoft Corporation. All rights reserved. +-- Licensed under the PostgreSQL License. +-- +-- Regression tests for pgautofailover.guard_data_loss GUC. +-- +-- Verifies that: +-- 1. With guard_data_loss=true (default), a 3-node failover stuck in the +-- report_lsn phase (one quorum node has not yet reported) does NOT +-- proceed -- perform_failover returns without selecting a candidate. +-- +-- 2. With guard_data_loss=false the same scenario proceeds: the surviving +-- report_lsn node is selected as the failover candidate even though +-- the other quorum node never reported its LSN. +-- +\x on +-- ── formation setup ────────────────────────────────────────────────────────── +SELECT pgautofailover.create_formation('gdl_test', 'pgsql', 'postgres', true, 1); +-[ RECORD 1 ]----+------------------------------ +create_formation | (gdl_test,pgsql,postgres,t,1) + +-- Register three nodes (number_sync_standbys=1, so two quorum standbys needed +-- to safely commit). +SELECT * + FROM pgautofailover.register_node('gdl_test', 'p', 5432, + 'postgres', 'p', 1); +-[ RECORD 1 ]---------------+------- +assigned_node_id | 10 +assigned_group_id | 0 +assigned_group_state | single +assigned_candidate_priority | 100 +assigned_replication_quorum | t +assigned_node_name | p + +SELECT nodeid AS np FROM pgautofailover.node + WHERE formationid = 'gdl_test' AND nodename = 'p' \gset +SELECT * + FROM pgautofailover.register_node('gdl_test', 's1', 5432, + 'postgres', 's1', 1); +-[ RECORD 1 ]---------------+------------- +assigned_node_id | 11 +assigned_group_id | 0 +assigned_group_state | wait_standby +assigned_candidate_priority | 100 +assigned_replication_quorum | t +assigned_node_name | s1 + +SELECT nodeid AS ns1 FROM pgautofailover.node + WHERE formationid = 'gdl_test' AND nodename = 's1' \gset +SELECT * + FROM pgautofailover.register_node('gdl_test', 's2', 5432, + 'postgres', 's2', 1); +-[ RECORD 1 ]---------------+------------- +assigned_node_id | 12 +assigned_group_id | 0 +assigned_group_state | wait_standby +assigned_candidate_priority | 100 +assigned_replication_quorum | t +assigned_node_name | s2 + +SELECT nodeid AS ns2 FROM pgautofailover.node + WHERE formationid = 'gdl_test' AND nodename = 's2' \gset +-- ── bootstrap ──────────────────────────────────────────────────────────────── +-- +-- Drive the FSM from init through to primary + secondary + secondary. +-- p: single (confirm) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :np, 0, + current_group_role => 'single'); +-[ RECORD 1 ]--------+------- +assigned_group_state | single + +-- s1: wait_standby (confirm) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :ns1, 0, + current_group_role => 'wait_standby'); +-[ RECORD 1 ]--------+------------- +assigned_group_state | wait_standby + +-- p: single → wait_primary +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :np, 0, + current_group_role => 'single', + current_lsn => '0/5000'); +-[ RECORD 1 ]--------+------------- +assigned_group_state | wait_primary + +-- p: wait_primary (confirm) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :np, 0, + current_group_role => 'wait_primary', + current_lsn => '0/5000'); +-[ RECORD 1 ]--------+------------- +assigned_group_state | wait_primary + +-- s1: wait_standby → catchingup +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :ns1, 0, + current_group_role => 'wait_standby'); +-[ RECORD 1 ]--------+----------- +assigned_group_state | catchingup + +-- s1: catchingup → secondary +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :ns1, 0, + current_group_role => 'catchingup', + current_lsn => '0/5000'); +-[ RECORD 1 ]--------+---------- +assigned_group_state | secondary + +-- s1: secondary (confirm) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :ns1, 0, + current_group_role => 'secondary', + current_lsn => '0/5000'); +-[ RECORD 1 ]--------+---------- +assigned_group_state | secondary + +-- p: wait_primary → primary (now that s1 is secondary) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :np, 0, + current_group_role => 'wait_primary', + current_lsn => '0/5000'); +-[ RECORD 1 ]--------+-------- +assigned_group_state | primary + +-- p: primary (confirm) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :np, 0, + current_group_role => 'primary', + current_lsn => '0/5000'); +-[ RECORD 1 ]--------+-------- +assigned_group_state | primary + +-- s1: secondary (confirm after primary confirmed) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :ns1, 0, + current_group_role => 'secondary', + current_lsn => '0/5000'); +-[ RECORD 1 ]--------+---------- +assigned_group_state | secondary + +-- s2: wait_standby (confirm) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :ns2, 0, + current_group_role => 'wait_standby'); +-[ RECORD 1 ]--------+----------- +assigned_group_state | catchingup + +-- s2: catchingup +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :ns2, 0, + current_group_role => 'catchingup', + current_lsn => '0/5000'); +-[ RECORD 1 ]--------+----------- +assigned_group_state | catchingup + +-- s2: secondary +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :ns2, 0, + current_group_role => 'secondary', + current_lsn => '0/5000'); +-[ RECORD 1 ]--------+----------- +assigned_group_state | catchingup + +-- p: primary (refresh to pick up new secondary) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :np, 0, + current_group_role => 'primary', + current_lsn => '0/5000'); +-[ RECORD 1 ]--------+--------------- +assigned_group_state | apply_settings + +-- Verify final formation state: p=primary, s1=secondary, s2=secondary. +SELECT nodename, goalstate, reportedstate + FROM pgautofailover.node + WHERE formationid = 'gdl_test' + ORDER BY nodename; +-[ RECORD 1 ]-+--------------- +nodename | p +goalstate | apply_settings +reportedstate | primary +-[ RECORD 2 ]-+--------------- +nodename | s1 +goalstate | secondary +reportedstate | secondary +-[ RECORD 3 ]-+--------------- +nodename | s2 +goalstate | catchingup +reportedstate | secondary + +-- ── simulate stuck failover ────────────────────────────────────────────────── +-- +-- Kill the primary (p) and one quorum standby (s2) simultaneously. +-- We simulate this by: +-- 1. Marking p and s2 as unhealthy (health=BAD, old reporttime). +-- 2. Manually placing s1 in report_lsn/report_lsn (it already called +-- node_active and transitioned itself), while s2 is still in +-- secondary/secondary with goalState=report_lsn assigned (missing). +-- +-- This mirrors what happens in production: the monitor assigns report_lsn +-- to all standbys, s1 reports immediately, but s2 is dead and never will. +SET pgautofailover.startup_grace_period = 1; +-- Mark p as dead (health=BAD, stale report). +UPDATE pgautofailover.node + SET health = 0, + healthchecktime = now(), + reporttime = now() - interval '60 seconds' + WHERE formationid = 'gdl_test' AND nodename = 'p'; +-- Mark s2 as dead as well (health=BAD, stale report, pgIsRunning=false). +UPDATE pgautofailover.node + SET health = 0, + healthchecktime = now(), + reporttime = now() - interval '60 seconds', + reportedpgisrunning = false + WHERE formationid = 'gdl_test' AND nodename = 's2'; +-- Demote p: set it to draining/draining so the FSM sees no primary. +UPDATE pgautofailover.node + SET goalstate = 'draining', reportedstate = 'draining' + WHERE formationid = 'gdl_test' AND nodename = 'p'; +-- Put s1 in report_lsn/report_lsn (it already ran node_active and confirmed). +UPDATE pgautofailover.node + SET goalstate = 'report_lsn', reportedstate = 'report_lsn' + WHERE formationid = 'gdl_test' AND nodename = 's1'; +-- Put s2 in secondary/secondary with goalState=report_lsn assigned but NOT +-- yet confirmed (missingNodesCount will be > 0 for s2). +UPDATE pgautofailover.node + SET goalstate = 'report_lsn', reportedstate = 'secondary' + WHERE formationid = 'gdl_test' AND nodename = 's2'; +-- Verify the manufactured stuck state. +SELECT nodename, goalstate, reportedstate, health + FROM pgautofailover.node + WHERE formationid = 'gdl_test' + ORDER BY nodename; +-[ RECORD 1 ]-+----------- +nodename | p +goalstate | draining +reportedstate | draining +health | 0 +-[ RECORD 2 ]-+----------- +nodename | s1 +goalstate | report_lsn +reportedstate | report_lsn +health | -1 +-[ RECORD 3 ]-+----------- +nodename | s2 +goalstate | report_lsn +reportedstate | secondary +health | 0 + +-- ── test 1: guard_data_loss = true (default) blocks ───────────────────────── +-- +-- perform_failover should find no primary, locate s1 in report_lsn, drive +-- ProceedGroupState for s1, which calls ProceedGroupStateForMSFailover, which +-- hits the guard and returns false without selecting a candidate. +-- The node states should remain unchanged (no candidate selected). +-- Default is true; make it explicit. +SET pgautofailover.guard_data_loss TO true; +SELECT pgautofailover.perform_failover('gdl_test', 0); +-[ RECORD 1 ]----+- +perform_failover | + +-- States must be unchanged: no candidate was promoted. +SELECT nodename, goalstate, reportedstate + FROM pgautofailover.node + WHERE formationid = 'gdl_test' + ORDER BY nodename; +-[ RECORD 1 ]-+----------- +nodename | p +goalstate | draining +reportedstate | draining +-[ RECORD 2 ]-+----------- +nodename | s1 +goalstate | report_lsn +reportedstate | report_lsn +-[ RECORD 3 ]-+----------- +nodename | s2 +goalstate | report_lsn +reportedstate | secondary + +-- ── test 2: guard_data_loss = false allows progress ───────────────────────── +-- +-- With guard_data_loss disabled the failover proceeds despite s2 being missing. +-- s1 (the only node in report_lsn) should be selected as the candidate and +-- transition toward prepare_promotion / stop_replication. +SET pgautofailover.guard_data_loss TO false; +SELECT pgautofailover.perform_failover('gdl_test', 0); +-[ RECORD 1 ]----+- +perform_failover | + +-- s1 should now have a goal state of prepare_promotion or stop_replication, +-- indicating the failover candidate was selected. +SELECT nodename, goalstate, reportedstate + FROM pgautofailover.node + WHERE formationid = 'gdl_test' + ORDER BY nodename; +-[ RECORD 1 ]-+------------------ +nodename | p +goalstate | draining +reportedstate | draining +-[ RECORD 2 ]-+------------------ +nodename | s1 +goalstate | prepare_promotion +reportedstate | report_lsn +-[ RECORD 3 ]-+------------------ +nodename | s2 +goalstate | report_lsn +reportedstate | secondary + +RESET pgautofailover.guard_data_loss; +RESET pgautofailover.startup_grace_period; diff --git a/src/monitor/group_state_machine.c b/src/monitor/group_state_machine.c index 145ad6ba6..0ad622dfa 100644 --- a/src/monitor/group_state_machine.c +++ b/src/monitor/group_state_machine.c @@ -18,6 +18,7 @@ #include "formation_metadata.h" #include "group_state_machine.h" +#include "metadata.h" #include "node_metadata.h" #include "notifications.h" #include "replication_state.h" @@ -1404,18 +1405,31 @@ ProceedGroupStateForMSFailover(GroupStateContext *ctx, { char message[BUFSIZE] = { 0 }; + if (GuardDataLoss) + { + LogAndNotifyMessage( + message, BUFSIZE, + "Failover still in progress after %d nodes reported their LSN " + "and we are waiting for %d nodes to report, " + "activeNode is " NODE_FORMAT + " and reported state \"%s\"", + candidateList.candidateCount, + candidateList.missingNodesCount, + NODE_FORMAT_ARGS(activeNode), + ReplicationStateGetName(activeNode->reportedState)); + + return false; + } + LogAndNotifyMessage( message, BUFSIZE, - "Failover still in progress after %d nodes reported their LSN " - "and we are waiting for %d nodes to report, " - "activeNode is " NODE_FORMAT - " and reported state \"%s\"", - candidateList.candidateCount, + "Proceeding with failover despite %d unreported quorum node(s): " + "pgautofailover.guard_data_loss is false. " + "Committed transactions on missing node(s) may be lost. " + "activeNode is " NODE_FORMAT " and reported state \"%s\"", candidateList.missingNodesCount, NODE_FORMAT_ARGS(activeNode), ReplicationStateGetName(activeNode->reportedState)); - - return false; } /* @@ -1438,29 +1452,45 @@ ProceedGroupStateForMSFailover(GroupStateContext *ctx, } /* not enough candidates to promote and then accept writes, pass */ - else if (candidateList.quorumCandidateCount < minCandidates) + if (candidateList.quorumCandidateCount < minCandidates) { char message[BUFSIZE] = { 0 }; + if (GuardDataLoss) + { + LogAndNotifyMessage( + message, BUFSIZE, + "Failover still in progress with %d candidates that participate " + "in the quorum having reported their LSN: %d nodes are required " + "in the quorum to satisfy number_sync_standbys=%d in " + "formation \"%s\", activeNode is " NODE_FORMAT + " and reported state \"%s\"", + candidateList.quorumCandidateCount, + minCandidates, + ctx->formation->number_sync_standbys, + ctx->formation->formationId, + NODE_FORMAT_ARGS(activeNode), + ReplicationStateGetName(activeNode->reportedState)); + + return false; + } + LogAndNotifyMessage( message, BUFSIZE, - "Failover still in progress with %d candidates that participate " - "in the quorum having reported their LSN: %d nodes are required " - "in the quorum to satisfy number_sync_standbys=%d in " - "formation \"%s\", activeNode is " NODE_FORMAT - " and reported state \"%s\"", + "Proceeding with failover with only %d quorum candidate(s) despite " + "number_sync_standbys=%d requiring %d: " + "pgautofailover.guard_data_loss is false. " + "The new primary may start in wait_primary state with fewer " + "sync standbys than required. " + "activeNode is " NODE_FORMAT " and reported state \"%s\"", candidateList.quorumCandidateCount, - minCandidates, ctx->formation->number_sync_standbys, - ctx->formation->formationId, + minCandidates, NODE_FORMAT_ARGS(activeNode), ReplicationStateGetName(activeNode->reportedState)); - - return false; } /* enough candidates to promote and then accept writes, let's do it! */ - else { /* build the list of most advanced standby nodes, not ordered */ List *mostAdvancedNodeList = diff --git a/src/monitor/metadata.c b/src/monitor/metadata.c index 0ddf81d1d..81614b2eb 100644 --- a/src/monitor/metadata.c +++ b/src/monitor/metadata.c @@ -39,6 +39,7 @@ #include "utils/relcache.h" bool EnableVersionChecks = true; /* version checks are enabled */ +bool GuardDataLoss = true; /* guard against data loss during failover */ /* * pgAutoFailoverRelationId returns the OID of a given relation in the diff --git a/src/monitor/metadata.h b/src/monitor/metadata.h index b99049e80..f7325c86e 100644 --- a/src/monitor/metadata.h +++ b/src/monitor/metadata.h @@ -38,6 +38,9 @@ typedef enum AutoFailoverHALocktagClass /* GUC variable for version checks, true by default */ extern bool EnableVersionChecks; +/* GUC variable to guard against data loss during failover, true by default */ +extern bool GuardDataLoss; + /* public function declarations */ extern Oid pgAutoFailoverRelationId(const char *relname); extern Oid pgAutoFailoverSchemaId(void); diff --git a/src/monitor/node_active_protocol.c b/src/monitor/node_active_protocol.c index 83f4453e1..f112e0501 100644 --- a/src/monitor/node_active_protocol.c +++ b/src/monitor/node_active_protocol.c @@ -1313,6 +1313,32 @@ perform_failover(PG_FUNCTION_ARGS) if (primaryNode == NULL) { + /* + * No primary to initiate a failover from. Maybe a failover is already + * in progress and stuck waiting for quorum nodes to report their LSN. + * In that case, drive the state machine for the first report_lsn node: + * if guard_data_loss is false this will proceed despite missing nodes. + */ + AutoFailoverNode *reportLsnNode = NULL; + ListCell *nodeCell = NULL; + + foreach(nodeCell, groupNodeList) + { + AutoFailoverNode *node = (AutoFailoverNode *) lfirst(nodeCell); + + if (IsCurrentState(node, REPLICATION_STATE_REPORT_LSN)) + { + reportLsnNode = node; + break; + } + } + + if (reportLsnNode != NULL) + { + (void) ProceedGroupState(reportLsnNode); + PG_RETURN_VOID(); + } + ereport(ERROR, (errmsg("couldn't find the primary node in formation \"%s\", " "group %d", formationId, groupId))); diff --git a/src/monitor/pg_auto_failover.c b/src/monitor/pg_auto_failover.c index 80778fe6f..4e5eee0a6 100644 --- a/src/monitor/pg_auto_failover.c +++ b/src/monitor/pg_auto_failover.c @@ -181,6 +181,19 @@ StartMonitorNode(void) NULL, &StartupGracePeriodMs, 10 * 1000, 1, INT_MAX, PGC_SUSET, GUC_UNIT_MS, NULL, NULL, NULL); + DefineCustomBoolVariable("pgautofailover.guard_data_loss", + "Refuse to proceed with failover when quorum nodes have not " + "yet reported their LSN, preventing potential data loss from " + "the synchronous replication gap. Set to false to allow " + "failover to proceed despite missing quorum nodes, accepting " + "the risk that committed transactions may be lost.", + NULL, + &GuardDataLoss, + true, + PGC_SUSET, + 0, + NULL, NULL, NULL); + PreviousProcessUtility_hook = ProcessUtility_hook; ProcessUtility_hook = pgautofailover_ProcessUtility; diff --git a/src/monitor/sql/guard_data_loss.sql b/src/monitor/sql/guard_data_loss.sql new file mode 100644 index 000000000..09066b844 --- /dev/null +++ b/src/monitor/sql/guard_data_loss.sql @@ -0,0 +1,222 @@ +-- Copyright (c) Microsoft Corporation. All rights reserved. +-- Licensed under the PostgreSQL License. +-- +-- Regression tests for pgautofailover.guard_data_loss GUC. +-- +-- Verifies that: +-- 1. With guard_data_loss=true (default), a 3-node failover stuck in the +-- report_lsn phase (one quorum node has not yet reported) does NOT +-- proceed -- perform_failover returns without selecting a candidate. +-- +-- 2. With guard_data_loss=false the same scenario proceeds: the surviving +-- report_lsn node is selected as the failover candidate even though +-- the other quorum node never reported its LSN. +-- + +\x on + +-- ── formation setup ────────────────────────────────────────────────────────── + +SELECT pgautofailover.create_formation('gdl_test', 'pgsql', 'postgres', true, 1); + +-- Register three nodes (number_sync_standbys=1, so two quorum standbys needed +-- to safely commit). +SELECT * + FROM pgautofailover.register_node('gdl_test', 'p', 5432, + 'postgres', 'p', 1); + +SELECT nodeid AS np FROM pgautofailover.node + WHERE formationid = 'gdl_test' AND nodename = 'p' \gset + +SELECT * + FROM pgautofailover.register_node('gdl_test', 's1', 5432, + 'postgres', 's1', 1); + +SELECT nodeid AS ns1 FROM pgautofailover.node + WHERE formationid = 'gdl_test' AND nodename = 's1' \gset + +SELECT * + FROM pgautofailover.register_node('gdl_test', 's2', 5432, + 'postgres', 's2', 1); + +SELECT nodeid AS ns2 FROM pgautofailover.node + WHERE formationid = 'gdl_test' AND nodename = 's2' \gset + +-- ── bootstrap ──────────────────────────────────────────────────────────────── +-- +-- Drive the FSM from init through to primary + secondary + secondary. + +-- p: single (confirm) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :np, 0, + current_group_role => 'single'); + +-- s1: wait_standby (confirm) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :ns1, 0, + current_group_role => 'wait_standby'); + +-- p: single → wait_primary +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :np, 0, + current_group_role => 'single', + current_lsn => '0/5000'); + +-- p: wait_primary (confirm) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :np, 0, + current_group_role => 'wait_primary', + current_lsn => '0/5000'); + +-- s1: wait_standby → catchingup +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :ns1, 0, + current_group_role => 'wait_standby'); + +-- s1: catchingup → secondary +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :ns1, 0, + current_group_role => 'catchingup', + current_lsn => '0/5000'); + +-- s1: secondary (confirm) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :ns1, 0, + current_group_role => 'secondary', + current_lsn => '0/5000'); + +-- p: wait_primary → primary (now that s1 is secondary) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :np, 0, + current_group_role => 'wait_primary', + current_lsn => '0/5000'); + +-- p: primary (confirm) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :np, 0, + current_group_role => 'primary', + current_lsn => '0/5000'); + +-- s1: secondary (confirm after primary confirmed) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :ns1, 0, + current_group_role => 'secondary', + current_lsn => '0/5000'); + +-- s2: wait_standby (confirm) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :ns2, 0, + current_group_role => 'wait_standby'); + +-- s2: catchingup +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :ns2, 0, + current_group_role => 'catchingup', + current_lsn => '0/5000'); + +-- s2: secondary +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :ns2, 0, + current_group_role => 'secondary', + current_lsn => '0/5000'); + +-- p: primary (refresh to pick up new secondary) +SELECT assigned_group_state + FROM pgautofailover.node_active('gdl_test', :np, 0, + current_group_role => 'primary', + current_lsn => '0/5000'); + +-- Verify final formation state: p=primary, s1=secondary, s2=secondary. +SELECT nodename, goalstate, reportedstate + FROM pgautofailover.node + WHERE formationid = 'gdl_test' + ORDER BY nodename; + +-- ── simulate stuck failover ────────────────────────────────────────────────── +-- +-- Kill the primary (p) and one quorum standby (s2) simultaneously. +-- We simulate this by: +-- 1. Marking p and s2 as unhealthy (health=BAD, old reporttime). +-- 2. Manually placing s1 in report_lsn/report_lsn (it already called +-- node_active and transitioned itself), while s2 is still in +-- secondary/secondary with goalState=report_lsn assigned (missing). +-- +-- This mirrors what happens in production: the monitor assigns report_lsn +-- to all standbys, s1 reports immediately, but s2 is dead and never will. + +SET pgautofailover.startup_grace_period = 1; + +-- Mark p as dead (health=BAD, stale report). +UPDATE pgautofailover.node + SET health = 0, + healthchecktime = now(), + reporttime = now() - interval '60 seconds' + WHERE formationid = 'gdl_test' AND nodename = 'p'; + +-- Mark s2 as dead as well (health=BAD, stale report, pgIsRunning=false). +UPDATE pgautofailover.node + SET health = 0, + healthchecktime = now(), + reporttime = now() - interval '60 seconds', + reportedpgisrunning = false + WHERE formationid = 'gdl_test' AND nodename = 's2'; + +-- Demote p: set it to draining/draining so the FSM sees no primary. +UPDATE pgautofailover.node + SET goalstate = 'draining', reportedstate = 'draining' + WHERE formationid = 'gdl_test' AND nodename = 'p'; + +-- Put s1 in report_lsn/report_lsn (it already ran node_active and confirmed). +UPDATE pgautofailover.node + SET goalstate = 'report_lsn', reportedstate = 'report_lsn' + WHERE formationid = 'gdl_test' AND nodename = 's1'; + +-- Put s2 in secondary/secondary with goalState=report_lsn assigned but NOT +-- yet confirmed (missingNodesCount will be > 0 for s2). +UPDATE pgautofailover.node + SET goalstate = 'report_lsn', reportedstate = 'secondary' + WHERE formationid = 'gdl_test' AND nodename = 's2'; + +-- Verify the manufactured stuck state. +SELECT nodename, goalstate, reportedstate, health + FROM pgautofailover.node + WHERE formationid = 'gdl_test' + ORDER BY nodename; + +-- ── test 1: guard_data_loss = true (default) blocks ───────────────────────── +-- +-- perform_failover should find no primary, locate s1 in report_lsn, drive +-- ProceedGroupState for s1, which calls ProceedGroupStateForMSFailover, which +-- hits the guard and returns false without selecting a candidate. +-- The node states should remain unchanged (no candidate selected). + +-- Default is true; make it explicit. +SET pgautofailover.guard_data_loss TO true; + +SELECT pgautofailover.perform_failover('gdl_test', 0); + +-- States must be unchanged: no candidate was promoted. +SELECT nodename, goalstate, reportedstate + FROM pgautofailover.node + WHERE formationid = 'gdl_test' + ORDER BY nodename; + +-- ── test 2: guard_data_loss = false allows progress ───────────────────────── +-- +-- With guard_data_loss disabled the failover proceeds despite s2 being missing. +-- s1 (the only node in report_lsn) should be selected as the candidate and +-- transition toward prepare_promotion / stop_replication. + +SET pgautofailover.guard_data_loss TO false; + +SELECT pgautofailover.perform_failover('gdl_test', 0); + +-- s1 should now have a goal state of prepare_promotion or stop_replication, +-- indicating the failover candidate was selected. +SELECT nodename, goalstate, reportedstate + FROM pgautofailover.node + WHERE formationid = 'gdl_test' + ORDER BY nodename; + +RESET pgautofailover.guard_data_loss; +RESET pgautofailover.startup_grace_period; From ec8c14816a76b35219db9fa4885f4c1745dd5376 Mon Sep 17 00:00:00 2001 From: Dimitri Fontaine Date: Sat, 11 Jul 2026 19:47:40 +0200 Subject: [PATCH 2/4] Add guard_data_loss end-to-end TAP spec Tests the full allow-data-loss escape hatch in a live Docker Compose cluster: - 3-node formation (number_sync_standbys=1), node1 primary - node1 (primary) and node2 (standby) killed simultaneously - node3 enters REPORT_LSN and stays stuck: missingNodesCount=1 prevents automatic promotion (guard_data_loss=true by default) - test_002 asserts node3 stays at report_lsn after 10s (no spurious promotion) - test_003 runs pg_autoctl perform failover --allow-data-loss on the monitor, node3 advances to wait_primary - test_004/005 bring node2 and node1 back; cluster reaches healthy 3-node state Added to tests/tap/schedule after multi_alternate. --- tests/tap/schedule | 1 + tests/tap/specs/guard_data_loss.pgaf | 116 +++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 tests/tap/specs/guard_data_loss.pgaf diff --git a/tests/tap/schedule b/tests/tap/schedule index a58a8f966..024b4bf81 100644 --- a/tests/tap/schedule +++ b/tests/tap/schedule @@ -22,6 +22,7 @@ multi_async multi_ifdown multi_maintenance multi_alternate +guard_data_loss extension_update installcheck # Upgrade test — requires pgaf:current and pgaf:next images to be pre-built: diff --git a/tests/tap/specs/guard_data_loss.pgaf b/tests/tap/specs/guard_data_loss.pgaf new file mode 100644 index 000000000..1030cbce5 --- /dev/null +++ b/tests/tap/specs/guard_data_loss.pgaf @@ -0,0 +1,116 @@ +# Test pgautofailover.guard_data_loss / pg_autoctl perform failover --allow-data-loss +# +# Scenario: 3-node formation (number_sync_standbys=1). +# The primary and one standby fail simultaneously. The surviving standby +# enters REPORT_LSN and waits — it cannot be promoted because the dead +# standby was a quorum member and may have acknowledged the last synchronous +# commit. Without intervention the cluster is stuck forever. +# +# This test verifies: +# 1. After both failures, the surviving standby (node3) stays at report_lsn +# and is NOT promoted automatically (guard_data_loss=true by default). +# 2. Running `pg_autoctl perform failover --allow-data-loss` from the monitor +# unblocks the situation: node3 advances to prepare_promotion and the +# cluster reaches a healthy primary state. +# +# Predecessor: monitor regression test src/monitor/sql/guard_data_loss.sql +# Related issues: #1113, #1060, #1059, #1014 + +cluster { + monitor + ssl off + formation { + node1 + node2 + node3 + } +} + +setup { + wait until primary, secondary timeout 120s + wait until node3 state is secondary timeout 60s + promote node1 + wait until node1 state is primary + and node2 state is secondary + and node3 state is secondary + timeout 120s +} + +teardown { + compose down +} + +# +# Step 1: kill the primary (node1) and one standby (node2) in quick succession. +# +# After node1 dies the monitor assigns report_lsn to both standbys. +# node3 will report its LSN. node2 is killed before it can report — it +# remains as a "missing" quorum member. +# +# With guard_data_loss=true (the default), ProceedGroupStateForMSFailover +# sees missingNodesCount=1 and refuses to promote anyone. node3 stays at +# report_lsn indefinitely. +# + +step test_001_kill_primary_and_one_standby { + compose kill node1 + compose kill node2 + wait until node1 assigned-state = draining timeout 120s + wait until node3 state is report_lsn timeout 120s +} + +# +# Step 2: verify node3 stays stuck — no automatic promotion. +# +# We sleep briefly to let the monitor run a few more health-check cycles. +# If guard_data_loss were incorrectly false, node3 would have advanced by now. +# + +step test_002_verify_stuck { + sleep 10s + assert node3 state is report_lsn +} + +# +# Step 3: unblock with --allow-data-loss. +# +# pg_autoctl perform failover --allow-data-loss opens a transaction on the +# monitor, sets LOCAL pgautofailover.guard_data_loss = false, and calls +# perform_failover(). The monitor selects node3 as the only available +# candidate and drives it toward prepare_promotion. +# + +step test_003_allow_data_loss_failover { + exec monitor pg_autoctl perform failover --allow-data-loss --formation default + wait until node3 state is wait_primary timeout 120s +} + +# +# Step 4: bring node2 back so node3 can become a full primary. +# + +step test_004_bringup_node2 { + compose start node2 + wait until node3 state is primary + and node2 state is secondary + timeout 180s +} + +# +# Step 5: bring node1 back as the second standby. +# + +step test_005_bringup_node1 { + compose start node1 + wait until node1 state is secondary + and node3 state is primary + and node2 state is secondary + timeout 120s +} + +sequence + test_001_kill_primary_and_one_standby + test_002_verify_stuck + test_003_allow_data_loss_failover + test_004_bringup_node2 + test_005_bringup_node1 From a97d356480720d02483bdf8ac9b8702ebd43c420 Mon Sep 17 00:00:00 2001 From: Dimitri Fontaine Date: Sat, 11 Jul 2026 19:52:00 +0200 Subject: [PATCH 3/4] Fix guard_data_loss GUC context: PGC_SUSET -> PGC_USERSET pg_autoctl perform failover --allow-data-loss connects to the monitor as autoctl_node, which is not a superuser. PGC_SUSET requires superuser to SET the parameter, so the SET LOCAL in the transaction failed with "permission denied to set parameter". Change to PGC_USERSET so any authenticated user can set it within their session. The GUC only affects the failover decision within the backend that runs perform_failover(), so no additional privilege is granted beyond what the caller already has by being able to call perform_failover(). --- src/monitor/pg_auto_failover.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/monitor/pg_auto_failover.c b/src/monitor/pg_auto_failover.c index 4e5eee0a6..40b7f1254 100644 --- a/src/monitor/pg_auto_failover.c +++ b/src/monitor/pg_auto_failover.c @@ -190,7 +190,7 @@ StartMonitorNode(void) NULL, &GuardDataLoss, true, - PGC_SUSET, + PGC_USERSET, 0, NULL, NULL, NULL); From e5354465ce2908e05997ba7fcfcef68a868ac614 Mon Sep 17 00:00:00 2001 From: Dimitri Fontaine Date: Sat, 11 Jul 2026 21:04:29 +0200 Subject: [PATCH 4/4] docs: add guard_data_loss feature docs and TikZ sequence diagrams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document pgautofailover.guard_data_loss GUC in configuration.rst - Add --allow-data-loss option to pg_autoctl perform failover ref page - Add stuck report_lsn scenario explanation to failover-state-machine.rst - Add FAQ entry for stuck report_lsn failover - Add two TikZ sequence diagrams (normal failover, stuck-election failover) replacing broken mermaid blocks in architecture-multi-standby.rst - Remove sphinxcontrib-mermaid dependency (no longer used) - Bump ensure/test_004_demoted timeout 90s→180s (CI timing headroom) --- docs/architecture-multi-standby.rst | 29 + docs/conf.py | 2 +- docs/failover-state-machine.rst | 11 + docs/faq.rst | 27 + docs/ref/configuration.rst | 28 + docs/ref/pg_autoctl_perform_failover.rst | 93 +- docs/requirements.txt | 1 + docs/tikz/Makefile | 2 +- docs/tikz/seq-normal-failover.svg | 776 +++++++++++++++ docs/tikz/seq-normal-failover.tex | 125 +++ docs/tikz/seq-stuck-failover.svg | 1106 ++++++++++++++++++++++ docs/tikz/seq-stuck-failover.tex | 136 +++ tests/tap/specs/ensure.pgaf | 2 +- 13 files changed, 2331 insertions(+), 7 deletions(-) create mode 100644 docs/tikz/seq-normal-failover.svg create mode 100644 docs/tikz/seq-normal-failover.tex create mode 100644 docs/tikz/seq-stuck-failover.svg create mode 100644 docs/tikz/seq-stuck-failover.tex diff --git a/docs/architecture-multi-standby.rst b/docs/architecture-multi-standby.rst index c526702d2..2fdc24530 100644 --- a/docs/architecture-multi-standby.rst +++ b/docs/architecture-multi-standby.rst @@ -56,6 +56,12 @@ In more details: standby node acknowledges the transactions locally committed, thus degrading your Postgres service to read-only. + The sequence below shows how a commit is confirmed and how a healthy + failover completes when all quorum standbys are available: + + .. figure:: ./tikz/seq-normal-failover.svg + :align: center + 0. It is possible to manually set ``number_sync_standbys`` to zero when having registered two standby nodes to the monitor, overriding the default behavior. @@ -73,6 +79,29 @@ In more details: data will be lost. How much depends on your backup and recovery mechanisms. +The sequence below shows the stuck-election scenario that can arise with any +``number_sync_standbys >= 1`` setting when the primary and one quorum standby +fail at the same time, and how ``--allow-data-loss`` unblocks it: + +.. figure:: ./tikz/seq-stuck-failover.svg + :align: center + +.. note:: + + **Failover when the primary and a quorum standby fail simultaneously.** + If the primary and one quorum standby are lost at the same time, the + surviving standby is assigned ``report_lsn`` but will not be promoted + automatically. The monitor cannot know whether the missing standby + acknowledged the last synchronous commit, and promoting the survivor + could silently discard those transactions. + + Once you have confirmed the missing node is permanently lost and you accept + the potential data loss, unblock the election with:: + + pg_autoctl perform failover --allow-data-loss + + See :ref:`perform_failover_allow_data_loss` for a full explanation. + .. _architecture_setup: Replication Settings and Postgres Architectures diff --git a/docs/conf.py b/docs/conf.py index 6c56594b3..3db4ede21 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -47,7 +47,7 @@ def __init__(self, **options): # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinx.ext.githubpages"] +extensions = ["sphinx.ext.githubpages", "sphinxcontrib.mermaid"] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/docs/failover-state-machine.rst b/docs/failover-state-machine.rst index 026eacec7..35027adef 100644 --- a/docs/failover-state-machine.rst +++ b/docs/failover-state-machine.rst @@ -250,6 +250,17 @@ restarting Postgres without a ``primary_conninfo``. This allows the primary node to detect :ref:`network_partitions`, i.e. when the primary can't connect to the monitor and there's no standby listed in ``pg_stat_replication``. +If one or more quorum standbys (nodes counted by ``number_sync_standbys``) +are unreachable and never report their LSN, the monitor will not advance the +election. The missing node may have acknowledged the last synchronous commit +before it disappeared, and promoting a lagging candidate would silently discard +those transactions. This protection is controlled by the +``pgautofailover.guard_data_loss`` GUC (default ``true``). When the missing +node cannot be recovered and the operator is willing to accept the potential +data loss, the election can be unblocked with +:ref:`pg_autoctl_perform_failover` ``--allow-data-loss``. See +:ref:`perform_failover_allow_data_loss` for details. + Fast_forward ^^^^^^^^^^^^ diff --git a/docs/faq.rst b/docs/faq.rst index 876dad748..16e60550f 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -84,6 +84,33 @@ default configuration deployed by ``pg_autoctl create ...``. When a custom Postgres setup is used, please refer to your actual setup to find Postgres logs. +My failover is stuck: standbys are in ``report_lsn`` and nothing moves +----------------------------------------------------------------------- + +This happens when the primary and one or more quorum standbys +(nodes counted by ``number_sync_standbys``) fail at the same time. The +monitor drives all surviving standbys to the ``report_lsn`` state to +determine the most advanced node, but it refuses to promote any candidate +while a quorum member is missing. The missing node may have acknowledged a +synchronous commit that no surviving standby has yet replicated, and +promoting would silently discard those transactions. + +**First, try to bring the missing node back.** If it recovers and reports +its LSN, the election resumes automatically. + +**If the missing node is permanently lost and you accept the data-loss +risk,** unblock the election:: + + pg_autoctl perform failover --allow-data-loss + +The command promotes the most advanced surviving standby. Transactions +acknowledged by the missing node but not yet replicated to any survivor +will be permanently lost once the new primary starts accepting writes. + +See :ref:`perform_failover_allow_data_loss` and the +``pgautofailover.guard_data_loss`` GUC in :ref:`configuration` for a full +explanation. + The state of the system is blocked, what should I do? ----------------------------------------------------- diff --git a/docs/ref/configuration.rst b/docs/ref/configuration.rst index 52d28a090..b09939bda 100644 --- a/docs/ref/configuration.rst +++ b/docs/ref/configuration.rst @@ -101,6 +101,34 @@ database where the extension has been deployed:: setting | 10000 unit | ms short_desc | Wait for at least this much time after startup before initiating a failover. + -[ RECORD 10 ]--------------------------------------------------------------------------------------------------- + name | pgautofailover.guard_data_loss + setting | true + unit | + short_desc | Refuse to proceed with failover when quorum nodes have not yet reported their LSN. + +pgautofailover.guard_data_loss +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When ``true`` (the default), ``ProceedGroupStateForMSFailover`` refuses to +promote any standby if one or more quorum nodes have not reported their LSN +position during a failover election. This prevents silent data loss: if the +missing node acknowledged a synchronous commit that no surviving standby +replicated, promoting a lagging standby would permanently discard those +transactions. + +Set to ``false`` to allow the election to proceed with only the surviving +candidates, accepting that committed transactions on the missing node(s) may +be lost. The recommended way to use this setting is through +:ref:`pg_autoctl_perform_failover` with the ``--allow-data-loss`` flag, which +scopes the change to a single transaction and emits a server log message for +each guard that is bypassed. + +Setting ``guard_data_loss = false`` globally in ``postgresql.conf`` is +**not** recommended: it would silently suppress the protection for all future +failovers. Use ``ALTER DATABASE pg_auto_failover SET +pgautofailover.guard_data_loss = false;`` only if you want the setting to +persist across monitor restarts with explicit intent, and document the reason. You can edit the parameters as usual with PostgreSQL, either in the ``postgresql.conf`` file or using ``ALTER DATABASE pg_auto_failover SET parameter = diff --git a/docs/ref/pg_autoctl_perform_failover.rst b/docs/ref/pg_autoctl_perform_failover.rst index cb37c2702..563a5ed6a 100644 --- a/docs/ref/pg_autoctl_perform_failover.rst +++ b/docs/ref/pg_autoctl_perform_failover.rst @@ -13,10 +13,11 @@ pg_auto_failover monitor:: usage: pg_autoctl perform failover [ --pgdata --formation --group ] - --pgdata path to data directory - --formation formation to target, defaults to 'default' - --group group to target, defaults to 0 - --wait how many seconds to wait, default to 60 + --pgdata path to data directory + --formation formation to target, defaults to 'default' + --group group to target, defaults to 0 + --wait how many seconds to wait, default to 60 + --allow-data-loss proceed even when quorum nodes have not reported their LSN Description ----------- @@ -34,6 +35,43 @@ The failover orchestration is done in the background by the monitor, so even if the ``pg_autoctl perform failover`` stops on the timeout, the failover orchestration continues at the monitor. +.. _perform_failover_allow_data_loss: + +Recovering a failover stuck in ``report_lsn`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In a formation with ``number_sync_standbys >= 1``, a failover that loses the +primary and one quorum standby at the same time can get permanently stuck. +When the primary fails, the monitor drives all standby nodes into the +``report_lsn`` state so it can elect the most advanced one. If a quorum +standby is unreachable, it never reports its LSN. The monitor then refuses to +promote any remaining candidate — it cannot know whether the missing node +acknowledged the last synchronous commit and holds WAL that no surviving +standby has replicated yet. + +This is the correct conservative default, controlled by the +``pgautofailover.guard_data_loss`` GUC (default ``true``). When you have +determined that the missing node is permanently lost and you are willing to +accept the potential data loss, use ``--allow-data-loss`` to unblock the +election: + +.. code-block:: bash + + pg_autoctl perform failover --allow-data-loss + +The command opens a single transaction on the monitor, sets +``pgautofailover.guard_data_loss`` to ``false`` for that transaction only, +and calls ``perform_failover()``. The monitor then selects the most advanced +surviving candidate and drives it toward ``prepare_promotion``. + +.. warning:: + + ``--allow-data-loss`` means exactly what it says. If the missing quorum + standby had acknowledged a synchronous commit that no surviving standby + replicated, those transactions will be permanently lost once the new primary + starts accepting writes. Use this option only when the missing node cannot + be recovered and the cluster being stuck is the worse outcome. + Options ------- @@ -60,6 +98,14 @@ Options the timeout has elapsed, whichever comes first. The value 0 (zero) disables the timeout and allows the command to wait forever. +--allow-data-loss + + Disable the ``pgautofailover.guard_data_loss`` protection for this + failover only. When set, the monitor will promote the most advanced + surviving candidate even if one or more quorum standby nodes have not yet + reported their LSN position. Committed transactions on the missing node + may be permanently lost. See :ref:`perform_failover_allow_data_loss`. + Environment ----------- @@ -141,3 +187,42 @@ Examples node1 | 1 | localhost:5501 | 0/4000F50 | read-only | secondary | secondary node2 | 2 | localhost:5502 | 0/4000F50 | read-write | primary | primary node3 | 3 | localhost:5503 | 0/4000F50 | read-only | secondary | secondary + +Example: unblocking a stuck election with ``--allow-data-loss`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In this example ``node1`` (primary) and ``node3`` (a quorum standby) have +both failed. ``node2`` reaches ``report_lsn`` and waits indefinitely because +``node3`` never reported its LSN:: + + $ pg_autoctl show state + Name | Node | Host:Port | LSN | Connection | Current State | Assigned State + ------+-------+----------------+-----------+--------------+---------------------+-------------------- + node1 | 1 | localhost:5501 | 0/0 | none | draining | draining + node2 | 2 | localhost:5502 | 0/5001F00 | read-only | report_lsn | report_lsn + node3 | 3 | localhost:5503 | 0/0 | none | secondary | report_lsn + +``node3`` is assigned ``report_lsn`` but its current state is still +``secondary`` — it has not reported and likely never will. The election is +stuck. After confirming ``node3`` is permanently lost:: + + $ pg_autoctl perform failover --allow-data-loss + 11:24:05 INFO Disabling guard_data_loss for this failover (--allow-data-loss) + 11:24:05 INFO Listening monitor notifications about state changes in formation "default" and group 0 + Time | Name | Node | Host:Port | Current State | Assigned State + ---------+-------+-------+----------------+---------------------+-------------------- + 11:24:06 | node2 | 2 | localhost:5502 | report_lsn | prepare_promotion + 11:24:06 | node2 | 2 | localhost:5502 | prepare_promotion | prepare_promotion + 11:24:06 | node2 | 2 | localhost:5502 | prepare_promotion | stop_replication + 11:24:07 | node2 | 2 | localhost:5502 | stop_replication | wait_primary + 11:24:07 | node2 | 2 | localhost:5502 | wait_primary | wait_primary + + $ pg_autoctl show state + Name | Node | Host:Port | LSN | Connection | Current State | Assigned State + ------+-------+----------------+-----------+--------------+---------------------+-------------------- + node1 | 1 | localhost:5501 | 0/0 | none | draining | draining + node2 | 2 | localhost:5502 | 0/5001F00 | read-write | wait_primary | wait_primary + node3 | 3 | localhost:5503 | 0/0 | none | secondary | report_lsn + +``node2`` is now in ``wait_primary``. It will become ``primary`` as soon as +either ``node1`` or ``node3`` reconnects and joins as a standby. diff --git a/docs/requirements.txt b/docs/requirements.txt index 27103625a..d2bcf11b5 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,3 +2,4 @@ Sphinx==8.2.3 sphinx-rtd-theme==3.0.2 docutils==0.21.2 readthedocs-sphinx-search==0.1.0 +sphinxcontrib-mermaid==2.0.3 diff --git a/docs/tikz/Makefile b/docs/tikz/Makefile index 84567c935..016a6e7ef 100644 --- a/docs/tikz/Makefile +++ b/docs/tikz/Makefile @@ -1,4 +1,4 @@ -SRC = $(wildcard arch*.tex fsm.tex) +SRC = $(wildcard arch*.tex fsm.tex seq-*.tex) PDF = $(SRC:.tex=.pdf) SVG = $(SRC:.tex=.svg) PNG = $(SRC:.tex=.png) diff --git a/docs/tikz/seq-normal-failover.svg b/docs/tikz/seq-normal-failover.svg new file mode 100644 index 000000000..63645417d --- /dev/null +++ b/docs/tikz/seq-normal-failover.svg @@ -0,0 +1,776 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/tikz/seq-normal-failover.tex b/docs/tikz/seq-normal-failover.tex new file mode 100644 index 000000000..04f766acd --- /dev/null +++ b/docs/tikz/seq-normal-failover.tex @@ -0,0 +1,125 @@ +\RequirePackage{luatex85} +\documentclass[border=10pt,12pt]{standalone} + +\usepackage{cfr-lm} +\usepackage{amssymb} +\usepackage{pgf} +\usepackage{tikz} +\usetikzlibrary{arrows.meta,calc,positioning} + +\begin{document} + +\sffamily\bfseries + +%% Actor x-positions (cm): wider spacing avoids mid-arrow label clashes +%% P=0 B=8 C=15 M=23 +\def\px{0} +\def\bx{8} +\def\cx{15} +\def\mx{23} +\def\ybot{14.2} + +\begin{tikzpicture}[ + >=Stealth, + yscale=-1, +] + +\input{common.tex} + +\tikzstyle{actor}=[rectangle, minimum width=3.2cm, minimum height=0.7cm, + inner sep=4pt, rounded corners=2pt, align=center, font=\bfseries] +\tikzstyle{ll}=[densely dashed, gray!60, line width=0.5pt] +\tikzstyle{lldead}=[densely dashed, red!40, line width=0.5pt] +\tikzstyle{msg}=[->, line width=0.65pt] +\tikzstyle{rsp}=[->, densely dashed, line width=0.65pt] +\tikzstyle{lbl}=[font=\small, inner sep=2pt] +\tikzstyle{note}=[rectangle, rounded corners=2pt, inner sep=5pt, + font=\small, align=center] +\tikzstyle{seclbl}=[font=\small\itshape, text=gray!70!black, anchor=west] + +%% ── actor headers ─────────────────────────────────────────────────────── +\node[actor, fill=pbox, text=ptxt] (Ph) at (\px,0) {Primary}; +\node[actor, fill=sbox, text=stxt] (Bh) at (\bx,0) {Standby B}; +\node[actor, fill=sbox, text=stxt] (Ch) at (\cx,0) {Standby C}; +\node[actor, fill=mbox, text=mtxt] (Mh) at (\mx,0) {Monitor}; + +%% lifelines +\draw[ll] (\px,0.35) -- (\px,\ybot); +\draw[ll] (\bx,0.35) -- (\bx,\ybot); +\draw[ll] (\cx,0.35) -- (\cx,\ybot); +\draw[ll] (\mx,0.35) -- (\mx,\ybot); + +%% ── steady state ──────────────────────────────────────────────────────── +\node[seclbl] at (-2.2,1.3) {steady state}; + +\draw[msg] (\px,1.6) -- (\bx,1.6) + node[lbl,midway,above] {WAL stream}; +\draw[msg] (\px,2.3) -- (\cx,2.3) + node[lbl,midway,above] {WAL stream}; + +%% ── client commit ─────────────────────────────────────────────────────── +\node[seclbl] at (-2.2,3.3) {client commit}; + +%% Arrow P→B; label near P to stay clear of C's lifeline +\draw[msg, color=pbox, line width=0.8pt] + (\px,3.6) -- (\bx,3.6); +\node[lbl, above, text=pbox] at (\px+2.4, 3.6) + {wait flush + ack}; +\node[lbl, below, text=pbox, font=\small\itshape] at (\px+2.4, 3.6) + {(ANY 1 of B or C)}; + +\draw[rsp] (\bx,4.5) -- (\px,4.5) + node[lbl,midway,above] {ack}; + +\node[note, fill=green!15, draw=green!50!black] + at (\px+1.8, 5.3) {\small commit confirmed $\checkmark$}; + +%% ── failover ──────────────────────────────────────────────────────────── +\node[seclbl] at (-2.2,6.2) {failover}; + +\node[note, fill=red!20, draw=red!60] + at (\px, 6.5) {\small\bfseries\color{red!80!black} $\times$~primary fails}; + +%% red dashed lifeline after failure +\draw[lldead] (\px,6.85) -- (\px,\ybot); + +%% Monitor → B: label near Monitor end (pos≈0.10) to clear C lifeline +\draw[msg] (\mx,7.5) -- (\bx,7.5); +\node[lbl, above] at (\mx-2.0, 7.5) {assign report\_lsn}; + +%% Monitor → C +\draw[msg] (\mx,8.3) -- (\cx,8.3) + node[lbl,midway,above] {assign report\_lsn}; + +%% B → Monitor: label near B to clear C lifeline +\draw[rsp] (\bx,9.2) -- (\mx,9.2); +\node[lbl, above] at (\bx+2.2, 9.2) {LSN = 0/5001F00}; + +%% C → Monitor +\draw[rsp] (\cx,10.0) -- (\mx,10.0) + node[lbl,midway,above] {LSN = 0/4FFF000}; + +%% Monitor note: B most advanced — placed below M lifeline, above next arrow +\node[note, fill=yellow!30, draw=orange!60] + at (\mx, 11.0) {\small B most advanced $\Rightarrow$ select B}; + +%% Monitor → B: assign prepare_promotion — label near Monitor end +\draw[msg, color=pbox, line width=1pt] + (\mx,12.0) -- (\bx,12.0); +\node[lbl, above, text=pbox, font=\small\bfseries] at (\mx-2.8, 12.0) + {assign prepare\_promotion}; + +%% outcome bar (midpoint of B–C span = (8+15)/2 = 11.5) +\node[note, fill=green!15, draw=green!50!black, + font=\small\bfseries, minimum width=8cm] + at (11.5, 13.1) {$\checkmark$~~B becomes primary \textemdash\ no data loss}; + +%% ── actor footers ─────────────────────────────────────────────────────── +\node[actor, fill=red!25, draw=red!60, text=red!80!black] + at (\px, \ybot+0.35) {\small Primary $\times$}; +\node[actor, fill=sbox, text=stxt] at (\bx, \ybot+0.35) {Standby B}; +\node[actor, fill=sbox, text=stxt] at (\cx, \ybot+0.35) {Standby C}; +\node[actor, fill=mbox, text=mtxt] at (\mx, \ybot+0.35) {Monitor}; + +\end{tikzpicture} +\end{document} diff --git a/docs/tikz/seq-stuck-failover.svg b/docs/tikz/seq-stuck-failover.svg new file mode 100644 index 000000000..709aeab01 --- /dev/null +++ b/docs/tikz/seq-stuck-failover.svg @@ -0,0 +1,1106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/tikz/seq-stuck-failover.tex b/docs/tikz/seq-stuck-failover.tex new file mode 100644 index 000000000..efe2d136d --- /dev/null +++ b/docs/tikz/seq-stuck-failover.tex @@ -0,0 +1,136 @@ +\RequirePackage{luatex85} +\documentclass[border=10pt,12pt]{standalone} + +\usepackage{cfr-lm} +\usepackage{amssymb} +\usepackage{pgf} +\usepackage{tikz} +\usetikzlibrary{arrows.meta,calc,positioning} + +\begin{document} + +\sffamily\bfseries + +%% Actor x-positions (cm) +%% P=0 B=8 C=15 M=23 +\def\px{0} +\def\bx{8} +\def\cx{15} +\def\mx{23} +\def\ybot{16.2} + +\begin{tikzpicture}[ + >=Stealth, + yscale=-1, +] + +\input{common.tex} + +\tikzstyle{actor}=[rectangle, minimum width=3.2cm, minimum height=0.7cm, + inner sep=4pt, rounded corners=2pt, align=center, font=\bfseries] +\tikzstyle{ll}=[densely dashed, gray!60, line width=0.5pt] +\tikzstyle{lldead}=[densely dashed, red!40, line width=0.5pt] +\tikzstyle{msg}=[->, line width=0.65pt] +\tikzstyle{rsp}=[->, densely dashed, line width=0.65pt] +\tikzstyle{lbl}=[font=\small, inner sep=2pt] +\tikzstyle{note}=[rectangle, rounded corners=2pt, inner sep=5pt, + font=\small, align=center] +\tikzstyle{seclbl}=[font=\small\itshape, text=gray!70!black, anchor=west] + +%% ── actor headers ─────────────────────────────────────────────────────── +\node[actor, fill=pbox, text=ptxt] at (\px,0) {Primary}; +\node[actor, fill=sbox, text=stxt] at (\bx,0) {Standby B}; +\node[actor, fill=sbox, text=stxt] at (\cx,0) {Standby C}; +\node[actor, fill=mbox, text=mtxt] at (\mx,0) {Monitor}; + +%% lifelines (all start alive) +\draw[ll] (\px,0.35) -- (\px,\ybot); +\draw[ll] (\bx,0.35) -- (\bx,\ybot); +\draw[ll] (\cx,0.35) -- (\cx,\ybot); +\draw[ll] (\mx,0.35) -- (\mx,\ybot); + +%% ── steady state ──────────────────────────────────────────────────────── +\node[seclbl] at (-2.2,1.3) {steady state}; + +\draw[msg] (\px,1.6) -- (\bx,1.6) node[lbl,midway,above] {WAL stream}; +\draw[msg] (\px,2.3) -- (\cx,2.3) node[lbl,midway,above] {WAL stream}; + +%% ── double failure ────────────────────────────────────────────────────── +\node[seclbl] at (-2.2,3.3) {double failure}; + +%% Two separate failure boxes, one per failed node +\node[note, fill=red!20, draw=red!60] + at (\px, 3.65) {\small\bfseries\color{red!80!black} $\times$~primary fails}; +\node[note, fill=red!20, draw=red!60] + at (\bx, 3.65) {\small\bfseries\color{red!80!black} $\times$~Standby B fails}; + +%% Red lifelines after failure +\draw[lldead] (\px,4.05) -- (\px,\ybot); +\draw[lldead] (\bx,4.05) -- (\bx,\ybot); + +%% ── report_lsn election ───────────────────────────────────────────────── +\node[seclbl] at (-2.2,5.0) {report\_lsn election}; + +%% Monitor → C: only surviving standby +\draw[msg] (\mx,5.3) -- (\cx,5.3) + node[lbl,midway,above] {assign report\_lsn}; + +%% C → Monitor +\draw[rsp] (\cx,6.2) -- (\mx,6.2) + node[lbl,midway,above] {LSN = 0/4FFF000}; + +%% ── monitor blocked ───────────────────────────────────────────────────── +\node[seclbl] at (-2.2,7.2) {stuck}; + +%% Block note on Monitor lifeline +\node[note, fill=red!15, draw=red!50, text width=5.5cm] + at (\mx, 8.1) + {\bfseries\color{red!80!black} BLOCKED\\[3pt] + \normalfont quorum member B has not\\ + reported its LSN yet\\[2pt] + promoting could discard\\ + committed transactions}; + +%% wait loop arrow (self-arrow on Monitor) +\draw[gray!50, line width=0.5pt, ->] + (\mx+0.5, 7.2) .. controls (\mx+2.0,7.5) and (\mx+2.0,9.0) .. (\mx+0.5,9.3) + node[right, font=\small\itshape, text=gray!70] {waiting\ldots}; + +%% ── operator action ───────────────────────────────────────────────────── +\node[seclbl] at (-2.2,10.4) {operator action}; + +%% (px+cx)/2 = 7.5 ; (cx+mx)/2 = 19.0 ; (px+mx)/2 = 11.5 +\node[note, fill=blue!10, draw=blue!40, text width=8.0cm] + at (7.5, 10.8) + {\ttfamily\small pg\_autoctl perform failover --allow-data-loss}; + +%% operator arrow → Monitor +\draw[msg, color=blue!60, line width=0.8pt] + (11.5, 11.4) -- (\mx-1.6, 11.4); + +%% Monitor → C: assign prepare_promotion — label near Monitor end +\draw[msg, color=pbox, line width=1pt] + (\mx,12.4) -- (\cx,12.4); +\node[lbl, above, text=pbox, font=\small\bfseries] at (\mx-2.8,12.4) + {assign prepare\_promotion}; + +%% outcome +\node[note, fill=yellow!20, draw=orange!60, font=\small\bfseries] + at (19.0, 13.5) {C becomes primary}; + +\node[note, fill=red!10, draw=red!40, text width=10cm, font=\small] + at (11.5, 14.7) + {\color{red!80!black}\bfseries Warning:~\normalfont + transactions acknowledged by B but not yet\\ + replicated to C may be permanently lost}; + +%% ── actor footers ─────────────────────────────────────────────────────── +\node[actor, fill=red!25, draw=red!60, text=red!80!black] + at (\px, \ybot+0.35) {\small Primary $\times$}; +\node[actor, fill=red!25, draw=red!60, text=red!80!black] + at (\bx, \ybot+0.35) {\small Standby B $\times$}; +\node[actor, fill=sbox, text=stxt] at (\cx, \ybot+0.35) {Standby C}; +\node[actor, fill=mbox, text=mtxt] at (\mx, \ybot+0.35) {Monitor}; + +\end{tikzpicture} +\end{document} diff --git a/tests/tap/specs/ensure.pgaf b/tests/tap/specs/ensure.pgaf index 7069b2786..7af5096c4 100644 --- a/tests/tap/specs/ensure.pgaf +++ b/tests/tap/specs/ensure.pgaf @@ -64,7 +64,7 @@ step test_004_demoted { # wait_primary the monitor has committed to node2 as the new primary; when # node1 restarts it will come back as secondary, and node2 will then # transition from wait_primary → primary. - wait until node2 state is wait_primary timeout 90s + wait until node2 state is wait_primary timeout 180s # Bring node1 back: pg_autoctl starts, connects to monitor, gets assigned # 'demoted', executes the demoted transition, then moves to secondary. # The Python test explicitly asserts the 'demoted' state, but 'demoted' is