From 2fb7d3046913d49347cbb29f0f2f2036259ccee8 Mon Sep 17 00:00:00 2001 From: Bryan Green Date: Sat, 8 Aug 2026 12:31:04 -0500 Subject: [PATCH] Release a replication slot leaked by a caught subtransaction error SQL replication slot functions acquire MyReplicationSlot and release it before returning. If one errors after acquiring the slot and the error is caught by a PL/pgSQL EXCEPTION handler, the subtransaction aborts without running ReplicationSlotRelease(), so MyReplicationSlot stays set. The next slot operation in the session then trips Assert(!MyReplicationSlot), or misbehaves in a non-assert build. The top-level error handler in PostgresMain() releases the slot, but a PL/pgSQL EXCEPTION handler catches the error at the subtransaction level and never reaches it. AbortSubTransaction() did not release slots, because a slot can legitimately be held across transaction boundaries. Release the slot in AbortSubTransaction(), but only when it was acquired at or below the aborting subtransaction. A new MyReplicationSlotSubid records where MyReplicationSlot was acquired, so slots held across subtransaction boundaries (logical apply workers, REPACK) are left alone. Add a TAP test covering the acquire path (pg_replication_slot_advance) and the create path (pg_create_logical_replication_slot with a missing plugin). Co-authored-by: Mark Dilger --- src/backend/access/transam/xact.c | 12 +++++ src/backend/replication/slot.c | 8 +++ src/include/replication/slot.h | 1 + src/test/recovery/t/056_replslot_subxact.pl | 55 +++++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 src/test/recovery/t/056_replslot_subxact.pl diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c index 3a89149016..f532e31d67 100644 --- a/src/backend/access/transam/xact.c +++ b/src/backend/access/transam/xact.c @@ -5355,6 +5355,18 @@ AbortSubTransaction(void) /* Reset logical streaming state. */ ResetLogicalStreamingState(); + /* + * Release a replication slot acquired in this subtransaction. A SQL slot + * function acquires MyReplicationSlot and releases it before returning; + * if it errors and the error is caught by a PL/pgSQL EXCEPTION handler, + * ReplicationSlotRelease() never runs and the slot leaks. Restrict this + * to slots acquired at or below this subtransaction, since apply workers + * and REPACK hold slots across subtransaction boundaries. + */ + if (MyReplicationSlot != NULL && + MyReplicationSlotSubid >= s->subTransactionId) + ReplicationSlotRelease(); + /* * No need for SnapBuildResetExportedSnapshotState() here, snapshot * exports are not supported in subtransactions. diff --git a/src/backend/replication/slot.c b/src/backend/replication/slot.c index 1a0ff68206..c59ee0041b 100644 --- a/src/backend/replication/slot.c +++ b/src/backend/replication/slot.c @@ -40,6 +40,7 @@ #include #include "access/transam.h" +#include "access/xact.h" #include "access/xlog_internal.h" #include "access/xlogrecovery.h" #include "common/file_utils.h" @@ -157,6 +158,9 @@ const ShmemCallbacks ReplicationSlotsShmemCallbacks = { /* My backend's replication slot in the shared memory array */ ReplicationSlot *MyReplicationSlot = NULL; +/* Subtransaction ID in which MyReplicationSlot was acquired */ +SubTransactionId MyReplicationSlotSubid = InvalidSubTransactionId; + /* GUC variables */ int max_replication_slots = 10; /* the maximum number of replication * slots */ @@ -519,6 +523,7 @@ ReplicationSlotCreate(const char *name, bool db_specific, slot->active_proc = MyProcNumber; SpinLockRelease(&slot->mutex); MyReplicationSlot = slot; + MyReplicationSlotSubid = GetCurrentSubTransactionId(); LWLockRelease(ReplicationSlotControlLock); @@ -724,6 +729,7 @@ retry: /* We made this slot active, so it's ours now. */ MyReplicationSlot = s; + MyReplicationSlotSubid = GetCurrentSubTransactionId(); /* * We need to check for invalidation after making the slot ours to avoid @@ -829,6 +835,7 @@ ReplicationSlotRelease(void) ReplicationSlotSetInactiveSince(slot, now, true); MyReplicationSlot = NULL; + MyReplicationSlotSubid = InvalidSubTransactionId; } /* might not have been set when we've been a plain slot */ @@ -1042,6 +1049,7 @@ ReplicationSlotDropAcquired(bool try_disable) /* slot isn't acquired anymore */ MyReplicationSlot = NULL; + MyReplicationSlotSubid = InvalidSubTransactionId; ReplicationSlotDropPtr(slot); diff --git a/src/include/replication/slot.h b/src/include/replication/slot.h index 9b29444cbc..1fbd873403 100644 --- a/src/include/replication/slot.h +++ b/src/include/replication/slot.h @@ -321,6 +321,7 @@ ReplicationSlotSetInactiveSince(ReplicationSlot *s, TimestampTz ts, */ extern PGDLLIMPORT ReplicationSlotCtlData *ReplicationSlotCtl; extern PGDLLIMPORT ReplicationSlot *MyReplicationSlot; +extern PGDLLIMPORT SubTransactionId MyReplicationSlotSubid; /* GUCs */ extern PGDLLIMPORT int max_replication_slots; diff --git a/src/test/recovery/t/056_replslot_subxact.pl b/src/test/recovery/t/056_replslot_subxact.pl new file mode 100644 index 0000000000..349a3b2c88 --- /dev/null +++ b/src/test/recovery/t/056_replslot_subxact.pl @@ -0,0 +1,55 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# A SQL slot function acquires MyReplicationSlot and releases it before +# returning. If it errors after acquiring the slot and the error is caught by +# a PL/pgSQL EXCEPTION handler, the release is skipped and the slot leaks; the +# next slot operation in the session then trips Assert(!MyReplicationSlot). +# Check that a subtransaction abort releases such a slot. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init(allows_streaming => 'logical'); +$node->start; + +# Acquire path: pg_replication_slot_advance() acquires the slot and then errors +# because it has never reserved WAL. After the caught error the follow-up +# create must succeed. +my $count = $node->safe_psql('postgres', q{ +DO $$ +BEGIN + PERFORM pg_create_physical_replication_slot('advance_src', false); + BEGIN + PERFORM pg_replication_slot_advance('advance_src', '0/1'); + EXCEPTION WHEN object_not_in_prerequisite_state THEN + NULL; + END; + PERFORM pg_create_physical_replication_slot('advance_next'); +END $$; +SELECT count(*) FROM pg_replication_slots + WHERE slot_name IN ('advance_src', 'advance_next'); +}); +is($count, '2', 'slot released after caught pg_replication_slot_advance error'); + +# Create path: creating a logical slot assigns MyReplicationSlot before the +# output plugin is loaded, so a missing plugin errors with the slot acquired. +$count = $node->safe_psql('postgres', q{ +DO $$ +BEGIN + BEGIN + PERFORM pg_create_logical_replication_slot('create_bad', 'no_such_plugin'); + EXCEPTION WHEN OTHERS THEN + NULL; + END; + PERFORM pg_create_physical_replication_slot('create_next'); +END $$; +SELECT count(*) FROM pg_replication_slots WHERE slot_name = 'create_next'; +}); +is($count, '1', 'slot released after caught pg_create_logical_replication_slot error'); + +$node->stop; +done_testing(); -- 2.49.0