From 89b9e046b6f0c3022b6fdec521b934919259c614 Mon Sep 17 00:00:00 2001
From: Bingshuai Li <lucian1412@outlook.com>
Date: Wed, 12 Aug 2026 10:17:04 +0800
Subject: [PATCH v2] Fix stale tuplecid records left behind by aborted
 subtransactions

Tuplecid changes are always queued on the toplevel transaction, so unlike regular changes they are not dropped when the subtransaction that wrote them aborts.  The stale mappings corrupt ReorderBufferBuildTupleCidHash() at commit time: if a later catalog insert reuses the aborted subtransaction's line pointer (made LP_UNUSED by on-access pruning), the fresh record collides with the stale one with a different cmin, tripping the cmin equality assertion; where nothing reuses the tid, a stale cmax makes a still-live catalog tuple look deleted on historic snapshots.

Remove the aborted subtransaction's tuplecid entries in ReorderBufferAbort().  The sub->top association is always known there, because any subtransaction with tuplecid entries must have written WAL (the xl_heap_new_cid record itself), and LogicalDecodingProcessRecord() assigns records carrying a top-level xid to their top-level transaction before dispatching them.

Bug: #19555
Reported-by: Alexander Kozhemyakin <a.kozhemyakin@postgrespro.ru>
Discussion: https://postgr.es/m/CAHgHdKu5e3XY5e90Tuaxq_R4WrKxSV734Q%2BLwo5y39Omp2A-Gg@mail.gmail.com
---
 contrib/test_decoding/Makefile                |   3 +-
 contrib/test_decoding/expected/tuplecid.out   | 484 ++++++++++++++++++
 contrib/test_decoding/meson.build             |   1 +
 contrib/test_decoding/sql/tuplecid.sql        |  73 +++
 .../replication/logical/reorderbuffer.c       |  48 +-
 src/backend/replication/logical/snapbuild.c   |   2 +-
 src/include/replication/reorderbuffer.h       |   9 +-
 7 files changed, 616 insertions(+), 4 deletions(-)
 create mode 100644 contrib/test_decoding/expected/tuplecid.out
 create mode 100644 contrib/test_decoding/sql/tuplecid.sql

diff --git a/contrib/test_decoding/Makefile b/contrib/test_decoding/Makefile
index 0111124399a..46db0eb3b75 100644
--- a/contrib/test_decoding/Makefile
+++ b/contrib/test_decoding/Makefile
@@ -5,7 +5,8 @@ PGFILEDESC = "test_decoding - example of a logical decoding output plugin"
 
 REGRESS = ddl xact rewrite toast permissions decoding_in_xact \
 	decoding_into_rel binary prepared replorigin time messages \
-	repack spill slot truncate stream stats twophase twophase_stream
+	repack spill slot truncate stream stats twophase twophase_stream \
+	tuplecid
 ISOLATION = mxact delayed_startup ondisk_startup concurrent_ddl_dml \
 	oldest_xmin snapshot_transfer subxact_without_top concurrent_stream \
 	twophase_snapshot slot_creation_error catalog_change_snapshot \
