From f35306f0eec6d8a418c613240d946da4247b4807 Mon Sep 17 00:00:00 2001 From: Vaibhav Dalvi Date: Mon, 19 Oct 2026 06:33:54 +0000 Subject: [PATCH v2 1/1] walreceiver: Do not leave stale xmin on primary when status interval is zero When hot_standby_feedback is on and wal_receiver_status_interval is zero, the walreceiver sends hot standby feedback only once after it connects, or when the configuration is reloaded, provided that Hot Standby is active at that time. That message was carrying a valid xmin, as only hot_standby_feedback was checked. After that, no more feedback was sent, because the wakeup time is infinite and the early return skips the function. So the primary kept the old xmin till the next reconnect, and it was holding back vacuum. The same happened when wal_receiver_status_interval was changed from a positive value to zero and the configuration was reloaded: the feedback sent on reload carried a fresh valid xmin, and it was never released. Fix it by sending InvalidTransactionId when wal_receiver_status_interval is zero. This also sets primary_has_standby_xmin to false, so the later calls return early, and the primary is not holding any xmin for the standby. This does not cover the case where Hot Standby is not yet active when the walreceiver connects. Then no feedback is sent at all, and the wakeup is not rearmed when it is infinite, so an old xmin left on a slot by a previous connection is not released. That is a separate problem and is not addressed here. Add a TAP test for this. It uses a physical replication slot, so that the xmin kept by the primary is visible in pg_replication_slots.xmin and survives a disconnection. It covers these cases: - A positive interval sends a valid xmin (control). - Changing the interval from a positive value to zero on reload releases the xmin. - Connecting with an interval of zero, while the slot still has an old xmin, does not leave a valid xmin. The walreceiver is made to reconnect before the check, so Hot Standby is surely active when the first feedback is computed. The two cases with a zero interval check the DEBUG2 message logged by the walreceiver on the standby, which does not depend on when the primary processes the feedback. They then poll until the slot xmin becomes NULL. The xmin is valid before the step under test, so the poll cannot pass early, and it times out if a stale xmin is left behind. Author: Vaibhav Dalvi Discussion: https://www.postgresql.org/message-id/flat/CA+vB=AHvkSsYXNNiVKakQtOEdyQBv7kob=LGESOdOC7AC0M9Ow@mail.gmail.com --- src/backend/replication/walreceiver.c | 6 +- src/test/recovery/meson.build | 1 + ...61_hot_standby_feedback_status_interval.pl | 102 ++++++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 src/test/recovery/t/061_hot_standby_feedback_status_interval.pl diff --git a/src/backend/replication/walreceiver.c b/src/backend/replication/walreceiver.c index ce28ba623cd..87df17e3e56 100644 --- a/src/backend/replication/walreceiver.c +++ b/src/backend/replication/walreceiver.c @@ -1333,9 +1333,11 @@ XLogWalRcvSendHSFeedback(bool immed) /* * Make the expensive call to get the oldest xmin once we are certain - * everything else has been checked. + * everything else has been checked. With wal_receiver_status_interval + * disabled no further feedback is sent, so don't leave a valid xmin + * behind on the primary. */ - if (hot_standby_feedback) + if (hot_standby_feedback && wal_receiver_status_interval > 0) { GetReplicationHorizons(&xmin, &catalog_xmin); } diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index b6ac9dd07a7..8d9558737b6 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_hot_standby_feedback_status_interval.pl', ], }, } diff --git a/src/test/recovery/t/061_hot_standby_feedback_status_interval.pl b/src/test/recovery/t/061_hot_standby_feedback_status_interval.pl new file mode 100644 index 00000000000..62c33ea7b72 --- /dev/null +++ b/src/test/recovery/t/061_hot_standby_feedback_status_interval.pl @@ -0,0 +1,102 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Check that a standby with hot_standby_feedback = on and +# wal_receiver_status_interval = 0 does not leave a stale xmin on the primary. +# +# With the interval set to zero, the walreceiver sends hot standby feedback +# at most once after a (re)configuration or a connection. If that message +# carries a valid xmin, the primary keeps it until the next message, which +# never comes, and it holds back vacuum. So the walreceiver must send an +# invalid xmin in this case. +# +# A physical replication slot is used to make the xmin visible on the primary +# in pg_replication_slots.xmin, as that is what holds back vacuum and it +# survives a disconnection. The first check is a control with a positive +# interval, and it only waits for the slot to get an xmin. Each check with a +# zero interval has two parts: +# +# - The DEBUG2 message that the walreceiver logs for each feedback message it +# sends, which does not depend on when the primary processes the message. +# - The slot xmin on the primary. The xmin is valid before the step under +# test, so we poll until it becomes NULL. This never passes early, and it +# times out if a stale xmin is left behind. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->start; +$primary->backup('bkp'); +$primary->safe_psql('postgres', + "SELECT pg_create_physical_replication_slot('sb_slot')"); + +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bkp', has_streaming => 1); +$standby->append_conf( + 'postgresql.conf', qq( +primary_slot_name = 'sb_slot' +hot_standby_feedback = on +wal_receiver_status_interval = 1 +wal_receiver_timeout = 0 +wal_retrieve_retry_interval = 100ms +log_min_messages = 'warning, walreceiver:debug2' +)); +$standby->start; + +my $slot_xmin_set = "SELECT xmin IS NOT NULL FROM pg_replication_slots + WHERE slot_name = 'sb_slot'"; +my $slot_xmin_null = "SELECT xmin IS NULL FROM pg_replication_slots + WHERE slot_name = 'sb_slot'"; +my $feedback_no_xmin = + qr/sending hot standby feedback xmin 0 epoch \d+ catalog_xmin 0 catalog_xmin_epoch \d+/; + +# Control: with a positive interval, feedback with a valid xmin is sent and +# the slot gets an xmin. +$primary->poll_query_until('postgres', $slot_xmin_set) + or die "timed out waiting for the slot to get the standby xmin"; +pass('slot gets the standby xmin with wal_receiver_status_interval > 0'); + +# Changing the interval to zero on reload must release the xmin. The +# walreceiver sends feedback right away on a configuration reload. +my $log_offset = -s $standby->logfile; +$standby->append_conf('postgresql.conf', "wal_receiver_status_interval = 0"); +$standby->reload; +$standby->wait_for_log($feedback_no_xmin, $log_offset); +pass('feedback without xmin is sent when the interval is set to 0'); +$primary->poll_query_until('postgres', $slot_xmin_null) + or die "timed out waiting for the slot xmin to be released"; +pass('slot xmin is released when the interval is set to 0'); + +# Now check the feedback sent at connection time. Get a valid xmin on the +# slot again, then restart the standby with the interval set to zero. The +# slot keeps the old xmin while the standby is down. +$standby->append_conf('postgresql.conf', "wal_receiver_status_interval = 1"); +$standby->reload; +$primary->poll_query_until('postgres', $slot_xmin_set) + or die "timed out waiting for the slot to get the standby xmin again"; +$standby->stop; +$standby->append_conf('postgresql.conf', "wal_receiver_status_interval = 0"); +$standby->start; + +# The first feedback message is sent as soon as the walreceiver connects, and +# at that time Hot Standby may not be active yet, in which case nothing is +# sent. Make the walreceiver connect again now that the standby accepts +# connections, so that the feedback computed at connection time is always the +# one that is checked. +$log_offset = -s $standby->logfile; +$primary->safe_psql('postgres', + "SELECT pg_terminate_backend(pid) FROM pg_stat_replication"); +$standby->wait_for_log($feedback_no_xmin, $log_offset); +pass('feedback sent at connection has no xmin with wal_receiver_status_interval = 0'); +$primary->poll_query_until('postgres', $slot_xmin_null) + or die "timed out waiting for the slot xmin to be released"; +pass('slot has no stale xmin after connecting with wal_receiver_status_interval = 0'); + +$standby->stop; +$primary->stop; + +done_testing(); -- 2.43.0