From 8142c388ee96cc8bf4980cc784cc36b80ab7e7f2 Mon Sep 17 00:00:00 2001
From: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
Date: Tue, 14 Jul 2026 16:44:35 +0000
Subject: [PATCH v1 2/2] Name the specific blocker holding back VACUUM's oldest
 xmin

The previous commit reports the category of the blocker. That is
enough to know which subsystem to look at, but when the horizon is
dangerously old the user needs the exact thing to act on.

So when OldestXmin is held back to an unsafe degree and VACUUM
warns that its cutoff is far in the past, name the specific
blocker in that warning: the backend PID for a running
transaction, the GID for a prepared transaction, the slot name for
a replication slot, and the walsender PID for standby feedback.

Finding the identity needs TwoPhaseStateLock or
ReplicationSlotControlLock, so it is done only for the warning,
not for the routine progress and verbose reporting the previous
commit added. The PID comes from the proc that sets the horizon,
captured under the ProcArrayLock ComputeXidHorizons() already
holds. The GID and slot name are looked up from the captured xid
at warning time, outside that lock, and are best-effort: if the
holder is gone by then, only the category is reported.
---
 src/backend/access/heap/vacuumlazy.c          |   9 +-
 src/backend/access/transam/twophase.c         |  33 +++++
 src/backend/commands/vacuum.c                 |  78 ++++++++++--
 src/backend/replication/slot.c                |  43 +++++++
 src/backend/storage/ipc/procarray.c           |  74 +++++++----
 src/include/access/twophase.h                 |   1 +
 src/include/commands/vacuum.h                 |   6 +-
 src/include/replication/slot.h                |   2 +
 src/include/storage/procarray.h               |  20 ++-
 .../t/055_vacuum_oldest_xmin_blocker.pl       | 117 ++++++++++++++----
 src/tools/pgindent/typedefs.list              |   1 +
 11 files changed, 317 insertions(+), 67 deletions(-)

diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index f171a7b1af4..ad7bf17508d 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -844,7 +844,7 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	 * pg_stat_progress_vacuum can see why dead tuples aren't being removed.
 	 */
 	pgstat_progress_update_param(PROGRESS_VACUUM_OLDEST_XMIN_BLOCKER,
-								 vacrel->cutoffs.oldest_xmin_blocker);
+								 vacrel->cutoffs.oldest_xmin_blocker.kind);
 
 	if (verbose)
 	{
@@ -1091,10 +1091,9 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 			appendStringInfo(&buf,
 							 _("removable cutoff: %u, which was %d XIDs old when operation ended\n"),
 							 vacrel->cutoffs.OldestXmin, diff);
-			if (vacrel->cutoffs.oldest_xmin_blocker != OLDEST_XMIN_BLOCKER_NONE)
-				appendStringInfo(&buf,
-								 _("oldest xmin held back by: %s\n"),
-								 vacuum_oldest_xmin_blocker_name(vacrel->cutoffs.oldest_xmin_blocker));
+			if (vacrel->cutoffs.oldest_xmin_blocker.kind != OLDEST_XMIN_BLOCKER_NONE)
+				appendStringInfo(&buf, _("oldest xmin held back by: %s\n"),
+								 vacuum_oldest_xmin_blocker_name(vacrel->cutoffs.oldest_xmin_blocker.kind));
 			if (frozenxid_updated)
 			{
 				diff = (int32) (vacrel->NewRelfrozenXid -
diff --git a/src/backend/access/transam/twophase.c b/src/backend/access/transam/twophase.c
index 1035e8b3fc7..d0985e83c59 100644
--- a/src/backend/access/transam/twophase.c
+++ b/src/backend/access/transam/twophase.c
@@ -850,6 +850,39 @@ TwoPhaseGetGXact(FullTransactionId fxid, bool lock_held)
 	return result;
 }
 
+/*
+ * Look up the GID of a prepared transaction by its XID, for diagnostics such
+ * as reporting what holds VACUUM's horizon back.
+ *
+ * Best-effort: returns false rather than erroring when no match is found,
+ * since this is only for reporting and the transaction may have committed or
+ * aborted by the time we look.
+ */
+bool
+TwoPhaseGetGidByXid(TransactionId xid, char *gid, int gidsize)
+{
+	bool		found = false;
+
+	if (!TransactionIdIsValid(xid))
+		return false;
+
+	LWLockAcquire(TwoPhaseStateLock, LW_SHARED);
+	for (int i = 0; i < TwoPhaseState->numPrepXacts; i++)
+	{
+		GlobalTransaction gxact = TwoPhaseState->prepXacts[i];
+
+		if (XidFromFullTransactionId(gxact->fxid) == xid)
+		{
+			strlcpy(gid, gxact->gid, gidsize);
+			found = true;
+			break;
+		}
+	}
+	LWLockRelease(TwoPhaseStateLock);
+
+	return found;
+}
+
 /*
  * TwoPhaseGetXidByVirtualXID
  *		Lookup VXID among xacts prepared since last startup.
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index a5a95df0ae3..7b8ad6ac1af 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -33,6 +33,7 @@
 #include "access/multixact.h"
 #include "access/tableam.h"
 #include "access/transam.h"
+#include "access/twophase.h"
 #include "access/xact.h"
 #include "catalog/namespace.h"
 #include "catalog/pg_database.h"
@@ -48,6 +49,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"
@@ -157,6 +159,54 @@ vacuum_oldest_xmin_blocker_name(OldestXminBlocker blocker)
 	return OldestXminBlockerNames[blocker];
 }
 
+/*
+ * Append a blocker's identity to buf, to follow its category name, and return
+ * true, or return false when there is nothing more specific to add. The 2PC
+ * and slot lookups are best-effort.
+ */
+bool
+vacuum_oldest_xmin_blocker_ident(const OldestXminBlockerInfo *blocker,
+								 StringInfo buf)
+{
+	if (blocker->kind == OLDEST_XMIN_BLOCKER_RUNNING_XACT)
+	{
+		appendStringInfo(buf, " (pid %d)", blocker->pid);
+		return true;
+	}
+	else if (blocker->kind == OLDEST_XMIN_BLOCKER_STANDBY_FEEDBACK)
+	{
+		appendStringInfo(buf,
+						 " (walsender pid %d; the transaction runs on the standby)",
+						 blocker->pid);
+		return true;
+	}
+	else if (blocker->kind == OLDEST_XMIN_BLOCKER_PREPARED_XACT)
+	{
+		char		gid[GIDSIZE];
+
+		if (TwoPhaseGetGidByXid(blocker->xid, gid, sizeof(gid)))
+		{
+			appendStringInfo(buf, " '%s'", gid);
+			return true;
+		}
+	}
+	else if (blocker->kind == OLDEST_XMIN_BLOCKER_REPLICATION_SLOT ||
+			 blocker->kind == OLDEST_XMIN_BLOCKER_LOGICAL_SLOT)
+	{
+		char		slotname[NAMEDATALEN];
+		bool		catalog = (blocker->kind == OLDEST_XMIN_BLOCKER_LOGICAL_SLOT);
+
+		if (ReplicationSlotNameForXmin(blocker->xid, catalog,
+									   slotname, sizeof(slotname)))
+		{
+			appendStringInfo(buf, " (slot \"%s\")", slotname);
+			return true;
+		}
+	}
+
+	return false;
+}
+
 /*
  * GUC check function to ensure GUC value specified is within the allowable
  * range.
@@ -1196,18 +1246,22 @@ vacuum_get_cutoffs(Relation rel, const VacuumParams *params,
 		safeOldestMxact = FirstMultiXactId;
 	if (TransactionIdPrecedes(cutoffs->OldestXmin, safeOldestXmin))
 	{
-		if (cutoffs->oldest_xmin_blocker != OLDEST_XMIN_BLOCKER_NONE)
-			ereport(WARNING,
-					(errmsg("cutoff for removing and freezing tuples is far in the past"),
-					 errdetail("The oldest xmin horizon is currently held back by: %s.",
-							   vacuum_oldest_xmin_blocker_name(cutoffs->oldest_xmin_blocker)),
-					 errhint("Close open transactions soon to avoid wraparound problems.\n"
-							 "You might also need to commit or roll back old prepared transactions, or drop stale replication slots.")));
-		else
-			ereport(WARNING,
-					(errmsg("cutoff for removing and freezing tuples is far in the past"),
-					 errhint("Close open transactions soon to avoid wraparound problems.\n"
-							 "You might also need to commit or roll back old prepared transactions, or drop stale replication slots.")));
+		OldestXminBlockerInfo *blocker = &cutoffs->oldest_xmin_blocker;
+		StringInfoData ident;
+
+		/* identity to append after the category name, empty if none */
+		initStringInfo(&ident);
+		if (blocker->kind != OLDEST_XMIN_BLOCKER_NONE)
+			vacuum_oldest_xmin_blocker_ident(blocker, &ident);
+
+		ereport(WARNING,
+				(errmsg("cutoff for removing and freezing tuples is far in the past"),
+				 blocker->kind != OLDEST_XMIN_BLOCKER_NONE ?
+				 errdetail("The oldest xmin horizon is currently held back by: %s%s.",
+						   vacuum_oldest_xmin_blocker_name(blocker->kind), ident.data) : 0,
+				 errhint("Close open transactions soon to avoid wraparound problems.\n"
+						 "You might also need to commit or roll back old prepared transactions, or drop stale replication slots.")));
+		pfree(ident.data);
 	}
 	if (MultiXactIdPrecedes(cutoffs->OldestMxact, safeOldestMxact))
 		ereport(WARNING,
diff --git a/src/backend/replication/slot.c b/src/backend/replication/slot.c
index d7fb9f5a67f..0311739f28d 100644
--- a/src/backend/replication/slot.c
+++ b/src/backend/replication/slot.c
@@ -1293,6 +1293,49 @@ ReplicationSlotsComputeRequiredXmin(bool already_locked)
 		LWLockRelease(ReplicationSlotControlLock);
 }
 
+/*
+ * Find a slot whose xmin (catalog_xmin, when catalog is true) equals the given
+ * xid, for diagnostics such as reporting what holds VACUUM's horizon back. The
+ * catalog flag keeps the named slot consistent with the blocker's category.
+ *
+ * Best-effort: returns false rather than erroring when no match is found,
+ * since this is only for reporting and the slot may have advanced by the time
+ * we look. When several slots share the xid, names just the first found.
+ */
+bool
+ReplicationSlotNameForXmin(TransactionId xid, bool catalog,
+						   char *name, int namesize)
+{
+	bool		found = false;
+
+	if (!TransactionIdIsValid(xid))
+		return false;
+
+	LWLockAcquire(ReplicationSlotControlLock, LW_SHARED);
+	for (int i = 0; i < max_replication_slots + max_repack_replication_slots; i++)
+	{
+		ReplicationSlot *s = &ReplicationSlotCtl->replication_slots[i];
+		TransactionId slot_xmin;
+
+		if (!s->in_use)
+			continue;
+
+		SpinLockAcquire(&s->mutex);
+		slot_xmin = catalog ? s->effective_catalog_xmin : s->effective_xmin;
+		SpinLockRelease(&s->mutex);
+
+		if (xid == slot_xmin)
+		{
+			strlcpy(name, NameStr(s->data.name), namesize);
+			found = true;
+			break;
+		}
+	}
+	LWLockRelease(ReplicationSlotControlLock);
+
+	return found;
+}
+
 /*
  * Compute the oldest restart LSN across all slots and inform xlog module.
  *
diff --git a/src/backend/storage/ipc/procarray.c b/src/backend/storage/ipc/procarray.c
index 2997bbcbb3a..baa37fd7745 100644
--- a/src/backend/storage/ipc/procarray.c
+++ b/src/backend/storage/ipc/procarray.c
@@ -1613,6 +1613,15 @@ TransactionIdIsInProgress(TransactionId xid)
 	return false;
 }
 
+/* Build an oldest-xmin blocker; the caller fills in the pid when relevant. */
+static inline OldestXminBlockerInfo
+make_xmin_blocker(OldestXminBlocker kind, TransactionId xid)
+{
+	OldestXminBlockerInfo b = {kind, 0, xid};
+
+	return b;
+}
+
 
 /*
  * Determine XID horizons.
@@ -1683,11 +1692,7 @@ ComputeXidHorizons(ComputeXidHorizonsResult *h, XminHorizonBlockers *b)
 
 	/* no blocker identified yet, for callers that asked for one */
 	if (b)
-	{
-		b->shared = OLDEST_XMIN_BLOCKER_NONE;
-		b->data = OLDEST_XMIN_BLOCKER_NONE;
-		b->catalog = OLDEST_XMIN_BLOCKER_NONE;
-	}
+		MemSet(b, 0, sizeof(*b));
 
 	LWLockAcquire(ProcArrayLock, LW_SHARED);
 
@@ -1743,7 +1748,8 @@ ComputeXidHorizons(ComputeXidHorizonsResult *h, XminHorizonBlockers *b)
 		int8		statusFlags = ProcGlobal->statusFlags[index];
 		TransactionId xid;
 		TransactionId xmin;
-		OldestXminBlocker this_blocker = OLDEST_XMIN_BLOCKER_NONE;
+		OldestXminBlockerInfo this_blocker;
+		bool		xid_owner = false;
 
 		/* Fetch xid just once - see GetNewTransactionId */
 		xid = UINT32_ACCESS_ONCE(other_xids[index]);
@@ -1785,18 +1791,39 @@ ComputeXidHorizons(ComputeXidHorizonsResult *h, XminHorizonBlockers *b)
 		 * PROC_AFFECTS_ALL_HORIZONS marks a walsender that reserves an xmin
 		 * via its PGPROC entry rather than a slot, i.e. hot_standby_feedback
 		 * with no slot (see ProcessStandbyHSFeedbackMessage). Anything else
-		 * is a running transaction.
+		 * is a running transaction. We keep the PID (for the running xact and
+		 * the walsender) or the xid (for the prepared xact, to resolve its
+		 * GID later), whichever names it.
+		 *
+		 * xid_owner is true when this proc's own xid reaches the horizon,
+		 * rather than a snapshot xmin that observes that xid. When several
+		 * procs share the same value, the owner is preferred, since ending it
+		 * is more likely to let the horizon advance. For example, backend A
+		 * holds xid 100 while backends B, C, D, E run with xmin 100; all five
+		 * are at 100, and A is reported.
 		 */
 		if (b)
 		{
+			xid_owner = TransactionIdIsValid(xid) && xid == xmin;
+
 			if (proc->pid == 0)
-				this_blocker = OLDEST_XMIN_BLOCKER_PREPARED_XACT;
+			{
+				this_blocker.kind = OLDEST_XMIN_BLOCKER_PREPARED_XACT;
+				this_blocker.xid = xid;
+			}
 			else if (statusFlags & PROC_AFFECTS_ALL_HORIZONS)
-				this_blocker = OLDEST_XMIN_BLOCKER_STANDBY_FEEDBACK;
+			{
+				this_blocker.kind = OLDEST_XMIN_BLOCKER_STANDBY_FEEDBACK;
+				this_blocker.pid = proc->pid;
+			}
 			else
-				this_blocker = OLDEST_XMIN_BLOCKER_RUNNING_XACT;
+			{
+				this_blocker.kind = OLDEST_XMIN_BLOCKER_RUNNING_XACT;
+				this_blocker.pid = proc->pid;
+			}
 
-			if (TransactionIdPrecedes(xmin, h->shared_oldest_nonremovable))
+			if (TransactionIdPrecedes(xmin, h->shared_oldest_nonremovable) ||
+				(xid_owner && xmin == h->shared_oldest_nonremovable))
 				b->shared = this_blocker;
 		}
 
@@ -1828,7 +1855,8 @@ ComputeXidHorizons(ComputeXidHorizonsResult *h, XminHorizonBlockers *b)
 			(statusFlags & PROC_AFFECTS_ALL_HORIZONS) ||
 			in_recovery)
 		{
-			if (b && TransactionIdPrecedes(xmin, h->data_oldest_nonremovable))
+			if (b && (TransactionIdPrecedes(xmin, h->data_oldest_nonremovable) ||
+					  (xid_owner && xmin == h->data_oldest_nonremovable)))
 				b->data = this_blocker;
 			h->data_oldest_nonremovable =
 				TransactionIdOlder(h->data_oldest_nonremovable, xmin);
@@ -1853,11 +1881,11 @@ ComputeXidHorizons(ComputeXidHorizonsResult *h, XminHorizonBlockers *b)
 		h->oldest_considered_running =
 			TransactionIdOlder(h->oldest_considered_running, kaxmin);
 		if (b && TransactionIdPrecedes(kaxmin, h->shared_oldest_nonremovable))
-			b->shared = OLDEST_XMIN_BLOCKER_RUNNING_XACT;
+			b->shared = make_xmin_blocker(OLDEST_XMIN_BLOCKER_RUNNING_XACT, InvalidTransactionId);
 		h->shared_oldest_nonremovable =
 			TransactionIdOlder(h->shared_oldest_nonremovable, kaxmin);
 		if (b && TransactionIdPrecedes(kaxmin, h->data_oldest_nonremovable))
-			b->data = OLDEST_XMIN_BLOCKER_RUNNING_XACT;
+			b->data = make_xmin_blocker(OLDEST_XMIN_BLOCKER_RUNNING_XACT, InvalidTransactionId);
 		h->data_oldest_nonremovable =
 			TransactionIdOlder(h->data_oldest_nonremovable, kaxmin);
 		/* temp relations cannot be accessed in recovery */
@@ -1875,9 +1903,11 @@ ComputeXidHorizons(ComputeXidHorizonsResult *h, XminHorizonBlockers *b)
 	if (b && TransactionIdIsValid(h->slot_xmin))
 	{
 		if (TransactionIdPrecedes(h->slot_xmin, h->shared_oldest_nonremovable))
-			b->shared = OLDEST_XMIN_BLOCKER_REPLICATION_SLOT;
+			b->shared = make_xmin_blocker(OLDEST_XMIN_BLOCKER_REPLICATION_SLOT,
+										  h->slot_xmin);
 		if (TransactionIdPrecedes(h->slot_xmin, h->data_oldest_nonremovable))
-			b->data = OLDEST_XMIN_BLOCKER_REPLICATION_SLOT;
+			b->data = make_xmin_blocker(OLDEST_XMIN_BLOCKER_REPLICATION_SLOT,
+										h->slot_xmin);
 	}
 	h->shared_oldest_nonremovable =
 		TransactionIdOlder(h->shared_oldest_nonremovable, h->slot_xmin);
@@ -1904,10 +1934,12 @@ ComputeXidHorizons(ComputeXidHorizonsResult *h, XminHorizonBlockers *b)
 		{
 			if (TransactionIdPrecedes(h->slot_catalog_xmin,
 									  h->shared_oldest_nonremovable))
-				b->shared = OLDEST_XMIN_BLOCKER_LOGICAL_SLOT;
+				b->shared = make_xmin_blocker(OLDEST_XMIN_BLOCKER_LOGICAL_SLOT,
+											  h->slot_catalog_xmin);
 			if (TransactionIdPrecedes(h->slot_catalog_xmin,
 									  h->catalog_oldest_nonremovable))
-				b->catalog = OLDEST_XMIN_BLOCKER_LOGICAL_SLOT;
+				b->catalog = make_xmin_blocker(OLDEST_XMIN_BLOCKER_LOGICAL_SLOT,
+											   h->slot_catalog_xmin);
 		}
 	}
 	h->shared_oldest_nonremovable =
