Hi, On Fri, Aug 21, 2026 at 3:05 PM Bharath Rupireddy <[email protected]> wrote: > > Please find the attached v14 patches. 0003 now adds support for > invalidating XID-aged synced replication slots on standbys.
Hi, while the discussion on invalidating the conflict detection slot continues upthread, I made the following changes and attached the v15 patches. I fixed an issue in the 0001 patch where the xid age invalidation used a slot's persisted xmins, while it is the effective ones that hold vacuum back. The patch now uses the effective xmins while deciding whether to invalidate the slot. This covers all the cases where the persisted and effective xmins differ. For example, a slot that is still being created and holds the data xmin to export a snapshot has data.xmin invalid while the effective value is set (added a test case for this in 0001). Another is a logical or synced slot whose catalog_xmin reaches disk before the effective value advances, so the effective one still holds the previous, older xid. On the concurrent repack side, I tested what happens to a repack slot if it gets invalidated midway due to xid age. In this case, the slot is temporary and holds an effective xmin along with the catalog xmin, while its persisted xmin stays invalid. The decoding worker (i.e. owner of the slot) gets signalled and terminated the usual way, and the backend running concurrent repack then gets notified and fails with an error. Since it is a temporary slot, the repack slot also gets dropped when the worker gets terminated, so no leftover slot remains. I didn't add a test for this as it doesn't bring new coverage. Apart from the above, I self-reviewed all the patches and fixed many things. Improved code comments, commit messages, docs, ensured the GUC follows the rules prescribed in 977d865c36, moved the TAP tests into a new file (099_invalidate_xid_aged_slots.pl for now to avoid patch conflicts, will rename it to the next free number before the commit), adjusted them to be simpler, deterministic and to use fewer resources for the same coverage, ensured CI is happy, improved invalidation messages, deduplicated common code, moved the replication slot release in vacuum code after abort transaction, added variable annotations to boolean params in the call sites, added a comment on top of drop_local_obsolete_slots() on how xid-age invalidation can happen for the synced slot on the standby while the primary's slot is valid (review comment raised upthread), ran pgperltidy on TAP tests and many more. Thanks for reading this far. Please have a look at the attached v15 patches. -- Bharath Rupireddy Amazon Web Services: https://aws.amazon.com
From 56b30dcbcb81e38e0636e365086dbcc1ee85dc7c Mon Sep 17 00:00:00 2001 From: Bharath Rupireddy <[email protected]> Date: Mon, 10 Aug 2026 16:13:00 +0000 Subject: [PATCH v15 1/3] Invalidate XID-aged replication slots. An inactive or forgotten replication slot holds vacuum back from freezing XIDs and from pruning dead rows, through the xmin or catalog_xmin it retains. This can lead to table and index bloat and, left unchecked, eventually to transaction ID wraparound. Such a slot has to be dropped manually. This commit implements invalidating a replication slot once the age of its xmin or catalog_xmin is beyond a new GUC called max_slot_xid_age (default 0, which disables the feature). This invalidation check runs during checkpoints, and on a standby during restartpoints, where all the replication slots whose xmin or catalog_xmin age is beyond the GUC's value are invalidated. Invalidating a slot that is still in use terminates the process that owns it and waits for the slot to be released, as the existing invalidation causes do. Because checkpoints happen at their own interval, there can be lag between when a slot ages past the limit and when it is invalidated. A CHECKPOINT triggers it promptly. On a standby, a restartpoint happens only after a checkpoint record from the primary is replayed, so how promptly a slot is invalidated there depends on the primary's checkpoint interval. Synced slots on the standby are exempt from this invalidation. Note that they can still hold vacuum back on the primary as catalog_xmin is synced from there. An upcoming commit adds support for invalidating these slots on the standby as well. Also, an upcoming commit adds support for non-blocking invalidation of XID-aged slots during vacuum, to be precise when vacuum computes its xmin cutoffs, so that a vacuum held back by an aged slot can invalidate that slot and unblock itself, proceeding to freeze XIDs and prune dead rows without waiting for the next checkpoint. Author: Bharath Rupireddy <[email protected]> Reviewed-by: John Hsu <[email protected]> Reviewed-by: Masahiko Sawada <[email protected]> Reviewed-by: Hayato Kuroda <[email protected]> Reviewed-by: Satya Narlapuram <[email protected]> Reviewed-by: Amit Kapila <[email protected]> Reviewed-by: Bertrand Drouvot <[email protected]> Reviewed-by: Nisha Moond <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/CALj2ACW4aUe-_uFQOjdWCEN-xXoLGhmvRFnL8SNw_TZ5nJe+aw@mail.gmail.com Discussion: https://postgr.es/m/CALj2ACUmPbkcj4y4oeXvzUkBejG68QDtrFF7QHDC_qz2vQcTCg@mail.gmail.com --- doc/src/sgml/config.sgml | 60 ++++++ doc/src/sgml/logical-replication.sgml | 4 +- doc/src/sgml/maintenance.sgml | 5 +- doc/src/sgml/system-views.sgml | 8 + src/backend/access/transam/xlog.c | 8 +- src/backend/replication/slot.c | 178 +++++++++++++++++- src/backend/utils/misc/guc_parameters.dat | 9 + src/backend/utils/misc/postgresql.conf.sample | 1 + src/bin/pg_basebackup/pg_createsubscriber.c | 2 +- src/include/replication/slot.h | 5 +- src/test/recovery/meson.build | 1 + .../t/099_invalidate_xid_aged_slots.pl | 119 ++++++++++++ 12 files changed, 389 insertions(+), 11 deletions(-) create mode 100644 src/test/recovery/t/099_invalidate_xid_aged_slots.pl diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index 0165eb9ec02..a36344f369a 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -5020,6 +5020,66 @@ HINT: If it is safe for all REPLICATION users to use this library as an output </listitem> </varlistentry> + <varlistentry id="guc-max-slot-xid-age" xreflabel="max_slot_xid_age"> + <term><varname>max_slot_xid_age</varname> (<type>integer</type>) + <indexterm> + <primary><varname>max_slot_xid_age</varname> configuration parameter</primary> + </indexterm> + </term> + <listitem> + <para> + Invalidate replication slots whose <structfield>xmin</structfield> or + <structfield>catalog_xmin</structfield> transaction age in the + <link linkend="view-pg-replication-slots">pg_replication_slots</link> + view has exceeded the age specified by this setting. + A value of zero (the default) disables this feature. Users can set + this value anywhere from zero to 2.1 billion transactions. This parameter + can only be set in the <filename>postgresql.conf</filename> file or on + the server command line. + </para> + + <para> + Slot invalidation due to this limit occurs during checkpoint. Because + checkpoints happen at their own interval, there can be some lag between + when a slot's <literal>xmin</literal> or <literal>catalog_xmin</literal> + age exceeds <varname>max_slot_xid_age</varname> and when the slot + invalidation is actually triggered. To avoid such lags, users can force + a checkpoint to promptly invalidate the slot. On a standby, invalidation + happens at a restartpoint, and a restartpoint occurs only after the + standby has replayed a checkpoint record from the primary. The lag on a + standby therefore depends on the primary's checkpoint interval, and + forcing a checkpoint on the standby does not invalidate a slot until + such a record has been replayed. + </para> + + <para> + The current age of a slot's <literal>xmin</literal> and + <literal>catalog_xmin</literal> can be monitored by applying the + <function>age</function> function to the corresponding columns in the + <link linkend="view-pg-replication-slots">pg_replication_slots</link> + view. + </para> + + <para> + An inactive or forgotten replication slot holds vacuum back from + freezing XIDs and from pruning dead rows, through the + <literal>xmin</literal> or <literal>catalog_xmin</literal> it retains. + This can lead to table and index bloat and, left unchecked, eventually + to transaction ID wraparound. Such a slot has to be dropped manually. + Invalidating such a slot lets vacuum freeze XIDs and prune dead rows + again. See <xref linkend="routine-vacuuming"/> for more details. + </para> + + <para> + Note that this invalidation mechanism is not applicable for slots + on the standby server that are being synced from the primary server + (i.e., standby slots having + <link linkend="view-pg-replication-slots">pg_replication_slots</link>.<structfield>synced</structfield> + value <literal>true</literal>). + </para> + </listitem> + </varlistentry> + <varlistentry id="guc-wal-sender-timeout" xreflabel="wal_sender_timeout"> <term><varname>wal_sender_timeout</varname> (<type>integer</type>) <indexterm> diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml index 4701a3d9d18..108afd20929 100644 --- a/doc/src/sgml/logical-replication.sgml +++ b/doc/src/sgml/logical-replication.sgml @@ -2699,7 +2699,9 @@ CONTEXT: processing remote data for replication origin "pg_16395" during "INSER <para> Logical replication slots are also affected by - <link linkend="guc-idle-replication-slot-timeout"><varname>idle_replication_slot_timeout</varname></link>. + <link linkend="guc-idle-replication-slot-timeout"><varname>idle_replication_slot_timeout</varname></link> + and + <link linkend="guc-max-slot-xid-age"><varname>max_slot_xid_age</varname></link>. </para> <para> diff --git a/doc/src/sgml/maintenance.sgml b/doc/src/sgml/maintenance.sgml index 137175ca3b5..9d56294f50c 100644 --- a/doc/src/sgml/maintenance.sgml +++ b/doc/src/sgml/maintenance.sgml @@ -729,7 +729,10 @@ HINT: Execute a database-wide VACUUM in that database. 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 - need to be rebuilt.</simpara> + need to be rebuilt. Setting <xref linkend="guc-max-slot-xid-age"/> makes the + server invalidate such slots automatically once their <literal>age(xmin)</literal> + or <literal>age(catalog_xmin)</literal> exceeds the configured limit, + preventing them from holding vacuum back indefinitely.</simpara> </listitem> <listitem> <simpara>Execute <command>VACUUM</command> in the target database. A database-wide diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml index 77202e2c765..fa08bc83d29 100644 --- a/doc/src/sgml/system-views.sgml +++ b/doc/src/sgml/system-views.sgml @@ -3103,6 +3103,14 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx <xref linkend="guc-idle-replication-slot-timeout"/> duration. </para> </listitem> + <listitem> + <para> + <literal>xid_aged</literal> means that the slot's + <literal>xmin</literal> or <literal>catalog_xmin</literal> + has reached the transaction age specified by + <xref linkend="guc-max-slot-xid-age"/> parameter. + </para> + </listitem> </itemizedlist> </para></entry> </row> diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c index 9ec0be77ca0..e816239bfa5 100644 --- a/src/backend/access/transam/xlog.c +++ b/src/backend/access/transam/xlog.c @@ -7689,6 +7689,8 @@ CreateCheckPoint(int flags) VirtualTransactionId *vxids; int nvxids; int oldXLogAllowed = 0; + uint32 possible_causes = RS_INVAL_WAL_REMOVED | + RS_INVAL_IDLE_TIMEOUT | RS_INVAL_XID_AGE; /* * An end-of-recovery checkpoint is really a shutdown checkpoint, just @@ -8185,7 +8187,7 @@ CreateCheckPoint(int flags) */ XLByteToSeg(RedoRecPtr, _logSegNo, wal_segment_size); KeepLogSeg(recptr, &_logSegNo); - if (InvalidateObsoleteReplicationSlots(RS_INVAL_WAL_REMOVED | RS_INVAL_IDLE_TIMEOUT, + if (InvalidateObsoleteReplicationSlots(possible_causes, _logSegNo, InvalidOid, InvalidTransactionId)) { @@ -8486,6 +8488,8 @@ CreateRestartPoint(int flags) uint32 checksum_state; XLogRecPtr checksum_lsn; bool checksum_is_local; + uint32 possible_causes = RS_INVAL_WAL_REMOVED | + RS_INVAL_IDLE_TIMEOUT | RS_INVAL_XID_AGE; /* Concurrent checkpoint/restartpoint cannot happen */ Assert(!IsUnderPostmaster || MyBackendType == B_CHECKPOINTER); @@ -8731,7 +8735,7 @@ CreateRestartPoint(int flags) INJECTION_POINT("restartpoint-before-slot-invalidation", NULL); - if (InvalidateObsoleteReplicationSlots(RS_INVAL_WAL_REMOVED | RS_INVAL_IDLE_TIMEOUT, + if (InvalidateObsoleteReplicationSlots(possible_causes, _logSegNo, InvalidOid, InvalidTransactionId)) { diff --git a/src/backend/replication/slot.c b/src/backend/replication/slot.c index 63ce6d27885..cdf31c0f1e3 100644 --- a/src/backend/replication/slot.c +++ b/src/backend/replication/slot.c @@ -118,6 +118,7 @@ static const SlotInvalidationCauseMap SlotInvalidationCauses[] = { {RS_INVAL_HORIZON, "rows_removed"}, {RS_INVAL_WAL_LEVEL, "wal_level_insufficient"}, {RS_INVAL_IDLE_TIMEOUT, "idle_timeout"}, + {RS_INVAL_XID_AGE, "xid_aged"}, }; /* @@ -169,6 +170,12 @@ int max_repack_replication_slots = 5; /* the maximum number of slots */ int idle_replication_slot_timeout_secs = 0; +/* + * Invalidate replication slots whose xmin or catalog_xmin transaction age + * has exceeded this setting; '0' disables it. + */ +int max_slot_xid_age = 0; + /* * This GUC lists streaming replication standby server slot names that * logical WAL sender processes will wait for. @@ -1792,7 +1799,9 @@ ReportSlotInvalidation(ReplicationSlotInvalidationCause cause, XLogRecPtr restart_lsn, XLogRecPtr oldestLSN, TransactionId snapshotConflictHorizon, - long slot_idle_seconds) + long slot_idle_seconds, + TransactionId slot_xmin, + TransactionId slot_catalog_xmin) { StringInfoData err_detail; StringInfoData err_hint; @@ -1837,6 +1846,64 @@ ReportSlotInvalidation(ReplicationSlotInvalidationCause cause, "idle_replication_slot_timeout"); break; } + + case RS_INVAL_XID_AGE: + { + /* + * The ages below are computed as of now. The next XID only + * moves forward, so an age here can only be larger than the + * one that caused the invalidation, never smaller. Similar to + * the age heap_vacuum_rel() reports for its removable cutoff. + */ + TransactionId nextXid = ReadNextTransactionId(); + int32 xmin_age = TransactionIdIsValid(slot_xmin) ? + (int32) (nextXid - slot_xmin) : 0; + int32 catalog_xmin_age = TransactionIdIsValid(slot_catalog_xmin) ? + (int32) (nextXid - slot_catalog_xmin) : 0; + + /* + * The caller passes each of xmin and catalog_xmin that has + * aged past the limit, at least one of which is valid here. + */ + Assert(TransactionIdIsValid(slot_xmin) || + TransactionIdIsValid(slot_catalog_xmin)); + + if (TransactionIdIsValid(slot_xmin) && + TransactionIdIsValid(slot_catalog_xmin)) + { + /* + * Both can be set for a logical slot that holds the + * data xmin to export a snapshot, and for a physical slot + * that receives both through hot_standby_feedback, where + * the catalog_xmin comes from a synced slot, a logical + * slot created on the standby, or a physical slot + * forwarding one from a cascaded standby. + */ + + /* translator: %s is a GUC variable name */ + appendStringInfo(&err_detail, _("The slot's xmin age of %d transactions and catalog xmin age of %d transactions exceed the configured \"%s\" of %d."), + xmin_age, catalog_xmin_age, + "max_slot_xid_age", max_slot_xid_age); + } + else if (TransactionIdIsValid(slot_xmin)) + { + /* translator: %s is a GUC variable name */ + appendStringInfo(&err_detail, _("The slot's xmin age of %d transactions exceeds the configured \"%s\" of %d."), + xmin_age, "max_slot_xid_age", max_slot_xid_age); + } + else if (TransactionIdIsValid(slot_catalog_xmin)) + { + /* translator: %s is a GUC variable name */ + appendStringInfo(&err_detail, _("The slot's catalog xmin age of %d transactions exceeds the configured \"%s\" of %d."), + catalog_xmin_age, "max_slot_xid_age", max_slot_xid_age); + } + + /* translator: %s is a GUC variable name */ + appendStringInfo(&err_hint, _("You might need to increase \"%s\"."), + "max_slot_xid_age"); + break; + } + case RS_INVAL_NONE: pg_unreachable(); } @@ -1875,6 +1942,52 @@ CanInvalidateIdleSlot(ReplicationSlot *s) !(RecoveryInProgress() && s->data.synced)); } +/* + * Get the oldest xid a replication slot may retain. + * + * Returns InvalidTransactionId when the limit is disabled, in which case no + * slot is invalidated for its XID age. + */ +static TransactionId +GetSlotXidAgeLimit(void) +{ + if (max_slot_xid_age == 0) + return InvalidTransactionId; + + return TransactionIdRetreatedBy(ReadNextTransactionId(), max_slot_xid_age); +} + +/* + * Can we invalidate an XID-aged replication slot? + * + * XID age invalidation is allowed only when: + * + * 1. XID age limit is set + * 2. Slot has a valid effective xmin or effective catalog_xmin + * 3. The slot is not the conflict detection slot. Invalidating it would + * silently lose conflict detection, and nothing recreates it. + * 4. The slot is not being synced from the primary while the server is in + * recovery. Note that they can still hold vacuum back on the primary as + * catalog_xmin is synced from there. + * + * ReplicationSlotsComputeRequiredXmin() computes the oldest xmin from the + * effective values, so those are the ones that hold vacuum back. They can + * differ from the persisted ones. A slot that holds the data xmin to export + * a snapshot sets only effective_xmin (see CreateInitDecodingContext()). An + * advancing catalog xmin is written to disk before effective_catalog_xmin is + * updated, so the effective value can be the older of the two (see + * LogicalConfirmReceivedLocation()). + */ +static inline bool +CanInvalidateXidAgedSlot(ReplicationSlot *s) +{ + return (max_slot_xid_age != 0 && + (TransactionIdIsValid(s->effective_xmin) || + TransactionIdIsValid(s->effective_catalog_xmin)) && + !IsSlotForConflictCheck(NameStr(s->data.name)) && + !(RecoveryInProgress() && s->data.synced)); +} + /* * DetermineSlotInvalidationCause - Determine the cause for which a slot * becomes invalid among the given possible causes. @@ -1886,7 +1999,10 @@ static ReplicationSlotInvalidationCause DetermineSlotInvalidationCause(uint32 possible_causes, ReplicationSlot *s, XLogRecPtr oldestLSN, Oid dboid, TransactionId snapshotConflictHorizon, - TimestampTz *inactive_since, TimestampTz now) + TimestampTz *inactive_since, TimestampTz now, + TransactionId xidLimit, + TransactionId *slot_xmin, + TransactionId *slot_catalog_xmin) { Assert(possible_causes != RS_INVAL_NONE); @@ -1957,6 +2073,42 @@ DetermineSlotInvalidationCause(uint32 possible_causes, ReplicationSlot *s, } } + /* Check if the slot needs to be invalidated due to max_slot_xid_age GUC */ + if ((possible_causes & RS_INVAL_XID_AGE) && CanInvalidateXidAgedSlot(s)) + { + TransactionId effective_xmin = s->effective_xmin; + TransactionId effective_catalog_xmin = s->effective_catalog_xmin; + + Assert(TransactionIdIsValid(xidLimit)); + + /* + * If the slot has a persisted xmin, it must also have an effective + * one, so checking the effective values alone cannot miss a slot that + * holds vacuum back. The reverse does not hold, see above. + */ + Assert(!TransactionIdIsValid(s->data.xmin) || + TransactionIdIsValid(effective_xmin)); + Assert(!TransactionIdIsValid(s->data.catalog_xmin) || + TransactionIdIsValid(effective_catalog_xmin)); + + /* + * Record each of xmin and catalog_xmin that has aged past the limit, + * so the invalidation message names the xids that actually triggered + * it. Either one alone is enough to invalidate the slot. + */ + if (TransactionIdIsValid(effective_xmin) && + TransactionIdPrecedes(effective_xmin, xidLimit)) + *slot_xmin = effective_xmin; + + if (TransactionIdIsValid(effective_catalog_xmin) && + TransactionIdPrecedes(effective_catalog_xmin, xidLimit)) + *slot_catalog_xmin = effective_catalog_xmin; + + if (TransactionIdIsValid(*slot_xmin) || + TransactionIdIsValid(*slot_catalog_xmin)) + return RS_INVAL_XID_AGE; + } + return RS_INVAL_NONE; } @@ -1979,6 +2131,7 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes, ReplicationSlot *s, XLogRecPtr oldestLSN, Oid dboid, TransactionId snapshotConflictHorizon, + TransactionId xidLimit, bool *released_lock_out) { int last_signaled_pid = 0; @@ -1995,6 +2148,8 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes, ReplicationSlotInvalidationCause invalidation_cause = RS_INVAL_NONE; TimestampTz now = 0; long slot_idle_secs = 0; + TransactionId slot_xmin = InvalidTransactionId; + TransactionId slot_catalog_xmin = InvalidTransactionId; Assert(LWLockHeldByMeInMode(ReplicationSlotControlLock, LW_SHARED)); @@ -2032,7 +2187,10 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes, dboid, snapshotConflictHorizon, &inactive_since, - now); + now, + xidLimit, + &slot_xmin, + &slot_catalog_xmin); /* if there's no invalidation, we're done */ if (invalidation_cause == RS_INVAL_NONE) @@ -2124,7 +2282,8 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes, ReportSlotInvalidation(invalidation_cause, true, active_pid, slotname, restart_lsn, oldestLSN, snapshotConflictHorizon, - slot_idle_secs); + slot_idle_secs, + slot_xmin, slot_catalog_xmin); if (MyBackendType == B_STARTUP) (void) SignalRecoveryConflict(GetPGProcByNumber(active_proc), @@ -2177,7 +2336,8 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes, ReportSlotInvalidation(invalidation_cause, false, active_pid, slotname, restart_lsn, oldestLSN, snapshotConflictHorizon, - slot_idle_secs); + slot_idle_secs, + slot_xmin, slot_catalog_xmin); /* done with this slot for now */ break; @@ -2204,6 +2364,8 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes, * logical. * - RS_INVAL_IDLE_TIMEOUT: has been idle longer than the configured * "idle_replication_slot_timeout" duration. + * - RS_INVAL_XID_AGE: has an xmin or catalog_xmin whose age exceeds the + * configured "max_slot_xid_age". * * Note: This function attempts to invalidate the slot for multiple possible * causes in a single pass, minimizing redundant iterations. The "cause" @@ -2220,6 +2382,7 @@ InvalidateObsoleteReplicationSlots(uint32 possible_causes, TransactionId snapshotConflictHorizon) { XLogRecPtr oldestLSN; + TransactionId xidLimit = InvalidTransactionId; bool invalidated = false; bool invalidated_logical = false; bool found_valid_logicalslot; @@ -2233,6 +2396,10 @@ InvalidateObsoleteReplicationSlots(uint32 possible_causes, XLogSegNoOffsetToRecPtr(oldestSegno, 0, wal_segment_size, oldestLSN); + /* Compute the XID age limit if requested */ + if (possible_causes & RS_INVAL_XID_AGE) + xidLimit = GetSlotXidAgeLimit(); + restart: found_valid_logicalslot = false; LWLockAcquire(ReplicationSlotControlLock, LW_SHARED); @@ -2256,6 +2423,7 @@ restart: if (InvalidatePossiblyObsoleteSlot(possible_causes, s, oldestLSN, dboid, snapshotConflictHorizon, + xidLimit, &released_lock)) { Assert(released_lock); diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index c57441f7d98..95046975d51 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -2150,6 +2150,15 @@ max => 'MAX_KILOBYTES', }, +{ name => 'max_slot_xid_age', type => 'int', context => 'PGC_SIGHUP', group => 'REPLICATION_SENDING', + short_desc => 'Sets the maximum transaction age of a replication slot\'s xmin or catalog_xmin before it is invalidated.', + long_desc => '0 disables invalidation based on transaction age.', + variable => 'max_slot_xid_age', + boot_val => '0', + min => '0', + max => '2100000000', +}, + # We use the hopefully-safely-small value of 100kB as the compiled-in # default for max_stack_depth. InitializeGUCOptions will increase it # if possible, depending on the actual platform-specific stack limit. diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample index e759f06b50f..5818603debd 100644 --- a/src/backend/utils/misc/postgresql.conf.sample +++ b/src/backend/utils/misc/postgresql.conf.sample @@ -362,6 +362,7 @@ #wal_keep_size = 0 # in megabytes; 0 disables #max_slot_wal_keep_size = -1 # in megabytes; -1 disables #idle_replication_slot_timeout = 0 # in seconds; 0 disables +#max_slot_xid_age = 0 # in transaction age; 0 disables #wal_sender_timeout = 60s # in milliseconds; 0 disables #wal_sender_shutdown_timeout = -1 # in milliseconds # -1 disables the timeout and waits for catch-up diff --git a/src/bin/pg_basebackup/pg_createsubscriber.c b/src/bin/pg_basebackup/pg_createsubscriber.c index 20b354aed56..3271d2b51af 100644 --- a/src/bin/pg_basebackup/pg_createsubscriber.c +++ b/src/bin/pg_basebackup/pg_createsubscriber.c @@ -1681,7 +1681,7 @@ start_standby_server(const struct CreateSubscriberOptions *opt, bool restricted_ appendPQExpBufferStr(pg_ctl_cmd, " -s -o \"-c sync_replication_slots=off\""); /* Prevent unintended slot invalidation */ - appendPQExpBufferStr(pg_ctl_cmd, " -o \"-c idle_replication_slot_timeout=0\""); + appendPQExpBufferStr(pg_ctl_cmd, " -o \"-c idle_replication_slot_timeout=0 -c max_slot_xid_age=0\""); if (restricted_access) { diff --git a/src/include/replication/slot.h b/src/include/replication/slot.h index 9b29444cbca..ab264c8c09a 100644 --- a/src/include/replication/slot.h +++ b/src/include/replication/slot.h @@ -66,10 +66,12 @@ typedef enum ReplicationSlotInvalidationCause RS_INVAL_WAL_LEVEL = (1 << 2), /* idle slot timeout has occurred */ RS_INVAL_IDLE_TIMEOUT = (1 << 3), + /* slot's xmin or catalog_xmin age exceeds the limit */ + RS_INVAL_XID_AGE = (1 << 4), } ReplicationSlotInvalidationCause; /* Maximum number of invalidation causes */ -#define RS_INVAL_MAX_CAUSES 4 +#define RS_INVAL_MAX_CAUSES 5 /* * When the slot synchronization worker is running, or when @@ -327,6 +329,7 @@ extern PGDLLIMPORT int max_replication_slots; extern PGDLLIMPORT int max_repack_replication_slots; extern PGDLLIMPORT char *synchronized_standby_slots; extern PGDLLIMPORT int idle_replication_slot_timeout_secs; +extern PGDLLIMPORT int max_slot_xid_age; /* management of individual slots */ extern void ReplicationSlotCreate(const char *name, bool db_specific, diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index 72113c5ac6e..c739fdad04f 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -65,6 +65,7 @@ tests += { 't/054_unlogged_sequence_promotion.pl', 't/055_cascade_reconnect.pl', 't/056_standby_snapshot_export.pl', + 't/099_invalidate_xid_aged_slots.pl', ], }, } diff --git a/src/test/recovery/t/099_invalidate_xid_aged_slots.pl b/src/test/recovery/t/099_invalidate_xid_aged_slots.pl new file mode 100644 index 00000000000..5459f8a4cee --- /dev/null +++ b/src/test/recovery/t/099_invalidate_xid_aged_slots.pl @@ -0,0 +1,119 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Test for replication slots invalidation due to XID-age + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Utils; +use PostgreSQL::Test::Cluster; +use Test::More; + +# Wait for the given slot to satisfy the given condition +sub wait_for_slot +{ + my ($node, $slot_name, $cond) = @_; + $node->poll_query_until('postgres', + "SELECT $cond FROM pg_replication_slots WHERE slot_name = '$slot_name'") + or die "Timed out waiting for slot $slot_name: $cond"; +} + +# A small age lets slots reach the limit after just a few XIDs +my $slot_xid_age = 100; + +# Consumes XIDs, one per committed transaction, to age a slot's xmin or +# catalog_xmin. +my $consume_xid_proc = qq{ + CREATE PROCEDURE consume_xid(cnt int) + AS \$\$ + DECLARE + i int; + BEGIN + FOR i IN 1..cnt LOOP + EXECUTE 'SELECT pg_current_xact_id()'; + COMMIT; + END LOOP; + END; + \$\$ LANGUAGE plpgsql; +}; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 'logical'); + +# No checkpoints and no autovacuum, so that a slot is invalidated only where a +# testcase asks for it. +$primary->append_conf( + 'postgresql.conf', qq{ +max_slot_xid_age = $slot_xid_age +autovacuum = off +checkpoint_timeout = 1h +}); +$primary->start; +$primary->safe_psql('postgres', $consume_xid_proc); + +# Testcase 1: an inactive logical slot with an aged catalog_xmin is invalidated +# at a checkpoint. +$primary->safe_psql('postgres', + "SELECT pg_create_logical_replication_slot('logical_slot', 'pgoutput')"); + +# Note the log position before the slot ages, so no checkpoint can beat us to it +my $log_offset = -s $primary->logfile; +$primary->safe_psql('postgres', qq{CALL consume_xid(2 * $slot_xid_age)}); +$primary->safe_psql('postgres', "CHECKPOINT"); +wait_for_slot($primary, 'logical_slot', "invalidation_reason = 'xid_aged'"); + +# The slot holds a catalog_xmin alone, so only its age is reported +ok( $primary->log_contains( + qr/invalidating obsolete replication slot "logical_slot"\n.*DETAIL:.*The slot's catalog xmin age of \d+ transactions exceeds the configured "max_slot_xid_age" of $slot_xid_age\./, + $log_offset), + 'aged catalog_xmin is reported on invalidation'); + +$primary->safe_psql('postgres', + "SELECT pg_drop_replication_slot('logical_slot')"); + +# Testcase 2: a slot still being created holds an in-memory effective_xmin that +# is never written to disk. Such a slot shows no xmin in pg_replication_slots, +# but its age still counts. +my $running_xact = $primary->background_psql('postgres'); +$running_xact->query_safe('BEGIN; SELECT pg_current_xact_id();'); + +# The open transaction keeps this slot from reaching a consistent point, so it +# stays in creation and keeps holding its xmin. +my $export = $primary->background_psql('postgres', replication => 'database'); +$export->query_until( + qr/create_started/, q( +\echo create_started +CREATE_REPLICATION_SLOT logical_export_slot LOGICAL pgoutput (SNAPSHOT 'export'); +)); +wait_for_slot($primary, 'logical_export_slot', 'catalog_xmin IS NOT NULL'); + +is( $primary->safe_psql('postgres', + "SELECT xmin IS NULL FROM pg_replication_slots WHERE slot_name = 'logical_export_slot'" + ), + 't', + 'slot holding an effective xmin reports no xmin'); + +$log_offset = -s $primary->logfile; +$primary->safe_psql('postgres', qq{CALL consume_xid(2 * $slot_xid_age)}); + +# The slot is in use, so invalidation terminates its owner to release it +$primary->safe_psql('postgres', "CHECKPOINT"); + +# The slot holds both an xmin and a catalog_xmin, both aged, so the message +# reports both ages. +ok( $primary->log_contains( + qr/terminating process \d+ to release replication slot "logical_export_slot"\n.*DETAIL:.*The slot's xmin age of \d+ transactions and catalog xmin age of \d+ transactions exceed the configured "max_slot_xid_age" of $slot_xid_age\./, + $log_offset), + 'aged slot holding an effective xmin has its owner terminated'); + +# A slot still in creation is dropped, not invalidated, once its owner is gone +wait_for_slot($primary, 'logical_export_slot', 'count(*) = 0'); + +$running_xact->quit; + +# The terminated backend took its psql down too, so just reap the process +$export->{run}->finish; + +$primary->stop; + +done_testing();
From 2739ebc8aeca2cea18c4b84ba1e12653e7a182e6 Mon Sep 17 00:00:00 2001 From: Bharath Rupireddy <[email protected]> Date: Wed, 23 Sep 2026 17:18:35 +0000 Subject: [PATCH v15 2/3] Allow vacuum to invalidate XID-aged replication slots. Commit XXX added support for invalidating a replication slot once the age of its xmin or catalog_xmin is beyond the max_slot_xid_age GUC. That check runs during checkpoints, and on a standby during restartpoints. Because checkpoints happen at their own interval, it doesn't always help when vacuum needs it the most, that is when such a slot is what holds vacuum back from freezing XIDs and from pruning dead rows. This commit implements non-blocking invalidation of XID-aged slots during vacuum, to be precise when vacuum computes its xmin cutoffs, so that a vacuum held back by an aged slot can invalidate that slot and unblock itself, proceeding to freeze XIDs and prune dead rows without waiting for the next checkpoint. The cutoffs are recomputed once a slot is invalidated, so the vacuum in progress is the one that benefits. This applies to both the VACUUM command and autovacuum (but not to VACUUM FULL or REPACK). The check runs per relation, and only when a replication slot is what holds that relation's oldest xmin back and has aged past the limit, so the extra work happens only where invalidating the slot can actually let vacuum freeze more XIDs and remove more rows. It reuses the cutoff computation vacuum already does for the relation, which now reports the oldest slot xmin and catalog_xmin alongside the oldest xmin, so no additional proc array scan is needed per relation. A logical slot holds vacuum back only on system catalogs, through its catalog_xmin, so vacuuming a user table does not invalidate it; such a slot is invalidated when a system catalog is vacuumed, or at a checkpoint. A physical slot holds back the removal of rows in both user tables and system catalogs, through its xmin, and so can be invalidated by vacuuming any table. Vacuum never blocks on this. It invalidates only the aged slots it can acquire immediately, and leaves any slot that is still in use to the next checkpoint, where the invalidation does terminate the process that owns the slot and wait for the slot to be released. This keeps vacuum simple to reason about and avoids many autovacuum workers and backends piling up on one slot waiting for a slow walsender. A slot that vacuum skips this way is invalidated at that later checkpoint, and relations vacuumed after that pick up the advanced cutoffs. Author: Bharath Rupireddy <[email protected]> Reviewed-by: John Hsu <[email protected]> Reviewed-by: Masahiko Sawada <[email protected]> Reviewed-by: Hayato Kuroda <[email protected]> Reviewed-by: Satya Narlapuram <[email protected]> Reviewed-by: Amit Kapila <[email protected]> Reviewed-by: Bertrand Drouvot <[email protected]> Reviewed-by: Nisha Moond <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/CALj2ACW4aUe-_uFQOjdWCEN-xXoLGhmvRFnL8SNw_TZ5nJe+aw@mail.gmail.com Discussion: https://postgr.es/m/CALj2ACUmPbkcj4y4oeXvzUkBejG68QDtrFF7QHDC_qz2vQcTCg@mail.gmail.com --- doc/src/sgml/config.sgml | 42 ++++-- src/backend/access/heap/vacuumlazy.c | 16 +++ src/backend/access/transam/xlog.c | 12 +- src/backend/commands/vacuum.c | 6 +- src/backend/postmaster/autovacuum.c | 11 ++ src/backend/replication/slot.c | 102 +++++++++++++- src/backend/storage/ipc/procarray.c | 64 +++++++-- src/backend/storage/ipc/standby.c | 4 +- src/include/commands/vacuum.h | 13 ++ src/include/replication/slot.h | 8 +- src/include/storage/procarray.h | 4 + .../t/099_invalidate_xid_aged_slots.pl | 125 +++++++++++++++++- 12 files changed, 370 insertions(+), 37 deletions(-) diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index a36344f369a..2b827c06b2b 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -5039,17 +5039,37 @@ HINT: If it is safe for all REPLICATION users to use this library as an output </para> <para> - Slot invalidation due to this limit occurs during checkpoint. Because - checkpoints happen at their own interval, there can be some lag between - when a slot's <literal>xmin</literal> or <literal>catalog_xmin</literal> - age exceeds <varname>max_slot_xid_age</varname> and when the slot - invalidation is actually triggered. To avoid such lags, users can force - a checkpoint to promptly invalidate the slot. On a standby, invalidation - happens at a restartpoint, and a restartpoint occurs only after the - standby has replayed a checkpoint record from the primary. The lag on a - standby therefore depends on the primary's checkpoint interval, and - forcing a checkpoint on the standby does not invalidate a slot until - such a record has been replayed. + Slot invalidation due to XID age occurs during vacuum (both the + <command>VACUUM</command> command and autovacuum, but not + <command>VACUUM FULL</command> or <command>REPACK</command>) and + during checkpoint. During vacuum, only the slots that can be acquired + immediately are invalidated, so that vacuum never blocks; a slot that + is still in use is left for the next checkpoint, where the + invalidation terminates the process that owns the slot and waits for + the slot to be released. Because vacuum and checkpoints happen at + their own intervals, there can be some lag between when a slot's + <literal>xmin</literal> or <literal>catalog_xmin</literal> age exceeds + <varname>max_slot_xid_age</varname> and when the slot invalidation is + actually triggered. To avoid such lags, users can force a checkpoint + to promptly invalidate the slot. On a standby, invalidation happens at + a restartpoint, and a restartpoint occurs only after the standby has + replayed a checkpoint record from the primary. The lag on a standby + therefore depends on the primary's checkpoint interval, and forcing a + checkpoint on the standby does not invalidate a slot until such a + record has been replayed. + </para> + + <para> + During vacuum, a slot is invalidated only when it is holding vacuum + of the current relation back. A logical replication slot holds back + only the removal of system catalog rows (through its + <literal>catalog_xmin</literal>), so vacuuming a user table does + not invalidate it, even when its age has exceeded + <varname>max_slot_xid_age</varname>; such a slot is invalidated when a + system catalog is vacuumed or at the next checkpoint. A physical + replication slot holds back the removal of rows in both user tables + and system catalogs (through its <literal>xmin</literal>), and so can + be invalidated by vacuuming any table. </para> <para> diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c index 8e1f660bc2f..1e081a4f34d 100644 --- a/src/backend/access/heap/vacuumlazy.c +++ b/src/backend/access/heap/vacuumlazy.c @@ -147,6 +147,7 @@ #include "pgstat.h" #include "portability/instr_time.h" #include "postmaster/autovacuum.h" +#include "replication/slot.h" #include "storage/bufmgr.h" #include "storage/freespace.h" #include "storage/latch.h" @@ -799,6 +800,21 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params, * to increase the number of dead tuples it can prune away.) */ vacrel->aggressive = vacuum_get_cutoffs(rel, params, &vacrel->cutoffs); + + /* + * If a replication slot whose XID age exceeds the limit is holding the + * vacuum cutoff back, invalidate it and recompute the cutoffs. + */ + if (InvalidateXidAgedReplicationSlots(vacrel->cutoffs.OldestXmin, + vacrel->cutoffs.SlotXmin, + vacrel->cutoffs.SlotCatalogXmin, + vacrel->cutoffs.SlotCatalogXminRelevant)) + { + /* Some slots have been invalidated; re-compute the vacuum cutoffs */ + vacrel->aggressive = vacuum_get_cutoffs(rel, params, + &vacrel->cutoffs); + } + vacrel->rel_pages = orig_rel_pages = RelationGetNumberOfBlocks(rel); vacrel->vistest = GlobalVisTestFor(rel); diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c index e816239bfa5..ad4a8d6ebbb 100644 --- a/src/backend/access/transam/xlog.c +++ b/src/backend/access/transam/xlog.c @@ -8189,7 +8189,9 @@ CreateCheckPoint(int flags) KeepLogSeg(recptr, &_logSegNo); if (InvalidateObsoleteReplicationSlots(possible_causes, _logSegNo, InvalidOid, - InvalidTransactionId)) + InvalidTransactionId, + false, /* nowait */ + true)) /* check_catalog_xmin */ { /* * Some slots have been invalidated; recalculate the old-segment @@ -8737,7 +8739,9 @@ CreateRestartPoint(int flags) if (InvalidateObsoleteReplicationSlots(possible_causes, _logSegNo, InvalidOid, - InvalidTransactionId)) + InvalidTransactionId, + false, /* nowait */ + true)) /* check_catalog_xmin */ { /* * Some slots have been invalidated; recalculate the old-segment @@ -9670,7 +9674,9 @@ xlog_redo(XLogReaderState *record) */ InvalidateObsoleteReplicationSlots(RS_INVAL_WAL_LEVEL, 0, InvalidOid, - InvalidTransactionId); + InvalidTransactionId, + false, /* nowait */ + true); /* check_catalog_xmin */ } else if (sync_replication_slots) { diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c index d8c2f33c615..c3066a351fc 100644 --- a/src/backend/commands/vacuum.c +++ b/src/backend/commands/vacuum.c @@ -1159,7 +1159,11 @@ vacuum_get_cutoffs(Relation rel, const VacuumParams *params, * that only one vacuum process can be working on a particular table at * any time, and that each vacuum is always an independent transaction. */ - cutoffs->OldestXmin = GetOldestNonRemovableTransactionId(rel); + cutoffs->OldestXmin = + GetOldestNonRemovableTransactionIdAndSlotXmins(rel, + &cutoffs->SlotXmin, + &cutoffs->SlotCatalogXmin, + &cutoffs->SlotCatalogXminRelevant); Assert(TransactionIdIsNormal(cutoffs->OldestXmin)); diff --git a/src/backend/postmaster/autovacuum.c b/src/backend/postmaster/autovacuum.c index 60ebe828900..f1abc28d936 100644 --- a/src/backend/postmaster/autovacuum.c +++ b/src/backend/postmaster/autovacuum.c @@ -89,6 +89,7 @@ #include "postmaster/autovacuum.h" #include "postmaster/interrupt.h" #include "postmaster/postmaster.h" +#include "replication/slot.h" #include "storage/aio_subsys.h" #include "storage/bufmgr.h" #include "storage/ipc.h" @@ -2559,6 +2560,16 @@ do_autovacuum(void) /* this resets ProcGlobal->statusFlags[i] too */ AbortOutOfAnyTransaction(); + + /* + * This worker may still hold a replication slot, from an error + * thrown while invalidating an XID-aged slot during vacuum. The + * transaction abort above does not release it, so release it here + * before moving on to the next table. + */ + if (MyReplicationSlot != NULL) + ReplicationSlotRelease(); + FlushErrorState(); MemoryContextReset(PortalContext); diff --git a/src/backend/replication/slot.c b/src/backend/replication/slot.c index cdf31c0f1e3..88a3f65ee6c 100644 --- a/src/backend/replication/slot.c +++ b/src/backend/replication/slot.c @@ -2002,7 +2002,8 @@ DetermineSlotInvalidationCause(uint32 possible_causes, ReplicationSlot *s, TimestampTz *inactive_since, TimestampTz now, TransactionId xidLimit, TransactionId *slot_xmin, - TransactionId *slot_catalog_xmin) + TransactionId *slot_catalog_xmin, + bool check_catalog_xmin) { Assert(possible_causes != RS_INVAL_NONE); @@ -2095,12 +2096,16 @@ DetermineSlotInvalidationCause(uint32 possible_causes, ReplicationSlot *s, * Record each of xmin and catalog_xmin that has aged past the limit, * so the invalidation message names the xids that actually triggered * it. Either one alone is enough to invalidate the slot. + * + * catalog_xmin is considered only when the caller asks for it; see + * InvalidateXidAgedReplicationSlots() for when vacuum leaves it out. */ if (TransactionIdIsValid(effective_xmin) && TransactionIdPrecedes(effective_xmin, xidLimit)) *slot_xmin = effective_xmin; - if (TransactionIdIsValid(effective_catalog_xmin) && + if (check_catalog_xmin && + TransactionIdIsValid(effective_catalog_xmin) && TransactionIdPrecedes(effective_catalog_xmin, xidLimit)) *slot_catalog_xmin = effective_catalog_xmin; @@ -2132,6 +2137,8 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes, XLogRecPtr oldestLSN, Oid dboid, TransactionId snapshotConflictHorizon, TransactionId xidLimit, + bool nowait, + bool check_catalog_xmin, bool *released_lock_out) { int last_signaled_pid = 0; @@ -2190,7 +2197,8 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes, now, xidLimit, &slot_xmin, - &slot_catalog_xmin); + &slot_catalog_xmin, + check_catalog_xmin); /* if there's no invalidation, we're done */ if (invalidation_cause == RS_INVAL_NONE) @@ -2255,6 +2263,10 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes, if (active_proc != INVALID_PROC_NUMBER) { + /* A nowait caller leaves an active slot untouched. */ + if (nowait) + break; + /* * Prepare the sleep on the slot's condition variable before * releasing the lock, to close a possible race condition if the @@ -2371,6 +2383,14 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes, * causes in a single pass, minimizing redundant iterations. The "cause" * parameter can be a MASK representing one or more of the defined causes. * + * If "nowait" is true, a slot that is still in use is skipped instead of + * terminating the process that owns it and waiting for the slot to be + * released. Vacuum uses this for XID-age invalidation so that it never + * blocks; a slot skipped that way is left for the next checkpoint, which + * does wait. + * + * "check_catalog_xmin" applies only to XID-age invalidation. + * * If it invalidates the last logical slot in the cluster, it requests to * disable logical decoding. * @@ -2379,7 +2399,9 @@ InvalidatePossiblyObsoleteSlot(uint32 possible_causes, bool InvalidateObsoleteReplicationSlots(uint32 possible_causes, XLogSegNo oldestSegno, Oid dboid, - TransactionId snapshotConflictHorizon) + TransactionId snapshotConflictHorizon, + bool nowait, + bool check_catalog_xmin) { XLogRecPtr oldestLSN; TransactionId xidLimit = InvalidTransactionId; @@ -2423,7 +2445,7 @@ restart: if (InvalidatePossiblyObsoleteSlot(possible_causes, s, oldestLSN, dboid, snapshotConflictHorizon, - xidLimit, + xidLimit, nowait, check_catalog_xmin, &released_lock)) { Assert(released_lock); @@ -2478,6 +2500,76 @@ restart: return invalidated; } +/* + * Invalidate replication slots whose XID age exceeds the limit. + * + * The caller passes the vacuum cutoff computed for the relation, plus the + * oldest xmin and catalog_xmin of any replication slot and whether that + * catalog_xmin is relevant for the relation, all as reported by + * GetOldestNonRemovableTransactionIdAndSlotXmins(). If a replication slot is + * not what holds that cutoff back, or the cutoff has not yet aged past the + * limit, there is nothing to do. + * + * slot_catalog_xmin_relevant tells whether a slot's catalog_xmin can hold this + * relation's cutoff back, which is true for catalog and shared relations, + * whose cutoff is computed from both the slot xmin and catalog_xmin. When it + * is false, a slot holding only a catalog_xmin cannot be blocking this vacuum, + * so such slots are neither considered here nor invalidated: even if one is + * aged, invalidating it would not advance the cutoff, and the slot may yet + * advance on its own before a catalog vacuum or a checkpoint acts on it. + * + * Returns true if at least one slot was invalidated. + */ +bool +InvalidateXidAgedReplicationSlots(TransactionId oldest_xmin, + TransactionId slot_xmin, + TransactionId slot_catalog_xmin, + bool slot_catalog_xmin_relevant) +{ + TransactionId xid_limit; + bool slot_holds_oldest_xmin; + + Assert(TransactionIdIsNormal(oldest_xmin)); + + /* + * Check if a replication slot's xmin, or its catalog_xmin when that is + * relevant for this relation, is what's holding the oldest xmin back. If + * not, skip the unnecessary work. + */ + slot_holds_oldest_xmin = + (TransactionIdIsValid(slot_xmin) && + TransactionIdEquals(oldest_xmin, slot_xmin)) || + (slot_catalog_xmin_relevant && + TransactionIdIsValid(slot_catalog_xmin) && + TransactionIdEquals(oldest_xmin, slot_catalog_xmin)); + + if (!slot_holds_oldest_xmin) + return false; + + /* Nothing to do if the age limit is disabled */ + xid_limit = GetSlotXidAgeLimit(); + if (!TransactionIdIsValid(xid_limit)) + return false; + + /* + * A replication slot holds the oldest xmin back, so invalidate any slot + * that has aged past the limit. + * + * Vacuum never blocks on this. It invalidates only the slots it can + * acquire immediately and leaves any slot still in use to the next + * checkpoint, so that autovacuum workers and backends do not pile up + * waiting on one slot. + */ + if (TransactionIdPrecedes(oldest_xmin, xid_limit)) + return InvalidateObsoleteReplicationSlots(RS_INVAL_XID_AGE, + 0, InvalidOid, + InvalidTransactionId, + true, /* nowait */ + slot_catalog_xmin_relevant); + + return false; +} + /* * Flush all replication slots to disk. * diff --git a/src/backend/storage/ipc/procarray.c b/src/backend/storage/ipc/procarray.c index b7e03134ed8..26afbd763a4 100644 --- a/src/backend/storage/ipc/procarray.c +++ b/src/backend/storage/ipc/procarray.c @@ -1929,6 +1929,30 @@ GlobalVisHorizonKindForRel(Relation rel) return VISHORIZON_TEMP; } +/* + * Return the oldest non-removable XID for the given relation, out of the + * horizons already computed by ComputeXidHorizons(). + */ +static inline TransactionId +GetOldestNonRemovableTransactionIdFromHorizons(ComputeXidHorizonsResult *horizons, + Relation rel) +{ + switch (GlobalVisHorizonKindForRel(rel)) + { + case VISHORIZON_SHARED: + return horizons->shared_oldest_nonremovable; + case VISHORIZON_CATALOG: + return horizons->catalog_oldest_nonremovable; + case VISHORIZON_DATA: + return horizons->data_oldest_nonremovable; + case VISHORIZON_TEMP: + return horizons->temp_oldest_nonremovable; + } + + /* just to prevent compiler warnings */ + return InvalidTransactionId; +} + /* * Return the oldest XID for which deleted tuples must be preserved in the * passed table. @@ -1947,20 +1971,34 @@ GetOldestNonRemovableTransactionId(Relation rel) ComputeXidHorizons(&horizons); - switch (GlobalVisHorizonKindForRel(rel)) - { - case VISHORIZON_SHARED: - return horizons.shared_oldest_nonremovable; - case VISHORIZON_CATALOG: - return horizons.catalog_oldest_nonremovable; - case VISHORIZON_DATA: - return horizons.data_oldest_nonremovable; - case VISHORIZON_TEMP: - return horizons.temp_oldest_nonremovable; - } + return GetOldestNonRemovableTransactionIdFromHorizons(&horizons, rel); +} - /* just to prevent compiler warnings */ - return InvalidTransactionId; +/* + * Same as GetOldestNonRemovableTransactionId(), but also reports the oldest + * replication slot xmin and catalog_xmin, and whether that catalog_xmin is + * relevant for this relation, from the same ComputeXidHorizons() call. This + * avoids a second ProcArrayLock acquisition for a caller that needs them all. + * See InvalidateXidAgedReplicationSlots() for what makes a catalog_xmin + * relevant. + */ +TransactionId +GetOldestNonRemovableTransactionIdAndSlotXmins(Relation rel, + TransactionId *slot_xmin, + TransactionId *slot_catalog_xmin, + bool *slot_catalog_xmin_relevant) +{ + ComputeXidHorizonsResult horizons; + GlobalVisHorizonKind kind = GlobalVisHorizonKindForRel(rel); + + ComputeXidHorizons(&horizons); + + *slot_xmin = horizons.slot_xmin; + *slot_catalog_xmin = horizons.slot_catalog_xmin; + *slot_catalog_xmin_relevant = (kind == VISHORIZON_CATALOG || + kind == VISHORIZON_SHARED); + + return GetOldestNonRemovableTransactionIdFromHorizons(&horizons, rel); } /* diff --git a/src/backend/storage/ipc/standby.c b/src/backend/storage/ipc/standby.c index 7f011e04990..7b12f8ca431 100644 --- a/src/backend/storage/ipc/standby.c +++ b/src/backend/storage/ipc/standby.c @@ -504,7 +504,9 @@ ResolveRecoveryConflictWithSnapshot(TransactionId snapshotConflictHorizon, */ if (IsLogicalDecodingEnabled() && isCatalogRel) InvalidateObsoleteReplicationSlots(RS_INVAL_HORIZON, 0, locator.dbOid, - snapshotConflictHorizon); + snapshotConflictHorizon, + false, /* nowait */ + true); /* check_catalog_xmin */ } /* diff --git a/src/include/commands/vacuum.h b/src/include/commands/vacuum.h index 6e3c912bf5c..f271f9b15fe 100644 --- a/src/include/commands/vacuum.h +++ b/src/include/commands/vacuum.h @@ -295,6 +295,19 @@ struct VacuumCutoffs */ TransactionId FreezeLimit; MultiXactId MultiXactCutoff; + + /* + * SlotXmin and SlotCatalogXmin are the oldest xmin and catalog_xmin of + * any replication slot, from the same ComputeXidHorizons() call that + * computed OldestXmin. + * + * SlotCatalogXminRelevant is whether a slot's catalog_xmin can hold + * OldestXmin back, which is true for catalog and shared relations. See + * InvalidateXidAgedReplicationSlots(). + */ + TransactionId SlotXmin; + TransactionId SlotCatalogXmin; + bool SlotCatalogXminRelevant; }; /* diff --git a/src/include/replication/slot.h b/src/include/replication/slot.h index ab264c8c09a..806ba37cab6 100644 --- a/src/include/replication/slot.h +++ b/src/include/replication/slot.h @@ -367,7 +367,13 @@ extern void ReplicationSlotsDropDBSlots(Oid dboid); extern bool InvalidateObsoleteReplicationSlots(uint32 possible_causes, XLogSegNo oldestSegno, Oid dboid, - TransactionId snapshotConflictHorizon); + TransactionId snapshotConflictHorizon, + bool nowait, + bool check_catalog_xmin); +extern bool InvalidateXidAgedReplicationSlots(TransactionId oldest_xmin, + TransactionId slot_xmin, + TransactionId slot_catalog_xmin, + bool slot_catalog_xmin_relevant); extern ReplicationSlot *SearchNamedReplicationSlot(const char *name, bool need_lock); extern int ReplicationSlotIndex(ReplicationSlot *slot); extern bool ReplicationSlotName(int index, Name name); diff --git a/src/include/storage/procarray.h b/src/include/storage/procarray.h index d718a5b542f..f41e039b091 100644 --- a/src/include/storage/procarray.h +++ b/src/include/storage/procarray.h @@ -51,6 +51,10 @@ extern RunningTransactions GetRunningTransactionData(void); extern bool TransactionIdIsInProgress(TransactionId xid); extern TransactionId GetOldestNonRemovableTransactionId(Relation rel); +extern TransactionId GetOldestNonRemovableTransactionIdAndSlotXmins(Relation rel, + TransactionId *slot_xmin, + TransactionId *slot_catalog_xmin, + bool *slot_catalog_xmin_relevant); extern TransactionId GetOldestTransactionIdConsideredRunning(void); extern TransactionId GetOldestActiveTransactionId(bool inCommitOnly, bool allDbs); diff --git a/src/test/recovery/t/099_invalidate_xid_aged_slots.pl b/src/test/recovery/t/099_invalidate_xid_aged_slots.pl index 5459f8a4cee..d6db34554d4 100644 --- a/src/test/recovery/t/099_invalidate_xid_aged_slots.pl +++ b/src/test/recovery/t/099_invalidate_xid_aged_slots.pl @@ -18,6 +18,27 @@ sub wait_for_slot or die "Timed out waiting for slot $slot_name: $cond"; } +# Vacuum the given relation, then check the slot's invalidation reason and +# whether the relation's dead tuples could be removed. +sub vacuum_and_check +{ + my ($node, $relname, $slot_name, $reason, $dead_removed) = @_; + + $node->safe_psql('postgres', "VACUUM $relname"); + is( $node->safe_psql('postgres', + "SELECT coalesce(invalidation_reason, 'none') FROM pg_replication_slots WHERE slot_name = '$slot_name'" + ), + $reason, + "slot $slot_name reads $reason after vacuuming $relname"); + is( $node->safe_psql('postgres', + "SELECT n_dead_tup = 0 FROM pg_stat_all_tables WHERE relname = '$relname'" + ), + $dead_removed ? 't' : 'f', + "vacuum " + . ($dead_removed ? "removes" : "leaves") + . " the dead tuples in $relname"); +} + # A small age lets slots reach the limit after just a few XIDs my $slot_xid_age = 100; @@ -40,16 +61,19 @@ my $consume_xid_proc = qq{ my $primary = PostgreSQL::Test::Cluster->new('primary'); $primary->init(allows_streaming => 'logical'); -# No checkpoints and no autovacuum, so that a slot is invalidated only where a -# testcase asks for it. +# No checkpoints, autovacuum or walsender timeouts, so that nothing invalidates +# a slot or advances its horizon behind a testcase's back. $primary->append_conf( 'postgresql.conf', qq{ max_slot_xid_age = $slot_xid_age autovacuum = off checkpoint_timeout = 1h +wal_sender_timeout = 0 }); $primary->start; $primary->safe_psql('postgres', $consume_xid_proc); +$primary->safe_psql('postgres', + "CREATE TABLE tbl_user AS SELECT generate_series(1,10) AS a"); # Testcase 1: an inactive logical slot with an aged catalog_xmin is invalidated # at a checkpoint. @@ -114,6 +138,103 @@ $running_xact->quit; # The terminated backend took its psql down too, so just reap the process $export->{run}->finish; +# Testcase 3: the VACUUM command skips an active logical slot with an aged +# catalog_xmin, rather than terminating its owner to invalidate it. +$primary->safe_psql('postgres', + "SELECT pg_create_logical_replication_slot('logical_active_slot', 'test_decoding')" +); + +# Dead catalog rows that only this slot's catalog_xmin holds back +$primary->safe_psql('postgres', + "CREATE TABLE tbl_tmp(a int); DROP TABLE tbl_tmp;"); + +# No status messages, so the client's feedback cannot advance the slot's +# catalog_xmin while the testcase ages it. +my ($stdout, $stderr); +my $recvlogical = IPC::Run::start( + [ + 'pg_recvlogical', + '--dbname' => $primary->connstr('postgres'), + '--slot' => 'logical_active_slot', + '--status-interval' => 0, + '--file' => '-', + '--no-loop', + '--start', + ], + '>' => \$stdout, + '2>' => \$stderr, + IPC::Run::timeout($PostgreSQL::Test::Utils::timeout_default)); +wait_for_slot($primary, 'logical_active_slot', 'active_pid IS NOT NULL'); + +$primary->safe_psql('postgres', qq{CALL consume_xid(2 * $slot_xid_age)}); + +# Fail rather than pass vacuously, should the slot's horizon have moved anyway +is( $primary->safe_psql('postgres', + "SELECT age(catalog_xmin) > $slot_xid_age FROM pg_replication_slots WHERE slot_name = 'logical_active_slot'" + ), + 't', + 'active slot is aged past the limit'); + +vacuum_and_check($primary, 'pg_class', 'logical_active_slot', 'none', 0); + +# Testcase 4: the VACUUM command invalidates that same slot once it is +# inactive, and then removes the rows it was holding back. + +# End the client's session to make the slot inactive (portable way) +$primary->safe_psql('postgres', + "SELECT pg_terminate_backend(active_pid) FROM pg_replication_slots WHERE slot_name = 'logical_active_slot'" +); +wait_for_slot($primary, 'logical_active_slot', 'active_pid IS NULL'); +$recvlogical->finish; + +# A logical slot holds only a catalog_xmin, so a user table's cutoff is not its +# to hold back. +$primary->safe_psql('postgres', "VACUUM tbl_user"); +is( $primary->safe_psql('postgres', + "SELECT invalidation_reason IS NULL FROM pg_replication_slots WHERE slot_name = 'logical_active_slot'" + ), + 't', + 'logical slot not invalidated by vacuuming a user table'); + +vacuum_and_check($primary, 'pg_class', 'logical_active_slot', 'xid_aged', 1); +$primary->safe_psql('postgres', + "SELECT pg_drop_replication_slot('logical_active_slot')"); + +# Testcase 5: the VACUUM command invalidates an inactive physical slot with an +# aged xmin. Feedback from a standby is what gives such a slot an xmin, and +# stopping the standby freezes it. +my $backup_name = 'backup'; +$primary->backup($backup_name); + +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, $backup_name, has_streaming => 1); + +$primary->safe_psql('postgres', + "SELECT pg_create_physical_replication_slot('phys_slot', true)"); +$standby->append_conf( + 'postgresql.conf', q{ +primary_slot_name = 'phys_slot' +hot_standby_feedback = on +wal_receiver_status_interval = 1 +}); +$standby->start; +$primary->wait_for_catchup($standby); +wait_for_slot($primary, 'phys_slot', 'xmin IS NOT NULL'); +$standby->stop; + +# Dead rows from an XID that the now frozen xmin holds back +$primary->safe_psql('postgres', "DELETE FROM tbl_user"); + +$log_offset = -s $primary->logfile; +$primary->safe_psql('postgres', qq{CALL consume_xid(2 * $slot_xid_age)}); +vacuum_and_check($primary, 'tbl_user', 'phys_slot', 'xid_aged', 1); + +# The slot holds an xmin alone, so only its age is reported +ok( $primary->log_contains( + qr/invalidating obsolete replication slot "phys_slot"\n.*DETAIL:.*The slot's xmin age of \d+ transactions exceeds the configured "max_slot_xid_age" of $slot_xid_age\./, + $log_offset), + 'aged xmin is reported on invalidation'); + $primary->stop; done_testing();
From 16bec62f74c5319f1d9eef7cb53498431898f654 Mon Sep 17 00:00:00 2001 From: Bharath Rupireddy <[email protected]> Date: Wed, 23 Sep 2026 17:18:35 +0000 Subject: [PATCH v15 3/3] Invalidate XID-aged synced replication slots on a standby. Commit XXX added support for invalidating a replication slot once the age of its xmin or catalog_xmin is beyond the max_slot_xid_age GUC. That check skips synced slots on a standby, because a synced slot's catalog_xmin is not its own, slot synchronization copies it from the slot on the primary. But such a slot can still hold vacuum back. A synced slot's catalog_xmin does not stay on the standby. Unlike the WAL a synced slot pins, it is reported to the primary with hot_standby_feedback enabled, and the primary's physical slot holds the catalog horizon there. Consequently, a synced slot whose catalog_xmin has aged keeps vacuum on the primary from pruning dead catalog rows and freezing XIDs. This commit implements XID-age invalidation for synced slots as well, so an aged synced slot is invalidated during a restartpoint like any other slot on the standby. Once it is invalidated the slot no longer contributes its catalog_xmin, the standby reports an advanced catalog_xmin through hot_standby_feedback, the catalog_xmin held by the primary's physical slot advances, and vacuum on the primary can proceed. A synced slot invalidated on the standby is dropped and recreated in the next sync cycle, as it already is for the other causes that can invalidate a synced slot. Author: Bharath Rupireddy <[email protected]> Reviewed-by: John Hsu <[email protected]> Reviewed-by: Masahiko Sawada <[email protected]> Reviewed-by: Hayato Kuroda <[email protected]> Reviewed-by: Satya Narlapuram <[email protected]> Reviewed-by: Amit Kapila <[email protected]> Reviewed-by: Bertrand Drouvot <[email protected]> Reviewed-by: Nisha Moond <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/CALj2ACW4aUe-_uFQOjdWCEN-xXoLGhmvRFnL8SNw_TZ5nJe+aw@mail.gmail.com Discussion: https://postgr.es/m/CALj2ACUmPbkcj4y4oeXvzUkBejG68QDtrFF7QHDC_qz2vQcTCg@mail.gmail.com --- doc/src/sgml/config.sgml | 16 +++-- src/backend/replication/logical/slotsync.c | 2 + src/backend/replication/slot.c | 16 +++-- .../t/099_invalidate_xid_aged_slots.pl | 66 +++++++++++++++++++ 4 files changed, 91 insertions(+), 9 deletions(-) diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index 2b827c06b2b..ae1d94fad10 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -5091,11 +5091,19 @@ HINT: If it is safe for all REPLICATION users to use this library as an output </para> <para> - Note that this invalidation mechanism is not applicable for slots - on the standby server that are being synced from the primary server - (i.e., standby slots having + This mechanism also applies to slots on a standby server that are being + synced from the primary server (i.e., standby slots having <link linkend="view-pg-replication-slots">pg_replication_slots</link>.<structfield>synced</structfield> - value <literal>true</literal>). + value <literal>true</literal>). A synced slot's + <literal>catalog_xmin</literal> is sent to the primary's physical slot + when <xref linkend="guc-hot-standby-feedback"/> is enabled on the + standby, holding the catalog horizon back there, so a synced slot whose + <literal>catalog_xmin</literal> has aged can keep vacuum on the primary + from pruning dead catalog rows and freezing XIDs. Invalidating such a + slot on the standby advances the <literal>catalog_xmin</literal> held by + the primary's physical slot, letting vacuum there proceed. As with the + other causes that can invalidate a synced slot, such a slot is dropped + and recreated by the next slot synchronization. </para> </listitem> </varlistentry> diff --git a/src/backend/replication/logical/slotsync.c b/src/backend/replication/logical/slotsync.c index c0403893e23..b50f9a771f0 100644 --- a/src/backend/replication/logical/slotsync.c +++ b/src/backend/replication/logical/slotsync.c @@ -519,6 +519,8 @@ local_sync_slot_required(ReplicationSlot *local_slot, List *remote_slots) * reasons: * - The 'max_slot_wal_keep_size' on the standby is insufficient to retain WAL * records from the restart_lsn of the slot. + * - The 'max_slot_xid_age' on the standby is insufficient to retain the + * catalog_xmin of the slot. * - 'primary_slot_name' is temporarily reset to null and the physical slot is * removed. * These dropped slots will get recreated in next sync-cycle and it is okay to diff --git a/src/backend/replication/slot.c b/src/backend/replication/slot.c index 88a3f65ee6c..b5f636dda91 100644 --- a/src/backend/replication/slot.c +++ b/src/backend/replication/slot.c @@ -1966,9 +1966,6 @@ GetSlotXidAgeLimit(void) * 2. Slot has a valid effective xmin or effective catalog_xmin * 3. The slot is not the conflict detection slot. Invalidating it would * silently lose conflict detection, and nothing recreates it. - * 4. The slot is not being synced from the primary while the server is in - * recovery. Note that they can still hold vacuum back on the primary as - * catalog_xmin is synced from there. * * ReplicationSlotsComputeRequiredXmin() computes the oldest xmin from the * effective values, so those are the ones that hold vacuum back. They can @@ -1977,6 +1974,16 @@ GetSlotXidAgeLimit(void) * advancing catalog xmin is written to disk before effective_catalog_xmin is * updated, so the effective value can be the older of the two (see * LogicalConfirmReceivedLocation()). + * + * Note that this includes synced slots on a standby. A synced slot's + * catalog_xmin is sent to the primary's physical slot when + * hot_standby_feedback is enabled, holding the catalog horizon back there, so + * a synced slot whose catalog_xmin has aged can keep vacuum on the primary + * from pruning dead catalog rows and freezing XIDs. Invalidating it advances + * the catalog_xmin held by the primary's physical slot, letting vacuum there + * proceed. As with the other causes that can invalidate a synced slot, such a + * slot is dropped and recreated by the next slot synchronization (see + * drop_local_obsolete_slots()). */ static inline bool CanInvalidateXidAgedSlot(ReplicationSlot *s) @@ -1984,8 +1991,7 @@ CanInvalidateXidAgedSlot(ReplicationSlot *s) return (max_slot_xid_age != 0 && (TransactionIdIsValid(s->effective_xmin) || TransactionIdIsValid(s->effective_catalog_xmin)) && - !IsSlotForConflictCheck(NameStr(s->data.name)) && - !(RecoveryInProgress() && s->data.synced)); + !IsSlotForConflictCheck(NameStr(s->data.name))); } /* diff --git a/src/test/recovery/t/099_invalidate_xid_aged_slots.pl b/src/test/recovery/t/099_invalidate_xid_aged_slots.pl index d6db34554d4..7a614123140 100644 --- a/src/test/recovery/t/099_invalidate_xid_aged_slots.pl +++ b/src/test/recovery/t/099_invalidate_xid_aged_slots.pl @@ -235,6 +235,72 @@ ok( $primary->log_contains( $log_offset), 'aged xmin is reported on invalidation'); +# Testcase 6: a synced slot on the standby with an aged catalog_xmin is +# invalidated by a restartpoint, which releases the catalog_xmin it had pinned +# on the primary's physical slot. + +# The limit stays off on the primary, or its own checkpoints invalidate the +# failover slot first. +$primary->adjust_conf('postgresql.conf', 'max_slot_xid_age', '0'); +$primary->reload; +$primary->poll_query_until('postgres', + "SELECT current_setting('max_slot_xid_age') = '0'") + or die "Timed out waiting for max_slot_xid_age to take effect"; + +# A fresh slot, as an invalidated one cannot be streamed from +$primary->safe_psql('postgres', + "SELECT pg_create_physical_replication_slot('phys_sync_slot', true)"); + +# Created before the standby starts, or its xmin lags the standby and never syncs +$primary->safe_psql('postgres', + "SELECT pg_create_logical_replication_slot('failover_slot', 'pgoutput', false, false, true)" +); + +# Sync needs a dbname, and hs_feedback is what pins the synced catalog_xmin onto +# the primary's slot. +my $connstr = $primary->connstr; +$standby->adjust_conf('postgresql.conf', 'primary_slot_name', + "'phys_sync_slot'"); +$standby->adjust_conf('postgresql.conf', 'primary_conninfo', + "'$connstr dbname=postgres'"); +$standby->adjust_conf('postgresql.conf', 'hot_standby_feedback', 'on'); + +$standby->start; + +# Sync once by hand, so the synced catalog_xmin stays frozen. The standby has to +# replay past the new slot first, or the sync only retries. +$primary->wait_for_replay_catchup($standby); +$standby->safe_psql('postgres', "SELECT pg_sync_replication_slots()"); +is( $standby->safe_psql('postgres', + "SELECT synced AND NOT temporary AND catalog_xmin IS NOT NULL AND invalidation_reason IS NULL FROM pg_replication_slots WHERE slot_name = 'failover_slot'" + ), + 't', + 'logical failover slot is synced to the standby'); + +# The primary's slot holds the synced catalog_xmin via hs_feedback +my $frozen = $standby->safe_psql('postgres', + "SELECT catalog_xmin FROM pg_replication_slots WHERE slot_name = 'failover_slot'" +); +wait_for_slot($primary, 'phys_sync_slot', "catalog_xmin = '$frozen'"); + +# Age it out; the primary's checkpoint gives the standby a restartpoint +$primary->safe_psql('postgres', qq{CALL consume_xid(2 * $slot_xid_age)}); +$primary->safe_psql('postgres', "CHECKPOINT"); +$primary->wait_for_replay_catchup($standby); +$standby->safe_psql('postgres', "CHECKPOINT"); +wait_for_slot($standby, 'failover_slot', "invalidation_reason = 'xid_aged'"); + +# Invalidation does not propagate, so a later sync recreates the slot +is( $primary->safe_psql('postgres', + "SELECT invalidation_reason IS NULL FROM pg_replication_slots WHERE slot_name = 'failover_slot'" + ), + 't', + 'slot on the primary not invalidated by the standby'); + +# An invalidated slot drops out of the horizon the standby feeds back +wait_for_slot($primary, 'phys_sync_slot', 'catalog_xmin IS NULL'); + +$standby->stop; $primary->stop; done_testing();
