Subject: Introduce XID age based replication slot invalidation

From: John H <[email-masked]>
Date: Thu, 18 Sep 2025 10:20:22 -0700

Hi folks,

I'd like to restart the discussion about providing an xid-based slot
invalidation mechanism. The previous effort [1]  presented an XID and
time-based invalidation and the inactive time-based approach was
implemented first. The latest XID based patch from Bharath Rupireddy
can be found here [2].

When thinking about availability of the database, inactive replication
slots cause two main pain points:
1) WAL accumulation
2) Replication slots with xmin/catalog_xmin can hold back vacuuming
leading to wrap-around

The first issue can be mitigated by 'max_slot_wal_keep_size'. However
in the second case there are no good mechanisms to prioritize write
availability of the database and avoid wraparound. The new GUC
'idle_replication_slot_timeout' partially addresses the concern if you
have similar workloads. However it's hard to set the same setting
across a fleet of different applications.

It's easy to imagine a high-XID churning workload in one cluster while
another has large batch jobs where changes get synced out
periodically. There isn't a "one-size" fits all setting for
'idle_replication_slot_timeout' in these two cases.

The attached patch addresses this by introducing 'max_slot_xid_age' in
a similar fashion. Replication slots with transaction ID greater than
the set age will get invalidated allowing vacuum to proceed, biasing
towards database availability.

Invalidation happens in CHECKPOINT, similar to
'idle_replication_slot_timeout', and when VACUUM occurs.

The patch currently attempts to invalidate once-per-autovacuum worker.
We're wondering if it should attempt invalidation on a per-relation
basis within the vacuum call itself. That would account for scenarios
where the cost_delay or naptime is high between autovac executions.

Thanks,

John H

[1] https://www.postgresql.org/message-id/flat/CALj2ACW4aUe-_uFQOjdWCEN-xXoLGhmvRFnL8SNw_TZ5nJe%2Baw%40mail.gmail.com
[2] https://www.postgresql.org/message-id/flat/CALj2ACXe8%2BxSNdMXTMaSRWUwX7v61Ad4iddUwnn%3DdjSwx3GLLg%40mail.gmail.com

-- 
John Hsu - Amazon Web Services

---

From: "Hayato Kuroda (Fujitsu)" <[email-masked]>
Date: Fri, 19 Sep 2025 08:07:48 +0000

Dear John,

> across a fleet of different applications.

IIUC, the feature can directly avoid the wraparound issue than other
invalidation mechanism. The motivation seems enough for me.

> where the cost_delay or naptime is high between autovac executions.

I have a concern that age calculation acquire the lock for XidGenLock thus
performance can be affected. Do you have insights for it?
> 'idle_replication_slot_timeout', and when VACUUM occurs.

Let me confirm because I'm new. VACUUM can also trigger because old XID make
VACUUM fail, right? Timeout is aimed for WAL thus it is not so related with VACUUM,
which does not recycle segments.

In contrast, is there a possibility that XID-age check can be done only at VACUUM?

Regarding the patch, try_replication_slot_invalidation() and ReplicationSlotIsXIDAged()
do the same task. Can we reduce duplicated part?

Best regards,
Hayato Kuroda
FUJITSU LIMITED

---

From: John H <[email-masked]>
Date: Fri, 19 Sep 2025 16:42:56 -0700

Hi Hayato,

Thank you for taking a look.

> performance can be affected. Do you have insights for it?

Are you concerned if we did the check on a per table case? Or in the
current situation
where it's only once per-worker.

> which does not recycle segments.

I feel that the timeout is used as a way to roughly address storage
accumulation or VACUUM
not progressing due to slots.

> In contrast, is there a possibility that XID-age check can be done only at VACUUM?

It's also done in CHECKPOINT because there can be stale replication
slots on standby that
aren't there on writer. We would still want them to be invalidated.

> do the same task. Can we reduce duplicated part?

Thanks for catching, I thought I did this but guess not. Updated in
the latest attachment.

---

From: Bharath Rupireddy <[email-masked]>
Date: Wed, 24 Sep 2025 17:18:42 -0700

Hi,

> 'idle_replication_slot_timeout' in these two cases.

+1.

> where the cost_delay or naptime is high between autovac executions.

IMO, computing XID horizons per-relation during vacuum is good. The
main reason we try to invalidate replication slots based on the XID
age in the vacuum path is to help the database when it needs it most -
when vacuum is computing the XID horizons. That said, it would be good
to have performance analysis with a large number of replication slots,
comparing once-per-relation vs. once-per-autovacuum worker vs.
once-per-autovacuum launcher wake-up cycle.

I haven't looked at the patch in depth, but it would be good to have a
TAP test with more realistic production workloads. We could set this
value to less than 1.5 billion and use xid_wraparound test to quickly
reach the wraparound limits, then verify if this setting can help
prevent the database from reaching wraparound errors. This approach
would also validate the age calculations in
try_replication_slot_invalidation with higher limits.

-- 
Bharath Rupireddy
PostgreSQL Contributors Team
RDS Open Source Databases
Amazon Web Services: https://aws.amazon.com

---

From: Bharath Rupireddy <[email-masked]>
Date: Fri, 20 Mar 2026 09:10:00 -0700

Hi John,

Thank you for sending in the rebased patch earlier. I will have some
cycles going forward and I would like to continue with this work.

Hi Kuroda-san,

Thank you for reviewing the patch.

> invalidation mechanism. The motivation seems enough for me.

That's correct. When enabled, replication slots whose XID age exceeds
the configured value get invalidated before vacuum computes the XID
horizons. This ensures that slots which would otherwise prevent vacuum
from freezing heap tuples don't come in the way of XID wraparound
prevention.

> performance can be affected. Do you have insights for it?

I made the following design choice: try invalidating only once per
vacuum cycle, not per table. While this keeps the cost of checking
(incl. the XidGenLock contention) for invalidation to a minimum when
there are a large number of tables and replication slots, it can be
less effective when individual tables/indexes are large. Invalidating
during checkpoints can help to some extent with the large table/index
cases. But I'm open to thoughts on this.

Please find the attached patch for further review. I fixed the XID age
calculation in ReplicationSlotIsXIDAged and adjusted the code
comments.

---

From: SATYANARAYANA NARLAPURAM <[email-masked]>
Date: Fri, 20 Mar 2026 23:28:57 -0700

Hi Bharath,

Do you think we need different GUCs for catalog_xmin and xmin? If table
bloat is a concern (not catalog bloat), then logical slots are not required
to invalidate unless the cluster is close to wraparound.

> cases. But I'm open to thoughts on this.

It may not solve the intent when the vacuum cycle is longer, which one can
expect on a large database particularly when there is heavy bloat.

> calculation in ReplicationSlotIsXIDAged and adjusted the code

I applied the patch and all the tests passed. A few comments:

@@ -495,7 +525,7 @@ vacuum(List *relations, const VacuumParams params,
BufferAccessStrategy bstrateg
    MemoryContext vac_context, bool isTopLevel)
 {
  static bool in_vacuum = false;
-
+ static bool first_time = true;

first_time variable is not self explanatory, maybe something like
try_replication_slot_invalidation and add comments that it will be set to
false after the first check?

+ if (TransactionIdIsValid(xmin))
+ appendStringInfo(&err_detail, _("The slot's xmin %u exceeds the maximum
xid age %d specified by \"max_slot_xid_age\"."),
+ xmin,
+ max_slot_xid_age);

Slot invalidates even when the age is max_slot_xid_age, isn't it?

Thanks,
Satya

---

From: Bharath Rupireddy <[email-masked]>
Date: Mon, 23 Mar 2026 09:00:00 -0700

Hi,

On Fri, Mar 20, 2026 at 11:29 PM SATYANARAYANA NARLAPURAM
<[email-masked]> wrote:
>
> Do you think we need different GUCs for catalog_xmin and xmin? If table bloat is a concern (not catalog bloat), then logical slots are not required to invalidate unless the cluster is close to wraparound.

IMO the main purpose of max_slot_xid_age is to prevent XID wraparound.
For bloat, I still think max_slot_wal_keep_size is the better choice.

Where max_slot_xid_age is really useful is when the vacuum can't
freeze because a replication slot (physical or logical) is holding
back the XID horizon and the system is getting close to wraparound.
Invalidating such a slot clears the way for vacuum. Setting
max_slot_xid_age above vacuum_failsafe_age allows vacuum to waste
cycles scanning tables it cannot freeze. Keeping max_slot_xid_age <=
vacuum_failsafe_age (default 1.6B) prevents this by invalidating the
slot before vacuum effort is wasted.

As far as XID wraparound is concerned, both xmin and catalog_xmin need
to be treated similarly. Either one can hold back freezing and push
the system toward wraparound. So I don't think we need separate GUCs
for xmin and catalog_xmin unless I'm missing something. One GUC
covering both keeps things simple.

> It may not solve the intent when the vacuum cycle is longer, which one can expect on a large database particularly when there is heavy bloat.

This design choice boils down to the following: a database instance
having either 1/ a large number of small tables or 2/ large tables.

---

From: Masahiko Sawada <[email-masked]>
Date: Mon, 23 Mar 2026 16:36:07 -0700

Hi,

> covering both keeps things simple.

I've studied the discussion on this thread and the patch. I understand
the purpose of this feature and agree that it's useful especially in
cases where orphaned (physical or logical) replication slots prevent
the xmin from advancing and inactive_since based slot invalidation
might not fit.

And +1 for treating both the slot's xmin and catalog_xmin similarly
with the single GUC.

> From my experience, I have seen both cases but mostly case 2 (others
> can correct me). In this context, having an XID age based slot
> invalidation check once per relation makes sense. However, I'm open to
> more thoughts here.

ISTM that checking the XID-based slot invalidation per table would be
more bullet-proof and cover many cases. How about checking the
XID-based slot invalidation opportunity only when the OldestXmin is
older than the new GUC? For example, we can do this check in
heap_vacuum_rel() based on the VacuumCutoffs returned by
vacuum_get_cutoffs(). If we invalidate at least one slot for its XID,
we can re-compute the OldestXmin.

Regards,

--
Masahiko Sawada

---

From: Bharath Rupireddy <[email-masked]>
Date: Tue, 24 Mar 2026 14:42:45 -0700

Hi,

> And +1 for treating both the slot's xmin and catalog_xmin similarly

Thanks for reviewing the patch.

> we can re-compute the OldestXmin.

Agreed. Here's the patch that moves the XID-age based slot
invalidation check to vacuum_get_cutoffs. This has some nice
advantages: 1/ It makes the check once per table (to help with large
tables). 2/ It makes the check less costly since we rely on already
computed OldestXmin and nextXID values. 3/ It avoids the checkpointer
to do XID-age based slot invalidation which keeps the usage of this
GUC simple with no additional costs to the checkpointer - just the
vacuum (both vacuum command and autovacuum) does the invalidation when
needed.

I moved the new tests to the existing TAP test file
t/019_replslot_limit.pl alongside other invalidation tests.

I added detailed comments around InvalidateXIDAgedReplicationSlots and
slightly modified the docs.

Please find the v3 patch for further review.

PS: Thanks Sawada-san for the offlist chat.

---

From: Masahiko Sawada <[email-masked]>
Date: Tue, 24 Mar 2026 23:50:13 -0700

> Please find the v3 patch for further review.

Thank you for updating the patch. I think the patch is reasonably
simple and can avoid unnecessary overheads well due to XID-based
checks. Here are some comments:

+   /*
+    * Try to invalidate XID-aged replication slots that may interfere with
+    * vacuum's ability to freeze and remove dead tuples. Since OldestXmin
+    * already covers the slot xmin/catalog_xmin values, pass it as a
+    * preliminary check to avoid additional iteration over all the slots.
+    *
+    * If at least one slot was invalidated, recompute OldestXmin so that this
+    * vacuum benefits from the advanced horizon immediately.
+    */
+   if (InvalidateXIDAgedReplicationSlots(cutoffs->OldestXmin, nextXID))
+   {
+       cutoffs->OldestXmin = GetOldestNonRemovableTransactionId(rel);
+       Assert(TransactionIdIsNormal(cutoffs->OldestXmin));
+   }

vacuum_get_cutoff() is also called by VACUUM FULL, CLUSTER, and
REPACK. I'm not sure that users would expect the slot invalidation
also in these commands. I think it's better to leave
vacuum_get_cutoff() a pure cutoff computation function and we can try
to invalidate slots in heap_vacuum_rel(). It requires additional
ReadNextTransactionId() but we can live with it, or we can make
vacuum_get_cutoffs() return the nextXID as well (stored in *cutoffs).

---
+   /* ensure it's a "normal" XID, else TransactionIdPrecedes misbehaves */
+   /* this can cause the limit to go backwards by 3, but that's OK */
+   if (!TransactionIdIsNormal(cutoffXID))
+       cutoffXID = FirstNormalTransactionId;
+
+   if (TransactionIdPrecedes(oldestXmin, cutoffXID))
+   {
+       invalidated = InvalidateObsoleteReplicationSlots(RS_INVAL_XID_AGE,
+                                                        0,
+                                                        InvalidOid,
+                                                        InvalidTransactionId,
+                                                        nextXID);
+   }

I think it's better to check the procArray->replication_slot_xmin and
procArray->replication_slot_catalog_xmin before iterating over each
slot. Otherwise, we would end up checking every slot even when a long
running transaction holds the oldestxmin back.

---
+   if (cutoffXID < FirstNormalTransactionId)
+       cutoffXID -= FirstNormalTransactionId;

and

+   if (!TransactionIdIsNormal(cutoffXID))
+       cutoffXID = FirstNormalTransactionId;

These codes have the same comment but are doing a slightly different
thing. I guess the latter is missing '-'?

Regards,

---

From: Bharath Rupireddy <[email-masked]>
Date: Wed, 25 Mar 2026 12:17:17 -0700

Hi,

> vacuum_get_cutoffs() return the nextXID as well (stored in *cutoffs).

+1. I chose to perform the slot invalidation in heap_vacuum_rel by
getting the next txn ID and calling vacuum_get_cutoffs again when a
slot gets invalidated. IMHO, this is simple than adding a flag and do
the invalidation selectively in vacuum_get_cutoffs.

>   if (TransactionIdPrecedes(oldestXmin, cutoffXID))
> +   {

+1. Changed.

> thing. I guess the latter is missing '-'?

Fixed the typo.

I fixed a test error being reported in CI.

Please find the attached v4 patch for further review.

I've also attached the 0002 patch that adds a test case to demo a
production-like scenario by pushing the database to XID wraparound
limits and checking if the XID-age based invalidation with the GUC
setting at the default vacuum_failsafe_age of 1.6B works correctly,
and whether autovacuum can successfully remove this replication slot
blocker to proceed with freezing and bring the database back to
normal. I don't intend to get this committed unless others think
otherwise, but I wanted to have this as a reference.

---

From: SATYANARAYANA NARLAPURAM <[email-masked]>
Date: Thu, 26 Mar 2026 02:48:42 -0700

On Wed, Mar 25, 2026 at 12:17 PM Bharath Rupireddy <
[email-masked]> wrote:

> > +       invalidated =
> InvalidateObsoleteReplicationSlots(RS_INVAL_XID_AGE,
> > +                                                        0,
> > +                                                        InvalidOid,
> > +
> InvalidTransactionId,
> > +                                                        nextXID);
> > +   }
> >

 InvalidateObsoleteReplicationSlots(uint32 possible_causes,
    XLogSegNo oldestSegno, Oid dboid,
-   TransactionId snapshotConflictHorizon)
+   TransactionId snapshotConflictHorizon, TransactionId nextXID)

