From fc047cbd292d0cfc0fd74674791c25b6901e8296 Mon Sep 17 00:00:00 2001 From: Shinya Kato Date: Sun, 20 Sep 2026 19:02:27 +0900 Subject: [PATCH v10] Report what held back the removable cutoff in VACUUM's log When VACUUM leaves dead tuples behind, its log output says how many could not be removed and what the removable cutoff was, but not what held that cutoff back. Finding that out means inspecting pg_stat_activity, pg_prepared_xacts and pg_replication_slots by hand, after the fact, when whatever held the horizon back may be long gone. Add GetXidHorizonBlocker(), which scans the ProcArray and the replication slots for whatever matches a horizon that has already been computed, and report what it finds after the removable cutoff when dead tuples were left unremovable. Classifying candidates while the horizon is still being computed would not do, because ComputeXidHorizons() folds each proc's xid and xmin into a single running minimum, losing the difference between the transaction that owns the horizon and one whose snapshot merely sits at it. Comparing the raw xid and the raw xmin separately against the final value keeps that difference, and an owner is reported in preference to a snapshot holder. Slots are reported by name whatever their type, since the name is enough to look the slot up in pg_replication_slots. Hot standby feedback is reported as such only when the walsender holds the xmin in its own PGPROC, which is the case when the standby uses no slot. The blocker is resolved just after the cutoffs are computed, because by the end of a long vacuum whatever held the cutoff back may be gone and a scan then would find nothing matching it. The line is worded in the past tense for the same reason, and the result is best-effort in any case. Author: Shinya Kato Discussion: https://postgr.es/m/CAOzEurSgy-gDtwFmEbj5+R9PL0_G3qYB6nnzJtNStyuf87VSVg@mail.gmail.com --- src/backend/access/heap/vacuumlazy.c | 43 +++ src/backend/storage/ipc/procarray.c | 307 ++++++++++++++- src/include/storage/procarray.h | 35 ++ src/test/modules/test_misc/meson.build | 1 + .../test_misc/t/016_log_vacuum_blockers.pl | 358 ++++++++++++++++++ src/tools/pgindent/typedefs.list | 2 + 6 files changed, 725 insertions(+), 21 deletions(-) create mode 100644 src/test/modules/test_misc/t/016_log_vacuum_blockers.pl diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c index 997d84a77b3..cdd4666d8d2 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" @@ -274,6 +275,8 @@ typedef struct LVRelState /* VACUUM operation's cutoffs for freezing and pruning */ struct VacuumCutoffs cutoffs; + /* What held back cutoffs.OldestXmin (invalid xid if unresolved) */ + XidHorizonBlocker oldest_xmin_blocker; GlobalVisState *vistest; /* Tracks oldest extant XID/MXID for setting relfrozenxid/relminmxid */ TransactionId NewRelfrozenXid; @@ -799,6 +802,18 @@ 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); + + /* + * For instrumented vacuums, resolve the blocker holding back OldestXmin + * now. By the time the log line is emitted, whatever held it back may be + * gone, and a scan then would find nothing matching OldestXmin. Avoiding + * that is worth taking ProcArrayLock and ReplicationSlotControlLock here, + * even though the log line may end up not mentioning the blocker. + */ + if (instrument) + (void) GetXidHorizonBlocker(rel, vacrel->cutoffs.OldestXmin, + &vacrel->oldest_xmin_blocker); + vacrel->rel_pages = orig_rel_pages = RelationGetNumberOfBlocks(rel); vacrel->vistest = GlobalVisTestFor(rel); @@ -1083,6 +1098,34 @@ 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->recently_dead_tuples > 0 && + TransactionIdIsValid(vacrel->oldest_xmin_blocker.xid)) + { + XidHorizonBlocker *blocker = &vacrel->oldest_xmin_blocker; + + switch (blocker->type) + { + case XHB_TRANSACTION: + appendStringInfo(&buf, _("removable cutoff was held back by: transaction (pid = %d)\n"), + blocker->pid); + break; + case XHB_PREPARED_TRANSACTION: + appendStringInfoString(&buf, _("removable cutoff was held back by: prepared transaction\n")); + break; + case XHB_XMIN_TRANSACTION: + appendStringInfo(&buf, _("removable cutoff was held back by: transaction holding snapshot (pid = %d)\n"), + blocker->pid); + break; + case XHB_HOT_STANDBY_FEEDBACK: + appendStringInfo(&buf, _("removable cutoff was held back by: hot standby feedback (pid = %d)\n"), + blocker->pid); + break; + case XHB_REPLICATION_SLOT: + appendStringInfo(&buf, _("removable cutoff was held back by: replication slot (slot name = %s)\n"), + blocker->name); + break; + } + } if (frozenxid_updated) { diff = (int32) (vacrel->NewRelfrozenXid - diff --git a/src/backend/storage/ipc/procarray.c b/src/backend/storage/ipc/procarray.c index b7e03134ed8..86ce53353f1 100644 --- a/src/backend/storage/ipc/procarray.c +++ b/src/backend/storage/ipc/procarray.c @@ -58,6 +58,7 @@ #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" @@ -1614,6 +1615,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 +1810,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 +2016,254 @@ GetReplicationHorizons(TransactionId *xmin, TransactionId *catalog_xmin) *catalog_xmin = horizons.slot_catalog_xmin; } +/* + * Find the candidates holding back the given xid horizon. + * + * Scans the ProcArray and the replication slots for anything whose xid or + * xmin equals the horizon, and returns the matches unranked; see + * XidHorizonBlockerType for the kinds and their priority. + * + * The horizon kind (see GlobalVisHorizonKindForRel) determines which backends + * and slot reservations are relevant. The 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 the ProcArray scan below reports it as + * XHB_HOT_STANDBY_FEEDBACK. With a slot, the walsender's PGPROC xmin is reset + * to invalid and the xmin is held in the slot, which the slot scan reports + * like any other slot. + * + * Because the horizon was computed earlier, the result is best-effort. The + * transaction whose xid was the horizon may have committed by now, leaving + * only the snapshots still at the horizon to be found, or nothing at all. + * + * Returns a palloc'd array of candidate blockers and stores the number of + * entries in *nblockers. The array may be empty if no blocker is found. + */ +static XidHorizonBlocker * +GetXidHorizonBlockers(TransactionId horizon, GlobalVisHorizonKind kind, + int *nblockers) +{ + ProcArrayStruct *arrayP = procArray; + TransactionId *other_xids = ProcGlobal->xids; + XidHorizonBlocker *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, and allocate it before acquiring ProcArrayLock so + * that the scan never allocates while holding the lock. + */ + max_blockers = arrayP->maxProcs + max_slots; + result = palloc_array(XidHorizonBlocker, 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; + XidHorizonBlocker *dst; + XidHorizonBlockerType type; + + /* Reporting the caller to itself would be no help, so skip it */ + if (proc == MyProc) + continue; + + /* + * Skip the backends ComputeXidHorizons() skips, those vacuuming or + * doing logical decoding. A decoding backend's xmin is 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. + */ + if (TransactionIdEquals(proc_xid, horizon)) + { + /* A proc with no pid is a prepared transaction */ + if (proc->pid == 0) + type = XHB_PREPARED_TRANSACTION; + else + type = XHB_TRANSACTION; + } + else if (TransactionIdEquals(proc_xmin, horizon)) + { + if (statusFlags & PROC_AFFECTS_ALL_HORIZONS) + type = XHB_HOT_STANDBY_FEEDBACK; + else + type = XHB_XMIN_TRANSACTION; + } + else + continue; + + dst = &result[count++]; + dst->type = type; + dst->pid = proc->pid; + dst->xid = horizon; + dst->name[0] = '\0'; + } + + LWLockRelease(ProcArrayLock); + + /* + * Also check replication slots. 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; + ReplicationSlotInvalidationCause invalidated; + XidHorizonBlocker *dst; + + if (!s->in_use) + continue; + + SpinLockAcquire(&s->mutex); + slot_xmin = s->effective_xmin; + slot_catalog_xmin = s->effective_catalog_xmin; + 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; + + /* + * Copy the name while we still hold ReplicationSlotControlLock. + * That is all the interlock needed, because the name of an + * existing slot never changes (see ReplicationSlotName), and it + * keeps us from having to re-find the slot by index afterwards, + * when it could have been dropped and the index reused by an + * unrelated slot. + */ + dst = &result[count++]; + dst->type = XHB_REPLICATION_SLOT; + dst->pid = 0; + dst->xid = horizon; + strlcpy(dst->name, NameStr(s->data.name), sizeof(dst->name)); + } + + LWLockRelease(ReplicationSlotControlLock); + } + + *nblockers = count; + return result; +} + +/* + * Get the highest-priority blocker holding back the xid horizon of rel. + * + * horizon must be the cutoff GetOldestNonRemovableTransactionId(rel) + * computed, since blockers are found by matching it exactly. + * + * Returns true and stores the blocker in *blocker if any are found. + * Otherwise returns false and sets blocker->xid to InvalidTransactionId. + */ +bool +GetXidHorizonBlocker(Relation rel, TransactionId horizon, + XidHorizonBlocker *blocker) +{ + XidHorizonBlocker *blockers; + XidHorizonBlocker *best = NULL; + GlobalVisHorizonKind kind; + int nblockers; + + Assert(TransactionIdIsValid(horizon)); + Assert(blocker != NULL); + + blocker->xid = InvalidTransactionId; + + 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]; + + /* + * At most one proc can have the horizon as its xid, and it + * outranks everything else, so stop once we find it. + */ + if (best->type <= XHB_PREPARED_TRANSACTION) + break; + } + } + + if (best != NULL) + *blocker = *best; + + pfree(blockers); + + return (best != NULL); +} + /* * GetMaxSnapshotXidCount -- get max size for snapshot XID array * diff --git a/src/include/storage/procarray.h b/src/include/storage/procarray.h index d718a5b542f..7464c7a3084 100644 --- a/src/include/storage/procarray.h +++ b/src/include/storage/procarray.h @@ -18,6 +18,38 @@ #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 are listed before blockers whose xmin matches, because + * the latter are merely held back by the former. + */ +typedef enum XidHorizonBlockerType +{ + /* xid-match types (horizon == proc's xid) */ + XHB_TRANSACTION, /* backend whose xid is the horizon */ + XHB_PREPARED_TRANSACTION, /* prepared (two-phase) transaction */ + /* xmin-match types (horizon == proc's xmin or slot's xmin) */ + XHB_XMIN_TRANSACTION, /* backend whose snapshot is at the horizon */ + XHB_HOT_STANDBY_FEEDBACK, /* walsender holding a standby's xmin */ + XHB_REPLICATION_SLOT, /* physical or logical replication slot */ +} XidHorizonBlockerType; + +/* + * Information about a blocker that is holding back the xid horizon. + */ +typedef struct XidHorizonBlocker +{ + XidHorizonBlockerType type; + TransactionId xid; /* the blocked horizon, which is the blocker's + * xid or xmin */ + int pid; /* backend pid (0 for prepared xacts and + * slots) */ + char name[NAMEDATALEN]; /* replication slot name; empty string for + * the other blocker types */ +} XidHorizonBlocker; + extern void ProcArrayAdd(PGPROC *proc); extern void ProcArrayRemove(PGPROC *proc, TransactionId latestXid); @@ -98,4 +130,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 5d81f5b13be..6d6662b1caa 100644 --- a/src/test/modules/test_misc/meson.build +++ b/src/test/modules/test_misc/meson.build @@ -24,6 +24,7 @@ tests += { 't/013_temp_obj_multisession.pl', 't/014_log_statement_max_length.pl', 't/015_temp_schema_exit_deferrable.pl', + 't/016_log_vacuum_blockers.pl', ], # The injection points are cluster-wide, so disable installcheck 'runningcheck': false, diff --git a/src/test/modules/test_misc/t/016_log_vacuum_blockers.pl b/src/test/modules/test_misc/t/016_log_vacuum_blockers.pl new file mode 100644 index 00000000000..afabe0ad398 --- /dev/null +++ b/src/test/modules/test_misc/t/016_log_vacuum_blockers.pl @@ -0,0 +1,358 @@ +# 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 = 1 +]); +$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 a standby's feedback xmin held on a slot is reported as that +# slot. +$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); +]); + +$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/removable cutoff was held back by: transaction holding snapshot \(pid = $blocker_pid\)/, + 'VACUUM VERBOSE reported the snapshot holder as oldest xmin blocker'); + +# Cleanup +$node->safe_psql('postgres', qq[ +SELECT pg_terminate_backend($blocker_pid); +DROP TABLE $active_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/removable cutoff was held back by: 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/removable cutoff was held back by: prepared transaction\n/, + '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/removable cutoff was held back by: replication slot \(slot name = logical_slot\)/, + 'VACUUM VERBOSE reported logical replication slot by name'); + +# 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/removable cutoff was held back by: hot standby feedback \(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; + + +# +# Physical replication slot +# +# When a standby streams through a physical slot, the feedback xmin is held +# by the slot rather than the walsender's PGPROC, and the slot is reported by +# name. +# +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. +$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'; +]); + + +# Disconnect the standby, so that the slot reserves the xmin on its own, with +# no walsender holding it. +# +# 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'); +]); + +# The slot's xmin is frozen now, so a tuple deleted with a newer xid stays +# "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/removable cutoff was held back by: replication slot \(slot name = physical_slot\)/, + 'VACUUM VERBOSE reported the slot of a disconnected standby by name'); + +# 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 656f1f60862..819eb2753a8 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -3529,6 +3529,8 @@ XactLockTableWaitInfo XidBoundsViolation XidCacheStatus XidCommitStatus +XidHorizonBlocker +XidHorizonBlockerType XidStatus XmlExpr XmlExprOp -- 2.47.3