diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c index 39395aed0d592..37a39e0c0ecf4 100644 --- a/src/backend/access/heap/vacuumlazy.c +++ b/src/backend/access/heap/vacuumlazy.c @@ -151,6 +151,7 @@ #include "storage/freespace.h" #include "storage/latch.h" #include "storage/lmgr.h" +#include "storage/procarray.h" #include "storage/read_stream.h" #include "utils/injection_point.h" #include "utils/lsyscache.h" @@ -1074,6 +1075,69 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params, vacrel->tuples_deleted, (int64) vacrel->new_rel_tuples, vacrel->recently_dead_tuples); + if (vacrel->recently_dead_tuples > 0) + { + XidHorizonBlocker blocker; + + if (GetXidHorizonBlocker(vacrel->rel, + vacrel->cutoffs.OldestXmin, + &blocker)) + { + switch (blocker.type) + { + case XHB_ACTIVE_TRANSACTION: + appendStringInfo(&buf, + _("oldest xmin blocker: active transaction (pid = %d)\n"), + blocker.pid); + break; + case XHB_IDLE_IN_TRANSACTION: + appendStringInfo(&buf, + _("oldest xmin blocker: idle in transaction (pid = %d)\n"), + blocker.pid); + break; + case XHB_XMIN_ACTIVE_TRANSACTION: + appendStringInfo(&buf, + _("oldest xmin blocker: active transaction holding snapshot (pid = %d)\n"), + blocker.pid); + break; + case XHB_XMIN_IDLE_IN_TRANSACTION: + appendStringInfo(&buf, + _("oldest xmin blocker: idle in transaction holding snapshot (pid = %d)\n"), + blocker.pid); + break; + case XHB_PREPARED_TRANSACTION: + if (blocker.name[0] != '\0') + appendStringInfo(&buf, + _("oldest xmin blocker: prepared transaction (gid = %s)\n"), + blocker.name); + else + appendStringInfo(&buf, + _("oldest xmin blocker: prepared transaction\n")); + break; + case XHB_HOT_STANDBY_FEEDBACK: + if (blocker.name[0] != '\0') + appendStringInfo(&buf, + _("oldest xmin blocker: hot standby feedback (standby name = %s, pid = %d)\n"), + blocker.name, + blocker.pid); + else + appendStringInfo(&buf, + _("oldest xmin blocker: hot standby feedback (pid = %d)\n"), + blocker.pid); + break; + case XHB_PHYSICAL_REPLICATION_SLOT: + appendStringInfo(&buf, + _("oldest xmin blocker: physical replication slot (slot name = %s)\n"), + blocker.name); + break; + case XHB_LOGICAL_REPLICATION_SLOT: + appendStringInfo(&buf, + _("oldest xmin blocker: logical replication slot (slot name = %s)\n"), + blocker.name); + break; + } + } + } if (vacrel->missed_dead_tuples > 0) appendStringInfo(&buf, _("tuples missed: %" PRId64 " dead from %u pages not removed due to cleanup lock contention\n"), diff --git a/src/backend/access/transam/twophase.c b/src/backend/access/transam/twophase.c index 1035e8b3fc795..69d1d82b34235 100644 --- a/src/backend/access/transam/twophase.c +++ b/src/backend/access/transam/twophase.c @@ -2822,6 +2822,45 @@ LookupGXactBySubid(Oid subid) return found; } +/* + * GetPreparedTransactionGid + * Get the GID for the prepared transaction with the given XID. + * + * Returns true when a matching prepared transaction is found. gid will be + * set to an empty string when no match is found. + */ +bool +GetPreparedTransactionGid(TransactionId xid, char gid[GIDSIZE]) +{ + bool found = false; + + Assert(TransactionIdIsValid(xid)); + + gid[0] = '\0'; + + if (max_prepared_xacts == 0 || TwoPhaseState == NULL) + return false; + + LWLockAcquire(TwoPhaseStateLock, LW_SHARED); + for (int i = 0; i < TwoPhaseState->numPrepXacts; i++) + { + GlobalTransaction gxact = TwoPhaseState->prepXacts[i]; + + if (!gxact->valid) + continue; + + if (!TransactionIdEquals(XidFromFullTransactionId(gxact->fxid), xid)) + continue; + + strlcpy(gid, gxact->gid, GIDSIZE); + found = true; + break; + } + LWLockRelease(TwoPhaseStateLock); + + return found; +} + /* * TwoPhaseGetOldestXidInCommit * Return the oldest transaction ID from prepared transactions that are diff --git a/src/backend/storage/ipc/procarray.c b/src/backend/storage/ipc/procarray.c index 60336b3180364..b12d44b8fc79c 100644 --- a/src/backend/storage/ipc/procarray.c +++ b/src/backend/storage/ipc/procarray.c @@ -58,11 +58,13 @@ #include "pgstat.h" #include "postmaster/bgworker.h" #include "port/pg_lfind.h" +#include "replication/slot.h" #include "storage/proc.h" #include "storage/procarray.h" #include "storage/procsignal.h" #include "storage/subsystems.h" #include "utils/acl.h" +#include "utils/backend_status.h" #include "utils/builtins.h" #include "utils/injection_point.h" #include "utils/lsyscache.h" @@ -282,6 +284,26 @@ typedef enum KAXCompressReason KAX_STARTUP_PROCESS_IDLE, /* startup process is about to sleep */ } KAXCompressReason; +/* + * A candidate blocker collected during the ProcArray/replication-slot scan. + * + * Kept deliberately small (the result array is sized for the worst case); the + * comparatively large blocker name is not stored per candidate but resolved + * later, only for the blockers actually reported. See GetXidHorizonBlockers + * (sizing) and FillXidHorizonBlocker (name resolution). + */ +typedef struct XidHorizonBlockerCandidate +{ + XidHorizonBlockerType type; + TransactionId xid; /* the blocking xid/xmin */ + int pid; /* backend pid (0 for prepared xacts and + * slots) */ + ProcNumber proc_number; /* walsender proc number for hot standby + * feedback; INVALID_PROC_NUMBER otherwise */ + int slot_index; /* replication_slots[] index for slot + * blockers; -1 otherwise */ +} XidHorizonBlockerCandidate; + static PGPROC *allProcs; /* @@ -1614,6 +1636,40 @@ TransactionIdIsInProgress(TransactionId xid) } +/* + * Decide whether a proc's xmin/xid must be included when computing the + * horizon for non-shared relations (the data horizon, from which the catalog + * horizon is derived), rather than only the shared horizon. + * + * Normally sessions in other databases are ignored for anything but the + * shared horizon. + * + * However, include them when MyDatabaseId is not (yet) set. A backend in + * the process of starting up must not compute a "too aggressive" horizon, + * otherwise we could end up using it to prune still-needed data away. If + * the current backend never connects to a database this is harmless, because + * data_oldest_nonremovable will never be utilized. + * + * Also, sessions marked with PROC_AFFECTS_ALL_HORIZONS should always be + * included. (This flag is used for hot standby feedback, which can't be + * tied to a specific database.) + * + * Also, while in recovery we cannot compute an accurate per-database + * horizon, as all xids are managed via the KnownAssignedXids machinery. + * + * The filter lives in this helper so that ComputeXidHorizons(), which uses + * it to compute the horizons, and GetXidHorizonBlockers(), which uses it to + * explain them, cannot drift apart. + */ +static inline bool +ProcAffectsDataHorizon(PGPROC *proc, int8 statusFlags, bool in_recovery) +{ + return proc->databaseId == MyDatabaseId || + MyDatabaseId == InvalidOid || + (statusFlags & PROC_AFFECTS_ALL_HORIZONS) || + in_recovery; +} + /* * Determine XID horizons. * @@ -1775,28 +1831,10 @@ ComputeXidHorizons(ComputeXidHorizonsResult *h) TransactionIdOlder(h->shared_oldest_nonremovable, xmin); /* - * Normally sessions in other databases are ignored for anything but - * the shared horizon. - * - * However, include them when MyDatabaseId is not (yet) set. A - * backend in the process of starting up must not compute a "too - * aggressive" horizon, otherwise we could end up using it to prune - * still-needed data away. If the current backend never connects to a - * database this is harmless, because data_oldest_nonremovable will - * never be utilized. - * - * Also, sessions marked with PROC_AFFECTS_ALL_HORIZONS should always - * be included. (This flag is used for hot standby feedback, which - * can't be tied to a specific database.) - * - * Also, while in recovery we cannot compute an accurate per-database - * horizon, as all xids are managed via the KnownAssignedXids - * machinery. + * Sessions in other databases are normally ignored for the data + * horizon; see ProcAffectsDataHorizon() for the exceptions. */ - if (proc->databaseId == MyDatabaseId || - MyDatabaseId == InvalidOid || - (statusFlags & PROC_AFFECTS_ALL_HORIZONS) || - in_recovery) + if (ProcAffectsDataHorizon(proc, statusFlags, in_recovery)) { h->data_oldest_nonremovable = TransactionIdOlder(h->data_oldest_nonremovable, xmin); @@ -1999,6 +2037,392 @@ GetReplicationHorizons(TransactionId *xmin, TransactionId *catalog_xmin) *catalog_xmin = horizons.slot_catalog_xmin; } +/* + * Find the blockers that are holding back the given xid horizon. + * + * This function searches for what is preventing the given horizon from being + * advanced to allow removal of dead tuples. It checks: + * 1. Active transactions (running statements) + * 2. Idle-in-transaction sessions + * 3. Prepared transactions + * 4. Hot standby feedback + * 5. Replication slots (physical or logical) + * + * The horizon kind (see GlobalVisHorizonKindForRel) determines which backends + * and slot reservations are relevant: the same database and catalog_xmin + * filtering ComputeXidHorizons() applied when computing the horizon is mirrored + * here, at the point of each scan (see the per-case comments below). + * + * Hot standby feedback deserves a note, because where the standby's xmin is + * stored depends on whether the connection uses a replication slot (see + * ProcessStandbyHSFeedbackMessage): + * + * - Without a slot, the xmin is held in the walsender's PGPROC and is found by + * the ProcArray scan below as XHB_HOT_STANDBY_FEEDBACK. + * - With a physical slot, the xmin is held in the slot (the walsender's PGPROC + * xmin is reset to invalid), so the ProcArray scan does not see it. The slot + * scan finds it instead: if a standby is currently connected (the slot is + * active) it is still reported as XHB_HOT_STANDBY_FEEDBACK, otherwise the + * persisted reservation is reported as XHB_PHYSICAL_REPLICATION_SLOT. + * + * Logical slots reserve catalog_xmin and are reported as + * XHB_LOGICAL_REPLICATION_SLOT. + * + * Because the horizon was computed earlier, the original blocker may have + * already committed by the time this function runs. The result is therefore + * best-effort: it may return a different blocker, or no blocker at all. + * + * Returns a palloc'd array of candidate blockers and stores the number of + * entries in *nblockers. The blocker names are not resolved here; the caller + * does that for the blocker it reports (see FillXidHorizonBlocker). The array + * may be empty if no blocker is found. + */ +static XidHorizonBlockerCandidate * +GetXidHorizonBlockers(TransactionId horizon, GlobalVisHorizonKind kind, + int *nblockers) +{ + ProcArrayStruct *arrayP = procArray; + TransactionId *other_xids = ProcGlobal->xids; + XidHorizonBlockerCandidate *result; + int count = 0; + int max_blockers; + int max_slots = max_replication_slots + max_repack_replication_slots; + + Assert(TransactionIdIsValid(horizon)); + Assert(nblockers != NULL); + + /* + * The only caller (VACUUM) cannot run during recovery, and this function + * does not support it: during recovery the horizon may stem from + * KnownAssignedXids (see ComputeXidHorizons()), which the scans below + * know nothing about, and the per-database filter would have to be + * disabled. A future caller that runs during recovery (e.g. a + * SQL-callable view usable on a standby) needs to add that handling. + */ + Assert(!RecoveryInProgress()); + + /* + * Size the result array for the worst case (one entry per PGPROC plus one + * per replication slot, including repack slots) and allocate it before + * acquiring ProcArrayLock, so the scan below never has to allocate while + * holding the lock. The other ProcArray scanners such as + * GetCurrentVirtualXIDs() size their result space the same way. Only 0-2 + * entries are returned in practice, and each entry is small because the + * blocker name is resolved later, not here. + */ + max_blockers = arrayP->maxProcs + max_slots; + result = palloc_array(XidHorizonBlockerCandidate, max_blockers); + + LWLockAcquire(ProcArrayLock, LW_SHARED); + + for (int index = 0; index < arrayP->numProcs; index++) + { + int pgprocno = arrayP->pgprocnos[index]; + PGPROC *proc = &allProcs[pgprocno]; + int8 statusFlags = ProcGlobal->statusFlags[index]; + TransactionId proc_xid; + TransactionId proc_xmin; + XidHorizonBlockerCandidate *dst; + XidHorizonBlockerType type; + + /* + * The caller itself never blocks its own horizon, and unlike the + * horizon computation we are not interested in its xmin; skip it. + * This exclusion is specific to this function; the two conditions + * below mirror ComputeXidHorizons(). + */ + if (proc == MyProc) + continue; + + /* + * Skip over backends either vacuuming (which is ok with rows being + * removed, as long as pg_subtrans is not truncated) or doing logical + * decoding (which manages xmin separately, covered by the slot scan + * below). + */ + if (statusFlags & (PROC_IN_VACUUM | PROC_IN_LOGICAL_DECODING)) + continue; + + /* + * For the data and catalog horizons, apply the same per-database + * filter the horizon computation applied; the shared horizon + * considers backends in all databases. Recovery is ruled out by the + * assertion above, hence in_recovery is passed as false. + */ + if (kind != VISHORIZON_SHARED && + !ProcAffectsDataHorizon(proc, statusFlags, false)) + continue; + + /* Fetch xid just once - see GetNewTransactionId */ + proc_xid = UINT32_ACCESS_ONCE(other_xids[index]); + proc_xmin = UINT32_ACCESS_ONCE(proc->xmin); + + /* + * Candidates are collected in ProcArray order; callers can reorder if + * needed. Only the blocker type is determined by the cases below; + * the entry itself is filled in once afterwards. Backends are + * provisionally recorded as "active"; the active vs. + * idle-in-transaction distinction is resolved from the reported + * backend state once all locks are released (see below), because the + * PGPROC alone cannot tell an idle-in-transaction session from one + * blocked on client I/O. + */ + if (TransactionIdEquals(proc_xid, horizon)) + { + /* This proc's xid matches the horizon (the root cause) */ + if (proc->pid == 0) + type = XHB_PREPARED_TRANSACTION; + else + type = XHB_ACTIVE_TRANSACTION; + } + else if (TransactionIdEquals(proc_xmin, horizon)) + { + /* This proc's xmin matches the horizon (held back by the above) */ + if (statusFlags & PROC_AFFECTS_ALL_HORIZONS) + type = XHB_HOT_STANDBY_FEEDBACK; + else + type = XHB_XMIN_ACTIVE_TRANSACTION; + } + else + continue; + + dst = &result[count++]; + dst->type = type; + dst->pid = proc->pid; + dst->xid = horizon; + dst->proc_number = pgprocno; + dst->slot_index = -1; + } + + LWLockRelease(ProcArrayLock); + + /* + * Also check replication slots. + * + * A physical slot reserves xmin on behalf of a standby using hot standby + * feedback. If a standby is currently connected we attribute the + * reservation to that feedback (and record the walsender pid so its + * application_name can be looked up below); otherwise it is a persisted + * physical-slot reservation with no connected standby. A logical slot + * reserves catalog_xmin for logical decoding. We compare against the + * effective xmin values, the same ones + * ReplicationSlotsComputeRequiredXmin() aggregates into the horizon + * (data.xmin/catalog_xmin can lag those, e.g. while a logical slot is + * being created). + */ + if (max_slots > 0) + { + LWLockAcquire(ReplicationSlotControlLock, LW_SHARED); + + for (int i = 0; i < max_slots; i++) + { + ReplicationSlot *s = &ReplicationSlotCtl->replication_slots[i]; + TransactionId slot_xmin; + TransactionId slot_catalog_xmin; + ProcNumber active_proc; + ReplicationSlotInvalidationCause invalidated; + XidHorizonBlockerCandidate *dst; + + if (!s->in_use) + continue; + + SpinLockAcquire(&s->mutex); + slot_xmin = s->effective_xmin; + slot_catalog_xmin = s->effective_catalog_xmin; + active_proc = s->active_proc; + invalidated = s->data.invalidated; + SpinLockRelease(&s->mutex); + + /* Invalidated slots no longer hold back the horizon. */ + if (invalidated != RS_INVAL_NONE) + continue; + + /* + * A slot's xmin holds back every (non-temp) horizon, but its + * catalog_xmin only holds back the shared and catalog horizons, + * mirroring ComputeXidHorizons(). + */ + if (!TransactionIdEquals(slot_xmin, horizon) && + !(TransactionIdEquals(slot_catalog_xmin, horizon) && + (kind == VISHORIZON_SHARED || kind == VISHORIZON_CATALOG))) + continue; + + dst = &result[count++]; + dst->xid = horizon; + dst->slot_index = i; + + if (SlotIsPhysical(s) && active_proc != INVALID_PROC_NUMBER) + { + /* Connected standby: report as hot standby feedback. */ + dst->type = XHB_HOT_STANDBY_FEEDBACK; + dst->pid = GetPGProcByNumber(active_proc)->pid; + dst->proc_number = active_proc; + } + else + { + /* + * A physical slot with no connected standby, or a logical + * slot. The slot name is resolved later from slot_index. + */ + dst->type = SlotIsPhysical(s) ? + XHB_PHYSICAL_REPLICATION_SLOT : + XHB_LOGICAL_REPLICATION_SLOT; + dst->pid = 0; + dst->proc_number = INVALID_PROC_NUMBER; + } + } + + LWLockRelease(ReplicationSlotControlLock); + } + + /* + * Resolve the active vs. idle-in-transaction distinction for backend + * candidates now that all locks are released. A backend's PGPROC cannot + * tell an idle-in-transaction session apart from one actively blocked on + * client I/O (both wait on WAIT_EVENT_CLIENT_READ), so consult the + * cumulative backend status, the same source as pg_stat_activity.state. + * This is a bsearch over a process-local snapshot; matching on + * proc_number plus st_procpid guards against the PGPROC being reused + * since the scan. + */ + for (int i = 0; i < count; i++) + { + XidHorizonBlockerCandidate *cand = &result[i]; + PgBackendStatus *beentry; + + if (cand->type != XHB_ACTIVE_TRANSACTION && + cand->type != XHB_XMIN_ACTIVE_TRANSACTION) + continue; + + beentry = pgstat_get_beentry_by_proc_number(cand->proc_number); + if (beentry != NULL && beentry->st_procpid == cand->pid && + (beentry->st_state == STATE_IDLEINTRANSACTION || + beentry->st_state == STATE_IDLEINTRANSACTION_ABORTED)) + cand->type = (cand->type == XHB_ACTIVE_TRANSACTION) ? + XHB_IDLE_IN_TRANSACTION : XHB_XMIN_IDLE_IN_TRANSACTION; + } + + *nblockers = count; + return result; +} + +/* + * Resolve a scanned candidate into a fully-populated blocker. + * + * The scan deliberately leaves the (comparatively large) blocker name out of + * every candidate; it is filled in here for a blocker that is actually + * reported, after all scan locks have been released: + * + * - prepared transaction: look up the GID from the xid; + * - hot standby feedback: look up the standby's application_name from the + * walsender's backend status entry. Matching on proc_number rather than pid + * avoids being fooled by pid reuse, and the lookup is a bsearch over a + * process-local snapshot; the st_procpid check guards against the proc being + * reused since the scan; + * - replication slot: copy the slot name (best-effort: empty if the slot has + * since been dropped). + */ +static void +FillXidHorizonBlocker(const XidHorizonBlockerCandidate *cand, + XidHorizonBlocker *blocker) +{ + blocker->type = cand->type; + blocker->xid = cand->xid; + blocker->pid = cand->pid; + blocker->proc_number = cand->proc_number; + blocker->name[0] = '\0'; + + switch (cand->type) + { + case XHB_PREPARED_TRANSACTION: + GetPreparedTransactionGid(cand->xid, blocker->name); + break; + + case XHB_HOT_STANDBY_FEEDBACK: + { + PgBackendStatus *beentry; + + beentry = pgstat_get_beentry_by_proc_number(cand->proc_number); + if (beentry != NULL && beentry->st_procpid == cand->pid && + beentry->st_appname != NULL && beentry->st_appname[0] != '\0') + strlcpy(blocker->name, beentry->st_appname, + sizeof(blocker->name)); + break; + } + + case XHB_PHYSICAL_REPLICATION_SLOT: + case XHB_LOGICAL_REPLICATION_SLOT: + { + NameData slotname; + + if (ReplicationSlotName(cand->slot_index, &slotname)) + strlcpy(blocker->name, NameStr(slotname), + sizeof(blocker->name)); + break; + } + + default: + break; + } +} + +/* + * Get the highest-priority blocker holding back the xid horizon of 'rel'. + * + * 'horizon' must be the relation's removal cutoff as computed by + * GetOldestNonRemovableTransactionId(rel); 'rel' identifies which kind of + * horizon that is, so the scan can apply the same database and catalog_xmin + * filtering ComputeXidHorizons() used to compute it. + * + * Returns true and stores the blocker in *blocker if any are found. + */ +bool +GetXidHorizonBlocker(Relation rel, TransactionId horizon, + XidHorizonBlocker *blocker) +{ + XidHorizonBlockerCandidate *blockers; + XidHorizonBlockerCandidate *best = NULL; + GlobalVisHorizonKind kind; + int nblockers; + + Assert(TransactionIdIsValid(horizon)); + Assert(blocker != NULL); + + kind = GlobalVisHorizonKindForRel(rel); + + /* + * The temp-table horizon is held back only by our own backend, which the + * scan skips, so there is never an external blocker to report. + */ + if (kind == VISHORIZON_TEMP) + return false; + + blockers = GetXidHorizonBlockers(horizon, kind, &nblockers); + for (int i = 0; i < nblockers; i++) + { + if (best == NULL || blockers[i].type < best->type) + { + best = &blockers[i]; + + /* + * xid-match types are the highest priority (the root cause + * holding the horizon), so nothing can outrank them; stop once we + * find one. + */ + if (best->type <= XHB_PREPARED_TRANSACTION) + break; + } + } + + /* Resolve the name only for the blocker we actually report. */ + if (best != NULL) + FillXidHorizonBlocker(best, blocker); + + pfree(blockers); + + return (best != NULL); +} + /* * GetMaxSnapshotXidCount -- get max size for snapshot XID array * diff --git a/src/include/access/twophase.h b/src/include/access/twophase.h index 1d2ff42c9b72f..fc7294a4e25f5 100644 --- a/src/include/access/twophase.h +++ b/src/include/access/twophase.h @@ -70,6 +70,7 @@ extern void TwoPhaseTransactionGid(Oid subid, TransactionId xid, char *gid_res, int szgid); extern bool LookupGXactBySubid(Oid subid); +extern bool GetPreparedTransactionGid(TransactionId xid, char gid[GIDSIZE]); extern TransactionId TwoPhaseGetOldestXidInCommit(void); #endif /* TWOPHASE_H */ diff --git a/src/include/storage/procarray.h b/src/include/storage/procarray.h index d718a5b542f04..09cf971fe491a 100644 --- a/src/include/storage/procarray.h +++ b/src/include/storage/procarray.h @@ -14,10 +14,51 @@ #ifndef PROCARRAY_H #define PROCARRAY_H +#include "access/xact.h" +#include "storage/procnumber.h" #include "storage/standby.h" #include "utils/relcache.h" #include "utils/snapshot.h" +/* + * Type of blocker that is holding back the xid horizon. + * Listed in priority order from highest to lowest. Blockers whose xid + * matches the horizon (the root cause) are listed before blockers whose + * xmin matches (held back by the root cause). Within each group, active + * transactions are listed first because they are the most actionable for + * the DBA (the running query can be identified and cancelled). + */ +typedef enum XidHorizonBlockerType +{ + /* xid-match types (horizon == proc's xid) */ + XHB_ACTIVE_TRANSACTION, /* backend running a statement */ + XHB_IDLE_IN_TRANSACTION, /* backend idle in transaction */ + XHB_PREPARED_TRANSACTION, /* prepared (two-phase) transaction */ + /* xmin-match types (horizon == proc's xmin or slot's xmin) */ + XHB_XMIN_ACTIVE_TRANSACTION, /* backend running a statement */ + XHB_XMIN_IDLE_IN_TRANSACTION, /* backend idle in transaction */ + XHB_HOT_STANDBY_FEEDBACK, /* connected standby with hot_standby_feedback */ + XHB_PHYSICAL_REPLICATION_SLOT, /* physical slot reserving xmin (no + * connected standby) */ + XHB_LOGICAL_REPLICATION_SLOT, /* logical replication slot */ +} XidHorizonBlockerType; + +/* + * Information about a blocker that is holding back the xid horizon. + */ +typedef struct XidHorizonBlocker +{ + XidHorizonBlockerType type; + TransactionId xid; /* the blocking xid/xmin */ + int pid; /* backend pid (0 for prepared xacts and + * slots) */ + ProcNumber proc_number; /* backend's proc number, used to look up its + * application_name; INVALID_PROC_NUMBER when + * there is no associated backend */ + /* large enough for prepared-txn GID or replication slot name */ + char name[Max(GIDSIZE, NAMEDATALEN)]; +} XidHorizonBlocker; + extern void ProcArrayAdd(PGPROC *proc); extern void ProcArrayRemove(PGPROC *proc, TransactionId latestXid); @@ -98,4 +139,7 @@ extern void ProcArraySetReplicationSlotXmin(TransactionId xmin, extern void ProcArrayGetReplicationSlotXmin(TransactionId *xmin, TransactionId *catalog_xmin); +extern bool GetXidHorizonBlocker(Relation rel, TransactionId horizon, + XidHorizonBlocker *blocker); + #endif /* PROCARRAY_H */ diff --git a/src/test/modules/test_misc/meson.build b/src/test/modules/test_misc/meson.build index ee290698b3119..ff9759f861a78 100644 --- a/src/test/modules/test_misc/meson.build +++ b/src/test/modules/test_misc/meson.build @@ -23,6 +23,7 @@ tests += { 't/012_ddlutils.pl', 't/013_temp_obj_multisession.pl', 't/014_log_statement_max_length.pl', + 't/015_log_vacuum_blockers.pl', ], # The injection points are cluster-wide, so disable installcheck 'runningcheck': false, diff --git a/src/test/modules/test_misc/t/015_log_vacuum_blockers.pl b/src/test/modules/test_misc/t/015_log_vacuum_blockers.pl new file mode 100644 index 0000000000000..d0bf6a1b98250 --- /dev/null +++ b/src/test/modules/test_misc/t/015_log_vacuum_blockers.pl @@ -0,0 +1,451 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Validate that VACUUM logs explain why dead tuples could not be removed. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +# Set up a cluster +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init(allows_streaming => 'logical'); +$node->append_conf('postgresql.conf', q[ +max_prepared_transactions = 5 +]); +$node->start; + +# Take the backup and initialize the standbys early, before any background +# psql sessions run. On Windows, terminated background psql sessions can +# leave lingering file handles that make a later pg_ctl start for the standby +# fail; doing it now lets the later start only have to launch pg_ctl. +$node->backup('oldestxmin_hotstandby_bkp'); +my $standby = PostgreSQL::Test::Cluster->new('oldestxmin_standby'); +$standby->init_from_backup($node, 'oldestxmin_hotstandby_bkp', + has_streaming => 1); +$standby->append_conf('postgresql.conf', q[ +hot_standby_feedback = on +wal_receiver_status_interval = 1s +]); + +# A second standby that streams through a physical replication slot, used to +# check that hot standby feedback held via a slot is reported correctly: as +# "hot standby feedback" while the standby is connected, and as "physical +# replication slot" once it has disconnected but the slot still reserves xmin. +$node->safe_psql('postgres', + "SELECT pg_create_physical_replication_slot('physical_slot');"); +my $slot_standby = PostgreSQL::Test::Cluster->new('oldestxmin_slot_standby'); +$slot_standby->init_from_backup($node, 'oldestxmin_hotstandby_bkp', + has_streaming => 1); +$slot_standby->append_conf('postgresql.conf', q[ +primary_slot_name = 'physical_slot' +hot_standby_feedback = on +wal_receiver_status_interval = 1s +]); + + +# +# Active statement +# +my $active_table = 'blocker_active'; +$node->safe_psql('postgres', qq[ +CREATE TABLE $active_table(id int); +INSERT INTO $active_table VALUES (0); +]); + +my $blocker = $node->background_psql('postgres'); +my $blocker_pid = $blocker->query_safe('SELECT pg_backend_pid();'); +chomp($blocker_pid); + +# Hold a snapshot by selecting from a table; pg_sleep alone takes no +# snapshot, so xmin would stay unset. +$blocker->query_until(qr//, qq[ +BEGIN; +SELECT * FROM $active_table, pg_sleep(60); +]); + +# Wait for the blocker to have xmin set +$node->poll_query_until('postgres', qq[ +SELECT backend_xmin IS NOT NULL +FROM pg_stat_activity +WHERE pid = $blocker_pid; +]); + +$node->safe_psql('postgres', "DELETE FROM $active_table;"); + +my $stderr = ''; +$node->psql('postgres', "VACUUM (VERBOSE) $active_table;", stderr => \$stderr); +like( + $stderr, + qr/oldest xmin blocker: active transaction holding snapshot \(pid = $blocker_pid\)/, + 'VACUUM VERBOSE reported active transaction holding snapshot as oldest xmin blocker'); + +# Cleanup +$node->safe_psql('postgres', qq[ +SELECT pg_terminate_backend($blocker_pid); +DROP TABLE $active_table; +]); + + +# +# Idle in transaction +# +my $idle_table = 'blocker_idle'; +$node->safe_psql('postgres', qq[ +CREATE TABLE $idle_table(id int); +INSERT INTO $idle_table VALUES (0); +]); + +my $idle_blocker = $node->background_psql('postgres'); +my $idle_blocker_pid = $idle_blocker->query_safe('SELECT pg_backend_pid();'); +chomp($idle_blocker_pid); + +# Set isolation level to REPEATABLE READ to ensure xmin is set +$idle_blocker->query_safe(qq[ +BEGIN; +SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; +SELECT * FROM $idle_table; +]); + +$node->safe_psql('postgres', "DELETE FROM $idle_table;"); + +$stderr = ''; +$node->psql('postgres', "VACUUM (VERBOSE) $idle_table;", stderr => \$stderr); +like( + $stderr, + qr/oldest xmin blocker: idle in transaction holding snapshot \(pid = $idle_blocker_pid\)/, + 'VACUUM VERBOSE reported idle in transaction holding snapshot as oldest xmin blocker'); + +# Cleanup +$idle_blocker->quit; +$node->safe_psql('postgres', "DROP TABLE $idle_table;"); + + +# +# Serializable transaction (idle in transaction) +# +my $serializable_table = 'blocker_serializable'; +$node->safe_psql('postgres', qq[ +CREATE TABLE $serializable_table(id int); +INSERT INTO $serializable_table VALUES (0); +]); + +my $ser_blocker = $node->background_psql('postgres'); +my $ser_blocker_pid = $ser_blocker->query_safe('SELECT pg_backend_pid();'); +chomp($ser_blocker_pid); + +$ser_blocker->query_safe(qq[ +BEGIN; +SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; +SELECT * FROM $serializable_table; +]); + +$node->safe_psql('postgres', "DELETE FROM $serializable_table;"); + +$stderr = ''; +$node->psql('postgres', "VACUUM (VERBOSE) $serializable_table;", stderr => \$stderr); +like( + $stderr, + qr/oldest xmin blocker: idle in transaction holding snapshot \(pid = $ser_blocker_pid\)/, + 'VACUUM VERBOSE reported serializable transaction as oldest xmin blocker'); + +# Cleanup +$ser_blocker->quit; +$node->safe_psql('postgres', "DROP TABLE $serializable_table;"); + + +# +# Prefer xid owner over xmin match +# +my $prefer_table = 'blocker_prefer_xid_owner'; +$node->safe_psql('postgres', qq[ +CREATE TABLE $prefer_table(id int); +INSERT INTO $prefer_table VALUES (0); +]); + +my $xid_owner = $node->background_psql('postgres'); +my $xid_owner_pid = $xid_owner->query_safe('SELECT pg_backend_pid();'); +chomp($xid_owner_pid); + +$xid_owner->query_safe(qq[ +BEGIN; +SELECT pg_current_xact_id(); +]); + +$node->poll_query_until('postgres', qq[ +SELECT backend_xid IS NOT NULL +FROM pg_stat_activity +WHERE pid = $xid_owner_pid; +]); + +my $owner_xid = $node->safe_psql('postgres', qq[ +SELECT backend_xid +FROM pg_stat_activity +WHERE pid = $xid_owner_pid; +]); +chomp($owner_xid); + +my $xmin_holder = $node->background_psql('postgres'); +my $xmin_holder_pid = $xmin_holder->query_safe('SELECT pg_backend_pid();'); +chomp($xmin_holder_pid); + +# Start a long-running query that will take a snapshot after xid_owner begins +$xmin_holder->query_until(qr//, qq[ +BEGIN; +SELECT * FROM $prefer_table, pg_sleep(60); +]); + +# Ensure xmin_holder's xmin is held back by xid_owner +$node->poll_query_until('postgres', qq[ +SELECT backend_xmin = '$owner_xid'::xid +FROM pg_stat_activity +WHERE pid = $xmin_holder_pid; +]); + +$node->safe_psql('postgres', "DELETE FROM $prefer_table;"); + +$stderr = ''; +$node->psql('postgres', "VACUUM (VERBOSE) $prefer_table;", stderr => \$stderr); +like( + $stderr, + qr/oldest xmin blocker: idle in transaction \(pid = $xid_owner_pid\)/, + 'VACUUM VERBOSE preferred xid owner over xmin match'); + +# Cleanup +$node->safe_psql('postgres', qq[ +SELECT pg_terminate_backend($xmin_holder_pid); +SELECT pg_terminate_backend($xid_owner_pid); +DROP TABLE $prefer_table; +]); + + +# +# Prepared transaction +# +my $prepared_table = 'blocker_prepared'; +$node->safe_psql('postgres', qq[ +CREATE TABLE $prepared_table(id int); +INSERT INTO $prepared_table VALUES (0); +BEGIN; +PREPARE TRANSACTION 'gx_vacuum_xmin'; +]); + +$node->safe_psql('postgres', "DELETE FROM $prepared_table;"); + +$stderr = ''; +$node->psql('postgres', "VACUUM (VERBOSE) $prepared_table;", stderr => \$stderr); +like( + $stderr, + qr/oldest xmin blocker: prepared transaction \(gid = gx_vacuum_xmin\)/, + 'VACUUM VERBOSE reported prepared transaction as oldest xmin blocker'); + +# Cleanup +$node->safe_psql('postgres', qq[ +ROLLBACK PREPARED 'gx_vacuum_xmin'; +DROP TABLE $prepared_table; +]); + + +# +# Logical replication slot +# +my $slot_table = 'blocker_slot'; +$node->safe_psql('postgres', qq[ +CREATE TABLE $slot_table(id int); +SELECT pg_create_logical_replication_slot('logical_slot', 'test_decoding'); +DROP TABLE $slot_table; +]); + +$stderr = ''; +$node->psql('postgres', 'VACUUM (VERBOSE) pg_class;', stderr => \$stderr); +like( + $stderr, + qr/oldest xmin blocker: logical replication slot \(slot name = logical_slot\)/, + 'VACUUM VERBOSE reported logical replication slot as oldest xmin source'); + +# Cleanup +$node->safe_psql('postgres', qq[ +SELECT pg_drop_replication_slot('logical_slot'); +]); + + +# +# Hot standby feedback +# +# The standby was already initialized from a backup taken above. Start it +# now, after all background psql sessions from earlier tests have been fully +# cleaned up. +my $hs_table = 'blocker_hotstandby'; +$node->safe_psql('postgres', qq[ +CREATE TABLE $hs_table(id int); +INSERT INTO $hs_table VALUES (0); +]); + +$standby->start; +$node->wait_for_replay_catchup($standby); + +my $standby_reader = $standby->background_psql('postgres'); +my $standby_reader_pid = $standby_reader->query_safe('SELECT pg_backend_pid();'); +chomp($standby_reader_pid); + +$standby_reader->query_until(qr//, qq[ +BEGIN; +SELECT * FROM $hs_table, pg_sleep(60); +]); + +# Establish the reader's snapshot on the standby and capture its xmin. The +# DELETE below must use a newer xid than this so the deleted tuple stays +# "recently dead". Waiting for the feedback to actually carry the reader's +# xmin (rather than merely being non-null) avoids racing a periodic feedback +# message that predates the reader's snapshot. +$standby->poll_query_until('postgres', qq[ +SELECT backend_xmin IS NOT NULL +FROM pg_stat_activity +WHERE pid = $standby_reader_pid; +]); +my $reader_xmin = $standby->safe_psql('postgres', qq[ +SELECT backend_xmin FROM pg_stat_activity WHERE pid = $standby_reader_pid; +]); + +# Wait for hot standby feedback carrying the reader's xmin to reach the primary +$node->poll_query_until('postgres', qq[ +SELECT backend_xmin = '$reader_xmin'::xid +FROM pg_stat_replication +WHERE application_name = 'oldestxmin_standby'; +]); + +my $hs_blocker_pid = $node->safe_psql('postgres', q[ +SELECT pid FROM pg_stat_replication +WHERE application_name = 'oldestxmin_standby'; +]); +chomp($hs_blocker_pid); + +$node->safe_psql('postgres', "DELETE FROM $hs_table;"); + +$stderr = ''; +$node->psql('postgres', "VACUUM (VERBOSE) $hs_table;", stderr => \$stderr); +like( + $stderr, + qr/oldest xmin blocker: hot standby feedback \(standby name = oldestxmin_standby, pid = $hs_blocker_pid\)/, + 'VACUUM VERBOSE reported hot standby feedback as oldest xmin blocker'); + +# Cleanup +$standby->safe_psql('postgres', "SELECT pg_terminate_backend($standby_reader_pid);"); +$node->safe_psql('postgres', "DROP TABLE $hs_table;"); +$standby->stop; + + +# +# Hot standby feedback held via a physical replication slot +# +# When the standby streams through a physical slot, the feedback xmin is held +# by the slot rather than the walsender's PGPROC. While the standby is +# connected this must still be reported as hot standby feedback. +# +my $slot_hs_table = 'blocker_slot_hotstandby'; +$node->safe_psql('postgres', qq[ +CREATE TABLE $slot_hs_table(id int); +INSERT INTO $slot_hs_table VALUES (0); +]); + +$slot_standby->start; +$node->wait_for_replay_catchup($slot_standby); + +my $slot_reader = $slot_standby->background_psql('postgres'); +my $slot_reader_pid = $slot_reader->query_safe('SELECT pg_backend_pid();'); +chomp($slot_reader_pid); + +$slot_reader->query_until(qr//, qq[ +BEGIN; +SELECT * FROM $slot_hs_table, pg_sleep(60); +]); + +# Establish the reader's snapshot on the standby and capture its xmin. When a +# physical slot is used the feedback xmin is held on the slot rather than in +# the walsender's PGPROC, so pg_stat_replication.backend_xmin stays null here; +# wait on the slot's xmin instead, requiring it to carry the reader's xmin so +# the DELETE below (a newer xid) leaves a "recently dead" tuple. +$slot_standby->poll_query_until('postgres', qq[ +SELECT backend_xmin IS NOT NULL +FROM pg_stat_activity +WHERE pid = $slot_reader_pid; +]); +my $slot_reader_xmin = $slot_standby->safe_psql('postgres', qq[ +SELECT backend_xmin FROM pg_stat_activity WHERE pid = $slot_reader_pid; +]); + +$node->poll_query_until('postgres', qq[ +SELECT xmin = '$slot_reader_xmin'::xid +FROM pg_replication_slots +WHERE slot_name = 'physical_slot'; +]); + +my $slot_hs_pid = $node->safe_psql('postgres', q[ +SELECT pid FROM pg_stat_replication +WHERE application_name = 'oldestxmin_slot_standby'; +]); +chomp($slot_hs_pid); + +$node->safe_psql('postgres', "DELETE FROM $slot_hs_table;"); + +$stderr = ''; +$node->psql('postgres', "VACUUM (VERBOSE) $slot_hs_table;", stderr => \$stderr); +like( + $stderr, + qr/oldest xmin blocker: hot standby feedback \(standby name = oldestxmin_slot_standby, pid = $slot_hs_pid\)/, + 'VACUUM VERBOSE reported slot-based hot standby feedback as oldest xmin blocker'); + + +# +# Physical replication slot with no connected standby +# +# After the standby disconnects, the physical slot keeps reserving the xmin. +# With no walsender connected, this must be reported as a physical replication +# slot rather than as hot standby feedback. +# +# Disconnect the standby so the slot is left with no connected walsender. +# +# On Windows this slot-using standby can be slow to fully shut down, so pg_ctl +# may spuriously report "server does not shut down"; tolerate it with fail_ok. +# This is safe: the primary's walsender releases the slot as soon as the +# connection drops, and stop() still detects the node is actually down. +$slot_standby->safe_psql('postgres', + "SELECT pg_terminate_backend($slot_reader_pid);"); +$slot_standby->stop('fast', fail_ok => 1); + +# Wait for the walsender to exit so the slot is no longer active. +$node->poll_query_until('postgres', q[ +SELECT NOT EXISTS ( + SELECT 1 FROM pg_stat_replication + WHERE application_name = 'oldestxmin_slot_standby') +AND NOT (SELECT active FROM pg_replication_slots + WHERE slot_name = 'physical_slot'); +]); + +# Once the standby disconnects the slot freezes its xmin, but terminating the +# reader may have let one last feedback advance that xmin past the earlier +# DELETE. Create a fresh dead tuple with a newer xid so something is +# guaranteed to remain "recently dead" for VACUUM to report on. +$node->safe_psql('postgres', qq[ +INSERT INTO $slot_hs_table VALUES (1); +DELETE FROM $slot_hs_table; +]); + +$stderr = ''; +$node->psql('postgres', "VACUUM (VERBOSE) $slot_hs_table;", stderr => \$stderr); +like( + $stderr, + qr/oldest xmin blocker: physical replication slot \(slot name = physical_slot\)/, + 'VACUUM VERBOSE reported physical replication slot as oldest xmin blocker'); + +# Cleanup +$node->safe_psql('postgres', qq[ +DROP TABLE $slot_hs_table; +SELECT pg_drop_replication_slot('physical_slot'); +]); + + +$node->stop; +done_testing(); diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index ffb413ab61212..390c8b2393d24 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -3546,6 +3546,9 @@ XactLockTableWaitInfo XidBoundsViolation XidCacheStatus XidCommitStatus +XidHorizonBlocker +XidHorizonBlockerCandidate +XidHorizonBlockerType XidStatus XmlExpr XmlExprOp