@@ -2032,7 +2064,7 @@ GetOldestNonRemovableTransactionId(Relation rel)
  * relations, never for a plain user table.
  */
 TransactionId
-GetOldestNonRemovableTransactionIdExt(Relation rel, OldestXminBlocker *blocker)
+GetOldestNonRemovableTransactionIdExt(Relation rel, OldestXminBlockerInfo *blocker)
 {
 	ComputeXidHorizonsResult horizons;
 	XminHorizonBlockers blockers;
@@ -2056,12 +2088,12 @@ GetOldestNonRemovableTransactionIdExt(Relation rel, OldestXminBlocker *blocker)
 			 * The temp horizon is bounded by this session's own xid, so
 			 * nothing external holds it back.
 			 */
-			*blocker = OLDEST_XMIN_BLOCKER_NONE;
+			*blocker = make_xmin_blocker(OLDEST_XMIN_BLOCKER_NONE, InvalidTransactionId);
 			return horizons.temp_oldest_nonremovable;
 	}
 
 	/* just to prevent compiler warnings */
-	*blocker = OLDEST_XMIN_BLOCKER_NONE;
+	*blocker = make_xmin_blocker(OLDEST_XMIN_BLOCKER_NONE, InvalidTransactionId);
 	return InvalidTransactionId;
 }
 
