From f8e65da149e0a0ea0baedd8429d03cff98b3c705 Mon Sep 17 00:00:00 2001
From: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
Date: Wed, 26 Aug 2026 22:23:59 +0000
Subject: [PATCH v14] Fix replication slot leak on error caught in a
 subtransaction.

The SQL-callable replication slot functions, such as
pg_replication_slot_advance(), acquire a slot and release it
before returning. When one of them throws an error, the slot is
normally released by the top-level error handler (the sigsetjmp
block in PostgresMain()).

However, PL/pgSQL, PL/Perl, PL/Python and PL/Tcl run their
error-handling block in an internal subtransaction. When an error
is raised there and a handler matches, they abort the
subtransaction and catch the error without re-throwing it, so the
top-level handler is never reached. Nothing on the
(sub)transaction abort path releases the slot either, so it is
left acquired after the caught error.

As a result, the next slot operation in the session fails an
assertion, or in a non-assertion build silently overwrites the
reference and leaks the slot, which keeps holding back WAL
removal and the catalog xmin (blocking vacuum) and can no longer
be acquired. An error that does reach the top-level handler, such
as a plain ROLLBACK TO SAVEPOINT or a PL/pgSQL exception handler
whose conditions do not match and which then re-throws, is
unaffected.

Fix this by recording the subtransaction that acquires the slot
and releasing the slot when that subtransaction aborts, the same
way other subtransaction-scoped resources are released, for
example in AtEOSubXact_LargeObject() and AtEOSubXact_Files(). The
slot cannot simply be released on every subtransaction abort,
because logical decoding starts and aborts an internal
transaction or subtransaction for each decoded transaction while
holding the slot; those internal aborts are nested below the
acquiring subtransaction and so are left alone.

Note that the subtransaction abort path only releases the slot;
it does not drop the session's temporary slots the way the
top-level error handler does. An error caught within a
subtransaction is normally meant to be handled so the session
continues, unlike a top-level error, so temporary slots are left
in place. This matches the behavior that predates this fix, where
a temporary slot is dropped only at session end or on a top-level
error, and is simpler to reason about. The documentation is
adjusted accordingly.

Back-patch to all supported branches, as the leak is reachable
from SQL.

Reported-by: Satya Narlapuram <satyanarlapuram@gmail.com>
Author: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
Co-authored-by: Satya Narlapuram <satyanarlapuram@gmail.com>
Co-authored-by: Masahiko Sawada <sawada.mshk@gmail.com>
Reviewed-by: Fujii Masao <masao.fujii@gmail.com>
Reviewed-by: shveta malik <shveta.malik@gmail.com>
Reviewed-by: Hou Zhijie <houzj.fnst@fujitsu.com>
Reviewed-by: Kyotaro Horiguchi <horikyota.ntt@gmail.com>
Reviewed-by: Ashutosh Sharma <ashu.coek88@gmail.com>
Discussion: https://postgr.es/m/CAHg+QDeuf9tCq3ce=kgFMJP0m=PZC+wi6B=yS+7V0vNXjLS31w@mail.gmail.com
Backpatch-through: 14
---
 contrib/test_decoding/expected/slot.out | 49 ++++++++++++++++
 contrib/test_decoding/sql/slot.sql      | 30 ++++++++++
 doc/src/sgml/protocol.sgml              |  5 +-
 doc/src/sgml/system-views.sgml          |  5 +-
 src/backend/access/transam/xact.c       |  4 ++
 src/backend/replication/slot.c          | 74 +++++++++++++++++++++++++
 src/backend/tcop/postgres.c             |  4 +-
 src/include/replication/slot.h          |  2 +
 8 files changed, 170 insertions(+), 3 deletions(-)

diff --git a/contrib/test_decoding/expected/slot.out b/contrib/test_decoding/expected/slot.out
index cf0136455b0..39257e9d863 100644
--- a/contrib/test_decoding/expected/slot.out
+++ b/contrib/test_decoding/expected/slot.out
@@ -469,3 +469,52 @@ SELECT pg_drop_replication_slot('physical_slot');
  
 (1 row)
 
