From b77916d929c5b1a2cbfe8eff39338d306752d5e5 Mon Sep 17 00:00:00 2001
From: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
Date: Thu, 20 Aug 2026 18:19:01 +0000
Subject: [PATCH v14 3/3] Invalidate XID-aged synced replication slots on a
 standby.

A synced slot on a standby carries a catalog_xmin. Unlike the WAL
a synced slot pins, which it holds only on the standby, its
catalog_xmin is sent to the primary's physical slot through HS
feedback and holds the catalog horizon back there. A synced slot
whose catalog_xmin has aged can therefore keep vacuum on the
primary from pruning dead catalog rows and freezing XIDs, holding
the primary back from below, even though the slot does no
decoding of its own on the standby.

This commit lets XID-age invalidation act on synced slots too, so
an aged synced slot on a standby is invalidated during a
restartpoint like any other slot. Once it is invalidated the slot
stops contributing its catalog_xmin, the standby reports an
advanced catalog_xmin through HS feedback, the catalog_xmin held
by the primary's physical slot advances, and vacuum on the
primary can proceed.

Author: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
Reviewed-by: Amit Kapila <amit.kapila16@gmail.com>
Reviewed-by: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Discussion: https://www.postgresql.org/message-id/CALj2ACWTz5fKMPa0wNghRFdfPBzc+j3x+kcXSeCo+3twZmDdyg@mail.gmail.com
Discussion: https://www.postgresql.org/message-id/CAA4eK1LsFi7GWtwtKwQm1kxgJWz_GvyDNSaO8=gr1zocPni-+g@mail.gmail.com
---
 doc/src/sgml/config.sgml                  | 16 +++--
 src/backend/replication/slot.c            | 14 ++--
 src/test/recovery/t/019_replslot_limit.pl | 86 +++++++++++++++++++++++
 3 files changed, 105 insertions(+), 11 deletions(-)

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index ac12da08bac..68d35e2bd29 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -5085,13 +5085,17 @@ HINT:  If it is safe for all REPLICATION users to use this library as an output
        </para>
 
        <para>
-        Note that this invalidation mechanism is not applicable for slots
-        on the standby server that are being synced from the primary server
-        (i.e., standby slots having
+        This mechanism also applies to slots on a standby server that are being
+        synced from the primary server (i.e., standby slots having
         <link linkend="view-pg-replication-slots">pg_replication_slots</link>.<structfield>synced</structfield>
-        value <literal>true</literal>). Synced slots are always considered to
-        be inactive because they don't perform logical decoding to produce
-        changes.
+        value <literal>true</literal>). A synced slot's
+        <literal>catalog_xmin</literal> is sent to the primary's physical slot
+        through the standby's <xref linkend="guc-hot-standby-feedback"/> and
+        holds the catalog horizon back there, so a synced slot whose
+        <literal>catalog_xmin</literal> has aged can keep vacuum on the primary
+        from pruning dead catalog rows and freezing XIDs. Invalidating such a
+        slot on the standby advances the <literal>catalog_xmin</literal> held by
+        the primary's physical slot, letting vacuum there proceed.
        </para>
       </listitem>
      </varlistentry>
diff --git a/src/backend/replication/slot.c b/src/backend/replication/slot.c
index c2bb5abde44..dd8db1bb74b 100644
--- a/src/backend/replication/slot.c
+++ b/src/backend/replication/slot.c
@@ -1931,17 +1931,21 @@ CanInvalidateIdleSlot(ReplicationSlot *s)
  *
  * 1. XID age limit is set
  * 2. Slot has a valid xmin or catalog_xmin