diff --git a/src/include/access/twophase.h b/src/include/access/twophase.h
index 1d2ff42c9b7..30d83248350 100644
--- a/src/include/access/twophase.h
+++ b/src/include/access/twophase.h
@@ -38,6 +38,7 @@ extern void PostPrepare_Twophase(void);
 
 extern TransactionId TwoPhaseGetXidByVirtualXID(VirtualTransactionId vxid,
 												bool *have_more);
+extern bool TwoPhaseGetGidByXid(TransactionId xid, char *gid, int gidsize);
 extern PGPROC *TwoPhaseGetDummyProc(FullTransactionId fxid, bool lock_held);
 extern int	TwoPhaseGetDummyProcNumber(FullTransactionId fxid, bool lock_held);
 
diff --git a/src/include/commands/vacuum.h b/src/include/commands/vacuum.h
index 9f107fd8262..1ad53afa09b 100644
--- a/src/include/commands/vacuum.h
+++ b/src/include/commands/vacuum.h
@@ -281,9 +281,9 @@ struct VacuumCutoffs
 
 	/*
 	 * A best-effort hint about what held OldestXmin back at the time it was
-	 * computed, for diagnostics only. See OldestXminBlocker.
+	 * computed, for diagnostics only. See OldestXminBlockerInfo.
 	 */