diff --git a/contrib/test_decoding/expected/tuplecid.out b/contrib/test_decoding/expected/tuplecid.out
new file mode 100644
index 00000000000..83228134106
--- /dev/null
+++ b/contrib/test_decoding/expected/tuplecid.out
@@ -0,0 +1,484 @@
+-- Tests for decoding of transactions in which a catalog-modifying
+-- subtransaction was rolled back (BUG #19555).
+--
+-- When a subtransaction modifies catalog tuples and is then rolled back,
+-- its dead heap-only line pointers can be marked LP_UNUSED by on-access
+-- pruning and reused by later catalog inserts of the same top-level
+-- transaction.  The aborted subtransaction's xl_heap_new_cid records must
+-- not be consulted when building the historic snapshot used to decode the
+-- transaction; on unfixed builds they collide with the records of the
+-- reused line pointer and decoding dies with
+--   TRAP: failed Assert("ent->cmin == change->data.tuplecid.cmin")
+-- (or, on non-assert builds, silently uses wrong cmin/cmax mappings).
+--
+-- Whether the collision triggers depends on the physical layout of the
+-- catalog pages, so run in a fresh database where prior tests cannot have
+-- changed it, and loop enough times to leave headroom for layout
+-- differences across versions.
+CREATE DATABASE tuplecid_db;
+-- snapshot the original database name (make and meson use different
+-- names for it) so we can switch back before dropping the test database
+\set prevdb :DBNAME
+\c tuplecid_db
+-- predictability
+SET synchronous_commit = on;
+SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding');
+ ?column? 
+----------
+ init
+(1 row)
+
+-- Catalog churn to push the catalog pages into a state where on-access
+-- pruning frees the aborted subtransaction's line pointer and the second
+-- ALTER's catalog insert reuses it.
+CREATE TABLE tpc_filler1(f1 char(4));
+CREATE TABLE tpc_filler2(data text);
+INSERT INTO tpc_filler2 VALUES ('before-test');
+CREATE TYPE tpc_complex AS (r float8, i float8);
+CREATE TABLE tpc_filler3 (a int PRIMARY KEY, b text DEFAULT 'Unspecified');
+CREATE TABLE tpc_filler4 (id serial, t text);
+CREATE TABLE tpc_filler5(data text);
+INSERT INTO tpc_filler5 SELECT repeat('a', 2000) || g.i FROM generate_series(1, 1) g(i);
+TRUNCATE table tpc_filler5;
+CHECKPOINT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ count 
+-------
+     9
+(1 row)
+
+-- Each iteration runs the trigger shape: catalog DDL inside a rolled-back
+-- subtransaction, then the same DDL again, then decode.  Unfixed builds
+-- typically die within a handful of iterations.
+SELECT format($fmt$
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+$fmt$)
+FROM generate_series(1, 25) \gexec
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+
+ count 
+-------
+     0
+(1 row)
+
+DROP TABLE tpc_filler1;
+DROP TABLE tpc_filler2;
+DROP TYPE tpc_complex;
+DROP TABLE tpc_filler3;
+DROP TABLE tpc_filler4;
+DROP TABLE tpc_filler5;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ count 
+-------
+     0
+(1 row)
+
+SELECT pg_drop_replication_slot('regression_slot');
+ pg_drop_replication_slot 
+--------------------------
+ 
+(1 row)
+
+\c :prevdb
+DROP DATABASE tuplecid_db;
diff --git a/contrib/test_decoding/meson.build b/contrib/test_decoding/meson.build
index ac655853d26..ac8f13c12ac 100644
--- a/contrib/test_decoding/meson.build
+++ b/contrib/test_decoding/meson.build
@@ -42,6 +42,7 @@ tests += {
       'stats',
       'twophase',
       'twophase_stream',
+      'tuplecid',
     ],
     'regress_args': [
       '--temp-config', files('logical.conf'),
diff --git a/contrib/test_decoding/sql/tuplecid.sql b/contrib/test_decoding/sql/tuplecid.sql
new file mode 100644
index 00000000000..d04dbec790f
--- /dev/null
+++ b/contrib/test_decoding/sql/tuplecid.sql
@@ -0,0 +1,73 @@
+-- Tests for decoding of transactions in which a catalog-modifying
+-- subtransaction was rolled back (BUG #19555).
+--
+-- When a subtransaction modifies catalog tuples and is then rolled back,
+-- its dead heap-only line pointers can be marked LP_UNUSED by on-access
+-- pruning and reused by later catalog inserts of the same top-level
+-- transaction.  The aborted subtransaction's xl_heap_new_cid records must
+-- not be consulted when building the historic snapshot used to decode the
+-- transaction; on unfixed builds they collide with the records of the
+-- reused line pointer and decoding dies with
+--   TRAP: failed Assert("ent->cmin == change->data.tuplecid.cmin")
+-- (or, on non-assert builds, silently uses wrong cmin/cmax mappings).
+--
+-- Whether the collision triggers depends on the physical layout of the
+-- catalog pages, so run in a fresh database where prior tests cannot have
+-- changed it, and loop enough times to leave headroom for layout
+-- differences across versions.
+CREATE DATABASE tuplecid_db;
+-- snapshot the original database name (make and meson use different
+-- names for it) so we can switch back before dropping the test database
+\set prevdb :DBNAME
+\c tuplecid_db
+
+-- predictability
+SET synchronous_commit = on;
+
+SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding');
+
+-- Catalog churn to push the catalog pages into a state where on-access
+-- pruning frees the aborted subtransaction's line pointer and the second
+-- ALTER's catalog insert reuses it.
+CREATE TABLE tpc_filler1(f1 char(4));
+CREATE TABLE tpc_filler2(data text);
+INSERT INTO tpc_filler2 VALUES ('before-test');
+CREATE TYPE tpc_complex AS (r float8, i float8);
+CREATE TABLE tpc_filler3 (a int PRIMARY KEY, b text DEFAULT 'Unspecified');
+CREATE TABLE tpc_filler4 (id serial, t text);
+CREATE TABLE tpc_filler5(data text);
+INSERT INTO tpc_filler5 SELECT repeat('a', 2000) || g.i FROM generate_series(1, 1) g(i);
+TRUNCATE table tpc_filler5;
+CHECKPOINT;
+
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- Each iteration runs the trigger shape: catalog DDL inside a rolled-back
+-- subtransaction, then the same DDL again, then decode.  Unfixed builds
+-- typically die within a handful of iterations.
+SELECT format($fmt$
+CREATE TABLE tpc_cmin(data int, pad1 text, pad2 int, pad3 text);
+BEGIN;
+SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE text;
+ROLLBACK TO SAVEPOINT a;
+ALTER TABLE tpc_cmin ALTER COLUMN data TYPE bigint;
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE tpc_cmin;
+$fmt$)
+FROM generate_series(1, 25) \gexec
+
+DROP TABLE tpc_filler1;
+DROP TABLE tpc_filler2;
+DROP TYPE tpc_complex;
+DROP TABLE tpc_filler3;
+DROP TABLE tpc_filler4;
+DROP TABLE tpc_filler5;
+
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+SELECT pg_drop_replication_slot('regression_slot');
+
+\c :prevdb
+DROP DATABASE tuplecid_db;
diff --git a/src/backend/replication/logical/reorderbuffer.c b/src/backend/replication/logical/reorderbuffer.c
index 6aed6346366..1f6c0ac0b33 100644
--- a/src/backend/replication/logical/reorderbuffer.c
+++ b/src/backend/replication/logical/reorderbuffer.c
@@ -3140,6 +3140,50 @@ ReorderBufferAbort(ReorderBuffer *rb, TransactionId xid, XLogRecPtr lsn,
 	/* cosmetic... */
 	txn->final_lsn = lsn;
 
+	/*
+	 * If this is a subtransaction, remove any tuplecid changes it added to
+	 * the toplevel transaction's list.  Unlike regular changes, tuplecid
+	 * changes are always queued on the toplevel transaction (see
+	 * ReorderBufferAddNewTupleCids), so they would otherwise survive the
+	 * abort of the subtransaction that wrote them.  That's wrong: they
+	 * describe catalog tuple versions created or killed by the aborted
+	 * subtransaction, which never became visible, and keeping them can
+	 * corrupt ReorderBufferBuildTupleCidHash() at commit time -- both when
+	 * the toplevel transaction later reuses the same tid (the stale entry
+	 * collides with the fresh one, breaking the cmin equality assumption)
+	 * and when nothing touches the tid again (a stale cmax would make a
+	 * still-live tuple look deleted on historic snapshots).
+	 *
+	 * The association between the subtransaction and its toplevel is
+	 * guaranteed to be known here: the subtransaction must have written at
+	 * least one WAL record (the xl_heap_new_cid record itself) for any
+	 * tuplecid change to exist, and records written inside a subtransaction
+	 * carry the toplevel xid, which LogicalDecodingProcessRecord() uses to
+	 * assign the subtransaction to its toplevel before dispatching the
+	 * record.
+	 */
+	if (rbtxn_is_known_subxact(txn))
+	{
+		ReorderBufferTXN *toptxn = rbtxn_get_toptxn(txn);
+		dlist_mutable_iter it;
+
+		dlist_foreach_modify(it, &toptxn->tuplecids)
+		{
+			ReorderBufferChange *change;
+
+			change = dlist_container(ReorderBufferChange, node, it.cur);
+
+			Assert(change->action == REORDER_BUFFER_CHANGE_INTERNAL_TUPLECID);
+
+			if (change->data.tuplecid.subxid == xid)
+			{
+				dlist_delete(&change->node);
+				ReorderBufferFreeChange(rb, change, false);
+				toptxn->ntuplecids--;
+			}
+		}
+	}
+
 	/* remove potential on-disk data, and deallocate */
 	ReorderBufferCleanupTXN(rb, txn);
 }
@@ -3477,7 +3521,8 @@ void
 ReorderBufferAddNewTupleCids(ReorderBuffer *rb, TransactionId xid,
 							 XLogRecPtr lsn, RelFileLocator locator,
 							 ItemPointerData tid, CommandId cmin,
-							 CommandId cmax, CommandId combocid)
+							 CommandId cmax, CommandId combocid,
+							 TransactionId subxid)
 {
 	ReorderBufferChange *change = ReorderBufferAllocChange(rb);
 	ReorderBufferTXN *txn;
@@ -3489,6 +3534,7 @@ ReorderBufferAddNewTupleCids(ReorderBuffer *rb, TransactionId xid,
 	change->data.tuplecid.cmin = cmin;
 	change->data.tuplecid.cmax = cmax;
 	change->data.tuplecid.combocid = combocid;
+	change->data.tuplecid.subxid = subxid;
 	change->lsn = lsn;
 	change->txn = txn;
 	change->action = REORDER_BUFFER_CHANGE_INTERNAL_TUPLECID;
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index f60bcf09605..11cc3c08ffd 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -703,7 +703,7 @@ SnapBuildProcessNewCid(SnapBuild *builder, TransactionId xid,
 	ReorderBufferAddNewTupleCids(builder->reorder, xlrec->top_xid, lsn,
 								 xlrec->target_locator, xlrec->target_tid,
 								 xlrec->cmin, xlrec->cmax,
-								 xlrec->combocid);
+								 xlrec->combocid, xid);
 
 	/* figure out new command id */
 	if (xlrec->cmin != InvalidCommandId &&
diff --git a/src/include/replication/reorderbuffer.h b/src/include/replication/reorderbuffer.h
index ff825e4b7b2..9d4f01e1d77 100644
--- a/src/include/replication/reorderbuffer.h
+++ b/src/include/replication/reorderbuffer.h
@@ -146,6 +146,11 @@ typedef struct ReorderBufferChange
 			CommandId	cmin;
 			CommandId	cmax;
 			CommandId	combocid;
+			TransactionId subxid;	/* xid that wrote the tuplecid record;
+									 * differs from the xid of the txn this
+									 * change is queued on (always the
+									 * toplevel xid) when a subtransaction
+									 * modified the catalog */
 		}			tuplecid;
 
 		/* Invalidation. */
@@ -751,7 +756,9 @@ extern void ReorderBufferAddNewCommandId(ReorderBuffer *rb, TransactionId xid,
 extern void ReorderBufferAddNewTupleCids(ReorderBuffer *rb, TransactionId xid,
 										 XLogRecPtr lsn, RelFileLocator locator,
 										 ItemPointerData tid,
-										 CommandId cmin, CommandId cmax, CommandId combocid);
+										 CommandId cmin, CommandId cmax,
+										 CommandId combocid,
+										 TransactionId subxid);
 extern void ReorderBufferAddInvalidations(ReorderBuffer *rb, TransactionId xid, XLogRecPtr lsn,
 										  Size nmsgs, SharedInvalidationMessage *msgs);
 extern void ReorderBufferAddDistributedInvalidations(ReorderBuffer *rb, TransactionId xid,
-- 
2.43.0

