From 879be9cc09d81138baf2fdb87ed69ea2ab8e70b2 Mon Sep 17 00:00:00 2001
From: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
Date: Mon, 10 Aug 2026 19:39:31 +0000
Subject: [PATCH v13 2/2] Allow vacuum to invalidate XID-aged replication
 slots.

Commit XXX invalidates replication slots whose xmin or
catalog_xmin has aged past max_slot_xid_age only at checkpoints
and, on a standby, at restartpoints. A slot holding the horizon
of a table being vacuumed right now is therefore not cleared
until the next checkpoint, and the vacuum in progress does not
benefit.

This commit lets vacuum clear such a slot itself, without waiting
for the next checkpoint, covering both the VACUUM command and
autovacuum. It runs per relation, and only when a slot holding
the vacuum of the current relation back has aged past the limit,
so the extra work happens only when clearing the slot can
actually let vacuum remove more rows. A logical slot holds back
only the removal of system catalog rows, through its
catalog_xmin, so vacuuming a user table does not invalidate it.
Such a slot is instead cleared when a system catalog is vacuumed
or at a checkpoint. A physical slot holds back the removal of
user table rows, through its xmin, and so can be invalidated by
vacuuming any table.

To avoid retaking the proc array lock for each relation, the
horizon computation now returns the oldest slot xmin and
catalog_xmin alongside the oldest xmin, all from the same call.

Vacuum never blocks on this. It invalidates only the aged slots
it can acquire immediately and leaves any slot still in use to
the checkpointer, which does the terminate-and-wait invalidation
on its regular pass. This keeps vacuum easy to reason about and
avoids many autovacuum workers and backends piling onto one slot
waiting for a slow walsender. A slot that vacuum skips this way
is cleared by the checkpointer, and later relations then pick up
the advanced horizon.

Author: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
Reviewed-by: John Hsu <johnhyvr@gmail.com>
Reviewed-by: Masahiko Sawada <sawada.mshk@gmail.com>
Reviewed-by: Hayato Kuroda <kuroda.hayato@fujitsu.com>
Reviewed-by: Satya Narlapuram <satyanarlapuram@gmail.com>
Discussion: https://www.postgresql.org/message-id/CALj2ACW4aUe-_uFQOjdWCEN-xXoLGhmvRFnL8SNw_TZ5nJe+aw@mail.gmail.com
Discussion: https://www.postgresql.org/message-id/CALj2ACUmPbkcj4y4oeXvzUkBejG68QDtrFF7QHDC_qz2vQcTCg@mail.gmail.com
Discussion: https://www.postgresql.org/message-id/CALj2ACVD0_DhCQ_QOAa7F=nFv8+ZGsHR8SbOc-FmuV8ZrV92HQ@mail.gmail.com
---
 doc/src/sgml/config.sgml                  |  31 ++++-
 src/backend/access/heap/vacuumlazy.c      |  16 +++
 src/backend/access/transam/xlog.c         |   7 +-
 src/backend/commands/vacuum.c             |  77 +++++++++++-
 src/backend/postmaster/autovacuum.c       |  11 ++
 src/backend/replication/slot.c            |  32 ++++-
 src/backend/storage/ipc/procarray.c       |  69 +++++++++--
 src/backend/storage/ipc/standby.c         |   2 +-
 src/include/commands/vacuum.h             |  19 +++
 src/include/replication/slot.h            |   4 +-
 src/include/storage/procarray.h           |   4 +
 src/test/recovery/t/019_replslot_limit.pl | 138 +++++++++++++++++++++-
 12 files changed, 378 insertions(+), 32 deletions(-)

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 6f61dd6ec28..3d43b335637 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -5039,12 +5039,31 @@ HINT:  If it is safe for all REPLICATION users to use this library as an output
        </para>
 
        <para>
