From 801d5a9ad3b768fe8952aa0a0ab75da147b8e74b Mon Sep 17 00:00:00 2001 From: Scott Ray Date: Tue, 7 Jul 2026 13:41:15 -0700 Subject: [PATCH v1] Add pg_xmin_horizon view showing per-input horizon pins Add a pg_xmin_horizon system view with one row per horizon input. An input is a snapshot-holding backend, replication slot, prepared transaction, or slot-less hot-standby-feedback walsender. The view shows each input's raw per-class contribution (shared_xmin, catalog_xmin, data_xmin) alongside identifying columns (pid, slot_name, gid, datid, xact_start). The rows that pin nothing, which are VACUUM backends, logical decoding backends, and idle or invalidated slots, appear with NULL xmin columns. The SRF, pg_get_xmin_horizon(), reads procs in a single pass while holding ProcArrayLock and reads slots in a separate pass while holding ReplicationSlotControlLock to get the effective xmins that ReplicationSlotsComputeRequiredXmin() uses. The view is readable by pg_read_all_stats. --- doc/src/sgml/maintenance.sgml | 38 ++- doc/src/sgml/system-views.sgml | 290 +++++++++++++++++++ src/backend/catalog/system_views.sql | 23 ++ src/backend/storage/ipc/Makefile | 1 + src/backend/storage/ipc/meson.build | 1 + src/backend/storage/ipc/procarray.c | 50 +++- src/backend/storage/ipc/procarrayfuncs.c | 278 ++++++++++++++++++ src/include/catalog/pg_proc.dat | 9 + src/include/storage/procarray.h | 11 + src/test/recovery/meson.build | 1 + src/test/recovery/t/055_xmin_horizon.pl | 279 ++++++++++++++++++ src/test/regress/expected/rules.out | 16 + src/test/regress/expected/xmin_horizon.out | 117 ++++++++ src/test/regress/expected/xmin_horizon_1.out | 21 ++ src/test/regress/parallel_schedule | 2 +- src/test/regress/sql/xmin_horizon.sql | 89 ++++++ src/tools/pgindent/typedefs.list | 2 + 17 files changed, 1209 insertions(+), 19 deletions(-) create mode 100644 src/backend/storage/ipc/procarrayfuncs.c create mode 100644 src/test/recovery/t/055_xmin_horizon.pl create mode 100644 src/test/regress/expected/xmin_horizon.out create mode 100644 src/test/regress/expected/xmin_horizon_1.out create mode 100644 src/test/regress/sql/xmin_horizon.sql diff --git a/doc/src/sgml/maintenance.sgml b/doc/src/sgml/maintenance.sgml index 0eec6e2e888..819ba19fe67 100644 --- a/doc/src/sgml/maintenance.sgml +++ b/doc/src/sgml/maintenance.sgml @@ -701,25 +701,33 @@ HINT: Execute a database-wide VACUUM in that database. - Resolve old prepared transactions. You can find these by checking - pg_prepared_xacts for rows where - age(transactionid) is large. Such transactions should be - committed or rolled back. + Identify the source. Query + pg_xmin_horizon, + which reports one row per snapshot-holding backend, replication slot, + prepared transaction, or hot-standby-feedback sender with each row's + per-class xmin contribution. Sort by + age(data_xmin) or + age(catalog_xmin) to find the oldest pinner, + considering only rows whose datid is the + affected database or null; a backend connected to another database + pins only that database's data and catalog horizons (though its + shared_xmin still applies to shared + catalogs everywhere). - End long-running open transactions. You can find these by checking - pg_stat_activity for rows where - age(backend_xid) or age(backend_xmin) is - large. Such transactions should be committed or rolled back, or the session - can be terminated using pg_terminate_backend. + Resolve old prepared transactions by committing or rolling + back. - Drop any old replication slots. Use - pg_replication_slots to - find slots where age(xmin) or age(catalog_xmin) - is large. In many cases, such slots were created for replication to servers that no - longer exist, or that have been down for a long time. If you drop a slot for a server - that still exists and might still try to connect to that slot, that replica may + End long-running open transactions by committing, rolling back, + or terminating the session using + pg_terminate_backend. + + + Drop any old replication slots. In many cases, such slots were + created for replication to servers that no longer exist, or that have + been down for a long time. If you drop a slot for a server that still + exists and might still try to connect to that slot, that replica may need to be rebuilt. diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml index 5ea19d68622..584a1d38b65 100644 --- a/doc/src/sgml/system-views.sgml +++ b/doc/src/sgml/system-views.sgml @@ -246,6 +246,11 @@ wait events + + pg_xmin_horizon + sources holding back the cluster's VACUUM removal horizon + + @@ -5783,4 +5788,289 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx + + <structname>pg_xmin_horizon</structname> + + + pg_xmin_horizon + + + + The pg_xmin_horizon view has one row per backend + currently holding a snapshot or transaction ID, plus one per + replication slot, prepared transaction, and hot-standby-feedback sender, + and reports each row's raw per-class xmin + contributions to the cluster's VACUUM removal horizon. + A row whose per-class xmin columns are all + null does not currently pin the horizon. + + + + <structname>pg_xmin_horizon</structname> Columns + + + + + Column Type + + + Description + + + + + + + + kind text + + + The row's kind: backend, + replication_slot, prepared_xact, + or standby_feedback. A + standby_feedback row represents a physical-replication + sender propagating hot_standby_feedback + without a replication slot; a sender that does use a slot is reported + through that slot's replication_slot row instead. + + + + + + pid integer + + + The process ID of the backend or + standby_feedback walsender that owns this row. + Null for replication_slot and + prepared_xact rows. + + + + + + slot_name name + + + The replication slot name for replication_slot + rows. Null for all other kinds. + + + + + + gid text + + + The global transaction identifier for prepared_xact + rows, joined in from + pg_prepared_xacts. + Null for all other kinds. + + + + + + datid oid + (references pg_database.oid) + + + For backend and prepared_xact + rows, the OID of the connected database. For logical + replication_slot rows, the OID of the database + whose data is decoded. Null for other rows. + datid scopes what a row pins: a + backend or prepared_xact row's + catalog_xmin and + data_xmin hold back those horizons only + within its own database (its + shared_xmin applies cluster-wide), while + slot and standby_feedback rows pin the horizon + cluster-wide regardless of + datid, and a query filtering + by datid will exclude them. + + + + + + shared_xmin xid + + + The transaction ID this row pins for the shared horizon class. + Null if it does not pin that class. + + + + + + catalog_xmin xid + + + The transaction ID this row pins for the catalog horizon class. + Null if it does not pin that class. + + + + + + data_xmin xid + + + The transaction ID this row pins for the data horizon class. + Null if it does not pin that class. A backend running + VACUUM, or one performing logical decoding, pins none + of the three classes and shows null in all three + *_xmin columns. + + + + + + xact_start timestamptz + + + Timestamp for the source row's transaction. For + backend rows this is the transaction's start + time; for prepared_xact rows it is the time of + PREPARE TRANSACTION. Null for + replication_slot and + standby_feedback rows. + + + + +
+ + + For replication_slot rows, + shared_xmin and + catalog_xmin both report the older of the + slot's xmin and + catalog_xmin. The + data_xmin column reports the slot's + xmin alone, because catalog pinning does + not restrict data-heap pruning. The reported values are the slot's + effective xmins — the values that actually feed the horizon — + which can briefly differ from + pg_replication_slots + while a slot is being created. An invalidated slot no longer pins the + horizon and is reported with all per-class columns null. + + + + Database administrators wanting to compute the leave-one-out advancement potential + — the value a per-class horizon would take if a given row's + contribution were removed — can do so in SQL: + +WITH base AS ( + SELECT kind, pid, slot_name, gid, datid, + shared_xmin, catalog_xmin, data_xmin + FROM pg_xmin_horizon +), +class_stats AS ( + SELECT + (SELECT shared_xmin FROM base + WHERE shared_xmin IS NOT NULL + ORDER BY age(shared_xmin) DESC + LIMIT 1) AS s_gmin, + (SELECT count(*) FROM base + WHERE shared_xmin = (SELECT shared_xmin FROM base + WHERE shared_xmin IS NOT NULL + ORDER BY age(shared_xmin) DESC + LIMIT 1)) AS s_cnt, + (SELECT shared_xmin FROM base + WHERE shared_xmin IS NOT NULL + AND shared_xmin <> (SELECT shared_xmin FROM base + WHERE shared_xmin IS NOT NULL + ORDER BY age(shared_xmin) DESC + LIMIT 1) + ORDER BY age(shared_xmin) DESC + LIMIT 1) AS s_smin, + (SELECT catalog_xmin FROM base + WHERE catalog_xmin IS NOT NULL + ORDER BY age(catalog_xmin) DESC + LIMIT 1) AS c_gmin, + (SELECT count(*) FROM base + WHERE catalog_xmin = (SELECT catalog_xmin FROM base + WHERE catalog_xmin IS NOT NULL + ORDER BY age(catalog_xmin) DESC + LIMIT 1)) AS c_cnt, + (SELECT catalog_xmin FROM base + WHERE catalog_xmin IS NOT NULL + AND catalog_xmin <> (SELECT catalog_xmin FROM base + WHERE catalog_xmin IS NOT NULL + ORDER BY age(catalog_xmin) DESC + LIMIT 1) + ORDER BY age(catalog_xmin) DESC + LIMIT 1) AS c_smin, + (SELECT data_xmin FROM base + WHERE data_xmin IS NOT NULL + ORDER BY age(data_xmin) DESC + LIMIT 1) AS d_gmin, + (SELECT count(*) FROM base + WHERE data_xmin = (SELECT data_xmin FROM base + WHERE data_xmin IS NOT NULL + ORDER BY age(data_xmin) DESC + LIMIT 1)) AS d_cnt, + (SELECT data_xmin FROM base + WHERE data_xmin IS NOT NULL + AND data_xmin <> (SELECT data_xmin FROM base + WHERE data_xmin IS NOT NULL + ORDER BY age(data_xmin) DESC + LIMIT 1) + ORDER BY age(data_xmin) DESC + LIMIT 1) AS d_smin +) +SELECT + b.kind, b.pid, b.slot_name, b.gid, + CASE + WHEN b.shared_xmin IS NULL THEN NULL + WHEN b.shared_xmin <> s.s_gmin THEN s.s_gmin + WHEN s.s_cnt > 1 THEN s.s_gmin + ELSE s.s_smin + END AS next_shared_xmin, + CASE + WHEN b.catalog_xmin IS NULL THEN NULL + WHEN b.catalog_xmin <> s.c_gmin THEN s.c_gmin + WHEN s.c_cnt > 1 THEN s.c_gmin + ELSE s.c_smin + END AS next_catalog_xmin, + CASE + WHEN b.data_xmin IS NULL THEN NULL + WHEN b.data_xmin <> s.d_gmin THEN s.d_gmin + WHEN s.d_cnt > 1 THEN s.d_gmin + ELSE s.d_smin + END AS next_data_xmin +FROM base b CROSS JOIN class_stats s; + + The recipe uses age() as a wraparound-safe sort key + because xid lacks a btree ordering + (no <, no min()), and recovers + xid identity through =, which is defined. For any + class, the binding row is the one whose + class_xmin has the + greatest age(); clearing it advances the horizon to + that row's computed + next_class_xmin. + + + + + Querying pg_xmin_horizon on a hot standby raises + an error. On a standby the primary's running transactions are tracked in + KnownAssignedXids and folded into the local data + horizon by GetSnapshotData(), but they are not + surfaced as procarray entries; the view's standby-local rows alone would + misattribute the horizon. + + + + + By default, the pg_xmin_horizon view can be + read only by superusers or roles with privileges of the + pg_read_all_stats role. + +
+ diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql index 6c1c5545cb5..0985c2d2ecb 100644 --- a/src/backend/catalog/system_views.sql +++ b/src/backend/catalog/system_views.sql @@ -1127,6 +1127,29 @@ CREATE VIEW pg_replication_slots AS FROM pg_get_replication_slots() AS L LEFT JOIN pg_database D ON (L.datoid = D.oid); +CREATE VIEW pg_xmin_horizon AS + SELECT + c.kind, + c.pid, + c.slot_name, + p.gid, + c.datid, + c.shared_xmin, + c.catalog_xmin, + c.data_xmin, + CASE c.kind + WHEN 'backend' THEN a.xact_start + WHEN 'prepared_xact' THEN p.prepared + ELSE NULL + END AS xact_start + FROM pg_get_xmin_horizon() c + LEFT JOIN pg_stat_activity a ON a.pid = c.pid + LEFT JOIN pg_prepared_xacts p + ON c.kind = 'prepared_xact' AND p.transaction = c.data_xmin; + +REVOKE ALL ON pg_xmin_horizon FROM PUBLIC; +GRANT SELECT ON pg_xmin_horizon TO pg_read_all_stats; + CREATE VIEW pg_stat_replication_slots AS SELECT s.slot_name, diff --git a/src/backend/storage/ipc/Makefile b/src/backend/storage/ipc/Makefile index f71653bbe48..b6522cc12fb 100644 --- a/src/backend/storage/ipc/Makefile +++ b/src/backend/storage/ipc/Makefile @@ -18,6 +18,7 @@ OBJS = \ latch.o \ pmsignal.o \ procarray.o \ + procarrayfuncs.o \ procsignal.o \ shm_mq.o \ shm_toc.o \ diff --git a/src/backend/storage/ipc/meson.build b/src/backend/storage/ipc/meson.build index b8c31e29967..e5c83d75108 100644 --- a/src/backend/storage/ipc/meson.build +++ b/src/backend/storage/ipc/meson.build @@ -10,6 +10,7 @@ backend_sources += files( 'latch.c', 'pmsignal.c', 'procarray.c', + 'procarrayfuncs.c', 'procsignal.c', 'shm_mq.c', 'shm_toc.c', diff --git a/src/backend/storage/ipc/procarray.c b/src/backend/storage/ipc/procarray.c index 60336b31803..59947a93393 100644 --- a/src/backend/storage/ipc/procarray.c +++ b/src/backend/storage/ipc/procarray.c @@ -1765,7 +1765,8 @@ ComputeXidHorizons(ComputeXidHorizonsResult *h) /* * 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, check below). + * decoding (which manages xmin separately, check below). Keep this + * filter in sync with pg_get_xmin_horizon()'s classifier. */ if (statusFlags & (PROC_IN_VACUUM | PROC_IN_LOGICAL_DECODING)) continue; @@ -1845,7 +1846,8 @@ ComputeXidHorizons(ComputeXidHorizonsResult *h) * catalog xmin is applied to the catalog one (so catalogs can be accessed * for logical decoding). Initialize with data horizon, and then back up * further if necessary. Have to back up the shared horizon as well, since - * that also can contain catalogs. + * that also can contain catalogs. Keep how slot xmins feed each horizon + * class in sync with pg_get_xmin_horizon()'s per-slot classification. */ h->shared_oldest_nonremovable_raw = h->shared_oldest_nonremovable; h->shared_oldest_nonremovable = @@ -2258,7 +2260,8 @@ GetSnapshotData(Snapshot snapshot) /* * Skip over backends doing logical decoding which manages xmin - * separately (check below) and ones running LAZY VACUUM. + * separately (check below) and ones running LAZY VACUUM. Keep + * this filter in sync with ComputeXidHorizons(). */ statusFlags = allStatusFlags[pgxactoff]; if (statusFlags & (PROC_IN_LOGICAL_DECODING | PROC_IN_VACUUM)) @@ -3145,6 +3148,47 @@ ProcNumberGetTransactionIds(ProcNumber procNumber, TransactionId *xid, LWLockRelease(ProcArrayLock); } +/* + * GetXidHorizonProcs -- snapshot every active proc's horizon inputs + * + * Returns a palloc'd array with one entry per active proc, all captured in + * a single ProcArrayLock SHARED pass so the rows are mutually consistent. The + * number of valid entries is returned into *n. + */ +XidHorizonProc * +GetXidHorizonProcs(int *n) +{ + XidHorizonProc *result; + ProcArrayStruct *arrayP = procArray; + int count = 0; + int index; + + result = palloc_array(XidHorizonProc, arrayP->maxProcs); + + LWLockAcquire(ProcArrayLock, LW_SHARED); + + for (index = 0; index < arrayP->numProcs; index++) + { + int pgprocno = arrayP->pgprocnos[index]; + PGPROC *proc = &allProcs[pgprocno]; + XidHorizonProc *p = &result[count++]; + + p->pid = proc->pid; + p->databaseId = proc->databaseId; + p->statusFlags = ProcGlobal->statusFlags[index]; + + /* Fetch xid just once - see GetNewTransactionId */ + p->xid = UINT32_ACCESS_ONCE(ProcGlobal->xids[index]); + p->xmin = UINT32_ACCESS_ONCE(proc->xmin); + } + + LWLockRelease(ProcArrayLock); + + *n = count; + + return result; +} + /* * BackendPidGetProc -- get a backend's PGPROC given its PID * diff --git a/src/backend/storage/ipc/procarrayfuncs.c b/src/backend/storage/ipc/procarrayfuncs.c new file mode 100644 index 00000000000..b0495497c54 --- /dev/null +++ b/src/backend/storage/ipc/procarrayfuncs.c @@ -0,0 +1,278 @@ +/*------------------------------------------------------------------------- + * + * procarrayfuncs.c + * SQL-callable functions exposing ProcArray-derived diagnostics. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * + * IDENTIFICATION + * src/backend/storage/ipc/procarrayfuncs.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/transam.h" +#include "access/twophase.h" +#include "access/xlog.h" +#include "funcapi.h" +#include "miscadmin.h" +#include "replication/slot.h" +#include "storage/lwlock.h" +#include "storage/proc.h" +#include "storage/procarray.h" +#include "utils/builtins.h" +#include "utils/tuplestore.h" + +/* + * One gathered row, with the per-class xmin values and the columns that + * identify it. Fields that may be SQL NULL use a sentinel: 0 for pid, + * empty NameData for slot_name, InvalidOid for datid, and + * InvalidTransactionId for shared/catalog/data. + */ +typedef struct XidHorizonRow +{ + Datum kind; /* text Datum (never NULL) */ + int pid; /* backend / walsender PID; 0 = NULL */ + NameData slot_name; /* replication slot name; empty = NULL */ + Oid datid; /* InvalidOid = NULL */ + TransactionId shared; /* per-class xmin values, InvalidTransactionId + * = NULL */ + TransactionId catalog; + TransactionId data; +} XidHorizonRow; + +/* + * pg_get_xmin_horizon - SQL SRF reporting, per row, the raw per-class xmin + * contributions to the cluster's xmin horizons. + * + * Emits one row per active backend that holds a snapshot (kind = 'backend'), + * one per in-use replication slot (kind = 'replication_slot'), one per prepared + * (2PC) transaction (kind = 'prepared_xact'), and one per slot-less + * hot-standby-feedback walsender (kind = 'standby_feedback'). The prepared-xact + * and standby_feedback rows are not distinct procarray passes: both are returned + * among the gathered procs and relabelled here (a prepared xact by its + * dummy PGPROC's pid == 0; an HSF walsender by PROC_AFFECTS_ALL_HORIZONS). + */ +Datum +pg_get_xmin_horizon(PG_FUNCTION_ARGS) +{ +#define PG_GET_XMIN_HORIZON_COLS 7 + ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; + XidHorizonProc *procs; + int nprocs; + XidHorizonRow *rows; + int nrows; + int maxrows; + + /* + * On a hot standby, KnownAssignedXids carries the primary's running xacts + * and is folded into the standby's data horizon by GetSnapshotData() but + * is not surfaced as procarray rows. Error instead of reporting + * incomplete data, which might be mistakenly interpreted as complete. + */ + if (RecoveryInProgress()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), + errmsg("recovery is in progress"), + errhint("pg_get_xmin_horizon() cannot be executed during recovery."))); + + InitMaterializedSRF(fcinfo, 0); + + /* MaxBackends covers walsenders (standby_feedback rows) too. */ + maxrows = MaxBackends + max_prepared_xacts + + max_replication_slots + max_repack_replication_slots; + rows = palloc_array(XidHorizonRow, maxrows); + nrows = 0; + + procs = GetXidHorizonProcs(&nprocs); + + for (int i = 0; i < nprocs; i++) + { + XidHorizonProc *p = &procs[i]; + TransactionId effective_xmin; + TransactionId shared; + TransactionId catalog; + TransactionId data; + int pid; + XidHorizonRow *r; + + /* + * Within a backend xmin is set before xid is assigned, so the only + * xmin > xid window is the cross-backend read race; see + * ComputeXidHorizons(). A backend with neither set holds no snapshot + * and does not influence the horizon, so skip it. + */ + effective_xmin = TransactionIdOlder(p->xid, p->xmin); + if (!TransactionIdIsValid(effective_xmin)) + continue; + + /* + * Classify this proc's per-class xmin. VACUUM backends do not pin + * xmin; logical decoding backends pin xmin indirectly through slots. + * Emit them with NULLs so the view lists them without claiming they + * pin. Every other proc pins all three classes. VISHORIZON_TEMP is + * omitted because the temp horizon is per-backend and has no + * cross-backend diagnostic value. Keep in sync with + * ComputeXidHorizons() and GetSnapshotData(). + */ + if (p->statusFlags & (PROC_IN_VACUUM | PROC_IN_LOGICAL_DECODING)) + shared = catalog = data = InvalidTransactionId; + else + shared = catalog = data = effective_xmin; + + Assert(nrows < maxrows); + r = &rows[nrows++]; + memset(r, 0, sizeof(*r)); + r->datid = p->databaseId; + + pid = p->pid; + if (pid == 0) + { + /* + * A prepared xact's dummy PGPROC never carries an xmin (see + * MarkAsPreparingGuts()), so this row's per-class values are + * always its xid; the pg_xmin_horizon view's join to + * pg_prepared_xacts relies on that to attach the gid. + */ + r->kind = CStringGetTextDatum("prepared_xact"); + } + else if (p->statusFlags & PROC_AFFECTS_ALL_HORIZONS) + { + r->kind = CStringGetTextDatum("standby_feedback"); + r->pid = pid; + r->datid = InvalidOid; + } + else + { + r->kind = CStringGetTextDatum("backend"); + r->pid = pid; + } + + r->shared = shared; + r->catalog = catalog; + r->data = data; + } + + pfree(procs); + + /* + * Two-phase gather: ProcArrayLock has been released inside + * GetXidHorizonProcs() before we acquire ReplicationSlotControlLock here. + * Do not "fix" this by holding both locks at once. The lock-ordering + * convention in this subsystem takes ReplicationSlotControlLock without + * ProcArrayLock held; pg_get_replication_slots() and + * ReplicationSlotsComputeRequiredXmin() both follow it. Holding + * ProcArrayLock across that acquisition would establish a new + * lock-ordering and risk deadlocks. + * + * This view does not guarantee cross-phase consistency: each row reports + * a per-source xmin observed under its own lock. + */ + + /* + * Gather replication slots. Unlike a backend, which is only listed while + * it holds a snapshot, a slot is reported as soon as it exists, even when + * it currently pins no xmin (e.g. a freshly created physical slot with no + * standby attached, or an invalidated slot); such a slot appears with all + * per-class columns NULL. + */ + LWLockAcquire(ReplicationSlotControlLock, LW_SHARED); + for (int i = 0; i < max_replication_slots + max_repack_replication_slots; i++) + { + ReplicationSlot *s = &ReplicationSlotCtl->replication_slots[i]; + TransactionId xmin; + TransactionId catalog_xmin; + ReplicationSlotInvalidationCause invalidated; + XidHorizonRow *r; + + if (!s->in_use) + continue; + + /* r->kind is palloc'd here so no allocation happens under the mutex */ + Assert(nrows < maxrows); + r = &rows[nrows++]; + memset(r, 0, sizeof(*r)); + r->kind = CStringGetTextDatum("replication_slot"); + + /* + * Read the effective xmins: they are what + * ReplicationSlotsComputeRequiredXmin() aggregates into the horizon, + * and can differ from data.xmin/data.catalog_xmin while a slot is + * being created. + */ + SpinLockAcquire(&s->mutex); + xmin = s->effective_xmin; + catalog_xmin = s->effective_catalog_xmin; + invalidated = s->data.invalidated; + r->datid = s->data.database; + namestrcpy(&r->slot_name, NameStr(s->data.name)); + SpinLockRelease(&s->mutex); + + /* + * Invalidated slots are excluded from the horizon aggregate (see + * ReplicationSlotsComputeRequiredXmin()); keep the row so the slot + * stays visible, but report that it pins nothing. + */ + if (invalidated != RS_INVAL_NONE) + xmin = catalog_xmin = InvalidTransactionId; + + r->shared = TransactionIdOlder(xmin, catalog_xmin); + r->catalog = r->shared; + r->data = xmin; + } + LWLockRelease(ReplicationSlotControlLock); + + for (int i = 0; i < nrows; i++) + { + XidHorizonRow *r = &rows[i]; + Datum values[PG_GET_XMIN_HORIZON_COLS]; + bool nulls[PG_GET_XMIN_HORIZON_COLS]; + int col; + + memset(values, 0, sizeof(values)); + memset(nulls, 0, sizeof(nulls)); + col = 0; + + values[col++] = r->kind; + + if (r->pid == 0) + nulls[col++] = true; + else + values[col++] = Int32GetDatum(r->pid); + + if (r->slot_name.data[0] == '\0') + nulls[col++] = true; + else + values[col++] = NameGetDatum(&r->slot_name); + + if (r->datid == InvalidOid) + nulls[col++] = true; + else + values[col++] = ObjectIdGetDatum(r->datid); + + if (TransactionIdIsValid(r->shared)) + values[col++] = TransactionIdGetDatum(r->shared); + else + nulls[col++] = true; + + if (TransactionIdIsValid(r->catalog)) + values[col++] = TransactionIdGetDatum(r->catalog); + else + nulls[col++] = true; + + if (TransactionIdIsValid(r->data)) + values[col++] = TransactionIdGetDatum(r->data); + else + nulls[col++] = true; + + Assert(col == PG_GET_XMIN_HORIZON_COLS); + + tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, + values, nulls); + } + + return (Datum) 0; +} diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index 3cb84359adf..c5381b18163 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -11679,6 +11679,15 @@ proargmodes => '{o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}', proargnames => '{slot_name,plugin,slot_type,datoid,temporary,active,active_pid,xmin,catalog_xmin,restart_lsn,confirmed_flush_lsn,wal_status,safe_wal_size,two_phase,two_phase_at,inactive_since,conflicting,invalidation_reason,failover,synced,slotsync_skip_reason}', prosrc => 'pg_get_replication_slots' }, +{ oid => '8054', + descr => 'per-source inputs to the cluster xmin horizons', + proname => 'pg_get_xmin_horizon', prorows => '100', proisstrict => 'f', + proretset => 't', provolatile => 'v', proparallel => 'r', prorettype => 'record', + proargtypes => '', + proallargtypes => '{text,int4,name,oid,xid,xid,xid}', + proargmodes => '{o,o,o,o,o,o,o}', + proargnames => '{kind,pid,slot_name,datid,shared_xmin,catalog_xmin,data_xmin}', + prosrc => 'pg_get_xmin_horizon', proacl => '{POSTGRES=X,pg_read_all_stats=X}' }, { oid => '3786', descr => 'set up a logical replication slot', proname => 'pg_create_logical_replication_slot', provolatile => 'v', proparallel => 'u', prorettype => 'record', diff --git a/src/include/storage/procarray.h b/src/include/storage/procarray.h index d718a5b542f..8313d3cffef 100644 --- a/src/include/storage/procarray.h +++ b/src/include/storage/procarray.h @@ -65,6 +65,17 @@ extern PGPROC *ProcNumberGetProc(int procNumber); extern void ProcNumberGetTransactionIds(int procNumber, TransactionId *xid, TransactionId *xmin, int *nsubxid, bool *overflowed); + +typedef struct XidHorizonProc +{ + int pid; /* OS pid; 0 for prepared-xact dummy procs */ + Oid databaseId; + uint8 statusFlags; + TransactionId xid; + TransactionId xmin; +} XidHorizonProc; + +extern XidHorizonProc *GetXidHorizonProcs(int *n); extern PGPROC *BackendPidGetProc(int pid); extern PGPROC *BackendPidGetProcWithLock(int pid); extern int BackendXidGetPid(TransactionId xid); diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index ad0d85f4189..9391f5fd037 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -63,6 +63,7 @@ tests += { 't/052_checkpoint_segment_missing.pl', 't/053_standby_login_event_trigger.pl', 't/054_unlogged_sequence_promotion.pl', + 't/055_xmin_horizon.pl', ], }, } diff --git a/src/test/recovery/t/055_xmin_horizon.pl b/src/test/recovery/t/055_xmin_horizon.pl new file mode 100644 index 00000000000..bcfe7564250 --- /dev/null +++ b/src/test/recovery/t/055_xmin_horizon.pl @@ -0,0 +1,279 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Tests pg_xmin_horizon's replication_slot and standby_feedback rows. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node_primary = PostgreSQL::Test::Cluster->new('primary'); +$node_primary->init(allows_streaming => 'logical'); +$node_primary->start; + +$node_primary->safe_psql('postgres', + "SELECT pg_create_physical_replication_slot('xmin_horizon_phys');"); + +my $backup_name = 'xmin_horizon_backup'; +$node_primary->backup($backup_name); + +# Slotted standby drives the replication_slot row. +my $node_slotted = PostgreSQL::Test::Cluster->new('standby_slotted'); +$node_slotted->init_from_backup($node_primary, $backup_name, + has_streaming => 1); +$node_slotted->append_conf( + 'postgresql.conf', qq[ +primary_slot_name = 'xmin_horizon_phys' +hot_standby_feedback = on +wal_receiver_status_interval = 1 +]); +$node_slotted->start; + +# Slot-less standby drives the standby_feedback row. +my $node_slotless = PostgreSQL::Test::Cluster->new('standby_slotless'); +$node_slotless->init_from_backup($node_primary, $backup_name, + has_streaming => 1); +$node_slotless->append_conf( + 'postgresql.conf', qq[ +hot_standby_feedback = on +wal_receiver_status_interval = 1 +]); +$node_slotless->start; + +$node_primary->wait_for_catchup($node_slotted); +$node_primary->wait_for_catchup($node_slotless); + +# (0) pg_xmin_horizon errors during recovery. KnownAssignedXids +# is not surfaced as procarray rows, so the standby-local view alone would +# misattribute the horizon; the function errors instead. +my $stderr; +$node_slotted->psql( + 'postgres', + 'SELECT * FROM pg_xmin_horizon LIMIT 1;', + stderr => \$stderr); +ok( $stderr =~ /recovery is in progress/, + 'pg_xmin_horizon errors on a hot standby'); + +# Pin a stable xmin on each standby for hot_standby_feedback to relay. +my $psql_slotted = $node_slotted->background_psql('postgres'); +$psql_slotted->query_safe("BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1;"); + +my $psql_slotless = $node_slotless->background_psql('postgres'); +$psql_slotless->query_safe( + "BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1;"); + +# (1) replication_slot row. +$node_primary->poll_query_until( + 'postgres', + "SELECT xmin IS NOT NULL FROM pg_replication_slots WHERE slot_name = 'xmin_horizon_phys'", + 't') + or die "timed out waiting for slot 'xmin_horizon_phys' to acquire an xmin"; + +is( $node_primary->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_xmin_horizon + WHERE kind = 'replication_slot' + AND slot_name = 'xmin_horizon_phys' + AND data_xmin IS NOT NULL"), + '1', + 'replication_slot row for xmin_horizon_phys carries an xmin'); + +is( $node_primary->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_xmin_horizon + WHERE kind = 'replication_slot' + AND slot_name = 'xmin_horizon_phys' + AND shared_xmin IS NOT NULL + AND shared_xmin = catalog_xmin + AND shared_xmin = data_xmin"), + '1', + 'slot row: shared_xmin = catalog_xmin = data_xmin (non-null)'); + +is( $node_primary->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_xmin_horizon + WHERE kind = 'replication_slot' + AND slot_name = 'xmin_horizon_phys' + AND datid IS NULL + AND xact_start IS NULL"), + '1', + 'slot row has NULL datid and xact_start'); + +# (2) standby_feedback row. Exactly one row: the slot-less standby's +# walsender. The slotted standby has no own xmin (the slot carries it) and +# must NOT appear here. +$node_primary->poll_query_until( + 'postgres', + "SELECT count(*) > 0 FROM pg_xmin_horizon WHERE kind = 'standby_feedback'", + 't') or die "timed out waiting for a standby_feedback row to appear"; + +is( $node_primary->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_xmin_horizon + WHERE kind = 'standby_feedback' + AND data_xmin IS NOT NULL + AND datid IS NULL + AND xact_start IS NULL"), + '1', + 'exactly one standby_feedback row: non-null data_xmin, NULL datid, NULL xact_start' +); + +is( $node_primary->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_xmin_horizon h + JOIN pg_stat_activity a ON a.pid = h.pid + WHERE h.kind = 'standby_feedback' + AND a.backend_type = 'walsender'"), + '1', + 'standby_feedback pid is the walsender PID'); + +# (3) logical replication slot row: catalog_xmin non-null, data_xmin NULL. +$node_primary->safe_psql('postgres', + "SELECT pg_create_logical_replication_slot('xmin_horizon_log', 'test_decoding');" +); + +is( $node_primary->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_xmin_horizon + WHERE kind = 'replication_slot' + AND slot_name = 'xmin_horizon_log' + AND catalog_xmin IS NOT NULL + AND shared_xmin = catalog_xmin + AND data_xmin IS NULL"), + '1', + 'logical slot row: shared_xmin = catalog_xmin (non-null), data_xmin NULL' +); + +is( $node_primary->safe_psql( + 'postgres', + "SELECT datid = (SELECT oid FROM pg_database WHERE datname = 'postgres') + FROM pg_xmin_horizon + WHERE kind = 'replication_slot' AND slot_name = 'xmin_horizon_log'"), + 't', + 'logical slot row: datid is the decoded database'); + +$node_primary->safe_psql('postgres', + "SELECT pg_drop_replication_slot('xmin_horizon_log');"); + +# (4) per-database scoping: backend row's datid reflects its connection. +$node_primary->safe_psql('postgres', 'CREATE DATABASE xmin_horizon_db2;'); + +my $psql_db2 = $node_primary->background_psql('xmin_horizon_db2'); +$psql_db2->query_safe("BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1;"); + +$node_primary->poll_query_until( + 'postgres', + "SELECT count(*) > 0 FROM pg_xmin_horizon + WHERE kind = 'backend' + AND datid = (SELECT oid FROM pg_database WHERE datname = 'xmin_horizon_db2') + AND data_xmin IS NOT NULL", + 't') or die "timed out waiting for xmin_horizon_db2 backend xmin"; + +is( $node_primary->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_xmin_horizon + WHERE kind = 'backend' + AND datid = (SELECT oid FROM pg_database WHERE datname = 'xmin_horizon_db2') + AND data_xmin IS NOT NULL"), + '1', + 'backend in xmin_horizon_db2 appears with the right datid'); + +$psql_db2->quit; +$node_primary->safe_psql('postgres', 'DROP DATABASE xmin_horizon_db2;'); + +# (5) Tied backend rows via shared exported snapshot; tear down older-xmin +# holders first so the tied pair is the global min (count_at_min > 1 branch). +$psql_slotted->quit; +$psql_slotless->quit; +$node_slotless->stop; +$node_slotted->stop; +$node_primary->safe_psql('postgres', + "SELECT pg_drop_replication_slot('xmin_horizon_phys')"); + +my $psql_tie_a = $node_primary->background_psql('postgres'); +my $psql_tie_b = $node_primary->background_psql('postgres'); + +$psql_tie_a->query_safe("BEGIN ISOLATION LEVEL REPEATABLE READ"); +my $snap_id = $psql_tie_a->query_safe("SELECT pg_export_snapshot()"); +chomp $snap_id; +my $tie_pid_a = $psql_tie_a->query_safe("SELECT pg_backend_pid()"); +chomp $tie_pid_a; + +$psql_tie_b->query_safe("BEGIN ISOLATION LEVEL REPEATABLE READ"); +$psql_tie_b->query_safe("SET TRANSACTION SNAPSHOT '$snap_id'"); +my $tie_pid_b = $psql_tie_b->query_safe("SELECT pg_backend_pid()"); +chomp $tie_pid_b; + +$node_primary->poll_query_until( + 'postgres', + "SELECT count(*) = 2 FROM pg_xmin_horizon + WHERE kind = 'backend' AND pid IN ($tie_pid_a, $tie_pid_b) + AND data_xmin IS NOT NULL", + 't') or die "timed out waiting for tied backends' xmin"; + +is( $node_primary->safe_psql( + 'postgres', + "SELECT (SELECT data_xmin FROM pg_xmin_horizon + WHERE kind = 'backend' AND pid = $tie_pid_a) + = (SELECT data_xmin FROM pg_xmin_horizon + WHERE kind = 'backend' AND pid = $tie_pid_b)"), + 't', + 'tied backends share data_xmin'); + +$psql_tie_a->quit; +$psql_tie_b->quit; + +# (6) VACUUM backend row: PROC_IN_VACUUM is set with all per-class xmins +# NULL. Parking VACUUM on a conflicting lock holds it in that state while +# the assertion runs. +$node_primary->safe_psql('postgres', + "CREATE UNLOGGED TABLE xmin_horizon_vac_t (id int);"); + +# Locker holds SHARE on the target so VACUUM's ShareUpdateExclusiveLock +# acquisition blocks. +my $psql_locker = $node_primary->background_psql('postgres'); +$psql_locker->query_safe( + "BEGIN; LOCK TABLE xmin_horizon_vac_t IN SHARE MODE;"); + +# VACUUM session: capture pid first, then fire VACUUM via query_until so +# the main thread proceeds while VACUUM is still blocked. +my $psql_vacuum = $node_primary->background_psql('postgres'); +my $vac_pid = $psql_vacuum->query_safe("SELECT pg_backend_pid()"); +chomp $vac_pid; + +$psql_vacuum->query_until( + qr/vacuum_started/, q( +\echo vacuum_started +VACUUM xmin_horizon_vac_t; +)); + +# The VACUUM backend's row appears once its snapshot is pushed (xmin set); +# by then PROC_IN_VACUUM is already set and the proc is blocked acquiring +# SUEL. +$node_primary->poll_query_until( + 'postgres', + "SELECT count(*) = 1 FROM pg_xmin_horizon + WHERE kind = 'backend' AND pid = $vac_pid", + 't') or die "timed out waiting for VACUUM backend row"; + +is( $node_primary->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_xmin_horizon + WHERE kind = 'backend' AND pid = $vac_pid + AND shared_xmin IS NULL + AND catalog_xmin IS NULL + AND data_xmin IS NULL"), + '1', + 'VACUUM backend row: shared_xmin, catalog_xmin, data_xmin all NULL'); + +# Release the lock so VACUUM completes, then drain both sessions. +$psql_locker->query_safe("COMMIT"); +$psql_locker->quit; +$psql_vacuum->quit; + +$node_primary->safe_psql('postgres', "DROP TABLE xmin_horizon_vac_t"); + +$node_primary->stop; + +done_testing(); diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out index 39905c2de14..7291694c810 100644 --- a/src/test/regress/expected/rules.out +++ b/src/test/regress/expected/rules.out @@ -2903,6 +2903,22 @@ pg_wait_events| SELECT type, name, description FROM pg_get_wait_events() pg_get_wait_events(type, name, description); +pg_xmin_horizon| SELECT c.kind, + c.pid, + c.slot_name, + p.gid, + c.datid, + c.shared_xmin, + c.catalog_xmin, + c.data_xmin, + CASE c.kind + WHEN 'backend'::text THEN a.xact_start + WHEN 'prepared_xact'::text THEN p.prepared + ELSE NULL::timestamp with time zone + END AS xact_start + FROM ((pg_get_xmin_horizon() c(kind, pid, slot_name, datid, shared_xmin, catalog_xmin, data_xmin) + LEFT JOIN pg_stat_activity a ON ((a.pid = c.pid))) + LEFT JOIN pg_prepared_xacts p ON (((c.kind = 'prepared_xact'::text) AND (p.transaction = c.data_xmin)))); SELECT tablename, rulename, definition FROM pg_rules WHERE schemaname = 'pg_catalog' ORDER BY tablename, rulename; diff --git a/src/test/regress/expected/xmin_horizon.out b/src/test/regress/expected/xmin_horizon.out new file mode 100644 index 00000000000..b769c961201 --- /dev/null +++ b/src/test/regress/expected/xmin_horizon.out @@ -0,0 +1,117 @@ +SELECT current_setting('max_prepared_transactions')::integer < 2 AS skip_test \gset +-- ACL coverage: pg_xmin_horizon is REVOKE'd from PUBLIC and GRANT'd to +-- pg_read_all_stats. Verify both halves. +CREATE ROLE regress_pxh_unpriv; +CREATE ROLE regress_pxh_priv; +GRANT pg_read_all_stats TO regress_pxh_priv; +SET ROLE regress_pxh_unpriv; +SELECT count(*) FROM pg_xmin_horizon; +ERROR: permission denied for view pg_xmin_horizon +RESET ROLE; +SET ROLE regress_pxh_priv; +SELECT count(*) >= 0 AS readable FROM pg_xmin_horizon; + readable +---------- + t +(1 row) + +RESET ROLE; +DROP ROLE regress_pxh_unpriv, regress_pxh_priv; +\if :skip_test +\quit +\endif +-- +-- XMIN_HORIZON +-- +-- Structure probe. +SELECT * FROM pg_xmin_horizon WHERE false; + kind | pid | slot_name | gid | datid | shared_xmin | catalog_xmin | data_xmin | xact_start +------+-----+-----------+-----+-------+-------------+--------------+-----------+------------ +(0 rows) + +-- A REPEATABLE READ backend with a snapshot but no xid contributes a row with +-- all three classes equal to its backend_xmin, and xact_start joined from +-- pg_stat_activity. +BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ; +SELECT 1; + ?column? +---------- + 1 +(1 row) + +SELECT + data_xmin = catalog_xmin AND catalog_xmin = shared_xmin AS classes_equal, + data_xmin = (SELECT backend_xmin + FROM pg_stat_activity + WHERE pid = pg_backend_pid()) AS matches_backend_xmin, + xact_start = (SELECT xact_start + FROM pg_stat_activity + WHERE pid = pg_backend_pid()) AS xact_start_correct +FROM pg_xmin_horizon +WHERE kind = 'backend' + AND pid = pg_backend_pid(); + classes_equal | matches_backend_xmin | xact_start_correct +---------------+----------------------+-------------------- + t | t | t +(1 row) + +COMMIT; +-- Writing transaction with assigned xid: data_xmin = backend_xmin still holds. +BEGIN ISOLATION LEVEL REPEATABLE READ; +SELECT pg_current_xact_id() IS NOT NULL AS xid_assigned; + xid_assigned +-------------- + t +(1 row) + +SELECT + data_xmin = catalog_xmin AND catalog_xmin = shared_xmin AS classes_equal, + data_xmin = (SELECT backend_xmin + FROM pg_stat_activity + WHERE pid = pg_backend_pid()) AS matches_backend_xmin +FROM pg_xmin_horizon +WHERE kind = 'backend' + AND pid = pg_backend_pid(); + classes_equal | matches_backend_xmin +---------------+---------------------- + t | t +(1 row) + +COMMIT; +-- A prepared transaction's dummy PGPROC must be reported as kind = +-- 'prepared_xact' with a non-null data_xmin, and not leak as a backend row +-- with pid = 0. +CREATE TABLE xmin_horizon_t(i int); +BEGIN; +INSERT INTO xmin_horizon_t VALUES (1); +PREPARE TRANSACTION 'xmin_horizon_p'; +SELECT count(*) AS prepared_xact_rows +FROM pg_xmin_horizon +WHERE kind = 'prepared_xact' AND gid = 'xmin_horizon_p' + AND data_xmin IS NOT NULL; + prepared_xact_rows +-------------------- + 1 +(1 row) + +SELECT count(*) AS leaked_backend_rows +FROM pg_xmin_horizon +WHERE kind = 'backend' AND pid = 0; + leaked_backend_rows +--------------------- + 0 +(1 row) + +-- gid joined in from pg_prepared_xacts; xact_start from pg_prepared_xacts.prepared. +SELECT gid = 'xmin_horizon_p' AS gid_matches, + xact_start = (SELECT prepared FROM pg_prepared_xacts + WHERE gid = 'xmin_horizon_p') AS xact_start_matches_prepared +FROM pg_xmin_horizon +WHERE kind = 'prepared_xact' AND gid = 'xmin_horizon_p'; + gid_matches | xact_start_matches_prepared +-------------+----------------------------- + t | t +(1 row) + +ROLLBACK PREPARED 'xmin_horizon_p'; +DROP TABLE xmin_horizon_t; diff --git a/src/test/regress/expected/xmin_horizon_1.out b/src/test/regress/expected/xmin_horizon_1.out new file mode 100644 index 00000000000..4300ed41269 --- /dev/null +++ b/src/test/regress/expected/xmin_horizon_1.out @@ -0,0 +1,21 @@ +SELECT current_setting('max_prepared_transactions')::integer < 2 AS skip_test \gset +-- ACL coverage: pg_xmin_horizon is REVOKE'd from PUBLIC and GRANT'd to +-- pg_read_all_stats. Verify both halves. +CREATE ROLE regress_pxh_unpriv; +CREATE ROLE regress_pxh_priv; +GRANT pg_read_all_stats TO regress_pxh_priv; +SET ROLE regress_pxh_unpriv; +SELECT count(*) FROM pg_xmin_horizon; +ERROR: permission denied for view pg_xmin_horizon +RESET ROLE; +SET ROLE regress_pxh_priv; +SELECT count(*) >= 0 AS readable FROM pg_xmin_horizon; + readable +---------- + t +(1 row) + +RESET ROLE; +DROP ROLE regress_pxh_unpriv, regress_pxh_priv; +\if :skip_test +\quit diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule index 8fa0a6c47fb..1c16ac2d2ee 100644 --- a/src/test/regress/parallel_schedule +++ b/src/test/regress/parallel_schedule @@ -128,7 +128,7 @@ test: partition_merge partition_split partition_join partition_prune reloptions # ---------- # Another group of parallel tests (compression) # ---------- -test: compression compression_lz4 compression_pglz cluster +test: compression compression_lz4 compression_pglz cluster xmin_horizon # event_trigger depends on create_am and cannot run concurrently with # any test that runs DDL diff --git a/src/test/regress/sql/xmin_horizon.sql b/src/test/regress/sql/xmin_horizon.sql new file mode 100644 index 00000000000..b917fb0b25a --- /dev/null +++ b/src/test/regress/sql/xmin_horizon.sql @@ -0,0 +1,89 @@ +SELECT current_setting('max_prepared_transactions')::integer < 2 AS skip_test \gset + +-- ACL coverage: pg_xmin_horizon is REVOKE'd from PUBLIC and GRANT'd to +-- pg_read_all_stats. Verify both halves. +CREATE ROLE regress_pxh_unpriv; +CREATE ROLE regress_pxh_priv; +GRANT pg_read_all_stats TO regress_pxh_priv; + +SET ROLE regress_pxh_unpriv; +SELECT count(*) FROM pg_xmin_horizon; +RESET ROLE; + +SET ROLE regress_pxh_priv; +SELECT count(*) >= 0 AS readable FROM pg_xmin_horizon; +RESET ROLE; + +DROP ROLE regress_pxh_unpriv, regress_pxh_priv; + +\if :skip_test +\quit +\endif +-- +-- XMIN_HORIZON +-- + +-- Structure probe. +SELECT * FROM pg_xmin_horizon WHERE false; + +-- A REPEATABLE READ backend with a snapshot but no xid contributes a row with +-- all three classes equal to its backend_xmin, and xact_start joined from +-- pg_stat_activity. +BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ; +SELECT 1; + +SELECT + data_xmin = catalog_xmin AND catalog_xmin = shared_xmin AS classes_equal, + data_xmin = (SELECT backend_xmin + FROM pg_stat_activity + WHERE pid = pg_backend_pid()) AS matches_backend_xmin, + xact_start = (SELECT xact_start + FROM pg_stat_activity + WHERE pid = pg_backend_pid()) AS xact_start_correct +FROM pg_xmin_horizon +WHERE kind = 'backend' + AND pid = pg_backend_pid(); + +COMMIT; + +-- Writing transaction with assigned xid: data_xmin = backend_xmin still holds. +BEGIN ISOLATION LEVEL REPEATABLE READ; +SELECT pg_current_xact_id() IS NOT NULL AS xid_assigned; + +SELECT + data_xmin = catalog_xmin AND catalog_xmin = shared_xmin AS classes_equal, + data_xmin = (SELECT backend_xmin + FROM pg_stat_activity + WHERE pid = pg_backend_pid()) AS matches_backend_xmin +FROM pg_xmin_horizon +WHERE kind = 'backend' + AND pid = pg_backend_pid(); + +COMMIT; + +-- A prepared transaction's dummy PGPROC must be reported as kind = +-- 'prepared_xact' with a non-null data_xmin, and not leak as a backend row +-- with pid = 0. +CREATE TABLE xmin_horizon_t(i int); +BEGIN; +INSERT INTO xmin_horizon_t VALUES (1); +PREPARE TRANSACTION 'xmin_horizon_p'; + +SELECT count(*) AS prepared_xact_rows +FROM pg_xmin_horizon +WHERE kind = 'prepared_xact' AND gid = 'xmin_horizon_p' + AND data_xmin IS NOT NULL; + +SELECT count(*) AS leaked_backend_rows +FROM pg_xmin_horizon +WHERE kind = 'backend' AND pid = 0; + +-- gid joined in from pg_prepared_xacts; xact_start from pg_prepared_xacts.prepared. +SELECT gid = 'xmin_horizon_p' AS gid_matches, + xact_start = (SELECT prepared FROM pg_prepared_xacts + WHERE gid = 'xmin_horizon_p') AS xact_start_matches_prepared +FROM pg_xmin_horizon +WHERE kind = 'prepared_xact' AND gid = 'xmin_horizon_p'; + +ROLLBACK PREPARED 'xmin_horizon_p'; +DROP TABLE xmin_horizon_t; diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 56c1f997f88..e3b860579b6 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -3548,6 +3548,8 @@ XactLockTableWaitInfo XidBoundsViolation XidCacheStatus XidCommitStatus +XidHorizonProc +XidHorizonRow XidStatus XmlExpr XmlExprOp -- 2.50.1 (Apple Git-155)