From b48f651729b9837a3faaabfa0cb771c79bedf475 Mon Sep 17 00:00:00 2001 From: Jan Nidzwetzki Date: Mon, 15 Jun 2026 13:45:05 +0200 Subject: [PATCH v7] Shut down when recovery cannot pause at the target Previously, with recovery_target_action = 'pause', the server could silently skip the pause and finish recovery if hot standby was not yet active, allowing writes instead. This could happen even after recovery had reached a consistent state, for example when subtransaction overflow left the initial recovery snapshot incomplete. Users then lost the opportunity to inspect the recovered database before deciding whether to end recovery or restart with a later target. Fix this by shutting down in this case, as with recovery_target_action = 'shutdown'. If a promotion request has been received, give it priority and proceed with promotion instead. Shutting down preserves the option to restart recovery with a later target and is consistent with treating 'pause' as 'shutdown' when hot_standby is disabled. Reported-by: Matt Blewitt Author: Matt Blewitt Author: Jan Nidzwetzki Reviewed-by: Michael Paquier Reviewed-by: Zsolt Parragi Reviewed-by: Fujii Masao Discussion: https://postgr.es/m/CACy-Nv24ZORVN9_S_yHF5Nsip45HKCBtKVNC3XdKgz+1wvGvEQ@mail.gmail.com --- doc/src/sgml/config.sgml | 5 + src/backend/access/transam/xlogrecovery.c | 16 +++ src/test/recovery/meson.build | 1 + .../recovery/t/061_recovery_pause_subxid.pl | 117 ++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 src/test/recovery/t/061_recovery_pause_subxid.pl diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index f7db2995c3e..1d8653325fc 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -4662,6 +4662,11 @@ restore_command = 'copy "C:\\server\\archivedir\\%f" "%p"' # Windows This setting has no effect if no recovery target is set. If is not enabled, a setting of pause will act the same as shutdown. + Likewise, if recovery has reached a consistent state but hot standby + has not yet become active when the recovery target is reached, + a setting of pause will act the same as + shutdown. This can happen, for example, when the + replayed standby snapshots remain overflowed. If the recovery target is reached while a promotion is ongoing, a setting of pause will act the same as promote. diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index 52ac6d58423..1ea116b9ce0 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -1855,6 +1855,22 @@ PerformWalRecovery(void) proc_exit(3); case RECOVERY_TARGET_ACTION_PAUSE: + + /* + * recoveryPausesHere() does nothing if hot standby is + * inactive. Shut down in that case instead of silently + * promoting, so the user can choose a different recovery + * target or action. However, honor any pending promotion + * request by falling through to promote below. + */ + if (!LocalHotStandbyActive && !CheckForStandbyTrigger()) + { + ereport(LOG, + (errmsg("recovery cannot pause at the recovery target because hot standby is not active"), + errhint("Set \"recovery_target_action\" to \"promote\", or choose a recovery target at which hot standby can be enabled."))); + proc_exit(3); + } + SetRecoveryPause(true); recoveryPausesHere(true); diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index b6ac9dd07a7..c31505c99ab 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -69,6 +69,7 @@ tests += { 't/058_shutdown_crash_restart.pl', 't/059_remote_apply_status_interval.pl', 't/060_multixact_crashed_updater.pl', + 't/061_recovery_pause_subxid.pl', ], }, } diff --git a/src/test/recovery/t/061_recovery_pause_subxid.pl b/src/test/recovery/t/061_recovery_pause_subxid.pl new file mode 100644 index 00000000000..91f8d11b0c7 --- /dev/null +++ b/src/test/recovery/t/061_recovery_pause_subxid.pl @@ -0,0 +1,117 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Verify that recovery_target_action = pause shuts down the standby at the +# recovery target if hot standby cannot start because of subxid overflow. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +# Initialize the primary with WAL archiving enabled. +my $node_primary = PostgreSQL::Test::Cluster->new('primary'); +$node_primary->init(allows_streaming => 1, has_archiving => 1); +$node_primary->start; + +# Create a table for the subtransactions to write to. +$node_primary->safe_psql('postgres', 'CREATE TABLE subxid_test (id int)'); + +# Open a transaction with more than PGPROC_MAX_CACHED_SUBXIDS (64) +# subtransactions and keep it open to force a snapshot overflow. The INSERT +# (not the SAVEPOINT) is what assigns each subtransaction its XID. +my $bg = $node_primary->background_psql('postgres'); +$bg->query_safe('BEGIN'); +for my $i (1 .. 70) +{ + $bg->query_safe("SAVEPOINT s$i; INSERT INTO subxid_test VALUES ($i)"); +} + +# Take a base backup while the transaction's subxid cache is overflowed, +# so the initial standby snapshot is incomplete. +my $backup_name = 'my_backup'; +$node_primary->backup($backup_name); + +# Log another overflowed standby snapshot and choose a recovery target +# after it. +$node_primary->safe_psql('postgres', 'CHECKPOINT'); +my $until_lsn = $node_primary->lsn('write'); + +# Switch WAL segments so the WAL needed to reach the recovery target can +# be archived. +$node_primary->safe_psql('postgres', 'SELECT pg_switch_wal()'); + +# Create a standby that restores WAL from the archive and pauses at the +# recovery target. +my $node_standby = PostgreSQL::Test::Cluster->new('standby'); +$node_standby->init_from_backup($node_primary, $backup_name, + has_restoring => 1); + +# Enable DEBUG1 messages to check the recovery snapshot state. +$node_standby->append_conf( + 'postgresql.conf', qq( +recovery_target_lsn = '$until_lsn' +recovery_target_action = pause +log_min_messages = debug1 +)); + +# Use pg_ctl directly because shutdown at the recovery target returns +# success, but leaves no postmaster.pid for Cluster::start() to read. +run_log( + [ + 'pg_ctl', + '--pgdata' => $node_standby->data_dir, + '--log' => $node_standby->logfile, + 'start', + ]); + +# Wait for shutdown or unexpected readiness so the checks below can report +# a failure without waiting for a timeout if the server accepts connections. +$node_standby->wait_for_log( + qr/database system is shut down|database system is ready to accept/); + +my $logfile = slurp_file($node_standby->logfile()); + +# Verify that recovery snapshots remained pending. +like( + $logfile, + qr/recovery snapshot waiting for non-overflowed snapshot/, + 'standby waited for a valid recovery snapshot'); + +unlike( + $logfile, + qr/recovery snapshots are now enabled/, + 'standby never reached STANDBY_SNAPSHOT_READY'); + +# Verify the reason for shutdown and the shutdown path. +like( + $logfile, + qr/recovery cannot pause at the recovery target because hot standby is not active/, + 'standby could not pause because hot standby was inactive'); + +like( + $logfile, + qr/shutdown at recovery target/, + 'standby entered the shutdown-at-recovery-target path'); + +# The standby must not become ready to accept connections. +unlike( + $logfile, + qr/database system is ready to accept/, + 'standby never became ready to accept connections'); + +# Close the held-open transaction on the primary. +$bg->quit; + +# Stop the standby directly in case it is still running, since Cluster.pm +# does not track its PID. Failure is expected if it has already shut down. +run_log( + [ + 'pg_ctl', + '--pgdata' => $node_standby->data_dir, + '--mode' => 'immediate', + 'stop', + ]); +$node_primary->teardown_node; + +done_testing(); -- 2.56.0