-	OldestXminBlocker oldest_xmin_blocker;
+	OldestXminBlockerInfo oldest_xmin_blocker;
 
 	/*
 	 * FreezeLimit is the Xid below which all Xids are definitely frozen or
@@ -394,6 +394,8 @@ extern bool vacuum_get_cutoffs(Relation rel, const VacuumParams *params,
 							   struct VacuumCutoffs *cutoffs);
 extern bool vacuum_xid_failsafe_check(const struct VacuumCutoffs *cutoffs);
 extern const char *vacuum_oldest_xmin_blocker_name(OldestXminBlocker blocker);
+extern bool vacuum_oldest_xmin_blocker_ident(const OldestXminBlockerInfo *blocker,
+											 StringInfo buf);
 extern void vac_update_datfrozenxid(void);
 extern void vacuum_delay_point(bool is_analyze);
 extern bool vacuum_is_permitted_for_relation(Oid relid, Form_pg_class reltuple,
diff --git a/src/include/replication/slot.h b/src/include/replication/slot.h
index 9b29444cbca..6b6bbc54175 100644
--- a/src/include/replication/slot.h
+++ b/src/include/replication/slot.h
@@ -357,6 +357,8 @@ extern bool ReplicationSlotValidateNameInternal(const char *name,
 extern void ReplicationSlotReserveWal(void);
 extern void ReplicationSlotsComputeRequiredXmin(bool already_locked);
 extern void ReplicationSlotsComputeRequiredLSN(void);
+extern bool ReplicationSlotNameForXmin(TransactionId xid, bool catalog,
+									   char *name, int namesize);
 extern XLogRecPtr ReplicationSlotsComputeLogicalRestartLSN(void);
 extern bool ReplicationSlotsCountDBSlots(Oid dboid, int *nslots, int *nactive);
 extern bool CheckLogicalSlotExists(void);
diff --git a/src/include/storage/procarray.h b/src/include/storage/procarray.h
index 2df5e494faa..f3dc07c6906 100644
--- a/src/include/storage/procarray.h
+++ b/src/include/storage/procarray.h
@@ -35,6 +35,18 @@ typedef enum OldestXminBlocker
 										 * decoding) */
 } OldestXminBlocker;
 