May be add TransactionId nextXID in a new line?

Thinking loud, vacuum doesn't  run on a hot_standby, that means this GUC is
not applicable for hot_standby. Is this intended? Why not call during
checkpoint/restorepoint itself like other slot invalidation checks?

Thanks,
Satya

---

From: SATYANARAYANA NARLAPURAM <[email-masked]>
Date: Thu, 26 Mar 2026 03:42:42 -0700

Hi,

> Please find the attached v4 patch for further review.

+ if (InvalidateXIDAgedReplicationSlots(vacrel->cutoffs.OldestXmin,
+  ReadNextTransactionId()))

Does this account catalog xmin for data tables?

Thanks,
Satya

---

From: Masahiko Sawada <[email-masked]>
Date: Thu, 26 Mar 2026 14:49:55 -0700

> Please find the attached v4 patch for further review.

Thank you for updating the patch. I've reviewed the patch and have
some review comments:

+                   /* translator: %s is a GUC variable name */
+                   appendStringInfo(&err_detail, _("The slot's xmin
%u at next transaction ID %u exceeds the age %d specified by
\"%s\"."),
+                                    xmin,
+                                    nextXID,
+                                    max_slot_xid_age,
+                                    "max_slot_xid_age");

I think it's better to show the age of the slot's xmin instead of the
recent XID.

---
+
+   if (!TransactionIdIsNormal(oldestXmin) || !TransactionIdIsNormal(nextXID))
+       return false;
+

Do we expect that the passed oldestXmin or nextXID could be non-normal
XIDs? I think the function assumes these are valid XIDs.

Also, since this function is called only by heap_vacuum_rel(), we can
call ReadNextTransactionId() within this function.

---
+       if (IsReplicationSlotXIDAged(slot_xmin, slot_catalog_xmin, nextXID))

We compute the cutoff XID in IsReplicationSlotXIDAged() again, which
seems redundant.

I've attached the fixup patch addressing these comments and having
some code cleanups. Please review it.

I'm reviewing the regression test part, and will share review comments soon.

> otherwise, but I wanted to have this as a reference.

Thank you for sharing the test script! I'll check it as well.

Regards,

-- 
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 809061110c7..bc9360b9743 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -805,16 +805,15 @@ heap_vacuum_rel(Relation rel, const VacuumParams params,
 	 * vacuum's ability to freeze and remove dead tuples. Since OldestXmin
 	 * already covers the slot xmin/catalog_xmin values, pass it as a
 	 * preliminary check to avoid additional iteration over all the slots.
-	 *
-	 * If at least one slot was invalidated, recompute cutoffs so that this
-	 * vacuum benefits from the advanced horizon immediately.
-	 *
-	 * XXX: Next XID could be returned as output from vacuum_get_cutoffs() but
-	 * for now we live with an additional ReadNextTransactionId() call.
 	 */
-	if (InvalidateXIDAgedReplicationSlots(vacrel->cutoffs.OldestXmin,
-										  ReadNextTransactionId()))
+	if (MaybeInvalidateXIDAgedSlots(vacrel->cutoffs.OldestXmin))
+	{
+		/*
+		 * Some slots have been invalidated based on their XID age; recompute
+		 * 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/replication/slot.c b/src/backend/replication/slot.c
index 8ca5d86fe0e..8296856eefe 100644
--- a/src/backend/replication/slot.c
+++ b/src/backend/replication/slot.c
@@ -160,7 +160,7 @@ int			max_replication_slots = 10; /* the maximum number of replication
 int			idle_replication_slot_timeout_secs = 0;
 
 /*
- * Invalidate replication slots that have xmin or catalog_xmin greater
+ * Invalidate replication slots that have xmin or catalog_xmin older
  * than the specified age; '0' disables it.
  */
 int			max_slot_xid_age = 0;
@@ -183,8 +183,6 @@ static XLogRecPtr ss_oldest_flush_lsn = InvalidXLogRecPtr;
 static void ReplicationSlotShmemExit(int code, Datum arg);
 static bool IsSlotForConflictCheck(const char *name);
 static void ReplicationSlotDropPtr(ReplicationSlot *slot);
-static bool IsReplicationSlotXIDAged(TransactionId xmin, TransactionId catalog_xmin,
-									 TransactionId nextXID);
 
 /* internal persistency functions */
 static void RestoreSlotFromDisk(const char *name);
@@ -1792,7 +1790,7 @@ ReportSlotInvalidation(ReplicationSlotInvalidationCause cause,
 					   long slot_idle_seconds,
 					   TransactionId xmin,
 					   TransactionId catalog_xmin,
-					   TransactionId nextXID)
+					   TransactionId recentXid)
 {
 	StringInfoData err_detail;
 	StringInfoData err_hint;
@@ -1845,20 +1843,14 @@ ReportSlotInvalidation(ReplicationSlotInvalidationCause cause,
 				if (TransactionIdIsValid(xmin))
 				{
 					/* translator: %s is a GUC variable name */
-					appendStringInfo(&err_detail, _("The slot's xmin %u at next transaction ID %u exceeds the age %d specified by \"%s\"."),
-									 xmin,
-									 nextXID,
-									 max_slot_xid_age,
-									 "max_slot_xid_age");
+					appendStringInfo(&err_detail, _("The slot's xmin %u is %d transactions old, which exceeds the configured \"%s\" value of %d."),
+									 xmin, (int32) (recentXid - xmin), "max_slot_xid_age", max_slot_xid_age);
 				}
 				else
 				{
 					/* translator: %s is a GUC variable name */
-					appendStringInfo(&err_detail, _("The slot's catalog xmin %u at next transaction ID %u exceeds the age %d specified by \"%s\"."),
-									 catalog_xmin,
-									 nextXID,
-									 max_slot_xid_age,
-									 "max_slot_xid_age");
+					appendStringInfo(&err_detail, _("The slot's xmin %u is %d transactions old, which exceeds the configured \"%s\" value of %d."),
+									 catalog_xmin, (int32) (recentXid - catalog_xmin), "max_slot_xid_age", max_slot_xid_age);
 				}
 
 				/* translator: %s is a GUC variable name */
@@ -1905,6 +1897,25 @@ CanInvalidateIdleSlot(ReplicationSlot *s)
 			!(RecoveryInProgress() && s->data.synced));
 }
 
+/*
+ * Can we invalidate an XID-aged replication slot?
+ *
+ * XID-aged based invalidation is allowed to the given slot when:
+ *
+ * 1. Max XID-age is set
+ * 2. Slot has valid xmin or catalog_xmin
+ * 3. The slot is not being synced from the primary while the server is in
+ *	  recovery.
+ */
+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));
+}
+
 /*
  * DetermineSlotInvalidationCause - Determine the cause for which a slot
  * becomes invalid among the given possible causes.
@@ -1916,7 +1927,7 @@ static ReplicationSlotInvalidationCause
 DetermineSlotInvalidationCause(uint32 possible_causes, ReplicationSlot *s,
 							   XLogRecPtr oldestLSN, Oid dboid,
 							   TransactionId snapshotConflictHorizon,
-							   TransactionId nextXID,
+							   TransactionId recentXid,
 							   TimestampTz *inactive_since, TimestampTz now)
 {
 	Assert(possible_causes != RS_INVAL_NONE);
@@ -1989,9 +2000,23 @@ DetermineSlotInvalidationCause(uint32 possible_causes, ReplicationSlot *s,
 	}
 
 	/* Check if the slot needs to be invalidated due to max_slot_xid_age GUC */
-	if ((possible_causes & RS_INVAL_XID_AGE) &&
-		IsReplicationSlotXIDAged(s->data.xmin, s->data.catalog_xmin, nextXID))
-		return RS_INVAL_XID_AGE;
+	if (possible_causes & RS_INVAL_XID_AGE)
+	{
+		Assert(TransactionIdIsValid(recentXid));
+
+		if (CanInvalidateXidAgedSlot(s))
+		{
+			TransactionId xidLimit;
+
+			xidLimit = TransactionIdRetreatedBy(recentXid, max_slot_xid_age);
+
+			if ((TransactionIdIsValid(s->data.xmin) &&
+				 TransactionIdPrecedes(s->data.xmin, xidLimit)) ||
+				(TransactionIdIsValid(s->data.catalog_xmin) &&
+				 TransactionIdPrecedes(s->data.catalog_xmin, xidLimit)))
+				return RS_INVAL_XID_AGE;
+		}
+	}
 
 	return RS_INVAL_NONE;
 }
@@ -2015,7 +2040,7 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
 							   ReplicationSlot *s,
 							   XLogRecPtr oldestLSN,
 							   Oid dboid, TransactionId snapshotConflictHorizon,
-							   TransactionId nextXID,
+							   TransactionId recentXid,
 							   bool *released_lock_out)
 {
 	int			last_signaled_pid = 0;
@@ -2068,7 +2093,7 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
 																s, oldestLSN,
 																dboid,
 																snapshotConflictHorizon,
-																nextXID,
+																recentXid,
 																&inactive_since,
 																now);
 
@@ -2163,7 +2188,7 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
 									   slotname, restart_lsn,
 									   oldestLSN, snapshotConflictHorizon,
 									   slot_idle_secs, s->data.xmin,
-									   s->data.catalog_xmin, nextXID);
+									   s->data.catalog_xmin, recentXid);
 
 				if (MyBackendType == B_STARTUP)
 					(void) SignalRecoveryConflict(GetPGProcByNumber(active_proc),
@@ -2217,7 +2242,7 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
 								   slotname, restart_lsn,
 								   oldestLSN, snapshotConflictHorizon,
 								   slot_idle_secs, s->data.xmin,
-								   s->data.catalog_xmin, nextXID);
+								   s->data.catalog_xmin, recentXid);
 
 			/* done with this slot for now */
 			break;
