From f8d792a5218562413bb9f80a9559b488554331e2 Mon Sep 17 00:00:00 2001 From: Ashutosh Sharma Date: Mon, 21 Sep 2026 05:47:31 +0000 Subject: [PATCH] Allow standby to switch its WAL source from archive to streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normally, a standby only switches to streaming replication (pulling WAL from the primary) after it finishes reading everything available in the WAL archive, or after that archive read fails outright. The problem is that while the standby is busy pulling from the archive, its replication slot sits inactive so the primary has no signal that the WAL is actually being consumed. As a result, the primary just keeps accumulating WAL that the standby will eventually need, since it can't tell the standby has already caught up on what's in the archive. This gets worse with the slot sync feature introduced in PG17. When synchronized_standby_slots is configured, the primary withholds logical streaming until every listed physical standby slot has confirmed receipt of that WAL. Because logical slots are tightly coupled to the physical slot representing the failover-candidate standby, a stall in that physical slot caused by the standby being stuck in archive mode — stalls logical replication too, not just physical. On top of that, if hot_standby_feedback is on, the same stalled slot can end up blocking VACUUM, leading to bloat on the primary. To address this, a new GUC is introduced that sets a time limit: once that limit is reached, the standby will attempt to switch its WAL source from the archive over to streaming replication. Before switching, though, the standby first works through whatever WAL is already sitting in pg_wal. And if the attempt to switch to streaming fails, the standby simply falls back to archive mode. Author: Bharath Rupireddy Author: Ashutosh Sharma Reviewed-by: Cary Huang, Nathan Bossart Reviewed-by: Kyotaro Horiguchi, SATYANARAYANA NARLAPURAM Reviewed-by: Michael Paquier Discussion: https://www.postgresql.org/message-id/ CAHg+QDdLmfpS0n0U3U+e+dw7X7jjEOsJJ0aLEsrtxs-tUyf5Ag@mail.gmail.com --- doc/src/sgml/config.sgml | 28 +++++++ doc/src/sgml/high-availability.sgml | 5 +- src/backend/access/transam/xlogrecovery.c | 69 +++++++++++++-- src/backend/utils/misc/guc_parameters.dat | 10 +++ src/backend/utils/misc/postgresql.conf.sample | 3 + src/include/access/xlogrecovery.h | 1 + src/test/recovery/meson.build | 1 + src/test/recovery/t/057_wal_source_switch.pl | 84 +++++++++++++++++++ src/tools/pgindent/typedefs.list | 1 + 9 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 src/test/recovery/t/057_wal_source_switch.pl diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index 0165eb9ec02..c7d29348c6f 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -5612,6 +5612,34 @@ ANY num_sync ( + streaming_replication_retry_interval (integer) + + streaming_replication_retry_interval configuration parameter + + + + + Specifies how long a standby should read WAL from the archive before + attempting to switch to streaming replication. Before switching, the + standby consumes all WAL already present in + pg_wal. If streaming cannot be started, the + standby returns to archive recovery. If this value is specified + without units, it is taken as seconds. The default is zero, which + disables this feature. This parameter can only be set in the + postgresql.conf file or on the server command + line. + + + + A switch attempt might occur later than the configured interval. For + example, recovery finishes processing the current WAL segment before + checking whether the interval has elapsed. + + + + + recovery_min_apply_delay (integer) diff --git a/doc/src/sgml/high-availability.sgml b/doc/src/sgml/high-availability.sgml index 7253f9d8287..c0dc7895bd4 100644 --- a/doc/src/sgml/high-availability.sgml +++ b/doc/src/sgml/high-availability.sgml @@ -628,7 +628,10 @@ protocol to make nodes agree on a serializable transactional order. In standby mode, the server continuously applies WAL received from the primary server. The standby server can read WAL from a WAL archive (see ) or directly from the primary - over a TCP connection (streaming physical replication). The standby server will + over a TCP connection (streaming physical replication). When + is set, the + standby can attempt to switch from archive recovery to streaming + replication before exhausting the archive. The standby server will also attempt to restore any WAL found in the standby cluster's pg_wal directory. That typically happens after a server restart, when the standby replays again WAL that was streamed from the diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index fff8d57ac61..1cdd86ad5c0 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -96,6 +96,7 @@ TimestampTz recoveryTargetTime; char *recoveryTargetName; XLogRecPtr recoveryTargetLSN; int recovery_min_apply_delay = 0; +int streaming_replication_retry_interval = 0; /* options formerly taken from recovery.conf for XLOG streaming */ char *PrimaryConnInfo = NULL; @@ -251,6 +252,8 @@ static XLogSource readSource = XLOG_FROM_ANY; static XLogSource currentSource = XLOG_FROM_ANY; static bool lastSourceFailed = false; static bool pendingWalRcvRestart = false; +static bool switchToStreamingPending = false; +static TimestampTz switched_to_archive_at = 0; /* * These variables track when we last obtained some WAL data to process, @@ -386,6 +389,7 @@ static bool rescanLatestTimeLine(TimeLineID replayTLI, XLogRecPtr replayLSN); static int XLogFileRead(XLogSegNo segno, TimeLineID tli, XLogSource source, bool notfoundOk); static int XLogFileReadAnyTLI(XLogSegNo segno, XLogSource source); +static bool ShouldSwitchWALSourceToStreaming(void); static bool CheckForStandbyTrigger(void); static void SetPromoteIsTriggered(void); @@ -3555,6 +3559,7 @@ WaitForWALToBecomeAvailable(XLogRecPtr RecPtr, bool randAccess, static TimestampTz last_fail_time = 0; TimestampTz now; bool streaming_reply_sent = false; + XLogSource readFrom; /*------- * Standby mode is implemented by a state machine: @@ -3602,6 +3607,9 @@ WaitForWALToBecomeAvailable(XLogRecPtr RecPtr, bool randAccess, * happened outside this function, e.g when a CRC check fails on a * record, or within this loop. */ + if (streaming_replication_retry_interval <= 0) + switchToStreamingPending = false; + if (lastSourceFailed) { /* @@ -3760,10 +3768,23 @@ WaitForWALToBecomeAvailable(XLogRecPtr RecPtr, bool randAccess, } if (currentSource != oldSource) - elog(DEBUG2, "switched WAL source from %s to %s after %s", + { + if (currentSource == XLOG_FROM_ARCHIVE) + switched_to_archive_at = GetCurrentTimestamp(); + + elog(DEBUG1, "switched WAL source from %s to %s after %s", xlogSourceNames[oldSource], xlogSourceNames[currentSource], + switchToStreamingPending ? "timeout" : lastSourceFailed ? "failure" : "success"); + if (switchToStreamingPending) + { + Assert(oldSource == XLOG_FROM_ARCHIVE); + Assert(currentSource == XLOG_FROM_STREAM); + switchToStreamingPending = false; + } + } + /* * We've now handled possible failure. Try to read from the chosen * source. @@ -3791,13 +3812,24 @@ WaitForWALToBecomeAvailable(XLogRecPtr RecPtr, bool randAccess, if (randAccess) curFileTLI = 0; + if (!switchToStreamingPending) + switchToStreamingPending = ShouldSwitchWALSourceToStreaming(); + /* * Try to restore the file from archive, or read an existing - * file from pg_wal. + * file from pg_wal. Before switching to streaming, consume + * all WAL already present in pg_wal. */ - readFile = XLogFileReadAnyTLI(readSegNo, - currentSource == XLOG_FROM_ARCHIVE ? XLOG_FROM_ANY : - currentSource); + if (switchToStreamingPending) + { + Assert(currentSource == XLOG_FROM_ARCHIVE); + readFrom = XLOG_FROM_PG_WAL; + } + else + readFrom = currentSource == XLOG_FROM_ARCHIVE ? + XLOG_FROM_ANY : currentSource; + + readFile = XLogFileReadAnyTLI(readSegNo, readFrom); if (readFile >= 0) return XLREAD_SUCCESS; /* success! */ @@ -4034,6 +4066,33 @@ WaitForWALToBecomeAvailable(XLogRecPtr RecPtr, bool randAccess, return XLREAD_FAIL; /* not reached */ } +/* + * Check whether the standby should try streaming after spending the + * configured amount of time reading from the archive. + */ +static bool +ShouldSwitchWALSourceToStreaming(void) +{ + TimestampTz now; + + if (streaming_replication_retry_interval <= 0 || + !StandbyMode || currentSource != XLOG_FROM_ARCHIVE) + return false; + + now = GetCurrentTimestamp(); + if (switched_to_archive_at == 0) + { + switched_to_archive_at = now; + return false; + } + + if (TimestampDifferenceExceedsSeconds(switched_to_archive_at, now, + streaming_replication_retry_interval)) + return true; + + return false; +} + /* * Determine what log level should be used to report a corrupt WAL record diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index c57441f7d98..e6f7ca0e2e9 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -2922,6 +2922,16 @@ assign_hook => 'assign_stats_fetch_consistency', }, +{ name => 'streaming_replication_retry_interval', type => 'int', context => 'PGC_SIGHUP', group => 'REPLICATION_STANDBY', + short_desc => 'Sets the time after which a standby attempts to switch from archive recovery to streaming replication.', + long_desc => 'Zero disables this feature.', + flags => 'GUC_UNIT_S', + variable => 'streaming_replication_retry_interval', + boot_val => '0', + min => '0', + max => 'INT_MAX', +}, + { name => 'subtransaction_buffers', type => 'int', context => 'PGC_POSTMASTER', group => 'RESOURCES_MEM', short_desc => 'Sets the size of the dedicated buffer pool used for the subtransaction cache.', long_desc => '0 means use a fraction of "shared_buffers".', diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample index e759f06b50f..3029b76b2e1 100644 --- a/src/backend/utils/misc/postgresql.conf.sample +++ b/src/backend/utils/misc/postgresql.conf.sample @@ -405,6 +405,9 @@ # in milliseconds; 0 disables #wal_retrieve_retry_interval = 5s # time to wait before retrying to # retrieve WAL after a failed attempt +#streaming_replication_retry_interval = 0 # time after which standby + # attempts to switch WAL source from + # archive to streaming; 0 disables #recovery_min_apply_delay = 0 # minimum delay for applying changes during recovery #sync_replication_slots = off # enables slot synchronization on the physical standby from the primary diff --git a/src/include/access/xlogrecovery.h b/src/include/access/xlogrecovery.h index 8786b6d3a8e..5be72b6b58c 100644 --- a/src/include/access/xlogrecovery.h +++ b/src/include/access/xlogrecovery.h @@ -140,6 +140,7 @@ extern PGDLLIMPORT char *PrimarySlotName; extern PGDLLIMPORT char *recoveryRestoreCommand; extern PGDLLIMPORT char *recoveryEndCommand; extern PGDLLIMPORT char *archiveCleanupCommand; +extern PGDLLIMPORT int streaming_replication_retry_interval; /* indirectly set via GUC system */ extern PGDLLIMPORT TransactionId recoveryTargetXid; diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index 72113c5ac6e..93438988bae 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -65,6 +65,7 @@ tests += { 't/054_unlogged_sequence_promotion.pl', 't/055_cascade_reconnect.pl', 't/056_standby_snapshot_export.pl', + 't/057_wal_source_switch.pl', ], }, } diff --git a/src/test/recovery/t/057_wal_source_switch.pl b/src/test/recovery/t/057_wal_source_switch.pl new file mode 100644 index 00000000000..dc1bf79705b --- /dev/null +++ b/src/test/recovery/t/057_wal_source_switch.pl @@ -0,0 +1,84 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Test switching the WAL source from archive to streaming replication. +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1, has_archiving => 1); +$primary->append_conf( + 'postgresql.conf', qq( +checkpoint_timeout = 1h +autovacuum = off +)); +$primary->start; +$primary->safe_psql('postgres', + "SELECT pg_create_physical_replication_slot('standby_slot')"); +$primary->safe_psql('postgres', + "CREATE TABLE tab_int AS SELECT generate_series(1, 10) AS a"); + +my $backup_name = 'my_backup'; +$primary->backup($backup_name); + +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup( + $primary, $backup_name, + has_streaming => 1, + has_restoring => 1); + +my $retry_interval = 1; +$standby->append_conf( + 'postgresql.conf', qq( +primary_slot_name = 'standby_slot' +streaming_replication_retry_interval = '${retry_interval}s' +log_min_messages = 'debug2' +)); +$standby->start; +$primary->wait_for_catchup($standby); + +$standby->stop; +for my $i (1 .. 10) +{ + $primary->safe_psql('postgres', + "INSERT INTO tab_int VALUES (generate_series(11, 20));"); + $primary->safe_psql('postgres', "SELECT pg_switch_wal();"); +} + +my $current_lsn = + $primary->safe_psql('postgres', "SELECT pg_current_wal_lsn()"); +$primary->advance_wal(1); + +my $walfile_name = + $primary->safe_psql('postgres', "SELECT pg_walfile_name('$current_lsn')"); +$primary->poll_query_until('postgres', + "SELECT count(*) = 1 FROM pg_stat_archiver WHERE last_archived_wal = '$walfile_name';" +) or die "Timed out while waiting for archiving by primary"; + +my $log_offset = -s $standby->logfile; +my $delay = $retry_interval * 5; +$standby->append_conf( + 'postgresql.conf', qq( +recovery_min_apply_delay = '${delay}s' +)); +$standby->start; +$primary->wait_for_catchup($standby); + +$standby->wait_for_log( + qr/DEBUG: ( [A-Z0-9]+:)? switched WAL source from archive to stream after timeout/, + $log_offset); +$standby->wait_for_log( + qr/LOG: ( [A-Z0-9]+:)? started streaming WAL from primary at .* on timeline .*/, + $log_offset); + +my $primary_count = + $primary->safe_psql('postgres', "SELECT count(*) FROM tab_int;"); +my $standby_count = + $standby->safe_psql('postgres', "SELECT count(*) FROM tab_int;"); +is($primary_count, $standby_count, + 'data from primary is streamed to standby'); + +done_testing(); \ No newline at end of file diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 5d432074c2c..ac0aa1eb175 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -3388,6 +3388,7 @@ WALReadError WALSegmentCloseCB WALSegmentContext WALSegmentOpenCB +WALSourceSwitchState WCHAR WCOKind WFW_WaitOption -- 2.43.0