From 56b30dcbcb81e38e0636e365086dbcc1ee85dc7c Mon Sep 17 00:00:00 2001 From: Bharath Rupireddy 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 Reviewed-by: John Hsu Reviewed-by: Masahiko Sawada Reviewed-by: Hayato Kuroda Reviewed-by: Satya Narlapuram Reviewed-by: Amit Kapila Reviewed-by: Bertrand Drouvot Reviewed-by: Nisha Moond Reviewed-by: Surya Poondla 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 + + max_slot_xid_age (integer) + + max_slot_xid_age configuration parameter + + + + + Invalidate replication slots whose xmin or + catalog_xmin transaction age in the + pg_replication_slots + 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 postgresql.conf file or on + the server command line. + + + + 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 xmin or catalog_xmin + age exceeds max_slot_xid_age 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. + + + + The current age of a slot's xmin and + catalog_xmin can be monitored by applying the + age function to the corresponding columns in the + pg_replication_slots + view. + + + + 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. + Invalidating such a slot lets vacuum freeze XIDs and prune dead rows + again. See for more details. + + + + 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 + pg_replication_slots.synced + value true). + + + + wal_sender_timeout (integer) 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 Logical replication slots are also affected by - idle_replication_slot_timeout. + idle_replication_slot_timeout + and + max_slot_xid_age. 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. + need to be rebuilt. Setting makes the + server invalidate such slots automatically once their age(xmin) + or age(catalog_xmin) exceeds the configured limit, + preventing them from holding vacuum back indefinitely. Execute VACUUM 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 duration. + + + xid_aged means that the slot's + xmin or catalog_xmin + has reached the transaction age specified by + parameter. + + 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();