@@ -2259,7 +2284,8 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
 bool
 InvalidateObsoleteReplicationSlots(uint32 possible_causes,
 								   XLogSegNo oldestSegno, Oid dboid,
-								   TransactionId snapshotConflictHorizon, TransactionId nextXID)
+								   TransactionId snapshotConflictHorizon,
+								   TransactionId recentXid)
 {
 	XLogRecPtr	oldestLSN;
 	bool		invalidated = false;
@@ -2298,7 +2324,7 @@ restart:
 
 		if (InvalidatePossiblyObsoleteSlot(possible_causes, s, oldestLSN,
 										   dboid, snapshotConflictHorizon,
-										   nextXID, &released_lock))
+										   recentXid, &released_lock))
 		{
 			Assert(released_lock);
 
@@ -3330,104 +3356,53 @@ WaitForStandbyConfirmation(XLogRecPtr wait_for_lsn)
 	ConditionVariableCancelSleep();
 }
 
-/*
- * Check if the passed-in xmin or catalog_xmin have aged beyond the
- * max_slot_xid_age GUC limit relative to nextXID.
- *
- * Returns true if either value exceeds the configured age.
- */
-static bool
-IsReplicationSlotXIDAged(TransactionId xmin, TransactionId catalog_xmin,
-						 TransactionId nextXID)
-{
-	TransactionId cutoffXID;
-	bool		aged = false;
-
-	if (max_slot_xid_age == 0)
-		return false;
-
-	if (!TransactionIdIsNormal(nextXID))
-		return false;
-
-	cutoffXID = nextXID - max_slot_xid_age;
-
-	/* ensure it's a "normal" XID, else TransactionIdPrecedes misbehaves */
-	/* this can cause the limit to go backwards by 3, but that's OK */
-	if (cutoffXID < FirstNormalTransactionId)
-		cutoffXID -= FirstNormalTransactionId;
-
-	if (TransactionIdIsNormal(xmin) &&
-		TransactionIdPrecedes(xmin, cutoffXID))
-		aged = true;
-
-	if (TransactionIdIsNormal(catalog_xmin) &&
-		TransactionIdPrecedes(catalog_xmin, cutoffXID))
-		aged = true;
-
-	return aged;
-}
-
 /*
  * Invalidate replication slots whose XID age exceeds the max_slot_xid_age
  * GUC.
  *
- * The caller supplies oldestXmin, either computed via
- * GetOldestNonRemovableTransactionId during vacuum, or computed via the
- * minimum of slot xmin values obtained from ProcArrayGetReplicationSlotXmin,
- * and nextXID, the next XID to be assigned used to compute the age.
- *
- * Preliminary checks based on the passed-in oldestXmin and the oldest slot
- * xmin and catalog_xmin are done to avoid unnecessarily iterating over all
- * the slots.  If the oldestXmin age does not exceed the GUC then no
- * individual slot can either, so the per-slot scan is skipped.  For example,
- * if oldestXmin is 100 and the GUC is 500, every slot's xmin must be >= 100,
- * so none can be older than the GUC.  Similarly, if the oldest slot xmin and
- * catalog_xmin from ProcArray are not aged, the per-slot scan is skipped;
- * this can happen when a long-running transaction holds the oldestXmin back.
- *
- * Even if the caller passes an oldestXmin that does not include the slot
- * xmin/catalog_xmin range, there is no risk of incorrect invalidation: each
- * slot's own xmin and catalog_xmin are individually verified against the GUC
- * inside IsReplicationSlotXIDAged(). The only downside is an additional
- * iteration over all the slots.
+ * The oldestXmin is expected to be a XID computed via
+ * GetOldestNonRemovableTransactionId() during vacuum. It is used as cutoffs
+ * for individual slot checks; if its age does not exceed the max_slot_xid_age,
+ * no individual slot can either, so we skip per-slot invalidation check.
  *
  * Returns true if at least one slot was invalidated.
  */
 bool
-InvalidateXIDAgedReplicationSlots(TransactionId oldestXmin, TransactionId nextXID)
+MaybeInvalidateXIDAgedSlots(TransactionId oldestXmin)
 {
-	TransactionId cutoffXID;
+	TransactionId recentXid;
+	TransactionId xidLimit;
+	TransactionId slot_xmin = InvalidTransactionId;
+	TransactionId slot_catalog_xmin = InvalidTransactionId;
 	bool		invalidated = false;
 
-	if (max_slot_xid_age == 0)
-		return false;
+	Assert(TransactionIdIsValid(oldestXmin));
 
-	if (!TransactionIdIsNormal(oldestXmin) || !TransactionIdIsNormal(nextXID))
+	if (max_slot_xid_age == 0)
 		return false;
 
-	cutoffXID = nextXID - max_slot_xid_age;
+	recentXid = ReadNextTransactionId();
+	xidLimit = TransactionIdRetreatedBy(recentXid, max_slot_xid_age);
 
-	/* ensure it's a "normal" XID, else TransactionIdPrecedes misbehaves */
-	/* this can cause the limit to go backwards by 3, but that's OK */
-	if (!TransactionIdIsNormal(cutoffXID))
-		cutoffXID -= FirstNormalTransactionId;
-
-	if (TransactionIdPrecedes(oldestXmin, cutoffXID))
-	{
-		TransactionId slot_xmin;
-		TransactionId slot_catalog_xmin;
+	/* oldestXmin is not behind the cutoff; no need to check slots */
+	if (TransactionIdPrecedes(xidLimit, oldestXmin))
+		return false;
 
-		ProcArrayGetReplicationSlotXmin(&slot_xmin, &slot_catalog_xmin);
+	ProcArrayGetReplicationSlotXmin(&slot_xmin, &slot_catalog_xmin);
 
-		if (IsReplicationSlotXIDAged(slot_xmin, slot_catalog_xmin, nextXID))
-		{
-			invalidated = InvalidateObsoleteReplicationSlots(RS_INVAL_XID_AGE,
-															 0,
-															 InvalidOid,
-															 InvalidTransactionId,
-															 nextXID);
-		}
-	}
+	/*
+	 * Invalidate possibly obsolete slots based on XID-age, if either slot's
+	 * xmin or catalog_xmin is older than the cutoff.
+	 */
+	if ((TransactionIdIsValid(slot_xmin) &&
+		 TransactionIdPrecedes(slot_xmin, xidLimit)) ||
+		(TransactionIdIsValid(slot_catalog_xmin) &&
+		 TransactionIdPrecedes(slot_catalog_xmin, xidLimit)))
+		invalidated = InvalidateObsoleteReplicationSlots(RS_INVAL_XID_AGE,
+														 0,
+														 InvalidOid,
+														 InvalidTransactionId,
+														 recentXid);
 
 	return invalidated;
 }
diff --git a/src/include/replication/slot.h b/src/include/replication/slot.h
index 83cf8438724..c483f0f531b 100644
--- a/src/include/replication/slot.h
+++ b/src/include/replication/slot.h
@@ -371,7 +371,8 @@ extern bool InvalidateObsoleteReplicationSlots(uint32 possible_causes,
 											   XLogSegNo oldestSegno,
 											   Oid dboid,
 											   TransactionId snapshotConflictHorizon,
-											   TransactionId nextXID);
+											   TransactionId recentXid);
+extern bool MaybeInvalidateXIDAgedSlots(TransactionId oldestXmin);
 extern ReplicationSlot *SearchNamedReplicationSlot(const char *name, bool need_lock);
 extern int	ReplicationSlotIndex(ReplicationSlot *slot);
 extern bool ReplicationSlotName(int index, Name name);
@@ -391,6 +392,4 @@ extern bool SlotExistsInSyncStandbySlots(const char *slot_name);
 extern bool StandbySlotsHaveCaughtup(XLogRecPtr wait_for_lsn, int elevel);
 extern void WaitForStandbyConfirmation(XLogRecPtr wait_for_lsn);
 
-extern bool InvalidateXIDAgedReplicationSlots(TransactionId oldestXmin, TransactionId nextXID);
-
 #endif							/* SLOT_H */

---

From: Bharath Rupireddy <[email-masked]>
Date: Sat, 28 Mar 2026 11:03:58 -0700

Hi,

> I think it's better to show the age of the slot's xmin instead of the

Agreed.

> XIDs? I think the function assumes these are valid XIDs.

The oldestXmin is now removed. Please see the responses at the end.

> call ReadNextTransactionId() within this function.

Agreed.

> some code cleanups. Please review it.

The fixup patch looked good to me, I had that merged in the attached v5 patch.

> Thank you for sharing the test script! I'll check it as well.

Thank you.

> Does this account catalog xmin for data tables?

Nice catch! When vacuum runs on regular tables, it doesn't cover
catalog_xmin in the OldestXmin. So if catalog_xmin is blocking
relfrozenxid advancement, slot invalidation doesn't happen. I updated
vacuum_get_cutoffs to return slot_catalog_xmin and slot_xmin. These
values are already available in ComputeXidHorizons, so this doesn't
require an additional proc-array lock.

I also added support for XID age based slot invalidation during
checkpoints. This helps standbys that can have replication slots but
where vacuum doesn't run. (It skips synced slots, just like
idle_replication_slot_timeout does.)

Please find the attached v5 patches for further review. Thank you!

---

From: Srinath Reddy Sadipiralla <[email-masked]>
Date: Mon, 30 Mar 2026 01:46:36 +0530

Hello,

Thanks for the v5 patch set, I have reviewed and did initial testing on
v5 patch set, and it LGTM, except these