+-- A slot function that errors out must still release the slot, otherwise the
+-- next slot operation in the session fails an assertion or leaks the slot.
+-- Advancing a freshly created slot to a low LSN always errors.
+-- Error raised inside a PL/pgSQL block with an EXCEPTION clause is caught in a
+-- subtransaction; the slot must still be released.
+SELECT 'init' FROM pg_create_logical_replication_slot('regress_subxact_slot', 'test_decoding');
+ ?column? 
+----------
+ init
+(1 row)
+
+DO $$
+BEGIN
+    PERFORM pg_replication_slot_advance('regress_subxact_slot', '0/1');
+EXCEPTION WHEN OTHERS THEN
+    RAISE NOTICE 'caught SQLSTATE %', SQLSTATE;
+END $$;
+NOTICE:  caught SQLSTATE 55000
+SELECT count(*) >= 0 AS peek_ok
+    FROM pg_logical_slot_peek_changes('regress_subxact_slot', NULL, NULL);
+ peek_ok 
+---------
+ t
+(1 row)
+
+-- The EXCEPTION clause does not match, so the error is not caught, but the
+-- slot is still released. Show only the SQLSTATE for stable output.
+\set VERBOSITY sqlstate
+DO $$
+BEGIN
+    PERFORM pg_replication_slot_advance('regress_subxact_slot', '0/1');
+EXCEPTION WHEN division_by_zero THEN
+    RAISE NOTICE 'unreachable';
+END $$;
+ERROR:  55000
+\set VERBOSITY default
+SELECT count(*) >= 0 AS peek_ok
+    FROM pg_logical_slot_peek_changes('regress_subxact_slot', NULL, NULL);
+ peek_ok 
+---------
+ t
+(1 row)
+
+SELECT pg_drop_replication_slot('regress_subxact_slot');
+ pg_drop_replication_slot 
+--------------------------
+ 
+(1 row)
+
diff --git a/contrib/test_decoding/sql/slot.sql b/contrib/test_decoding/sql/slot.sql
index 50a950341fc..8da357a7eb1 100644
--- a/contrib/test_decoding/sql/slot.sql
+++ b/contrib/test_decoding/sql/slot.sql
@@ -193,3 +193,33 @@ SELECT pg_drop_replication_slot('failover_true_slot');
 SELECT pg_drop_replication_slot('failover_false_slot');
 SELECT pg_drop_replication_slot('failover_default_slot');
 SELECT pg_drop_replication_slot('physical_slot');
+
+-- A slot function that errors out must still release the slot, otherwise the
+-- next slot operation in the session fails an assertion or leaks the slot.
+-- Advancing a freshly created slot to a low LSN always errors.
+
+-- Error raised inside a PL/pgSQL block with an EXCEPTION clause is caught in a
+-- subtransaction; the slot must still be released.
+SELECT 'init' FROM pg_create_logical_replication_slot('regress_subxact_slot', 'test_decoding');
+DO $$
+BEGIN
+    PERFORM pg_replication_slot_advance('regress_subxact_slot', '0/1');
+EXCEPTION WHEN OTHERS THEN
+    RAISE NOTICE 'caught SQLSTATE %', SQLSTATE;
+END $$;
+SELECT count(*) >= 0 AS peek_ok
+    FROM pg_logical_slot_peek_changes('regress_subxact_slot', NULL, NULL);
+
+-- The EXCEPTION clause does not match, so the error is not caught, but the
+-- slot is still released. Show only the SQLSTATE for stable output.
+\set VERBOSITY sqlstate
+DO $$
+BEGIN
+    PERFORM pg_replication_slot_advance('regress_subxact_slot', '0/1');
+EXCEPTION WHEN division_by_zero THEN
+    RAISE NOTICE 'unreachable';
+END $$;
+\set VERBOSITY default
+SELECT count(*) >= 0 AS peek_ok
+    FROM pg_logical_slot_peek_changes('regress_subxact_slot', NULL, NULL);
+SELECT pg_drop_replication_slot('regress_subxact_slot');
diff --git a/doc/src/sgml/protocol.sgml b/doc/src/sgml/protocol.sgml
index 49f81676712..259dfd5cfbd 100644
--- a/doc/src/sgml/protocol.sgml
+++ b/doc/src/sgml/protocol.sgml
@@ -2382,7 +2382,10 @@ psql "dbname=postgres replication=database" -c "IDENTIFY_SYSTEM;"
          <para>
           Specify that this replication slot is a temporary one. Temporary
           slots are not saved to disk and are automatically dropped on error