-        Slot invalidation due to this limit occurs during checkpoint. Because
-        checkpoints happen at their own interval, there can be some lag between
-        when a slot's <literal>xmin</literal> or <literal>catalog_xmin</literal>
-        age exceeds <varname>max_slot_xid_age</varname> and when the slot
-        invalidation is actually triggered. To avoid such lags, users can force
-        a checkpoint to promptly invalidate the slot.
+        Slot invalidation due to XID age occurs during vacuum (both the
+        <command>VACUUM</command> command and autovacuum) and during checkpoint.
+        During vacuum, only inactive slots (i.e., not currently in use by a
+        replication connection) are invalidated, so that vacuum never waits
+        for a connection to release a slot; an active slot is left for the
+        next checkpoint, which invalidates it even while it is in use. Because
+        vacuum and checkpoints happen at their own intervals, there can be
+        some lag between when a slot's <literal>xmin</literal> or
+        <literal>catalog_xmin</literal> age exceeds
+        <varname>max_slot_xid_age</varname> and when the slot invalidation is
+        actually triggered. To avoid such lags, users can force a checkpoint to
+        promptly invalidate the slot.
+       </para>
+
+       <para>
+        During vacuum, a slot is invalidated only when it is holding vacuum
+        of the current relation back. A logical replication slot holds back
+        only the removal of system catalog rows (through its
+        <literal>catalog_xmin</literal>), so vacuuming a user table does
+        not invalidate it, even when its age has exceeded
+        <varname>max_slot_xid_age</varname>; such a slot is invalidated when a
+        system catalog is vacuumed or at the next checkpoint. A physical
+        replication slot holds back the removal of user table rows (through
+        its <literal>xmin</literal>) and so can be invalidated by vacuuming any
+        table.
        </para>
 
        <para>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 9bce43e563c..4a04fa775be 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -800,6 +800,22 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	 * to increase the number of dead tuples it can prune away.)
 	 */
 	vacrel->aggressive = vacuum_get_cutoffs(rel, params, &vacrel->cutoffs);
+
+	/*
+	 * If a replication slot whose XID age exceeds the limit is holding the
+	 * vacuum cutoff (OldestXmin) back, invalidate it and recompute the
+	 * cutoffs.
+	 */
+	if (InvalidateXidAgedReplicationSlots(vacrel->cutoffs.OldestXmin,
+										  vacrel->cutoffs.SlotXmin,
+										  vacrel->cutoffs.SlotCatalogXmin,
+										  vacrel->cutoffs.SlotCatalogXminRelevant))
+	{
+		/* Some slots have been invalidated; re-compute the vacuum cutoffs */
+		vacrel->aggressive = vacuum_get_cutoffs(rel, params,
+												&vacrel->cutoffs);
+	}
+
 	vacrel->rel_pages = orig_rel_pages = RelationGetNumberOfBlocks(rel);
 	vacrel->vistest = GlobalVisTestFor(rel);
 
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index dda66bca9e9..c2c881f9a61 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -7864,7 +7864,7 @@ CreateCheckPoint(int flags)
 	if (InvalidateObsoleteReplicationSlots(slotInvalidationCauses,
 										   _logSegNo, InvalidOid,
 										   InvalidTransactionId,
-										   slotXidLimit))
+										   slotXidLimit, false, true))
 	{
 		/*
 		 * Some slots have been invalidated; recalculate the old-segment
@@ -8350,7 +8350,7 @@ CreateRestartPoint(int flags)
 	if (InvalidateObsoleteReplicationSlots(slotInvalidationCauses,
 										   _logSegNo, InvalidOid,
 										   InvalidTransactionId,
-										   slotXidLimit))
+										   slotXidLimit, false, true))
 	{
 		/*
 		 * Some slots have been invalidated; recalculate the old-segment
@@ -9249,7 +9249,8 @@ xlog_redo(XLogReaderState *record)
 				InvalidateObsoleteReplicationSlots(RS_INVAL_WAL_LEVEL,
 												   0, InvalidOid,
 												   InvalidTransactionId,
-												   InvalidTransactionId);
+												   InvalidTransactionId, false,
+												   true);
 			}
 			else if (sync_replication_slots)
 			{
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index 31f9824899c..ab621703e9c 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -48,6 +48,7 @@
 #include "postmaster/autovacuum.h"
 #include "postmaster/bgworker_internals.h"
 #include "postmaster/interrupt.h"
+#include "replication/slot.h"
 #include "storage/bufmgr.h"
 #include "storage/lmgr.h"
 #include "storage/pmsignal.h"
@@ -1157,7 +1158,11 @@ vacuum_get_cutoffs(Relation rel, const VacuumParams *params,
 	 * that only one vacuum process can be working on a particular table at
 	 * any time, and that each vacuum is always an independent transaction.
 	 */
-	cutoffs->OldestXmin = GetOldestNonRemovableTransactionId(rel);
+	cutoffs->OldestXmin =
+		GetOldestNonRemovableTransactionIdAndSlotXmins(rel,
+													   &cutoffs->SlotXmin,
+													   &cutoffs->SlotCatalogXmin,
+													   &cutoffs->SlotCatalogXminRelevant);
 
 	Assert(TransactionIdIsNormal(cutoffs->OldestXmin));
 
@@ -2732,3 +2737,73 @@ vac_tid_reaped(ItemPointer itemptr, void *state)
 
 	return TidStoreIsMember(dead_items, itemptr);
 }