diff --git a/src/backend/replication/slot.c b/src/backend/replication/slot.c
index 286f0f46341..c2ff7e464f0 100644
--- a/src/backend/replication/slot.c
+++ b/src/backend/replication/slot.c
@@ -1849,7 +1849,7 @@
ReportSlotInvalidation(ReplicationSlotInvalidationCause cause,
                                else
                                {
                                        /* translator: %s is a GUC variable
name */
-                                       appendStringInfo(&err_detail,
_("The slot's xmin %u is %d transactions old, which exceeds the configured
\"%s\" value of %d."),
+                                       appendStringInfo(&err_detail,
_("The slot's catalog_xmin %u is %d transactions old, which exceeds the
configured \"%s\" value of %d."),

 catalog_xmin, (int32) (recentXid - catalog_xmin), "max_slot_xid_age",
max_slot_xid_age);
                                }

while testing the active slot XID age invalidation (SIGTERM path) , i
observed that slot got invalidated , walsender was killed because of
SIGTERM , then starts the infinite-retry-cycle problem where
walreceiver starts walsender and walsender will try to use an invalidated
slot and dies, will think more on this.

-- 
Thanks,
Srinath Reddy Sadipiralla
EDB: https://www.enterprisedb.com/

---

From: Bharath Rupireddy <[email-masked]>
Date: Sun, 29 Mar 2026 18:35:00 -0700

Hi,

> v5 patch set, and it LGTM, except these

Thank you for reviewing and testing. I appreciate it.

> @@ -1849,7 +1849,7 @@ ReportSlotInvalidation(ReplicationSlotInvalidationCause cause,
>                                 else
>                                 {
> -                                       appendStringInfo(&err_detail, _("The slot's xmin %u is %d transactions old, which exceeds the configured \"%s\" value of %d."),
> +                                       appendStringInfo(&err_detail, _("The slot's catalog_xmin %u is %d transactions old, which exceeds the configured \"%s\" value of %d."),
>                                                                          catalog_xmin, (int32) (recentXid - catalog_xmin), "max_slot_xid_age", max_slot_xid_age);
>                                 }

Fixed the typo.

> slot and dies, will think more on this.

I would like to clarify that once a slot is invalidated due to any of
the reasons (ReplicationSlotInvalidationCause), it becomes unusable;
the sender will error out if the receiver tries to use it. This is
consistent with all existing slot invalidation mechanisms.

Please find the attached v6 patches fixing the typo for further review.

---

From: Masahiko Sawada <[email-masked]>
Date: Mon, 30 Mar 2026 17:13:08 -0700

> Please find the attached v6 patches fixing the typo for further review.

I've reviewed the v6 patch. Here are some comments.

 bool
 vacuum_get_cutoffs(Relation rel, const VacuumParams params,
-                  struct VacuumCutoffs *cutoffs)
+                  struct VacuumCutoffs *cutoffs,
+                  TransactionId *slot_xmin,
+                  TransactionId *slot_catalog_xmin)

How about storing both slot_xmin and catalog_xmin into VacuumCutoffs?

---
-   if (InvalidateObsoleteReplicationSlots(RS_INVAL_WAL_REMOVED |
RS_INVAL_IDLE_TIMEOUT,
+   possibleInvalidationCauses = RS_INVAL_WAL_REMOVED | RS_INVAL_IDLE_TIMEOUT |
+       RS_INVAL_XID_AGE;
+
+   if (InvalidateObsoleteReplicationSlots(possibleInvalidationCauses,
                                           _logSegNo, InvalidOid,
+                                          InvalidTransactionId,
+                                          max_slot_xid_age > 0 ?
+                                          ReadNextTransactionId() :
                                           InvalidTransactionId))

It's odd to me that we specify RS_INVAL_XID_AGE while passing
InvalidTransactionId. I think we can specify RS_INVAL_XID_AGE along
with a valid recentXId only when we'd like to check the slots based on
their XIDs.

---
+   /* Check if the slot needs to be invalidated due to max_slot_xid_age GUC */
+   if ((possible_causes & RS_INVAL_XID_AGE) && CanInvalidateXidAgedSlot(s))
+   {
+       TransactionId xidLimit;
+
+       Assert(TransactionIdIsValid(recentXid));
+
+       xidLimit = TransactionIdRetreatedBy(recentXid, max_slot_xid_age);
+

I think we can avoid calculating xidLimit for every slot by
calculating it in InvalidatePossiblyObsoleteSlot() and passing it to
DetermineSlotInvalidationCause().

---
  */
 TransactionId
 GetOldestNonRemovableTransactionId(Relation rel)
+{
+   return GetOldestNonRemovableTransactionIdExt(rel, NULL, NULL);
+}
+
+/*
+ * 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.
+ */
+TransactionId
+GetOldestNonRemovableTransactionIdExt(Relation rel,
+                                     TransactionId *slot_xmin,
+                                     TransactionId *slot_catalog_xmin)
 {

I understand that the primary reason why the patch introduces another
variant of GetOldestNonRemovableTransactionId() is to avoid extra
ProcArrayLock acquision to get replication slot xmin and catalog_xmin.
While it's not very elegant, I find that it would not be bad because
otherwise autovacuum takes extra ProcArrayLock (in shared mode) for
every table to vacuum. The ProcArrayLock is already known
high-contented lock it would be better to avoid taking it once more.
If others think differently, we can just call
ProcArrayGetReplicationSlotXmin() separately and compare them to the
limit of XID-age based slot invalidation.

Having said that, I personally don't want to add new instructions to
the existing GetOldestNonRemovableTransactionId(). I guess we might
want to make both the existing function and new function call a common
(inline) function that takes ComputeXidHorizonsResult and returns
appropriate transaction id based on the given relation .

---
+   # Do some work to advance xids
+   $node->safe_psql(
+       'postgres', qq[
+       do \$\$
+       begin
+       for i in 1..$nxids loop
+           -- use an exception block so that each iteration eats an XID
+           begin
+           insert into $table_name values (i);
+           exception
+           when division_by_zero then null;
+           end;
+       end loop;
+       end\$\$;
+   ]);

I think it's fater to use pg_current_xact_id() instead.

---
+   else
+   {
+       $node->safe_psql('postgres', "VACUUM");
+   }

We don't need to vacuum all tables here.

---
+# Configure primary with XID age settings. Set autovacuum_naptime high so
+# that the checkpointer (not vacuum) triggers the invalidation.
+my $max_slot_xid_age = 500;
+$primary5->append_conf(
+   'postgresql.conf', qq{
+max_slot_xid_age = $max_slot_xid_age
+autovacuum_naptime = '1h'
+});

I think that it's better to disable autovacuum than setting a large number.

---
+# Testcase end: Invalidate streaming standby's slot due to max_slot_xid_age
+# GUC (via checkpoint).

I think that we can say "physical slot" instead of standby's slot to
avoid confusion as I thought standby's slot is a slot created on the
standby at the first glance.

---
Do we have tests for invalidating slots on the standbys?

Regards,

---

From: "Hayato Kuroda (Fujitsu)" <[email-masked]>
Date: Tue, 31 Mar 2026 07:25:07 +0000

Dear Bharath,

Thanks for re-working the project.

While seeing the old discussion, I found that Robert Haas was agaist the XID-based
invalidation, because it's difficult to determine the cutoff age [1].
Can you clarify your thought against the point? Are you focusing on solving the
wraparound issues, not for bloated instance issue?
The code may not be accepted unless we got his agreement.

[1]: https://www.postgresql.org/message-id/[email-masked]

Best regards,
Hayato Kuroda
FUJITSU LIMITED

---

From: Bharath Rupireddy <[email-masked]>
Date: Tue, 31 Mar 2026 09:45:08 -0700

Hi,

> Thanks for re-working the project.

Thank you for looking into this.

> [1]: https://www.postgresql.org/message-id/[email-masked]

I summarized what others (Nathan, Robert, Amit, Alvaro, Bertrand) said
about it here with my responses:
https://www.postgresql.org/message-id/CALj2ACVY%2BFd5vC0VjW%3D5VDK9mmt-Y%2BPDZxnBp8ngGAZc24Vv9g%40mail.gmail.com.

Please have a look.

A good setting for this in production scenarios is to set
max_slot_xid_age to vacuum_failsafe_age (1.6B) or little less, so that
autovacuum invalidates the slot before entering failsafe mode,
unblocking datfrozenxid advancement and avoiding XID wraparound
without manual VACUUM or downtime. I added a test for this in the 0002
patch. Please have a look.

---

From: Bharath Rupireddy <[email-masked]>
Date: Tue, 31 Mar 2026 10:20:56 -0700

Hi,

> How about storing both slot_xmin and catalog_xmin into VacuumCutoffs?

Done.

> with a valid recentXId only when we'd like to check the slots based on

Done.

> DetermineSlotInvalidationCause().

Done.

> limit of XID-age based slot invalidation.

I understand the concerns around the ProcArrayLock and I think a new
function to return the computed slot's xmin and catalog_xmin is good.

> appropriate transaction id based on the given relation .

Done.

> I think it's fater to use pg_current_xact_id() instead.

Done. I pulled this from an existing test case in 001_stream_rep.pl.
Used the pg_current_xact_id approach. Testing times stay the same i.e.
9 wallclock secs.

> We don't need to vacuum all tables here.

Fixed.

> I think that it's better to disable autovacuum than setting a large number.

Done.

> standby at the first glance.

Fixed.

> Do we have tests for invalidating slots on the standbys?

Added a test case for this.

Please find the attached v7 patches for further review. Thank you!

---

From: Masahiko Sawada <[email-masked]>
Date: Wed, 1 Apr 2026 12:38:23 -0700

> Please find the attached v7 patches for further review. Thank you!

I've reviewed the v7 patch and have some review comments:

+# Advance the given number of XIDs
+sub advance_xids
+{
+   my ($node, $nxids) = @_;
+   my $sql = join(";\n", ("SELECT pg_current_xact_id()") x $nxids);
+   $node->safe_psql('postgres', $sql);
+}

I think we can create a procedure on primary5 instance to consume XIDs
as follow:

$standby5->safe_psql(
   'postgres',
   qq{CREATE PROCEDURE consume_xid(cnt int)
AS \$\$
DECLARE
    i int;
    BEGIN
        FOR i in 1..cnt LOOP
            EXECUTE 'SELECT pg_current_xact_id()';
            COMMIT;
        END LOOP;
    END;
+\$\$
LANGUAGE plpgsql;
});

---
+# Create a subscriber node
+my $subscriber5 = PostgreSQL::Test::Cluster->new('subscriber5');
+$subscriber5->init(allows_streaming => 'logical');
+$subscriber5->start;

Do we really need to create a subscriber for this test? I think we can
simply create a logical slot on the primary5 and test the XID-age
based slot invalidation.

---
I've attached a fixup patch to propose some cleanup and refactoring, including:

- changes to invalidation errdetail message.
- passing xidLimit instead of recentXid to simplify the invalidation logic.
- documentation changes.
- comment changes.

Regards,

--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 46aac59cb20..9ad662b8b6f 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -4772,17 +4772,24 @@ restore_command = 'copy "C:\\server\\archivedir\\%f" "%p"'  # Windows
       </term>
       <listitem>
        <para>
-        Invalidate replication slots whose <literal>xmin</literal> (the oldest
-        transaction that this slot needs the database to retain) or
-        <literal>catalog_xmin</literal> (the oldest transaction affecting the
-        system catalogs that this slot needs the database to retain) has reached
-        the age specified by this setting. This invalidation check happens
-        during vacuum (both <command>VACUUM</command> command and autovacuum)
-        and during checkpoints.
-        A value of zero (which is default) disables this feature. Users can set
-        this value anywhere from zero to two billion. This parameter can only be
-        set in the <filename>postgresql.conf</filename> file or on the server
-        command line.
+        Invalidate replication slots whose <structfield>xmin</structfield> age
+        or <structfield>catalog_xmin</structfield> age in the
+        <link linkend="view-pg-replication-slots">pg_replication_slots</link>
+        view has exceeded the age specified by this setting. Slot invalidation
+        due to this limit occurs during vacuum (both <command>VACUUM</command>
+        command and autovacuum) and during checkpoint.
+        A value of zero (the default) disables this feature. Users can set
+        this value anywhere from zero to two billion transactions. This parameter
+        can only be set in the <filename>postgresql.conf</filename> file or on
+        the server command line.
+       </para>
+
+       <para>
+        The current age of a slot's <literal>xmin</literal> and
+        <literal>catalog_xmin</literal> can be monitored by applying the
+        <function>age</function> function to the corresponding columns in the
+        <link linkend="view-pg-replication-slots">pg_replication_slots</link>
+        view.
        </para>
 
        <para>
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 70c1d5c5559..1aec1bcb79e 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -7019,8 +7019,8 @@ CreateCheckPoint(int flags)
 	VirtualTransactionId *vxids;
 	int			nvxids;
 	int			oldXLogAllowed = 0;
-	uint32		possibleInvalidationCauses;
-	TransactionId recentXid;
+	uint32		slotInvalidationCauses;
+	TransactionId slotXidLimit;
 
 	/*
 	 * An end-of-recovery checkpoint is really a shutdown checkpoint, just
@@ -7450,19 +7450,19 @@ CreateCheckPoint(int flags)
 	XLByteToSeg(RedoRecPtr, _logSegNo, wal_segment_size);
 	KeepLogSeg(recptr, &_logSegNo);
 
-	possibleInvalidationCauses = RS_INVAL_WAL_REMOVED | RS_INVAL_IDLE_TIMEOUT;
-	recentXid = InvalidTransactionId;
-
+	slotInvalidationCauses = RS_INVAL_WAL_REMOVED | RS_INVAL_IDLE_TIMEOUT;
+	slotXidLimit = InvalidTransactionId;
 	if (max_slot_xid_age > 0)
 	{
-		possibleInvalidationCauses |= RS_INVAL_XID_AGE;
-		recentXid = ReadNextTransactionId();
+		slotInvalidationCauses |= RS_INVAL_XID_AGE;
+		slotXidLimit = TransactionIdRetreatedBy(ReadNextTransactionId(),
+												max_slot_xid_age);
 	}
 
-	if (InvalidateObsoleteReplicationSlots(possibleInvalidationCauses,
+	if (InvalidateObsoleteReplicationSlots(slotInvalidationCauses,
 										   _logSegNo, InvalidOid,
 										   InvalidTransactionId,
-										   recentXid))
+										   slotXidLimit))
 	{
 		/*
 		 * Some slots have been invalidated; recalculate the old-segment
@@ -7743,8 +7743,8 @@ CreateRestartPoint(int flags)
 	XLogRecPtr	endptr;
 	XLogSegNo	_logSegNo;
 	TimestampTz xtime;
-	uint32		possibleInvalidationCauses;
-	TransactionId recentXid;
+	uint32		slotInvalidationCauses;
+	TransactionId slotXidLimit;
 
 	/* Concurrent checkpoint/restartpoint cannot happen */
 	Assert(!IsUnderPostmaster || MyBackendType == B_CHECKPOINTER);
@@ -7919,19 +7919,19 @@ CreateRestartPoint(int flags)
 
 	INJECTION_POINT("restartpoint-before-slot-invalidation", NULL);
 
-	possibleInvalidationCauses = RS_INVAL_WAL_REMOVED | RS_INVAL_IDLE_TIMEOUT;
-	recentXid = InvalidTransactionId;
-
+	slotInvalidationCauses = RS_INVAL_WAL_REMOVED | RS_INVAL_IDLE_TIMEOUT;
+	xidLimit = InvalidTransactionId;
 	if (max_slot_xid_age > 0)
 	{
-		possibleInvalidationCauses |= RS_INVAL_XID_AGE;
-		recentXid = ReadNextTransactionId();
+		slotInvalidationCauses |= RS_INVAL_XID_AGE;
+		slotXidLimit = TransactionIdRetreatedBy(ReadNextTransactionId(),
+												max_slot_xid_age);
 	}
 
-	if (InvalidateObsoleteReplicationSlots(possibleInvalidationCauses,
+	if (InvalidateObsoleteReplicationSlots(slotInvalidationCauses,
 										   _logSegNo, InvalidOid,
 										   InvalidTransactionId,
-										   recentXid))
+										   slotXidLimit))
 	{
 		/*
 		 * Some slots have been invalidated; recalculate the old-segment
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index 7f36f795b72..cda86b9d50f 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -1133,9 +1133,10 @@ 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 = GetOldestNonRemovableTransactionIdExt(rel,
-																&cutoffs->slot_xmin,
-																&cutoffs->slot_catalog_xmin);
+	cutoffs->OldestXmin =
+		GetOldestNonRemovableTransactionIdWithSlotXids(rel,
+													   &cutoffs->slot_xmin,
+													   &cutoffs->slot_catalog_xmin);
 
 	Assert(TransactionIdIsNormal(cutoffs->OldestXmin));
 
diff --git a/src/backend/replication/slot.c b/src/backend/replication/slot.c
index 20729d2fb42..e6271e2a519 100644
--- a/src/backend/replication/slot.c
+++ b/src/backend/replication/slot.c
@@ -1790,7 +1790,7 @@ ReportSlotInvalidation(ReplicationSlotInvalidationCause cause,
 					   long slot_idle_seconds,
 					   TransactionId xmin,
 					   TransactionId catalog_xmin,
-					   TransactionId recentXid)
+					   TransactionId xidLimit)
 {
 	StringInfoData err_detail;
 	StringInfoData err_hint;
@@ -1838,20 +1838,18 @@ ReportSlotInvalidation(ReplicationSlotInvalidationCause cause,
 
 		case RS_INVAL_XID_AGE:
 			{
-				Assert(TransactionIdIsValid(xmin) || TransactionIdIsValid(catalog_xmin));
-
-				if (TransactionIdIsValid(xmin))
-				{
-					/* translator: %s is a GUC variable name */
-					appendStringInfo(&err_detail, _("The slot's xmin %u is %d transactions old, which exceeds the configured \"%s\" value of %d."),
-									 xmin, (int32) (recentXid - xmin), "max_slot_xid_age", max_slot_xid_age);
-				}
-				else
-				{
-					/* translator: %s is a GUC variable name */
-					appendStringInfo(&err_detail, _("The slot's catalog_xmin %u is %d transactions old, which exceeds the configured \"%s\" value of %d."),
-									 catalog_xmin, (int32) (recentXid - catalog_xmin), "max_slot_xid_age", max_slot_xid_age);
-				}
+				TransactionId slot_xid = TransactionIdIsValid(xmin) ? xmin : catalog_xmin;
+				int32		exceeded_by = (int32) (xidLimit - slot_xid);
+				int32		slot_age = (int32) max_slot_xid_age + exceeded_by;
+
+				Assert(TransactionIdIsValid(slot_xid));
+
+				/* translator: %s is a GUC variable name */
+				appendStringInfo(&err_detail,
+								 TransactionIdIsValid(xmin)
+								 ? _("The slot's xmin age of %d exceeds the configured \"%s\" of %d by %d transactions")
+								 : _("The slot's catalog xmin age of %d exceeds the configured \"%s\" of %d by %d transactions"),
+								 slot_age, "max_slot_xid_age", max_slot_xid_age, exceeded_by);
 
 				/* translator: %s is a GUC variable name */
 				appendStringInfo(&err_hint, _("You might need to increase \"%s\"."),
@@ -2033,19 +2031,13 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
 							   ReplicationSlot *s,
 							   XLogRecPtr oldestLSN,
 							   Oid dboid, TransactionId snapshotConflictHorizon,
-							   TransactionId recentXid,
+							   TransactionId xidLimit,
 							   bool *released_lock_out)
 {
 	int			last_signaled_pid = 0;
 	bool		released_lock = false;
 	bool		invalidated = false;
 	TimestampTz inactive_since = 0;
-	TransactionId xidLimit = InvalidTransactionId;
-
-	/* Compute the XID limit once, to avoid redundant work per slot */
-	if ((possible_causes & RS_INVAL_XID_AGE) &&
-		TransactionIdIsValid(recentXid))
-		xidLimit = TransactionIdRetreatedBy(recentXid, max_slot_xid_age);
 
 	for (;;)
 	{
@@ -2187,7 +2179,7 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
 									   slotname, restart_lsn,
 									   oldestLSN, snapshotConflictHorizon,
 									   slot_idle_secs, s->data.xmin,
-									   s->data.catalog_xmin, recentXid);
+									   s->data.catalog_xmin, xidLimit);
 
 				if (MyBackendType == B_STARTUP)
 					(void) SignalRecoveryConflict(GetPGProcByNumber(active_proc),
@@ -2241,7 +2233,7 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
 								   slotname, restart_lsn,
 								   oldestLSN, snapshotConflictHorizon,
 								   slot_idle_secs, s->data.xmin,
-								   s->data.catalog_xmin, recentXid);
+								   s->data.catalog_xmin, xidLimit);
 
 			/* done with this slot for now */
 			break;
@@ -2284,7 +2276,7 @@ bool
 InvalidateObsoleteReplicationSlots(uint32 possible_causes,
 								   XLogSegNo oldestSegno, Oid dboid,
 								   TransactionId snapshotConflictHorizon,
-								   TransactionId recentXid)
+								   TransactionId xidLimit)
 {
 	XLogRecPtr	oldestLSN;
 	bool		invalidated = false;
@@ -2323,7 +2315,7 @@ restart:
 
 		if (InvalidatePossiblyObsoleteSlot(possible_causes, s, oldestLSN,
 										   dboid, snapshotConflictHorizon,
-										   recentXid, &released_lock))
+										   xidLimit, &released_lock))
 		{
 			Assert(released_lock);
 
@@ -3391,7 +3383,7 @@ MaybeInvalidateXIDAgedSlots(TransactionId slot_xmin,
 														 0,
 														 InvalidOid,
 														 InvalidTransactionId,
-														 recentXid);
+														 xidLimit);
 
 	return invalidated;
 }
diff --git a/src/backend/storage/ipc/procarray.c b/src/backend/storage/ipc/procarray.c
index 9e0acf7309d..898ef4a0833 100644
--- a/src/backend/storage/ipc/procarray.c
+++ b/src/backend/storage/ipc/procarray.c
@@ -1938,10 +1938,11 @@ GlobalVisHorizonKindForRel(Relation rel)
 }
 
 /*
- * Helper to return the appropriate oldest non-removable TransactionId from
- * pre-computed horizons, based on the relation type.
+ * A helper function to return the appropriate oldest non-removable
+ * TransactionId from the pre-computed horizons, based on the relation
+ * type.
  */
-static inline TransactionId
+static pg_attribute_always_inline TransactionId
 GetOldestNonRemovableTransactionIdFromHorizons(ComputeXidHorizonsResult *horizons,
 											   Relation rel)
 {
@@ -1989,9 +1990,9 @@ GetOldestNonRemovableTransactionId(Relation rel)
  * needs both values.
  */
 TransactionId
-GetOldestNonRemovableTransactionIdExt(Relation rel,
-									  TransactionId *slot_xmin,
-									  TransactionId *slot_catalog_xmin)
+GetOldestNonRemovableTransactionIdWithSlotXids(Relation rel,
+											   TransactionId *slot_xmin,
+											   TransactionId *slot_catalog_xmin)
 {
 	ComputeXidHorizonsResult horizons;
 
diff --git a/src/include/replication/slot.h b/src/include/replication/slot.h
index 0baa7112559..d143b19a7b3 100644
--- a/src/include/replication/slot.h
+++ b/src/include/replication/slot.h
@@ -371,7 +371,7 @@ extern bool InvalidateObsoleteReplicationSlots(uint32 possible_causes,
 											   XLogSegNo oldestSegno,
 											   Oid dboid,
 											   TransactionId snapshotConflictHorizon,
-											   TransactionId recentXid);
+											   TransactionId xidLimit);
 extern bool MaybeInvalidateXIDAgedSlots(TransactionId slot_xmin,
 										TransactionId slot_catalog_xmin);
 extern ReplicationSlot *SearchNamedReplicationSlot(const char *name, bool need_lock);
diff --git a/src/include/storage/procarray.h b/src/include/storage/procarray.h
index c198fd22515..a94091ce7fd 100644
--- a/src/include/storage/procarray.h
+++ b/src/include/storage/procarray.h
@@ -53,9 +53,9 @@ extern RunningTransactions GetRunningTransactionData(void);
 
 extern bool TransactionIdIsInProgress(TransactionId xid);
 extern TransactionId GetOldestNonRemovableTransactionId(Relation rel);
-extern TransactionId GetOldestNonRemovableTransactionIdExt(Relation rel,
-														   TransactionId *slot_xmin,
-														   TransactionId *slot_catalog_xmin);
+extern TransactionId GetOldestNonRemovableTransactionIdWithSlotXids(Relation rel,
+																	TransactionId *slot_xmin,
+																	TransactionId *slot_catalog_xmin);
 extern TransactionId GetOldestTransactionIdConsideredRunning(void);
 extern TransactionId GetOldestActiveTransactionId(bool inCommitOnly,
 												  bool allDbs);

---

From: Bharath Rupireddy <[email-masked]>
Date: Wed, 1 Apr 2026 14:21:13 -0700

Hi,

>             EXECUTE 'SELECT pg_current_xact_id()';

Agreed. Although the test timings don't improve (9 seconds on my dev
machine) after moving to the procedure vs. sending pg_current_xact_id
SQL statements, the procedure approach looks better and is more
consistent.

> simply create a logical slot on the primary5 and test the XID-age

Nice catch! Removed.

> - passing xidLimit instead of recentXid to simplify the invalidation logic.

I took the above changes into v8 and fixed a typo in using xidLimit
instead of slotXidLimit.

Please find the attached v8 patches for further review. Thank you!

---

From: Bharath Rupireddy <[email-masked]>
Date: Fri, 3 Apr 2026 12:04:48 -0700

Hi,

> Please find the attached v8 patches for further review. Thank you!

Thank you, Sawada-san, for reviewing and providing some offlist comments.

1/ Included a note in the docs to say that logical replication slots
are also affected by XID age GUC (similar to
idle_replication_slot_timeout).

2/ Added the code to disable the XID age invalidation in
pg_createsubscriber similar to timeout invalidation. Commit 72e6c08fea
ensured that none of the logical replication slots get invalidated
during the upgrade. (I believe the work that pg_upgrade and
pg_createsubscriber do is more important, and the slots created and
used by them or slots in use during those processes must not interfere
with the upgrade or creating a logical replica from a standby.)

3/ Changed the max value of XID age GUC to be equal to that of vacuum
failsafe age. In my opinion, the best use of max_slot_xid_age would be
to set it equal to or a little less than vacuum_failsafe_age. Also
added a note in the docs about this.

4/ Changed variable names for consistency.

5/ Added code to MaybeInvalidateXIDAgedSlots() to skip the slot
invalidation attempt (unnecessary work) when slots are not the reason
for holding back the OldestXmin. Added an equality check to see if
OldestXmin is either OldestSlotXmin or OldestSlotCatalogXmin (all
these OldestXXXXmins are computed from the same ComputeXidHorizons()
call). This should allow us to skip the slot invalidation attempt when
a backend is holding the xmin back (a long-running transaction, for
example).

Please find the attached v9 patches for further review. Thank you!

---

From: Masahiko Sawada <[email-masked]>
Date: Sun, 5 Apr 2026 01:03:00 -0700

> Please find the attached v9 patches for further review. Thank you!

Thank you for updating the patch!

I've made some changes including moving MaybeInvalidateXidAgedSlot()
to vacuum.c since the function seems more inherently tied to vacuum
context. Also, updated the commit message and fixed typos.

Please review the attached patch.

Regards,

-- 
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com
From 05b6194a321f4168dcc17b1fd5589dfcfb6e49d4 Mon Sep 17 00:00:00 2001
From: Bharath Rupireddy <[email-masked]>
Date: Fri, 3 Apr 2026 18:27:15 +0000
Subject: [PATCH v10] Introduce max_slot_xid_age to invalidate old replication
 slots.

This commit introduces a new GUC parameter, max_slot_xid_age. It
invalidates replication slots whose xmin or catalog_xmin age exceeds
the configured limit. While time-based slot invalidation is useful for
cleaning up inactive slots, an XID-age-based limit acts as a critical
backstop to directly prevent transaction ID wraparound and severe
bloat caused by orphaned slots.

The invalidation check occurs during both VACUUM (manual and autovacuum)
and checkpoints. During vacuum, the check is performed on a per-relation
basis. Crucially, the XID-age based slot invalidation is considered
only when slots' XIDs are actively holding back the OldestXmin for the
current relation. In other words, we only invalidate slots when doing
so has the potential to advance the vacuum cutoff and allow dead tuple
reclamation.

Checking slot XIDs per relation could introduce significant performance
overhead if it required acquiring ProcArrayLock repeatedly to get
replication slot xmin values (xmin and catalog_xmin in procArray). To
avoid this, the vacuum cutoff calculation has been optimized. A new
function GetOldestNonRemovableTransactionIdWithSlotXids() is
introduced to return the global OldestXmin along with the oldest slot
xmin and catalog_xmin values. This allows the per-relation invalidation
check to be performed with zero additional lock acquisitions.

In addition to vacuum, slots are also checked and invalidated during
checkpoints. This is particularly important for standby servers where
vacuum does not run. Note that on standbys, slots that are currently
being synced from the primary (i.e., synced = true) are exempt from
this invalidation mechanism.

Author: Bharath Rupireddy <[email-masked]>
Co-authored-by: John Hsu <[email-masked]>
Reviewed-by: Masahiko Sawada <[email-masked]>
Reviewed-by: Bertrand Drouvot <[email-masked]>
Reviewed-by: shveta malik <[email-masked]>
Reviewed-by: Amit Kapila <[email-masked]>
Reviewed-by: SATYANARAYANA NARLAPURAM <[email-masked]>
Discussion: https://postgr.es/m/[email-masked]
Discussion: https://postgr.es/m/[email-masked]
---
 doc/src/sgml/config.sgml                      |  54 ++++++
 doc/src/sgml/logical-replication.sgml         |   4 +-
 doc/src/sgml/system-views.sgml                |   8 +
 src/backend/access/heap/vacuumlazy.c          |  18 ++
 src/backend/access/transam/xlog.c             |  34 +++-
 src/backend/commands/vacuum.c                 |  80 +++++++-
 src/backend/replication/slot.c                |  82 +++++++-
 src/backend/storage/ipc/procarray.c           |  61 ++++--
 src/backend/storage/ipc/standby.c             |   3 +-
 src/backend/utils/misc/guc_parameters.dat     |   8 +
 src/backend/utils/misc/postgresql.conf.sample |   2 +
 src/bin/pg_basebackup/pg_createsubscriber.c   |   2 +-
 src/include/commands/vacuum.h                 |  10 +
 src/include/replication/slot.h                |   8 +-
 src/include/storage/procarray.h               |   3 +
 src/test/recovery/t/019_replslot_limit.pl     | 175 ++++++++++++++++++
 16 files changed, 524 insertions(+), 28 deletions(-)

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index d3fea738ca3..16e45748118 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -4764,6 +4764,60 @@ restore_command = 'copy "C:\\server\\archivedir\\%f" "%p"'  # Windows
       </listitem>
      </varlistentry>
 
+     <varlistentry id="guc-max-slot-xid-age" xreflabel="max_slot_xid_age">
+      <term><varname>max_slot_xid_age</varname> (<type>integer</type>)
+      <indexterm>
+       <primary><varname>max_slot_xid_age</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Invalidate replication slots whose <structfield>xmin</structfield> age
+        or <structfield>catalog_xmin</structfield> age in the
+        <link linkend="view-pg-replication-slots">pg_replication_slots</link>
+        view has exceeded the age specified by this setting. Slot invalidation
+        due to this limit occurs during vacuum (both <command>VACUUM</command>
+        command and autovacuum) and during checkpoint.
+        A value of zero (the default) disables this feature. Users can set
+        this value anywhere from zero to two billion transactions. This parameter
+        can only be set in the <filename>postgresql.conf</filename> file or on
+        the server command line.
+       </para>
+
+       <para>
+        The current age of a slot's <literal>xmin</literal> and
+        <literal>catalog_xmin</literal> can be monitored by applying the
+        <function>age</function> function to the corresponding columns in the
+        <link linkend="view-pg-replication-slots">pg_replication_slots</link>
+        view.
+       </para>
+
+       <para>
+        Idle or forgotten replication slots can hold back vacuum, leading to
+        bloat and eventually transaction ID wraparound. This setting avoids
+        that by invalidating slots that have fallen too far behind.
+        See <xref linkend="routine-vacuuming"/> for more details.
+       </para>
+
+       <para>
+        It is recommended to set <varname>max_slot_xid_age</varname>
+        to a value equal to or slightly less than
+        <xref linkend="guc-vacuum-failsafe-age"/>, so that the slot holding the
+        vacuum back is invalidated before vacuum enters failsafe mode.
+       </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
+        <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.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="guc-wal-sender-timeout" xreflabel="wal_sender_timeout">
       <term><varname>wal_sender_timeout</varname> (<type>integer</type>)
       <indexterm>
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index 23b268273b9..a865a5e6c28 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -2649,7 +2649,9 @@ CONTEXT:  processing remote data for replication origin "pg_16395" during "INSER
 
    <para>
     Logical replication slots are also affected by
-    <link linkend="guc-idle-replication-slot-timeout"><varname>idle_replication_slot_timeout</varname></link>.
+    <link linkend="guc-idle-replication-slot-timeout"><varname>idle_replication_slot_timeout</varname></link>
+    and
+    <link linkend="guc-max-slot-xid-age"><varname>max_slot_xid_age</varname></link>.
    </para>
 
    <para>
diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 9ee1a2bfc6a..1a507b430f9 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -3102,6 +3102,14 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
           <xref linkend="guc-idle-replication-slot-timeout"/> duration.
          </para>
         </listitem>
+        <listitem>
+         <para>
+          <literal>xid_aged</literal> means that the slot's
+          <literal>xmin</literal> or <literal>catalog_xmin</literal>
+          has reached the age specified by
+          <xref linkend="guc-max-slot-xid-age"/> parameter.
+         </para>
+        </listitem>
        </itemizedlist>
       </para></entry>
      </row>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 88c71cd85b6..56b231f2350 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -799,6 +799,24 @@ 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 the current vacuum cutoff (OldestXmin) is being held back by a
+	 * replication slot that has exceeded max_slot_xid_age, attempt to
+	 * invalidate such slots.
+	 */
+	if (maybe_invalidate_xid_aged_slots(vacrel->cutoffs.OldestXmin,
+										vacrel->cutoffs.OldestSlotXmin,
+										vacrel->cutoffs.OldestSlotCatalogXmin))
+	{
+		/*
+		 * Some slots have been invalidated; re-compute the vacuum cutoffs and
+		 * aggressiveness.
+		 */
+		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 9e8999bbb61..42285c21cb3 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -7401,6 +7401,8 @@ CreateCheckPoint(int flags)
 	VirtualTransactionId *vxids;
 	int			nvxids;
 	int			oldXLogAllowed = 0;
+	uint32		slotInvalidationCauses;
+	TransactionId slotXidLimit;
 
 	/*
 	 * An end-of-recovery checkpoint is really a shutdown checkpoint, just
@@ -7845,9 +7847,20 @@ CreateCheckPoint(int flags)
 	 */
 	XLByteToSeg(RedoRecPtr, _logSegNo, wal_segment_size);
 	KeepLogSeg(recptr, &_logSegNo);
-	if (InvalidateObsoleteReplicationSlots(RS_INVAL_WAL_REMOVED | RS_INVAL_IDLE_TIMEOUT,
+
+	slotInvalidationCauses = RS_INVAL_WAL_REMOVED | RS_INVAL_IDLE_TIMEOUT;
+	slotXidLimit = InvalidTransactionId;
+	if (max_slot_xid_age > 0)
+	{
+		slotInvalidationCauses |= RS_INVAL_XID_AGE;
+		slotXidLimit = TransactionIdRetreatedBy(ReadNextTransactionId(),
+												max_slot_xid_age);
+	}
+
+	if (InvalidateObsoleteReplicationSlots(slotInvalidationCauses,
 										   _logSegNo, InvalidOid,
-										   InvalidTransactionId))
+										   InvalidTransactionId,
+										   slotXidLimit))
 	{
 		/*
 		 * Some slots have been invalidated; recalculate the old-segment
@@ -8134,6 +8147,8 @@ CreateRestartPoint(int flags)
 	XLogRecPtr	endptr;
 	XLogSegNo	_logSegNo;
 	TimestampTz xtime;
+	uint32		slotInvalidationCauses;
+	TransactionId slotXidLimit;
 
 	/* Concurrent checkpoint/restartpoint cannot happen */
 	Assert(!IsUnderPostmaster || MyBackendType == B_CHECKPOINTER);
@@ -8312,9 +8327,19 @@ CreateRestartPoint(int flags)

-	if (InvalidateObsoleteReplicationSlots(RS_INVAL_WAL_REMOVED | RS_INVAL_IDLE_TIMEOUT,
+	slotInvalidationCauses = RS_INVAL_WAL_REMOVED | RS_INVAL_IDLE_TIMEOUT;
+	slotXidLimit = InvalidTransactionId;
+	if (max_slot_xid_age > 0)
+	{
+		slotInvalidationCauses |= RS_INVAL_XID_AGE;
+		slotXidLimit = TransactionIdRetreatedBy(ReadNextTransactionId(),
+												max_slot_xid_age);
+	}
+
+	if (InvalidateObsoleteReplicationSlots(slotInvalidationCauses,
 										   _logSegNo, InvalidOid,
-										   InvalidTransactionId))
+										   InvalidTransactionId,
+										   slotXidLimit))
 	{
 		/*
 		 * Some slots have been invalidated; recalculate the old-segment
@@ -9230,6 +9255,7 @@ xlog_redo(XLogReaderState *record)
 				 */
 				InvalidateObsoleteReplicationSlots(RS_INVAL_WAL_LEVEL,
 												   0, InvalidOid,
+												   InvalidTransactionId,
 												   InvalidTransactionId);
 			}
 			else if (sync_replication_slots)
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index 0ed363d1c85..ed647e77615 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"
@@ -1133,7 +1134,10 @@ 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 =
+		GetOldestNonRemovableTransactionIdWithSlotXids(rel,
+													   &cutoffs->OldestSlotXmin,
+													   &cutoffs->OldestSlotCatalogXmin);

@@ -2688,3 +2692,77 @@ vac_tid_reaped(ItemPointer itemptr, void *state)
 
 	return TidStoreIsMember(dead_items, itemptr);
 }
+
+/*
+ * Invalidate replication slots whose XID age exceeds max_slot_xid_age.
+ *
+ * The caller provides the overall oldest xmin along with the oldest
+ * slot and catalog_xmin, typically all obtained from a single consistent
+ * snapshot via ComputeXidHorizons(). These values are used to avoid
+ * unnecessary work: if the global oldest_xmin is held back by something
+ * other than a replication slot (e.g., a long-running transaction),
+ * invalidating slots would not advance the horizon and is therefore
+ * skipped. Similarly, no action is taken if the current horizons have
+ * not yet exceeded the threshold.
+ *
+ * Returns true if at least one slot was invalidated.
+ */
+bool
+maybe_invalidate_xid_aged_slots(TransactionId oldest_xmin,
+								TransactionId oldest_slot_xmin,
+								TransactionId oldest_slot_catalog_xmin)
+{
+	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 catalog_xmin is what's holding
+	 * back oldest_xmin. If not, skip the unnecessary work.
+	 */
+	slot_holds_oldest_xmin =
+		(TransactionIdIsValid(oldest_slot_xmin) &&
+		 TransactionIdEquals(oldest_xmin, oldest_slot_xmin)) ||
+		(TransactionIdIsValid(oldest_slot_catalog_xmin) &&
+		 TransactionIdEquals(oldest_xmin, oldest_slot_catalog_xmin));
+
+	if (!slot_holds_oldest_xmin)
+		return false;
+
+	xid_limit = TransactionIdRetreatedBy(ReadNextTransactionId(),
+										 max_slot_xid_age);
+
+	/*
+	 * A replication slot is holding back oldest_xmin. We invalidate slots
+	 * that have exceeded the XID age limit.
+	 *
+	 * Note that while a non-catalog vacuum is technically only blocked by
+	 * physical slots' xmin values, we invalidate logical slots too that
+	 * exceed the XID age limit if we trigger the XID-age based slot
+	 * invalidation. One might think that this is unnecessary for non-catalog
+	 * tables as invalidating logical slots while vacuuming a non-catalog
+	 * table doesn't help advance vacuum cutoffs. But performing invalidation
+	 * trials for physical and logical slots would add complexity.
+	 *
+	 * In practice, XID-age-based invalidation is lightweight (e.g., it does
+	 * not require process termination). This unified approach keeps the API
+	 * simple by avoiding the need to distinguish between catalog and
+	 * non-catalog tables here.
+	 *
+	 * Note: Invalidating a slot does not guarantee that the oldest xmin will
+	 * advance. Due to a race condition, a long-running transaction might be
+	 * holding the same xmin as the slot. In such cases, the slot is
+	 * invalidated, but the global horizon remains unchanged.
+	 */
+	if (TransactionIdPrecedes(oldest_xmin, xid_limit))
+		return InvalidateObsoleteReplicationSlots(RS_INVAL_XID_AGE,
+												  0, InvalidOid,
+												  InvalidTransactionId,
+												  xid_limit);
+
+	return false;
+}
diff --git a/src/backend/replication/slot.c b/src/backend/replication/slot.c
index a9092fc2382..833ed196128 100644
--- a/src/backend/replication/slot.c
+++ b/src/backend/replication/slot.c
@@ -117,6 +117,7 @@ static const SlotInvalidationCauseMap SlotInvalidationCauses[] = {
 	{RS_INVAL_HORIZON, "rows_removed"},
 	{RS_INVAL_WAL_LEVEL, "wal_level_insufficient"},
 	{RS_INVAL_IDLE_TIMEOUT, "idle_timeout"},
+	{RS_INVAL_XID_AGE, "xid_aged"},
 };
 
 /*
@@ -158,6 +159,12 @@ int			max_replication_slots = 10; /* the maximum number of replication
  */
 int			idle_replication_slot_timeout_secs = 0;
 
+/*
+ * Invalidate replication slots that have xmin or catalog_xmin older
+ * than the specified age; '0' disables it.
+ */
+int			max_slot_xid_age = 0;
+
 /*
  * This GUC lists streaming replication standby server slot names that
  * logical WAL sender processes will wait for.
@@ -1780,7 +1787,10 @@ ReportSlotInvalidation(ReplicationSlotInvalidationCause cause,
 					   XLogRecPtr restart_lsn,
 					   XLogRecPtr oldestLSN,
 					   TransactionId snapshotConflictHorizon,
-					   long slot_idle_seconds)
+					   long slot_idle_seconds,
+					   TransactionId xmin,
+					   TransactionId catalog_xmin,
+					   TransactionId xidLimit)
 {
 	StringInfoData err_detail;
 	StringInfoData err_hint;
@@ -1825,6 +1835,29 @@ ReportSlotInvalidation(ReplicationSlotInvalidationCause cause,
 								 "idle_replication_slot_timeout");
 				break;
 			}
+
+		case RS_INVAL_XID_AGE:
+			{
+				TransactionId slot_xid = TransactionIdIsValid(xmin) ? xmin : catalog_xmin;
+				int32		exceeded_by = (int32) (xidLimit - slot_xid);
+				int32		slot_age = (int32) max_slot_xid_age + exceeded_by;
+
+				/* Either the slot's xmin or catalog_xmin must be valid */
+				Assert(TransactionIdIsValid(slot_xid));
+
+				/* translator: %s is a GUC variable name */
+				appendStringInfo(&err_detail,
+								 TransactionIdIsValid(xmin)
+								 ? _("The slot's xmin age of %d exceeds the configured \"%s\" of %d by %d transactions")
+								 : _("The slot's catalog xmin age of %d exceeds the configured \"%s\" of %d by %d transactions"),
+								 slot_age, "max_slot_xid_age", max_slot_xid_age, exceeded_by);
+
+				/* translator: %s is a GUC variable name */
+				appendStringInfo(&err_hint, _("You might need to increase \"%s\"."),
+								 "max_slot_xid_age");
+				break;
+			}
+
 		case RS_INVAL_NONE:
 			pg_unreachable();
 	}
@@ -1863,6 +1896,25 @@ CanInvalidateIdleSlot(ReplicationSlot *s)
 			!(RecoveryInProgress() && s->data.synced));
 }
 