-          or when the session has finished.
+          or when the session has finished. An error raised and caught in a
+          subtransaction, for example by a
+          <application>PL/pgSQL</application> exception block, does not drop
+          them.
          </para>
         </listitem>
        </varlistentry>
diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 5ea19d68622..5db727fa178 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -2894,7 +2894,10 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
       <para>
        True if this is a temporary replication slot. Temporary slots are
        not saved to disk and are automatically dropped on error or when
-       the session has finished.
+       the session has finished. An error raised and caught in a
+       subtransaction, for example by a
+       <application>PL/pgSQL</application> exception block, does not drop
+       them.
       </para></entry>
      </row>
 
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index aca92507ebd..2dd0bd9ce8d 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -5203,6 +5203,8 @@ CommitSubTransaction(void)
 						s->parent->curTransactionOwner);
 	AtEOSubXact_LargeObject(true, s->subTransactionId,
 							s->parent->subTransactionId);
+	AtEOSubXact_ReplicationSlot(true, s->subTransactionId,
+								s->parent->subTransactionId);
 	AtSubCommit_Notify();
 
 	CallSubXactCallbacks(SUBXACT_EVENT_COMMIT_SUB, s->subTransactionId,
@@ -5380,6 +5382,8 @@ AbortSubTransaction(void)
 						   s->parent->curTransactionOwner);
 		AtEOSubXact_LargeObject(false, s->subTransactionId,
 								s->parent->subTransactionId);
+		AtEOSubXact_ReplicationSlot(false, s->subTransactionId,
+									s->parent->subTransactionId);
 		AtSubAbort_Notify();
 
 		/* Advertise the fact that we aborted in pg_xact. */
diff --git a/src/backend/replication/slot.c b/src/backend/replication/slot.c
index 63ce6d27885..056e75362c2 100644
--- a/src/backend/replication/slot.c
+++ b/src/backend/replication/slot.c
@@ -59,6 +59,7 @@
 #include "utils/builtins.h"
 #include "utils/guc_hooks.h"
 #include "utils/injection_point.h"
+#include "utils/snapmgr.h"
 #include "utils/varlena.h"
 #include "utils/wait_event.h"
 