+/*
+ * A blocker's kind plus what we can cheaply name it by: a backend or walsender
+ * pid, or an xid to look up a GID or slot name later. Unused fields are left
+ * at 0 or InvalidTransactionId.
+ */
+typedef struct OldestXminBlockerInfo
+{
+	OldestXminBlocker kind;
+	int			pid;
+	TransactionId xid;
+} OldestXminBlockerInfo;
+
 /*
  * What holds each visibility horizon back, filled in by ComputeXidHorizons()
  * alongside the horizons themselves. Kept out of ComputeXidHorizonsResult, and
@@ -43,9 +55,9 @@ typedef enum OldestXminBlocker
  */
 typedef struct XminHorizonBlockers
 {
-	OldestXminBlocker shared;
-	OldestXminBlocker data;
-	OldestXminBlocker catalog;
+	OldestXminBlockerInfo shared;
+	OldestXminBlockerInfo data;
+	OldestXminBlockerInfo catalog;
 } XminHorizonBlockers;
 
 extern void ProcArrayAdd(PGPROC *proc);
@@ -81,7 +93,7 @@ extern RunningTransactions GetRunningTransactionData(void);
 extern bool TransactionIdIsInProgress(TransactionId xid);
 extern TransactionId GetOldestNonRemovableTransactionId(Relation rel);
 extern TransactionId GetOldestNonRemovableTransactionIdExt(Relation rel,
-														   OldestXminBlocker *blocker);
+														   OldestXminBlockerInfo *blocker);
 extern TransactionId GetOldestTransactionIdConsideredRunning(void);
 extern TransactionId GetOldestActiveTransactionId(bool inCommitOnly,
 												  bool allDbs);