+/*
@@ -1874,6 +1926,7 @@ static ReplicationSlotInvalidationCause
@@ -1945,6 +1998,18 @@ DetermineSlotInvalidationCause(uint32 possible_causes, ReplicationSlot *s,
 		}
 	}
 
+		Assert(TransactionIdIsValid(xidLimit));
 
@@ -1967,6 +2032,7 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
 							   ReplicationSlot *s,
 							   XLogRecPtr oldestLSN,
 							   Oid dboid, TransactionId snapshotConflictHorizon,
+							   TransactionId xidLimit,
 							   bool *released_lock_out)
 {
 	int			last_signaled_pid = 0;
@@ -2019,6 +2085,7 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
 																s, oldestLSN,
 																dboid,
 																snapshotConflictHorizon,
+																xidLimit,
 																&inactive_since,
 																now);
 
@@ -2112,7 +2179,8 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
 				ReportSlotInvalidation(invalidation_cause, true, active_pid,
 									   slotname, restart_lsn,
 									   oldestLSN, snapshotConflictHorizon,
-									   slot_idle_secs);
+									   slot_idle_secs, s->data.xmin,
+									   s->data.catalog_xmin, xidLimit);
 
 				if (MyBackendType == B_STARTUP)
 					(void) SignalRecoveryConflict(GetPGProcByNumber(active_proc),
@@ -2165,7 +2233,8 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
 			ReportSlotInvalidation(invalidation_cause, false, active_pid,
 								   slotname, restart_lsn,
 								   oldestLSN, snapshotConflictHorizon,
-								   slot_idle_secs);
+								   slot_idle_secs, s->data.xmin,
+								   s->data.catalog_xmin, xidLimit);
 
 			/* done with this slot for now */
 			break;
