From 33ba61e1edcea3ff5f2bd01f17dd90cffe5e2a86 Mon Sep 17 00:00:00 2001
From: Vaijayanti Bharadwaj <vaijayanti.bharadwaj@enterprisedb.com>
Date: Thu, 22 Oct 2026 15:50:15 +0530
Subject: [PATCH v1] Fix concurrent-abort detection for subtransactions of
 prepared xacts

When decoding a prepared transaction at PREPARE time,
ReorderBufferProcessTXN() is supposed to set CheckXidAlive for each
change, so that a catalog scan notices if the transaction is rolled back
concurrently.  The condition tested rbtxn_is_prepared() on the change's
own ReorderBufferTXN, but RBTXN_IS_PREPARED is only set on the top-level
transaction.  Changes made in the first subtransaction therefore
never set CheckXidAlive.

If such a transaction made a catalog change and then DML inside a
subtransaction, and was rolled back with ROLLBACK PREPARED while being
decoded, the decoder could go on using catalog tuples whose xmax had been
overwritten by a later transaction, and decode the DML with the wrong
catalog version.

Fix is to test the flag on the top-level transaction instead.
CheckXidAlive is still set to the subtransaction's xid, which aborts
together with its parent once the transaction is prepared.

Add an injection point in the change loop and an isolation test that
holds decoding at the first change while the prepared transaction is
rolled back and its catalog row is updated by another transaction.
The test fails without the fix and passes with it.
Backpatch-through: 15
---
 .../replication/logical/reorderbuffer.c       |  5 +-
 src/test/modules/injection_points/Makefile    |  3 +-
 .../prepared_subxact_concurrent_abort.out     | 49 +++++++++++++
 src/test/modules/injection_points/extra.conf  |  1 +
 src/test/modules/injection_points/meson.build |  1 +
 .../prepared_subxact_concurrent_abort.spec    | 71 +++++++++++++++++++
 6 files changed, 128 insertions(+), 2 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/prepared_subxact_concurrent_abort.out
 create mode 100644 src/test/modules/injection_points/specs/prepared_subxact_concurrent_abort.spec

diff --git a/src/backend/replication/logical/reorderbuffer.c b/src/backend/replication/logical/reorderbuffer.c
index 6c4eadc9972..924a8f3fb73 100644
--- a/src/backend/replication/logical/reorderbuffer.c
+++ b/src/backend/replication/logical/reorderbuffer.c
@@ -109,6 +109,7 @@
 #include "storage/procarray.h"
 #include "storage/sinval.h"
 #include "utils/builtins.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/rel.h"
