From 16bec62f74c5319f1d9eef7cb53498431898f654 Mon Sep 17 00:00:00 2001 From: Bharath Rupireddy 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 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 | 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 - 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 pg_replication_slots.synced - value true). + value true). A synced slot's + catalog_xmin is sent to the primary's physical slot + when is enabled on the + standby, 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 such a + slot on the standby 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. 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();