@@ -2192,6 +2261,8 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
  *   logical.
  * - RS_INVAL_IDLE_TIMEOUT: has been idle longer than the configured
  *   "idle_replication_slot_timeout" duration.
+ * - RS_INVAL_XID_AGE: slot xid age is older than the configured
+ *   "max_slot_xid_age" age.
  *
  * Note: This function attempts to invalidate the slot for multiple possible
  * causes in a single pass, minimizing redundant iterations. The "cause"
@@ -2205,7 +2276,8 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes,
 bool
 InvalidateObsoleteReplicationSlots(uint32 possible_causes,
 								   XLogSegNo oldestSegno, Oid dboid,
-								   TransactionId snapshotConflictHorizon)
+								   TransactionId snapshotConflictHorizon,
+								   TransactionId xidLimit)
 {
 	XLogRecPtr	oldestLSN;
 	bool		invalidated = false;
@@ -2244,7 +2316,7 @@ restart:
 
-										   &released_lock))
 
diff --git a/src/backend/storage/ipc/procarray.c b/src/backend/storage/ipc/procarray.c
index cc207cb56e3..898ef4a0833 100644
--- a/src/backend/storage/ipc/procarray.c
+++ b/src/backend/storage/ipc/procarray.c
@@ -1937,6 +1937,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 pg_attribute_always_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.
@@ -1955,20 +1980,30 @@ 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.
+ */
+TransactionId
+GetOldestNonRemovableTransactionIdWithSlotXids(Relation rel,
+											   TransactionId *slot_xmin,
+											   TransactionId *slot_catalog_xmin)
+{
+	ComputeXidHorizonsResult horizons;
+
+	ComputeXidHorizons(&horizons);
+
+	if (slot_xmin)
+		*slot_xmin = horizons.slot_xmin;
+	if (slot_catalog_xmin)
+		*slot_catalog_xmin = horizons.slot_catalog_xmin;
+
+	return GetOldestNonRemovableTransactionIdFromHorizons(&horizons, rel);
 }
 
 /*
diff --git a/src/backend/storage/ipc/standby.c b/src/backend/storage/ipc/standby.c
index de9092fdf5b..d60f39ec08e 100644
--- a/src/backend/storage/ipc/standby.c
+++ b/src/backend/storage/ipc/standby.c
@@ -504,7 +504,8 @@ ResolveRecoveryConflictWithSnapshot(TransactionId snapshotConflictHorizon,
 	 */
 	if (IsLogicalDecodingEnabled() && isCatalogRel)
 		InvalidateObsoleteReplicationSlots(RS_INVAL_HORIZON, 0, locator.dbOid,
-										   snapshotConflictHorizon);
+										   snapshotConflictHorizon,
+										   InvalidTransactionId);
 }
 
 /*
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index a315c4ab8ab..5a438df93d0 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -2090,6 +2090,14 @@
   max => 'MAX_KILOBYTES',
 },
 
+{ name => 'max_slot_xid_age', type => 'int', context => 'PGC_SIGHUP', group => 'REPLICATION_SENDING',
+  short_desc => 'Age of the transaction ID at which a replication slot gets invalidated.',
+  variable => 'max_slot_xid_age',
+  boot_val => '0',
+  min => '0',
+  max => '2100000000',
+},
+
 # We use the hopefully-safely-small value of 100kB as the compiled-in
 # default for max_stack_depth.  InitializeGUCOptions will increase it
 # if possible, depending on the actual platform-specific stack limit.
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index 6d0337853e0..1817330484d 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -351,6 +351,8 @@
 #wal_keep_size = 0              # in megabytes; 0 disables
 #max_slot_wal_keep_size = -1    # in megabytes; -1 disables
 #idle_replication_slot_timeout = 0      # in seconds; 0 disables
+#max_slot_xid_age = 0           # maximum XID age before a replication slot
+                                # gets invalidated; 0 disables
 #wal_sender_timeout = 60s       # in milliseconds; 0 disables
 #track_commit_timestamp = off   # collect timestamp of transaction commit
                                 # (change requires restart)
diff --git a/src/bin/pg_basebackup/pg_createsubscriber.c b/src/bin/pg_basebackup/pg_createsubscriber.c
index 37631f700af..9ebc71fcac2 100644
--- a/src/bin/pg_basebackup/pg_createsubscriber.c
+++ b/src/bin/pg_basebackup/pg_createsubscriber.c
@@ -1901,7 +1901,7 @@ start_standby_server(const struct CreateSubscriberOptions *opt, bool restricted_
 	appendPQExpBufferStr(pg_ctl_cmd, " -s -o \"-c sync_replication_slots=off\"");
 
 	/* Prevent unintended slot invalidation */