diff --git a/src/test/recovery/t/055_vacuum_oldest_xmin_blocker.pl b/src/test/recovery/t/055_vacuum_oldest_xmin_blocker.pl
index e01fa62d2fe..3b59fbf2707 100644
--- a/src/test/recovery/t/055_vacuum_oldest_xmin_blocker.pl
+++ b/src/test/recovery/t/055_vacuum_oldest_xmin_blocker.pl
@@ -10,16 +10,28 @@ use PostgreSQL::Test::Cluster;
 use PostgreSQL::Test::Utils;
 use Test::More;
 
+# Consuming this many XIDs pushes the horizon past autovacuum_freeze_max_age,
+# so VACUUM emits the warning that names the specific blocker.
+my $freeze_max_age = 100000;
+my $xids_to_consume = $freeze_max_age + 10000;
+
 my $node = PostgreSQL::Test::Cluster->new('primary');
 $node->init(allows_streaming => 'logical');
 $node->append_conf('postgresql.conf', qq{
 autovacuum = off
+autovacuum_freeze_max_age = $freeze_max_age
 max_prepared_transactions = 5
 });
 $node->start;
 
-$node->safe_psql('postgres',
-	"CREATE TABLE t (id int); INSERT INTO t VALUES (1)");
+$node->safe_psql('postgres', qq{
+	CREATE TABLE t (id int);
+	INSERT INTO t VALUES (1);
+	CREATE PROCEDURE consume_xids(n int) AS \$\$
+	BEGIN
+	    FOR i IN 1..n LOOP PERFORM txid_current(); COMMIT; END LOOP;
+	END \$\$ LANGUAGE plpgsql;
+});
 
 # Advance a few XIDs so a concurrent transaction's xmin is older than this
 # session's and the blocker gets reported.
@@ -40,6 +52,41 @@ sub vacuum_verbose
 	return $stderr;
 }
 
+# Push the horizon far into the past and VACUUM, so the warning fires and names
+# the specific blocker. Returns the server log written during the VACUUM.
+sub vacuum_far_in_past
+{
+	my $rel = shift // 't';
+	my $logstart = -s $node->logfile;
+	$node->safe_psql('postgres', "CALL consume_xids($xids_to_consume)");
+	$node->safe_psql('postgres', "VACUUM $rel");
+	return slurp_file($node->logfile, $logstart);
+}
+
+# Reset relfrozenxid so a prior holder doesn't keep the warning firing.
+sub reset_table
+{
+	$node->safe_psql('postgres', "VACUUM FREEZE t");
+}
+
+# Open n backends that hold a snapshot whose xmin is the current oldest xid,
+# without owning that xid themselves. The blocker that owns the xid must be
+# reported ahead of these. Returns the session handles.
+sub open_snapshot_holders
+{
+	my ($n) = @_;
+	my @sessions;
+
+	for (1 .. $n)
+	{
+		my $s = $node->background_psql('postgres');
+		$s->query_safe("BEGIN ISOLATION LEVEL REPEATABLE READ");
+		$s->query_safe("SELECT 1");
+		push @sessions, $s;
+	}
+	return @sessions;
+}
+
 # Nothing holds the horizon back: no blocker line.
 {
 	my $log = vacuum_verbose();
@@ -47,21 +94,32 @@ sub vacuum_verbose
 		'no blocker reported when nothing holds the horizon back');
 }
 
-# Running transaction.
+# Running transaction: named by its backend PID. It owns the oldest xid; the
+# other backends only see that xid as their xmin, so the one that owns it must
+# be reported ahead of them, whatever the proc array order.
 {
 	my $bg = $node->background_psql('postgres');
 	$bg->query_safe("BEGIN");
 	$bg->query_safe("SELECT txid_current()");
+	my $pid = $bg->query_safe("SELECT pg_backend_pid()");
 
-	my $log = vacuum_verbose();
-	like($log, qr/oldest xmin held back by: running transaction/,
-		'blocker reported: running transaction');
+	my @holders = open_snapshot_holders(3);
+
+	my $log = vacuum_far_in_past();
+	like($log,
+		qr/held back by: running transaction \(pid $pid\)/,
+		'blocker reported: the xid owner, not a snapshot holder');
 
+	$_->query_safe("ROLLBACK"), $_->quit for @holders;
 	$bg->query_safe("ROLLBACK");
 	$bg->quit;
+	reset_table();
 }
 