+
+/*
+ * Invalidate replication slots whose XID age exceeds the limit.
+ *
+ * The caller passes the overall oldest xmin, plus the oldest slot xmin and
+ * catalog_xmin. If a replication slot is not what holds the oldest xmin back,
+ * or the horizon has not yet aged past the limit, there is nothing to do.
+ *
+ * slot_catalog_xmin_relevant tells whether a slot's catalog_xmin can hold this
+ * relation's oldest xmin back (true for catalog and shared relations). When
+ * it is false, a slot holding only a catalog_xmin cannot be blocking this
+ * vacuum, so such slots are neither considered here nor invalidated: even if
+ * one is aged, invalidating it would not advance this vacuum's cutoff, and it
+ * still has a chance to advance on its own before a vacuum of a catalog
+ * relation or a checkpoint acts on it.
+ *
+ * Returns true if at least one slot was invalidated.
+ */
+bool
+InvalidateXidAgedReplicationSlots(TransactionId oldest_xmin,
+								  TransactionId slot_xmin,
+								  TransactionId slot_catalog_xmin,
+								  bool slot_catalog_xmin_relevant)
+{
+	TransactionId xid_limit;
+	bool		slot_holds_oldest_xmin;
+
+	if (max_slot_xid_age == 0)
+		return false;
+
+	Assert(TransactionIdIsNormal(oldest_xmin));
+
+	/*
+	 * Check if a replication slot's xmin, or its catalog_xmin when that is
+	 * relevant for this relation, is what's holding the oldest xmin back. If
+	 * not, skip the unnecessary work.
+	 */
+	slot_holds_oldest_xmin =
+		(TransactionIdIsValid(slot_xmin) &&
+		 TransactionIdEquals(oldest_xmin, slot_xmin)) ||
+		(slot_catalog_xmin_relevant &&
+		 TransactionIdIsValid(slot_catalog_xmin) &&
+		 TransactionIdEquals(oldest_xmin, slot_catalog_xmin));
+
+	if (!slot_holds_oldest_xmin)
+		return false;
+
+	xid_limit = TransactionIdRetreatedBy(ReadNextTransactionId(),
+										 max_slot_xid_age);
+
+	/*
+	 * A replication slot holds the oldest xmin back, so invalidate any slot
+	 * that has aged past the limit. When catalog_xmin is not relevant for
+	 * this relation, only a slot's xmin is considered, so a slot holding only
+	 * a catalog_xmin (a logical slot) is left alone.
+	 *
+	 * Vacuum never blocks on this. It invalidates only the slots it can
+	 * acquire immediately and leaves any slot still in use to the
+	 * checkpointer, so that many vacuum processes never pile up waiting on
+	 * one slot.
+	 */
+	if (TransactionIdPrecedes(oldest_xmin, xid_limit))
+		return InvalidateObsoleteReplicationSlots(RS_INVAL_XID_AGE,
+												  0, InvalidOid,
+												  InvalidTransactionId,
+												  xid_limit,
+												  true, slot_catalog_xmin_relevant);
+
+	return false;
+}
diff --git a/src/backend/postmaster/autovacuum.c b/src/backend/postmaster/autovacuum.c
index ee202a4b47e..454562e5d87 100644
--- a/src/backend/postmaster/autovacuum.c
+++ b/src/backend/postmaster/autovacuum.c
@@ -89,6 +89,7 @@
 #include "postmaster/autovacuum.h"
 #include "postmaster/interrupt.h"
 #include "postmaster/postmaster.h"