-	appendPQExpBufferStr(pg_ctl_cmd, " -o \"-c idle_replication_slot_timeout=0\"");
+	appendPQExpBufferStr(pg_ctl_cmd, " -o \"-c idle_replication_slot_timeout=0 -c max_slot_xid_age=0\"");
 
 	if (restricted_access)
 	{
diff --git a/src/include/commands/vacuum.h b/src/include/commands/vacuum.h
index 5b8023616c0..c47558fc6b3 100644
--- a/src/include/commands/vacuum.h
+++ b/src/include/commands/vacuum.h
@@ -287,6 +287,13 @@ 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 OldestSlotXmin;
+	TransactionId OldestSlotCatalogXmin;
 };
 
 /*
@@ -399,6 +406,9 @@ extern IndexBulkDeleteResult *vac_bulkdel_one_index(IndexVacuumInfo *ivinfo,
 													VacDeadItemsInfo *dead_items_info);
 extern IndexBulkDeleteResult *vac_cleanup_one_index(IndexVacuumInfo *ivinfo,
 													IndexBulkDeleteResult *istat);
+extern bool maybe_invalidate_xid_aged_slots(TransactionId oldest_xmin,
+											TransactionId oldest_slot_xmin,
+											TransactionId oldest_slot_catalog_xmin);
 
 /* In postmaster/autovacuum.c */
 extern void AutoVacuumUpdateCostLimit(void);
diff --git a/src/include/replication/slot.h b/src/include/replication/slot.h
index 4b4709f6e2c..5040d53072b 100644
--- a/src/include/replication/slot.h
+++ b/src/include/replication/slot.h
@@ -66,10 +66,12 @@ typedef enum ReplicationSlotInvalidationCause
 	RS_INVAL_WAL_LEVEL = (1 << 2),
 	/* idle slot timeout has occurred */
 	RS_INVAL_IDLE_TIMEOUT = (1 << 3),
+	/* slot's xmin or catalog_xmin has reached max xid age */
+	RS_INVAL_XID_AGE = (1 << 4),
 } ReplicationSlotInvalidationCause;
 
 /* Maximum number of invalidation causes */
-#define	RS_INVAL_MAX_CAUSES 4
+#define	RS_INVAL_MAX_CAUSES 5
 
 /*
  * When the slot synchronization worker is running, or when
@@ -326,6 +328,7 @@ extern PGDLLIMPORT ReplicationSlot *MyReplicationSlot;
 extern PGDLLIMPORT int max_replication_slots;
 extern PGDLLIMPORT char *synchronized_standby_slots;
 extern PGDLLIMPORT int idle_replication_slot_timeout_secs;
+extern PGDLLIMPORT int max_slot_xid_age;
 
 /* shmem initialization functions */
 extern Size ReplicationSlotsShmemSize(void);
@@ -367,7 +370,8 @@ extern void ReplicationSlotsDropDBSlots(Oid dboid);
 extern bool InvalidateObsoleteReplicationSlots(uint32 possible_causes,
 											   XLogSegNo oldestSegno,
 											   Oid dboid,
-											   TransactionId snapshotConflictHorizon);
+											   TransactionId snapshotConflictHorizon,
+											   TransactionId xidLimit);
 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 abdf021e66e..a94091ce7fd 100644