@@ -2305,12 +2306,14 @@ ReorderBufferProcessTXN(ReorderBuffer *rb, ReorderBufferTXN *txn,
 			 * required for the cases when we decode the changes before the
 			 * COMMIT record is processed.
 			 */
-			if (streaming || rbtxn_is_prepared(change->txn))
+			if (streaming || rbtxn_is_prepared(rbtxn_get_toptxn(change->txn)))
 			{
 				curtxn = change->txn;
 				SetupCheckXidLive(curtxn->xid);
 			}
 
+			INJECTION_POINT("reorderbuffer-process-change", NULL);
+
 			switch (change->action)
 			{
 				case REORDER_BUFFER_CHANGE_INTERNAL_SPEC_CONFIRM:
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 775ad37860d..34c8190c22e 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -36,7 +36,8 @@ ISOLATION = basic \
 	    $(WAIT_CLEANUP_TST) \
 	    heap_lock_update \
 	    on_conflict_probe_window \
-	    multixact-aborted-updater
+	    multixact-aborted-updater \
+	    prepared_subxact_concurrent_abort
 
 # some isolation tests require wal_level=replica
 ISOLATION_OPTS = --temp-config $(top_srcdir)/src/test/modules/injection_points/extra.conf
diff --git a/src/test/modules/injection_points/expected/prepared_subxact_concurrent_abort.out b/src/test/modules/injection_points/expected/prepared_subxact_concurrent_abort.out
new file mode 100644
index 00000000000..cde18918e23
--- /dev/null
+++ b/src/test/modules/injection_points/expected/prepared_subxact_concurrent_abort.out
@@ -0,0 +1,49 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s2_prepare s1_decode s2_rollback_prepared s2_update_catalog s2_detach s2_wakeup
+?column?
+--------
+init    
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s2_prepare: 
+	BEGIN;
+	SAVEPOINT sp;
+	ALTER TABLE t ADD COLUMN b int;
+	INSERT INTO t VALUES (1, 1);
+	RELEASE SAVEPOINT sp;
+	PREPARE TRANSACTION 'x';
+
+step s1_decode: 
+	SELECT data FROM pg_logical_slot_get_changes('s', NULL, NULL,
+		'skip-empty-xacts', '1', 'include-xids', '0');
+ <waiting ...>
+step s2_rollback_prepared: ROLLBACK PREPARED 'x';
+step s2_update_catalog: ALTER TABLE t SET (fillfactor = 90);
+step s2_detach: SELECT injection_points_detach('reorderbuffer-process-change');
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+step s2_wakeup: SELECT injection_points_wakeup('reorderbuffer-process-change');
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s1_decode: <... completed>
+data
+----
+(0 rows)
+
+pg_drop_replication_slot
+------------------------
+                        
+(1 row)
+
diff --git a/src/test/modules/injection_points/extra.conf b/src/test/modules/injection_points/extra.conf
index 010abb193a8..50aaa646bf1 100644
--- a/src/test/modules/injection_points/extra.conf
+++ b/src/test/modules/injection_points/extra.conf
@@ -1 +1,2 @@
 wal_level=replica
+max_prepared_transactions=5
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index 60148f96fda..78cd6aa5001 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -44,6 +44,7 @@ injection_points_isolation = [
   'heap_lock_update',
   'on_conflict_probe_window',
   'multixact-aborted-updater',
+  'prepared_subxact_concurrent_abort',
 ]
 
 # wait_cleanup terminates another backend, whose FATAL message can be lost
diff --git a/src/test/modules/injection_points/specs/prepared_subxact_concurrent_abort.spec b/src/test/modules/injection_points/specs/prepared_subxact_concurrent_abort.spec
new file mode 100644
index 00000000000..b4f521a93e8
--- /dev/null
+++ b/src/test/modules/injection_points/specs/prepared_subxact_concurrent_abort.spec
@@ -0,0 +1,71 @@
+# Concurrent abort of a prepared transaction whose changes all belong to a
+# subtransaction.
+#
+# Decoding a prepared transaction at PREPARE time must notice if the
+# transaction is rolled back while it is being decoded, because the catalog
+# versions it sees can no longer be trusted.  ReorderBufferProcessTXN arms
+# that check by calling SetupCheckXidLive() for each change.  Here the
+# transaction's catalog change and the DML after it are made inside a
+# subtransaction, with no change of the top-level transaction, so the check
+# has to be armed for the subtransaction's changes.
+#
+# s1 decodes the prepared transaction and is held at its first change by an
+# injection point.  Meanwhile s2 rolls the transaction back and updates the
+# same pg_class row that the aborted ALTER TABLE had updated, which
+# overwrites the xmax of the old row version left by the aborted transaction.
+
+setup
+{
+	CREATE EXTENSION injection_points;
+	CREATE TABLE t (a int);
+}
+
+# Slot creation cannot share a transaction with writes.
+setup
+{
+	SELECT 'init' FROM pg_create_logical_replication_slot('s', 'test_decoding', false, true);
+}
+
+teardown
+{
+	DROP TABLE t;
+	DROP EXTENSION injection_points;
+	SELECT pg_drop_replication_slot('s');
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('reorderbuffer-process-change', 'wait');
+}
+step s1_decode
+{
+	SELECT data FROM pg_logical_slot_get_changes('s', NULL, NULL,
+		'skip-empty-xacts', '1', 'include-xids', '0');
+}
+
+session s2
+step s2_prepare
+{
+	BEGIN;
+	SAVEPOINT sp;
+	ALTER TABLE t ADD COLUMN b int;
+	INSERT INTO t VALUES (1, 1);
+	RELEASE SAVEPOINT sp;
+	PREPARE TRANSACTION 'x';
+}
+step s2_rollback_prepared	{ ROLLBACK PREPARED 'x'; }
+# Update the pg_class row of t that the aborted transaction had updated.
+step s2_update_catalog		{ ALTER TABLE t SET (fillfactor = 90); }
+# Detach first so that decoding does not wait again at its next change.
+step s2_detach	{ SELECT injection_points_detach('reorderbuffer-process-change'); }
+step s2_wakeup	{ SELECT injection_points_wakeup('reorderbuffer-process-change'); }
+
+permutation
+	s2_prepare
+	s1_decode
+	s2_rollback_prepared
+	s2_update_catalog
+	s2_detach
+	s2_wakeup
-- 
2.55.0