+#include "replication/slot.h"
 #include "storage/aio_subsys.h"
 #include "storage/bufmgr.h"
 #include "storage/ipc.h"
@@ -2541,6 +2542,16 @@ do_autovacuum(void)
 						   tab->at_datname, tab->at_nspname, tab->at_relname);
 			EmitErrorReport();
 
+			/*
+			 * We may still be holding a replication slot if we errored out
+			 * while invalidating an XID-aged slot during vacuum. A slot is
+			 * not released by the transaction abort below, so release it
+			 * here. Otherwise this autovacuum worker would carry it into the
+			 * next table.
+			 */
+			if (MyReplicationSlot != NULL)
+				ReplicationSlotRelease();
+
 			/* this resets ProcGlobal->statusFlags[i] too */
 			AbortOutOfAnyTransaction();
 			FlushErrorState();
diff --git a/src/backend/replication/slot.c b/src/backend/replication/slot.c
index 5d873198567..04949050cb7 100644
--- a/src/backend/replication/slot.c
+++ b/src/backend/replication/slot.c
@@ -1958,7 +1958,8 @@ DetermineSlotInvalidationCause(uint32 possible_causes, ReplicationSlot *s,
 							   TimestampTz *inactive_since, TimestampTz now,
 							   TransactionId xidLimit,
 							   TransactionId *slot_xmin,
-							   TransactionId *slot_catalog_xmin)
+							   TransactionId *slot_catalog_xmin,
+							   bool check_catalog_xmin)
 {
 	Assert(possible_causes != RS_INVAL_NONE);
 
@@ -2039,12 +2040,19 @@ DetermineSlotInvalidationCause(uint32 possible_causes, ReplicationSlot *s,
 		 * so the invalidation message names the xid that actually triggered
 		 * it. Both can have aged in the rare case of a physical slot that
 		 * also holds a catalog_xmin for cascaded logical decoding.
+		 *
+		 * catalog_xmin is considered only when it is relevant for the
+		 * caller's relation. A slot holding only a catalog_xmin cannot block
+		 * a vacuum of a user table, so such a slot is left alone there; a
+		 * checkpoint or a vacuum of a catalog relation still invalidates it
+		 * later.
 		 */
 		if (TransactionIdIsValid(s->data.xmin) &&
 			TransactionIdPrecedes(s->data.xmin, xidLimit))
 			*slot_xmin = s->data.xmin;
 
-		if (TransactionIdIsValid(s->data.catalog_xmin) &&
+		if (check_catalog_xmin &&
+			TransactionIdIsValid(s->data.catalog_xmin) &&
 			TransactionIdPrecedes(s->data.catalog_xmin, xidLimit))
 			*slot_catalog_xmin = s->data.catalog_xmin;
 
@@ -2076,6 +2084,8 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
 							   XLogRecPtr oldestLSN,
 							   Oid dboid, TransactionId snapshotConflictHorizon,
 							   TransactionId xidLimit,
+							   bool nowait,
+							   bool check_catalog_xmin,
 							   bool *released_lock_out)
 {
 	int			last_signaled_pid = 0;
@@ -2134,7 +2144,8 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
 																now,
 																xidLimit,
 																&slot_xmin,
-																&slot_catalog_xmin);
+																&slot_catalog_xmin,
+																check_catalog_xmin);
 
 		/* if there's no invalidation, we're done */
 		if (invalidation_cause == RS_INVAL_NONE)
@@ -2199,6 +2210,10 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
 
 		if (active_proc != INVALID_PROC_NUMBER)
 		{
+			/* A nowait caller leaves an active slot untouched. */
+			if (nowait)
+				break;
+
 			/*
 			 * Prepare the sleep on the slot's condition variable before
 			 * releasing the lock, to close a possible race condition if the
@@ -2315,6 +2330,11 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
  * causes in a single pass, minimizing redundant iterations. The "cause"
  * parameter can be a MASK representing one or more of the defined causes.
  *
+ * If "nowait" is true, slots that are currently held by a live process are
+ * left untouched instead of terminating the owner and waiting for the slot to
+ * be released. Vacuum uses this for XID-age invalidation so it never blocks.
+ * Those slots are instead cleaned up by the checkpointer, which always waits.
+ *
  * If it invalidates the last logical slot in the cluster, it requests to
  * disable logical decoding.
  *
@@ -2324,7 +2344,9 @@ bool
 InvalidateObsoleteReplicationSlots(uint32 possible_causes,
 								   XLogSegNo oldestSegno, Oid dboid,
 								   TransactionId snapshotConflictHorizon,
-								   TransactionId xidLimit)
+								   TransactionId xidLimit,
+								   bool nowait,
+								   bool check_catalog_xmin)
 {
 	XLogRecPtr	oldestLSN;
 	bool		invalidated = false;
@@ -2364,7 +2386,7 @@ restart:
 
 		if (InvalidatePossiblyObsoleteSlot(possible_causes, s, oldestLSN,
 										   dboid, snapshotConflictHorizon,
-										   xidLimit,
+										   xidLimit, nowait, check_catalog_xmin,
 										   &released_lock))
 		{
 			Assert(released_lock);
diff --git a/src/backend/storage/ipc/procarray.c b/src/backend/storage/ipc/procarray.c
index 60336b31803..b6c29c61538 100644
--- a/src/backend/storage/ipc/procarray.c
+++ b/src/backend/storage/ipc/procarray.c
@@ -1929,6 +1929,31 @@ GlobalVisHorizonKindForRel(Relation rel)
 		return VISHORIZON_TEMP;
 }
 
+/*
+ * A helper function to return the appropriate oldest non-removable
+ * TransactionId from the pre-computed horizons, based on the relation
+ * type.
+ */
+static inline TransactionId
+GetOldestNonRemovableTransactionIdFromHorizons(ComputeXidHorizonsResult *horizons,
+											   Relation rel)
+{
+	switch (GlobalVisHorizonKindForRel(rel))
+	{
+		case VISHORIZON_SHARED:
+			return horizons->shared_oldest_nonremovable;
+		case VISHORIZON_CATALOG:
+			return horizons->catalog_oldest_nonremovable;
+		case VISHORIZON_DATA:
+			return horizons->data_oldest_nonremovable;
+		case VISHORIZON_TEMP:
+			return horizons->temp_oldest_nonremovable;
+	}
+
+	/* just to prevent compiler warnings */
+	return InvalidTransactionId;
+}
+
 /*
  * Return the oldest XID for which deleted tuples must be preserved in the
  * passed table.
@@ -1947,20 +1972,38 @@ GetOldestNonRemovableTransactionId(Relation rel)
 
 	ComputeXidHorizons(&horizons);
 
-	switch (GlobalVisHorizonKindForRel(rel))
-	{
-		case VISHORIZON_SHARED:
-			return horizons.shared_oldest_nonremovable;
-		case VISHORIZON_CATALOG:
-			return horizons.catalog_oldest_nonremovable;
-		case VISHORIZON_DATA:
-			return horizons.data_oldest_nonremovable;
-		case VISHORIZON_TEMP:
-			return horizons.temp_oldest_nonremovable;
-	}
+	return GetOldestNonRemovableTransactionIdFromHorizons(&horizons, rel);
+}
 
-	/* just to prevent compiler warnings */
-	return InvalidTransactionId;
+/*
+ * Same as GetOldestNonRemovableTransactionId(), but also returns the
+ * replication slot xmin and catalog_xmin from the same ComputeXidHorizons()
+ * call. This avoids a separate ProcArrayLock acquisition when the caller
+ * needs both values.
+ *
+ * *slot_catalog_xmin_relevant is set to whether a slot's catalog_xmin can
+ * hold this relation's cutoff back. That is true for catalog and shared
+ * relations, whose horizon is computed from both the slot xmin and
+ * catalog_xmin, and false for ordinary and temporary relations, whose horizon
+ * uses only the slot xmin.
+ */
+TransactionId
+GetOldestNonRemovableTransactionIdAndSlotXmins(Relation rel,
+											   TransactionId *slot_xmin,
+											   TransactionId *slot_catalog_xmin,
+											   bool *slot_catalog_xmin_relevant)
+{
+	ComputeXidHorizonsResult horizons;
+	GlobalVisHorizonKind kind = GlobalVisHorizonKindForRel(rel);
+
+	ComputeXidHorizons(&horizons);
+
+	*slot_xmin = horizons.slot_xmin;
+	*slot_catalog_xmin = horizons.slot_catalog_xmin;
+	*slot_catalog_xmin_relevant = (kind == VISHORIZON_CATALOG ||
+								   kind == VISHORIZON_SHARED);
+
+	return GetOldestNonRemovableTransactionIdFromHorizons(&horizons, rel);
 }
 
 /*
diff --git a/src/backend/storage/ipc/standby.c b/src/backend/storage/ipc/standby.c
index 6cbf456459f..ae88d608ede 100644
--- a/src/backend/storage/ipc/standby.c
+++ b/src/backend/storage/ipc/standby.c
@@ -505,7 +505,7 @@ ResolveRecoveryConflictWithSnapshot(TransactionId snapshotConflictHorizon,
 	if (IsLogicalDecodingEnabled() && isCatalogRel)
 		InvalidateObsoleteReplicationSlots(RS_INVAL_HORIZON, 0, locator.dbOid,
 										   snapshotConflictHorizon,
-										   InvalidTransactionId);
+										   InvalidTransactionId, false, true);
 }
 
 /*
diff --git a/src/include/commands/vacuum.h b/src/include/commands/vacuum.h
index e62f23748dc..8dcb1bf18e6 100644
--- a/src/include/commands/vacuum.h
+++ b/src/include/commands/vacuum.h
@@ -287,6 +287,21 @@ struct VacuumCutoffs
 	 */
 	TransactionId FreezeLimit;
 	MultiXactId MultiXactCutoff;
+
+	/*
+	 * Oldest xmin and catalog xmin of any replication slot obtained from the
+	 * same ComputeXidHorizons() call that computed OldestXmin.
+	 */
+	TransactionId SlotXmin;
+	TransactionId SlotCatalogXmin;
+
+	/*
+	 * Whether a slot's catalog_xmin can hold this relation's OldestXmin back.
+	 * That is true for catalog and shared relations, and false for ordinary
+	 * and temporary ones. It decides whether an aged slot holding only a
+	 * catalog_xmin (a logical slot) is worth invalidating for this relation.
+	 */
+	bool		SlotCatalogXminRelevant;
 };
 
 /*
@@ -399,6 +414,10 @@ extern IndexBulkDeleteResult *vac_bulkdel_one_index(IndexVacuumInfo *ivinfo,
 													VacDeadItemsInfo *dead_items_info);
 extern IndexBulkDeleteResult *vac_cleanup_one_index(IndexVacuumInfo *ivinfo,
 													IndexBulkDeleteResult *istat);
+extern bool InvalidateXidAgedReplicationSlots(TransactionId oldest_xmin,
+											  TransactionId slot_xmin,
+											  TransactionId slot_catalog_xmin,
+											  bool slot_catalog_xmin_relevant);
 
 /* In postmaster/autovacuum.c */
 extern void AutoVacuumUpdateCostLimit(void);
diff --git a/src/include/replication/slot.h b/src/include/replication/slot.h
index 8c77a61db8e..1208b8650de 100644
--- a/src/include/replication/slot.h
+++ b/src/include/replication/slot.h
@@ -368,7 +368,9 @@ extern bool InvalidateObsoleteReplicationSlots(uint32 possible_causes,
 											   XLogSegNo oldestSegno,
 											   Oid dboid,
 											   TransactionId snapshotConflictHorizon,
-											   TransactionId xidLimit);
+											   TransactionId xidLimit,
+											   bool nowait,
+											   bool check_catalog_xmin);
 extern ReplicationSlot *SearchNamedReplicationSlot(const char *name, bool need_lock);
 extern int	ReplicationSlotIndex(ReplicationSlot *slot);
 extern bool ReplicationSlotName(int index, Name name);