--- a/src/include/storage/procarray.h
+++ b/src/include/storage/procarray.h
@@ -53,6 +53,9 @@ extern RunningTransactions GetRunningTransactionData(void);
 
 extern bool TransactionIdIsInProgress(TransactionId xid);
 extern TransactionId GetOldestNonRemovableTransactionId(Relation rel);
+extern TransactionId GetOldestNonRemovableTransactionIdWithSlotXids(Relation rel,
+																	TransactionId *slot_xmin,
+																	TransactionId *slot_catalog_xmin);
 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 7b253e64d9c..6b7e4818bf4 100644
--- a/src/test/recovery/t/019_replslot_limit.pl
+++ b/src/test/recovery/t/019_replslot_limit.pl
@@ -540,4 +540,179 @@ is( $publisher4->safe_psql(
 $publisher4->stop;
 $subscriber4->stop;
 
+# Wait for the given slot to be invalidated with reason 'xid_aged'
+sub wait_for_xid_aged_invalidation
+{
+	my ($node, $slot_name) = @_;
+	$node->poll_query_until(
+		'postgres', qq[
+		SELECT COUNT(slot_name) = 1 FROM pg_replication_slots
+			WHERE slot_name = '$slot_name' AND
+			active = false AND
+			invalidation_reason = 'xid_aged';
+	]) or die "Timed out waiting for slot $slot_name to be invalidated";
+}
+
+# =====================================================================
+# Testcase start: Invalidate physical slot due to max_slot_xid_age GUC
+
+# Initialize primary node for XID age tests
+my $primary5 = PostgreSQL::Test::Cluster->new('primary5');
+$primary5->init(allows_streaming => 'logical');
+
+# Disable autovacuum so checkpointer triggers the invalidation
+my $max_slot_xid_age = 100;
+$primary5->append_conf(
+	'postgresql.conf', qq{
+max_slot_xid_age = $max_slot_xid_age
+autovacuum = off
+});
+
+$primary5->start;
+
+# Create a procedure to consume XIDs
+$primary5->safe_psql(
+	'postgres', qq{
+	CREATE PROCEDURE consume_xid(cnt int)
+	AS \$\$
+	DECLARE
+	    i int;
+	BEGIN
+	    FOR i IN 1..cnt LOOP
+	        EXECUTE 'SELECT pg_current_xact_id()';
+	        COMMIT;
+	    END LOOP;
+	END;
+	\$\$ LANGUAGE plpgsql;
+});
+
+# Take a backup for creating standby
+$backup_name = 'backup5';
+$primary5->backup($backup_name);
+
+# Create standby with HS feedback so the slot gains an xmin
+my $standby5 = PostgreSQL::Test::Cluster->new('standby5');
+$standby5->init_from_backup($primary5, $backup_name, has_streaming => 1);
+$standby5->append_conf(
+	'postgresql.conf', q{
+primary_slot_name = 'sb5_slot'
+hot_standby_feedback = on
+wal_receiver_status_interval = 1
+});
+$primary5->safe_psql(
+	'postgres', qq[
+    SELECT pg_create_physical_replication_slot(slot_name := 'sb5_slot', immediately_reserve := true);
+]);
+$standby5->start;
+
+# Create some content on primary to move xmin
+$primary5->safe_psql('postgres',
+	"CREATE TABLE tab_int5 AS SELECT generate_series(1,10) AS a");
+$primary5->wait_for_catchup($standby5);
+
+# Wait for the physical slot to get xmin via hot_standby_feedback
+$primary5->poll_query_until(
+	'postgres', qq[
+	SELECT xmin IS NOT NULL
+		FROM pg_catalog.pg_replication_slots
+		WHERE slot_name = 'sb5_slot';
+]) or die "Timed out waiting for slot sb5_slot xmin from HS feedback";
+
+# Stop standby so the slot becomes inactive with its xmin frozen
+$standby5->stop;
+
+# Advance XIDs past 2x max_slot_xid_age so the slot's xmin is stale enough
+$primary5->safe_psql('postgres', qq{CALL consume_xid(2 * $max_slot_xid_age)});
+$primary5->safe_psql('postgres', "CHECKPOINT");
+wait_for_xid_aged_invalidation($primary5, 'sb5_slot');
+ok(1, "physical slot invalidated due to XID age (via checkpoint)");
+
+# Testcase end: Invalidate physical slot due to max_slot_xid_age GUC
+# ===================================================================
+
+# ====================================================================
+# Testcase start: Invalidate logical slot due to max_slot_xid_age GUC
+
+# Create a logical slot directly on the primary (no subscriber needed).
+# The slot gets a catalog_xmin immediately upon creation.
+$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_catalog.pg_replication_slots
+	WHERE slot_name = 'lsub5_slot';
+]) or die "Timed out waiting for slot lsub5_slot catalog_xmin";
+
+# Advance XIDs past 2x max_slot_xid_age so the slot's catalog_xmin is stale enough
+$primary5->safe_psql('postgres', qq{CALL consume_xid(2 * $max_slot_xid_age)});
+
+# Vacuum a user table so OldestXmin does not include the slot's catalog_xmin,
+# skipping the invalidation of the slot.
+$primary5->safe_psql('postgres', "VACUUM tab_int5");
+is( $primary5->safe_psql(
+		'postgres',
+		qq[SELECT invalidation_reason IS NULL FROM pg_replication_slots WHERE slot_name = 'lsub5_slot';]
+	),
+	't',
+	'logical slot not invalidated after vacuuming a data table');
+
+# Vacuum a catalog table so OldestXmin includes the slot's catalog_xmin,
+# triggering invalidation of the slot.
+$primary5->safe_psql('postgres', "VACUUM pg_class");
+wait_for_xid_aged_invalidation($primary5, 'lsub5_slot');
+ok(1, "logical slot invalidated due to XID age (via vacuum)");
+
+# Testcase end: Invalidate logical slot due to max_slot_xid_age GUC
+# ==================================================================
+
+# ===============================================================================
+# Testcase start: Invalidate logical slot on standby due to max_slot_xid_age GUC
+
+# Disable max_slot_xid_age on primary and recreate the streaming slot
+$primary5->safe_psql(
+	'postgres',
+	q{
+ALTER SYSTEM SET max_slot_xid_age = 0;
+SELECT pg_reload_conf();
+});
+$primary5->safe_psql('postgres',
+	"SELECT pg_drop_replication_slot('sb5_slot')");
+$primary5->safe_psql('postgres',
+	"SELECT pg_create_physical_replication_slot('sb5_slot', true)");
+$standby5->append_conf(
+	'postgresql.conf', qq{
+max_slot_xid_age = $max_slot_xid_age
+autovacuum = off
+});
+$standby5->start;
+
+$primary5->wait_for_catchup($standby5);
+
+$standby5->create_logical_slot_on_standby($primary5, 'sb5_logical_slot',
+	'postgres');
+
+$standby5->poll_query_until(
+	'postgres', qq[
+	SELECT catalog_xmin IS NOT NULL
+	FROM pg_catalog.pg_replication_slots
+	WHERE slot_name = 'sb5_logical_slot';
+]) or die "Timed out waiting for sb5_logical_slot catalog_xmin";
+
+# Advance XIDs on primary, replay on standby, then restartpoint to invalidate
+$primary5->safe_psql('postgres', qq{CALL consume_xid(2 * $max_slot_xid_age)});
+$primary5->safe_psql('postgres', "CHECKPOINT");
+$primary5->wait_for_replay_catchup($standby5);
+$standby5->safe_psql('postgres', "CHECKPOINT");
+
+wait_for_xid_aged_invalidation($standby5, 'sb5_logical_slot');
+ok(1, "logical (standby) slot invalidated due to XID age (via restartpoint)");
+
+$standby5->stop;
+$primary5->stop;
+
+# Testcase end: Invalidate logical slot on standby due to max_slot_xid_age GUC
+# =============================================================================
+
 done_testing();
-- 
2.53.0

---

From: Bharath Rupireddy <[email-masked]>
Date: Sun, 5 Apr 2026 19:52:07 -0700

Hi,

> Please review the attached patch.

Thank you Sawada-san!

I took a look at the v10 patch and it LGTM. I tested it - make
check-world passes, pgindent doesn't complain.

---

From: Masahiko Sawada <[email-masked]>
Date: Mon, 6 Apr 2026 01:44:52 -0700

> check-world passes, pgindent doesn't complain.

While reviewing the patch, I found that with this patch, backend
processes and autovacuum workers can simultaneously attempt to
invalidate the same slot for the same reason. When invalidating a
slot, we send a signal to the process owning the slot and wait for it
to exit and release the slot. If the process takes a long time to exit
for some reason, subsequent autovacuum workers attempting to
invalidate the same slot will also send a SIGTERM and get stuck at
InvalidatePossiblyObsoleteSlot(). In the worst case, this could result
in all autovacuum activity being blocked. I think we need to address
this problem.

Regards,

---

From: Bharath Rupireddy <[email-masked]>
Date: Mon, 6 Apr 2026 10:42:03 -0700

Hi,

> in all autovacuum activity being blocked. I think we need to address

Thank you!

You're right that multiple autovacuum workers can wait on the same
slot for SIGTERM to take effect on the process (mainly walsenders)
holding the slot. Once the process holding the slot exits, one worker
finishes the invalidation and the others see it's done and move on.

However, IMHO, this is unlikely to be a problem in practice.

First, SIGTERM must take a long time to terminate the process holding
the slot. This seems unlikely unless I'm missing some cases.

Second, the slot's xmin must be very old (past XID age) while the
process is still running but slow to exit. If we set max_slot_xid_age
close to vacuum_failsafe_age (e.g., 1.6 billion. I've added this note
in the docs), it seems unlikely that the replication connection would
still be active at that point.

Also, concurrent invalidation can already happen today between the
startup process and checkpointer on standby.

If needed, we could add a flag to skip extra invalidation attempts
based on field experience. Since this feature is off by default, I'd
prefer to keep things simple, but I'm open to other approaches.

Thoughts?

---

From: Srinath Reddy Sadipiralla <[email-masked]>
Date: Tue, 7 Apr 2026 20:09:45 +0530

Hi,

> However, IMHO, this is unlikely to be a problem in practice.

I was able to reproduce this using pg_recvlogical on a slot, by pausing the
walsender using debugger , then i did some hacky stuff around the GUCs
(just to test), but in production IIUC I think During decoding a large
transaction
or network delay , the walsender gets stuck for "some" time, so backend and
autovacuum workers get stuck until then, after that they resume their work,
Correct me if I am wrong :)

> based on field experience.
>

+1, yeah this would help other backends or autovacuum workers not
to retry again the same invalidation and stuck , instead they can check
the flag and be assured that slot invalidation is being taken care of,
so others can move on.

---

From: Bharath Rupireddy <[email-masked]>
Date: Wed, 15 Apr 2026 22:03:33 -0700

Hi,

> prefer to keep things simple, but I'm open to other approaches.

Thank you Sawada-san. I've been thinking more about it and I agree we
need to address this. While I still think the scenario is unlikely in
practice (SIGTERM would have to take a long time, the slot's xmin
would have to be very old while the walsender is still running, etc.),
I think it's worth handling.

I can think of a couple of approaches:

1. Use ConditionVariableTimedSleep instead of ConditionVariableSleep
when called from an autovacuum worker. Workers don't block forever,
but they still wait for the timeout duration, still send redundant
SIGTERMs, and a correct timeout value needs to be chosen. When it
expires, the worker either retries (still stuck) or gives up (same as
approach 2).

2. Make the vacuum path non-blocking when another process is already
invalidating the same slot. The first process to attempt invalidation
proceeds normally: it sends SIGTERM and waits on
ConditionVariableSleep for the process holding the slot to exit. But
if a subsequent autovacuum worker finds that another process has
already initiated invalidation of this slot, it skips the slot and
proceeds with vacuum instead of waiting on the same
ConditionVariableSleep.

I think approach 2 is simple. If another process is already
invalidating the slot, there's no reason for the autovacuum worker to
also block. The tradeoff is that this vacuum cycle's OldestXmin won't
move forward and it will need another cycle for this relation. But
that's fine given that the scenario as explained above is unlikely to
happen in practice.

Please let me know if my thinking sounds reasonable. I'm open to other
ideas too.

Thoughts?

---

From: Bharath Rupireddy <[email-masked]>
Date: Thu, 23 Apr 2026 11:11:14 -0700

Hi,

> Please let me know if my thinking sounds reasonable. I'm open to other

I implemented the approach 2 (patch 0003). I added an injection point
to mimic the walsender taking time to process SIGTERM, so that the
process invalidating the slot waits on the slot's CV.

Please have a look and share your thoughts. Thank you!