@@ -157,6 +158,13 @@ const ShmemCallbacks ReplicationSlotsShmemCallbacks = {
 /* My backend's replication slot in the shared memory array */
 ReplicationSlot *MyReplicationSlot = NULL;
 
+/*
+ * Subtransaction that acquired MyReplicationSlot, or invalid if none is held
+ * or it was acquired with no transaction in progress (as a walsender does).
+ * Used to release the slot when that subxact aborts.
+ */
+static SubTransactionId MyReplicationSlotSubId = InvalidSubTransactionId;
+
 /* GUC variables */
 int			max_replication_slots = 10; /* the maximum number of replication
 										 * slots */
@@ -519,6 +527,7 @@ ReplicationSlotCreate(const char *name, bool db_specific,
 	slot->active_proc = MyProcNumber;
 	SpinLockRelease(&slot->mutex);
 	MyReplicationSlot = slot;
+	MyReplicationSlotSubId = GetCurrentSubTransactionId();
 
 	LWLockRelease(ReplicationSlotControlLock);
 
@@ -724,6 +733,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
@@ -848,6 +858,69 @@ ReplicationSlotRelease(void)
 
 		pfree(slotname);
 	}
+
+	/* The slot is no longer acquired in any subxact. */
+	MyReplicationSlotSubId = InvalidSubTransactionId;
+}
+
+/*
+ * At subxact end, hand off or release MyReplicationSlot if it was acquired
+ * in this subxact. On commit, ownership passes to the parent subxact; on
+ * abort, the slot is released.
+ */
+void
+AtEOSubXact_ReplicationSlot(bool isCommit, SubTransactionId mySubid,
+							SubTransactionId parentSubid)
+{
+	/* Nothing to do unless the slot was acquired in this subxact. */
+	if (MyReplicationSlotSubId != mySubid)
+		return;
+
+	/*
+	 * On commit, hand the slot to the parent subxact. A slot held across a
+	 * subxact commit (acquired but not yet released by its caller) would
+	 * otherwise be attributed to a gone subxact, and we still want it
+	 * released if the parent, or an ancestor, later aborts.
+	 *
+	 * This acts as a safety check against a slot function that misses
+	 * releasing the slot before returning. Today none do, so the check above
+	 * returns early and we never get here with a slot still held. Handling it
+	 * anyway, the same way AtEOSubXact_LargeObject() and AtEOSubXact_Files()
+	 * do, keeps such a slot from being leaked.
+	 */
+	if (isCommit)
+	{
+		MyReplicationSlotSubId = parentSubid;
+		return;
+	}
+
+	/*
+	 * We must not get here while decoding is running. Decoding starts and
+	 * aborts an internal (sub)transaction while holding the slot, for each
+	 * decoded transaction (ReorderBufferProcessTXN()) and when executing
+	 * invalidations (ReorderBufferImmediateInvalidation()). However, those
+	 * subtransactions are always nested below the one that acquired the slot,
+	 * so their subtransaction ids are deeper and do not match here. Decoding
+	 * also runs with a historic snapshot set up, so assert that it is not.
+	 */
+	Assert(!HistoricSnapshotActive());
+
+	/*
+	 * The aborting subxact is the one that acquired the slot, so the slot is
+	 * still held and must be released. MyReplicationSlotSubId is set only
+	 * when a slot is held and cleared when it is released, so a matching
+	 * subxact id means the slot is ours.
+	 *
+	 * We only release the slot here and do not drop the session's temporary
+	 * slots, unlike the top-level error handler in PostgresMain(). An error
+	 * caught within a subtransaction, for example by a PL/pgSQL exception
+	 * block, is normally meant to be handled so the session carries on,
+	 * unlike a top-level error, so a temporary slot is left in place. That
+	 * matches the temporary slot behavior that predates this callback and is
+	 * simpler to reason about; the slot lives on until the session ends or a
+	 * top-level error occurs, as documented.
+	 */
+	ReplicationSlotRelease();
 }
 
 /*
@@ -1042,6 +1115,7 @@ ReplicationSlotDropAcquired(bool try_disable)
 
 	/* slot isn't acquired anymore */
 	MyReplicationSlot = NULL;
+	MyReplicationSlotSubId = InvalidSubTransactionId;
 
 	ReplicationSlotDropPtr(slot);
 
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index b6bdfe213fe..a2f72f9eb5b 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -4638,7 +4638,9 @@ PostgresMain(const char *dbname, const char *username)
 		 * need to be able to start and abort transactions while having a slot
 		 * acquired. But we never need to hold them across top level errors,
 		 * so releasing here is fine. There also is a before_shmem_exit()
-		 * callback ensuring correct cleanup on FATAL errors.
+		 * callback for FATAL errors, and AtEOSubXact_ReplicationSlot() for an
+		 * error caught in a subtransaction. The latter only releases the slot
+		 * and, unlike here, does not drop the session's temporary slots.
 		 */
 		if (MyReplicationSlot != NULL)
 			ReplicationSlotRelease();
diff --git a/src/include/replication/slot.h b/src/include/replication/slot.h
index 9b29444cbca..31f8755dc39 100644
--- a/src/include/replication/slot.h
+++ b/src/include/replication/slot.h
@@ -343,6 +343,8 @@ extern void ReplicationSlotAcquire(const char *name, bool nowait,
 								   bool error_if_invalid);
 extern void ReplicationSlotRelease(void);
 extern void ReplicationSlotCleanup(bool synced_only);
+extern void AtEOSubXact_ReplicationSlot(bool isCommit, SubTransactionId mySubid,
+										SubTransactionId parentSubid);
 extern void ReplicationSlotSave(void);
 extern void ReplicationSlotMarkDirty(void);
 
-- 
2.47.3

