Greetings,

A SQL slot function that errors after acquiring MyReplicationSlot leaks the
slot when the error is caught by a PL/pgSQL EXCEPTION handler: the
subtransaction aborts without releasing it, MyReplicationSlot stays set, and
the next slot operation in the session trips Assert(!MyReplicationSlot).

Releasing on error is done at the top level, in PostgresMain(), and the
comment there is explicit that AbortTransaction() must not do it:

    /*
     * We can't release replication slots inside AbortTransaction() as we
     * need to be able to start and abort transactions while having a slot
     * acquired. ...
     */

A caught error never reaches that top-level path, so the slot is never let
go.

Reproduction --

    DO $$
    BEGIN
        PERFORM pg_create_physical_replication_slot('s', false);
        BEGIN
            PERFORM pg_replication_slot_advance('s', '0/1');
        EXCEPTION WHEN object_not_in_prerequisite_state THEN
            NULL;
        END;
        PERFORM pg_create_physical_replication_slot('s2');
    END $$;

    TRAP: failed Assert("!MyReplicationSlot"), slotfuncs.c:51

The fix releases the slot in AbortSubTransaction(), but only when it was
acquired at or below the aborting subtransaction, tracked by a new
MyReplicationSlotSubid.  That preserves the property the comment above
depends on: a slot acquired outside the current subtransaction survives its
abort, which is what logical apply workers and REPACK rely on.

The patch adds a TAP test for the two paths that actually leak: the acquire
path (pg_replication_slot_advance) and the create path
(pg_create_logical_replication_slot with a missing plugin, which errors
after the slot is acquired).  The test fails without the fix and passes with
it; the regression suite passes.

--
Bryan Green
EDB: https://www.enterprisedb.com
From 2fb7d3046913d49347cbb29f0f2f2036259ccee8 Mon Sep 17 00:00:00 2001
From: Bryan Green <[email protected]>
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 <[email protected]>
---
 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 <sys/stat.h>
 
 #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

Reply via email to