- * 3. The slot is not being synced from the primary while the server is in
- *	  recovery. This is because synced slots are always considered to be
- *	  inactive because they don't perform logical decoding to produce changes.
+ *
+ * Note that this includes synced slots on a standby. A synced slot's
+ * catalog_xmin is sent to the primary's physical slot through
+ * hot_standby_feedback and holds the catalog horizon back there, so a synced
+ * slot whose catalog_xmin has aged can keep vacuum on the primary from pruning
+ * dead catalog rows and freezing XIDs, even though it does no decoding of its
+ * own on the standby. Invalidating it advances the catalog_xmin held by the
+ * primary's physical slot, letting vacuum there proceed.
  */
 static inline bool
 CanInvalidateXidAgedSlot(ReplicationSlot *s)
 {
 	return (max_slot_xid_age != 0 &&
 			(TransactionIdIsValid(s->data.xmin) ||
-			 TransactionIdIsValid(s->data.catalog_xmin)) &&
-			!(RecoveryInProgress() && s->data.synced));
+			 TransactionIdIsValid(s->data.catalog_xmin)));
 }
 
 /*
diff --git a/src/test/recovery/t/019_replslot_limit.pl b/src/test/recovery/t/019_replslot_limit.pl
index 18ae27bb8d7..581af0b6375 100644
--- a/src/test/recovery/t/019_replslot_limit.pl
+++ b/src/test/recovery/t/019_replslot_limit.pl
@@ -813,4 +813,90 @@ is( $primary5->safe_psql(
 
 $primary5->stop;
 
+# Testcase 6: a synced slot on a standby (aged catalog_xmin) is invalidated
+# by a restartpoint, which releases the catalog_xmin it had pinned on the
+# primary's physical slot via hs_feedback. Sync is off after one manual sync
+# so the synced catalog_xmin stays frozen, and the age limit is set only on
+# the standby.
+my $primary6 = PostgreSQL::Test::Cluster->new('primary6');
+$primary6->init(allows_streaming => 'logical');
+$primary6->append_conf(
+	'postgresql.conf', qq{
+autovacuum = off
+checkpoint_timeout = 1h
+});
+$primary6->start;
+$primary6->safe_psql('postgres', $consume_xid_proc);
+$primary6->safe_psql('postgres',
+	"SELECT pg_create_physical_replication_slot('sb6_phys')");
+$primary6->backup('backup6');
+
+my $standby6 = PostgreSQL::Test::Cluster->new('standby6');
+$standby6->init_from_backup($primary6, 'backup6', has_streaming => 1);
+my $connstr6 = $primary6->connstr;
+$standby6->append_conf(
+	'postgresql.conf', qq{
+primary_slot_name = 'sb6_phys'
+primary_conninfo = '$connstr6 dbname=postgres'
+hot_standby_feedback = on
+wal_receiver_status_interval = 1
+sync_replication_slots = off
+checkpoint_timeout = 1h
+max_slot_xid_age = $slot_xid_age
+});
+$standby6->start;
+$primary6->wait_for_replay_catchup($standby6);
+
+# Create the failover slot now that the standby is up, then sync it once by
+# hand.
+$primary6->safe_psql('postgres',
+	"SELECT pg_create_logical_replication_slot('failover6_slot', 'pgoutput', false, false, true)"
+);
+my $synced6 = 0;
+foreach (1 .. 10)
+{
+	$primary6->safe_psql('postgres', "SELECT pg_log_standby_snapshot()");
+	$primary6->wait_for_replay_catchup($standby6);
+	$standby6->safe_psql('postgres', "SELECT pg_sync_replication_slots()");
+	$synced6 = $standby6->safe_psql(
+		'postgres', qq[
+		SELECT count(*) = 1 FROM pg_replication_slots
+			WHERE slot_name = 'failover6_slot' AND synced AND NOT temporary
+			AND catalog_xmin IS NOT NULL;
+	]);
+	last if $synced6 eq 't';
+}
+$synced6 eq 't' or die "Timed out waiting for failover6_slot to be synced";
+
+# The synced slot's catalog_xmin, pinned onto sb6_phys via hs_feedback.
+my $frozen = $standby6->safe_psql('postgres',
+	"SELECT catalog_xmin FROM pg_replication_slots WHERE slot_name = 'failover6_slot'"
+);
+$primary6->poll_query_until(
+	'postgres', qq[
+	SELECT catalog_xmin = '$frozen' FROM pg_replication_slots
+		WHERE slot_name = 'sb6_phys';
+]) or die "Timed out waiting for sb6_phys to pick up the synced catalog_xmin";
+
+# Age the frozen synced catalog_xmin out, then a restartpoint on the standby
+# invalidates it (a primary checkpoint gives the standby one to restart from).
+$primary6->safe_psql('postgres', qq{CALL consume_xid(2 * $slot_xid_age)});
+$primary6->safe_psql('postgres', "CHECKPOINT");
+$primary6->wait_for_replay_catchup($standby6);
+$standby6->safe_psql('postgres', "CHECKPOINT");
+wait_for_xid_aged_invalidation($standby6, 'failover6_slot');
+ok(1, 'synced slot on standby invalidated by restartpoint');
+
+# With the synced slot gone, the standby stops reporting its catalog_xmin, so
+# the feedback horizon on the primary's physical slot is released.
+$primary6->poll_query_until(
+	'postgres', qq[
+	SELECT catalog_xmin IS DISTINCT FROM '$frozen'::xid FROM pg_replication_slots
+		WHERE slot_name = 'sb6_phys';
+]) or die "Timed out waiting for sb6_phys catalog_xmin to be released";
+ok(1, 'invalidation releases the feedback horizon on the primary');
+
+$standby6->stop;
+$primary6->stop;
+
 done_testing();
-- 
2.47.3