diff --git a/src/include/storage/procarray.h b/src/include/storage/procarray.h
index d718a5b542f..f41e039b091 100644
--- a/src/include/storage/procarray.h
+++ b/src/include/storage/procarray.h
@@ -51,6 +51,10 @@ extern RunningTransactions GetRunningTransactionData(void);
 
 extern bool TransactionIdIsInProgress(TransactionId xid);
 extern TransactionId GetOldestNonRemovableTransactionId(Relation rel);
+extern TransactionId GetOldestNonRemovableTransactionIdAndSlotXmins(Relation rel,
+																	TransactionId *slot_xmin,
+																	TransactionId *slot_catalog_xmin,
+																	bool *slot_catalog_xmin_relevant);
 extern TransactionId GetOldestTransactionIdConsideredRunning(void);
 extern TransactionId GetOldestActiveTransactionId(bool inCommitOnly,
 												  bool allDbs);
diff --git a/src/test/recovery/t/019_replslot_limit.pl b/src/test/recovery/t/019_replslot_limit.pl
index 52ff48034c8..18ae27bb8d7 100644
--- a/src/test/recovery/t/019_replslot_limit.pl
+++ b/src/test/recovery/t/019_replslot_limit.pl
@@ -596,9 +596,11 @@ $primary5->backup($backup_name);
 my $standby5 = PostgreSQL::Test::Cluster->new('standby5');
 $standby5->init_from_backup($primary5, $backup_name, has_streaming => 1);
 