-# Prepared transaction.
+# Prepared transaction: named by its identifier. Like the running case, its own
+# xid is the oldest; the other backends only see that xid as their xmin. The
+# prepared xact must be reported ahead of them, otherwise the wrong category
+# (running transaction) and PID would be reported.
 {
 	$node->safe_psql('postgres', qq{
 		BEGIN;
@@ -69,15 +127,20 @@ sub vacuum_verbose
 		PREPARE TRANSACTION 'hold_xmin';
 	});
 
-	my $log = vacuum_verbose();
-	like($log, qr/oldest xmin held back by: prepared transaction/,
-		'blocker reported: prepared transaction');
+	my @holders = open_snapshot_holders(3);
+
+	my $log = vacuum_far_in_past();
+	like($log,
+		qr/held back by: prepared transaction 'hold_xmin'/,
+		'blocker reported: the prepared xact, not a snapshot holder');
 
+	$_->query_safe("ROLLBACK"), $_->quit for @holders;
 	$node->safe_psql('postgres', "ROLLBACK PREPARED 'hold_xmin'");
+	reset_table();
 }
 
-# Logical slot's catalog_xmin: not reported for a plain user table, but
-# reported for a catalog relation.
+# Logical slot's catalog_xmin: not reported for a plain user table, but names
+# the slot for a catalog relation.
 {
 	$node->safe_psql('postgres',
 		"SELECT pg_create_logical_replication_slot('s', 'test_decoding')");
@@ -92,14 +155,16 @@ sub vacuum_verbose
 	unlike($log, qr/oldest xmin held back by: logical replication slot/,
 		'user table not held back by logical slot catalog_xmin');
 
-	my $catlog = vacuum_verbose('pg_class');
-	like($catlog, qr/oldest xmin held back by: logical replication slot/,
-		'catalog relation held back by logical slot catalog_xmin');
+	my $catlog = vacuum_far_in_past('pg_class');
+	like($catlog,
+		qr/held back by: logical replication slot \(slot "s"\)/,
+		'catalog relation held back by logical slot, slot named');
 
 	$node->safe_psql('postgres', "SELECT pg_drop_replication_slot('s')");
+	reset_table();
 }
 
-# Physical slot with a reserved xmin.
+# Physical slot with a reserved xmin: named by the slot.
 {
 	$node->safe_psql('postgres',
 		"SELECT pg_create_physical_replication_slot('ps', true)");
@@ -122,15 +187,17 @@ wal_receiver_status_interval = 1
 	# Stop the standby so only the slot's frozen xmin holds the horizon.
 	$standby->stop;
 
-	my $log = vacuum_verbose();
-	like($log, qr/oldest xmin held back by: replication slot/,
-		'blocker reported: replication slot');
+	my $log = vacuum_far_in_past();
+	like($log,
+		qr/held back by: replication slot \(slot "ps"\)/,
+		'blocker reported: replication slot, slot named');
 
 	$node->safe_psql('postgres', "SELECT pg_drop_replication_slot('ps')");
+	reset_table();
 }
 
 # hot_standby_feedback without a slot: held by the walsender proc on the
-# primary, reported as standby feedback rather than a slot.
+# primary, so named by the walsender PID, noting the xact runs on the standby.
 {
 	$node->backup('backup2');
 	my $standby = PostgreSQL::Test::Cluster->new('standby_fb');
@@ -149,14 +216,18 @@ wal_receiver_status_interval = 1
 	$node->poll_query_until('postgres', qq{
 		SELECT backend_xmin IS NOT NULL FROM pg_stat_replication;
 	}) or die "timed out waiting for standby feedback xmin";
+	my $wspid = $node->safe_psql('postgres',
+		"SELECT pid FROM pg_stat_replication LIMIT 1");
 
-	my $log = vacuum_verbose();
-	like($log, qr/oldest xmin held back by: standby feedback/,
-		'blocker reported: standby feedback');
+	my $log = vacuum_far_in_past();
+	like($log,
+		qr/held back by: standby feedback \(walsender pid $wspid; the transaction runs on the standby\)/,
+		'blocker reported: standby feedback with walsender PID');
 
 	$bg->query_safe("ROLLBACK");
 	$bg->quit;
 	$standby->stop;
+	reset_table();
 }
 
 $node->stop;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 95284065e19..6fbe57051c2 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1904,6 +1904,7 @@ OldMultiXactReader
 OldToNewMapping
 OldToNewMappingData
 OldestXminBlocker
+OldestXminBlockerInfo
 OnCommitAction
 OnCommitItem
 OnConflictAction
-- 
2.47.3