-# Testcase 1: an active physical slot (aged xmin) is invalidated by the
+# Testcase 1: an active physical slot (aged xmin) is skipped by the VACUUM
+# command, which never blocks on an active slot, and invalidated by the
 # checkpoint, which terminates its owner. A running standby keeps the slot
-# active; an open transaction there, reported via feedback, freezes its xmin.
+# active, with an open transaction there, reported via feedback, freezing its
+# xmin.
 $primary5->safe_psql('postgres',
 	"SELECT pg_create_physical_replication_slot('sb5_slot_a', true)");
 
@@ -632,6 +634,15 @@ $held->query_safe("BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1;");
 
 $primary5->safe_psql('postgres', qq{CALL consume_xid(2 * $slot_xid_age)});
 
+# Vacuum leaves the active slot for the checkpoint, so it stays valid
+$primary5->safe_psql('postgres', "VACUUM tbl_user5");
+is( $primary5->safe_psql(
+		'postgres',
+		qq[SELECT invalidation_reason IS NULL AND active FROM pg_replication_slots WHERE slot_name = 'sb5_slot_a';]
+	),
+	't',
+	'active physical slot not invalidated by VACUUM');
+
 # The checkpoint terminates the owner and invalidates the slot
 $primary5->safe_psql('postgres', "CHECKPOINT");
 wait_for_xid_aged_invalidation($primary5, 'sb5_slot_a');
@@ -677,6 +688,129 @@ wait_for_xid_aged_invalidation($standby5, 'sb5_logical_slot');
 ok(1, "inactive logical slot on standby invalidated by restartpoint");
 
 $standby5->stop;
+
+# Restore the age limit on the primary, disabled earlier for the standby's
+# own logical slot.
+$primary5->safe_psql(
+	'postgres', q{
+ALTER SYSTEM RESET max_slot_xid_age;
+SELECT pg_reload_conf();
+});
+
+# Testcase 3: an inactive logical slot (aged catalog_xmin) is invalidated by
+# vacuuming a system catalog, whose horizon includes catalog_xmin. VACUUM is in
+# the foreground and the slot is inactive, so it is invalidated synchronously.
+$primary5->safe_psql('postgres',
+	"SELECT pg_create_logical_replication_slot('lsub5_slot', 'pgoutput')");
+$primary5->poll_query_until(
+	'postgres', qq[
+	SELECT catalog_xmin IS NOT NULL FROM pg_replication_slots
+		WHERE slot_name = 'lsub5_slot';
+]) or die "Timed out waiting for slot lsub5_slot catalog_xmin";
+
+$primary5->safe_psql('postgres', qq{CALL consume_xid(2 * $slot_xid_age)});
+
+$primary5->safe_psql('postgres', "VACUUM pg_class");
+is( $primary5->safe_psql(
+		'postgres',
+		qq[SELECT invalidation_reason = 'xid_aged' FROM pg_replication_slots WHERE slot_name = 'lsub5_slot';]
+	),
+	't',
+	'inactive logical slot invalidated by vacuuming a system catalog');
+
+# Testcase 4: an inactive physical slot (aged xmin) is invalidated by
+# autovacuum. hs_feedback gives the slot an xmin, and stopping the
+# standby freezes it. Autovacuum runs on dead tuples with naptime 1s.
+$standby5->append_conf('postgresql.conf', "hot_standby_feedback = on");
+$standby5->start;
+$primary5->wait_for_catchup($standby5);
+
+$primary5->poll_query_until(
+	'postgres', qq[
+	SELECT xmin IS NOT NULL FROM pg_replication_slots
+		WHERE slot_name = 'sb5_slot_b';
+]) or die "Timed out waiting for slot sb5_slot_b xmin from HS feedback";
+
+$standby5->stop;
+
+$primary5->safe_psql('postgres', qq{CALL consume_xid(2 * $slot_xid_age)});
+
+# Turn autovacuum on and give it a table with dead tuples
+$primary5->append_conf(
+	'postgresql.conf', q{
+autovacuum = on
+autovacuum_naptime = 1s
+log_autovacuum_min_duration = 0
+});
+$primary5->reload;
+$primary5->safe_psql(
+	'postgres', q{
+	CREATE TABLE tbl_dead5 (a int);
+	INSERT INTO tbl_dead5 SELECT generate_series(1, 10000);
+	DELETE FROM tbl_dead5;
+});
+wait_for_xid_aged_invalidation($primary5, 'sb5_slot_b');
+ok(1, "inactive physical slot invalidated by autovacuum");
+
+$primary5->append_conf('postgresql.conf', "autovacuum = off");
+$primary5->reload;
+
+# Testcase 5: with an aged physical slot (xmin) and an aged logical slot
+# (catalog_xmin) both present, vacuuming a user table invalidates only the
+# physical slot. A user table's horizon uses xmin, not catalog_xmin, so the
+# logical slot is left alone. Vacuuming a system catalog then invalidates it.
+$primary5->safe_psql('postgres',
+	"SELECT pg_create_logical_replication_slot('lsub5b_slot', 'pgoutput')");
+$primary5->poll_query_until(
+	'postgres', qq[
+	SELECT catalog_xmin IS NOT NULL FROM pg_replication_slots
+		WHERE slot_name = 'lsub5b_slot';
+]) or die "Timed out waiting for slot lsub5b_slot catalog_xmin";
+
+# A fresh physical slot for the standby, since the previous one was
+# invalidated. hs_feedback gives it an xmin, and stopping the standby
+# freezes it.
+$primary5->safe_psql('postgres',
+	"SELECT pg_create_physical_replication_slot('sb5_slot_c', true)");
+$standby5->append_conf('postgresql.conf', "primary_slot_name = 'sb5_slot_c'");
+$standby5->start;
+$primary5->wait_for_catchup($standby5);
+
+$primary5->poll_query_until(
+	'postgres', qq[
+	SELECT xmin IS NOT NULL FROM pg_replication_slots
+		WHERE slot_name = 'sb5_slot_c';
+]) or die "Timed out waiting for slot sb5_slot_c xmin from HS feedback";
+
+$standby5->stop;
+
+$primary5->safe_psql('postgres', qq{CALL consume_xid(2 * $slot_xid_age)});
+
+# Vacuum a user table, which invalidates the physical slot but leaves the
+# logical one alone
+$primary5->safe_psql('postgres', "VACUUM tbl_user5");
+is( $primary5->safe_psql(
+		'postgres',
+		qq[SELECT invalidation_reason = 'xid_aged' FROM pg_replication_slots WHERE slot_name = 'sb5_slot_c';]
+	),
+	't',
+	'physical slot invalidated by vacuuming a user table');
+is( $primary5->safe_psql(
+		'postgres',
+		qq[SELECT invalidation_reason IS NULL FROM pg_replication_slots WHERE slot_name = 'lsub5b_slot';]
+	),
+	't',
+	'logical slot not invalidated by vacuuming a user table');
+
+# Vacuum a system catalog, which now invalidates the logical slot
+$primary5->safe_psql('postgres', "VACUUM pg_class");
+is( $primary5->safe_psql(
+		'postgres',
+		qq[SELECT invalidation_reason = 'xid_aged' FROM pg_replication_slots WHERE slot_name = 'lsub5b_slot';]
+	),
+	't',
+	'logical slot invalidated by vacuuming a system catalog');
+
 $primary5->stop;
 
 done_testing();
-- 
2.47.3

