From 4c12deb22a1516c9b8af4f279ba8ad7f8d98dc17 Mon Sep 17 00:00:00 2001
From: Manu <manuelreyesbravo@gmail.com>
Date: Mon, 26 Oct 2026 12:00:00 -0300
Subject: [PATCH v3 1/2] Set HEAP_XMAX_INVALID for aborted updates of tuples
 with a running inserter

e7f4463e869 made HeapTupleSatisfiesVacuumHorizon() set HEAP_XMAX_INVALID
on a tuple inserted by the current transaction and updated by one of its
subtransactions that rolled back.  The same tuple is seen by other
backends while the inserting transaction is still running, and there
the hint was not set either.  rewriteheap.c then treats the tuple as
updated, so a VACUUM FULL or CLUSTER of a catalog run by another session
loses the row: for example, the pg_class row of a table created in an
open transaction, after an ALTER TABLE on it was rolled back to a
savepoint.

The hint is now also set in the branch where the inserter is in
progress, if the updater has aborted.  This is skipped when xmax is
equal to xmin, as xmax is then in progress as well, and when xmax is a
MultiXact.  The clog is asked first, as a cheap filter for an updater
that is still running, and the proc array has to confirm that the
updater is gone: an abort reaches the clog before the transaction
leaves the proc array, and until then the new version of the tuple,
whose xmin is this xmax, is still in progress.  Setting the hint from
the clog alone would leave the old version marked as not updated while
its successor is still in progress.

The xmin != xmax check and the race with the proc array come from
Zhijie Hou, and the MultiXact skip from Michael Paquier.  The isolation
test is from Shihao Zhong.

Author: Manu <manuelreyesbravo@gmail.com>
Author: Shihao Zhong <zhong950419@gmail.com>
Reviewed-by: Zhijie Hou <houzhijie22@gmail.com>
Discussion: https://postgr.es/m/19760-8f9a0eba17bc8c92@postgresql.org
Backpatch-through: 14
---
 src/backend/access/heap/heapam_visibility.c   | 29 +++++++++++++++++
 .../cluster-aborted-subxact-update.out        | 20 ++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/cluster-aborted-subxact-update.spec | 32 +++++++++++++++++++
 4 files changed, 82 insertions(+)
 create mode 100644 src/test/isolation/expected/cluster-aborted-subxact-update.out
 create mode 100644 src/test/isolation/specs/cluster-aborted-subxact-update.spec

diff --git a/src/backend/access/heap/heapam_visibility.c b/src/backend/access/heap/heapam_visibility.c
index 905444a..681aaed 100644
--- a/src/backend/access/heap/heapam_visibility.c
+++ b/src/backend/access/heap/heapam_visibility.c
@@ -1184,6 +1184,35 @@ HeapTupleSatisfiesVacuumHorizon(HeapTuple htup, Buffer buffer, TransactionId *de
 		}
 		else if (TransactionIdIsInProgress(HeapTupleHeaderGetRawXmin(tuple)))
 		{
+			/*
+			 * If the updater of the tuple has already aborted (a rolled-back
+			 * subtransaction of the inserter), remember that, as callers like
+			 * rewriteheap.c rely on HEAP_XMAX_INVALID to tell a live tuple
+			 * from an updated one.
+			 */
+			if (!(tuple->t_infomask & HEAP_XMAX_INVALID) &&
+				!(tuple->t_infomask & HEAP_XMAX_IS_MULTI) &&
+				!HEAP_XMAX_IS_LOCKED_ONLY(tuple->t_infomask) &&
+				!HeapTupleHeaderIsOnlyLocked(tuple))
+			{
+				TransactionId xmax = HeapTupleHeaderGetUpdateXid(tuple);
+
+				/*
+				 * xmax == xmin: xmax is in progress too, nothing to do.
+				 * The clog is only a cheap filter here: a running xmax is not
+				 * aborted there.  An abort reaches the clog before the xid
+				 * leaves the proc array, and the new version of the tuple,
+				 * whose xmin is this xmax, is in progress until then.  So the
+				 * proc array must confirm it, as the note at the top of this
+				 * file asks.
+				 */
+				if (!TransactionIdEquals(xmax, HeapTupleHeaderGetRawXmin(tuple)) &&
+					TransactionIdDidAbort(xmax) &&
+					!TransactionIdIsInProgress(xmax))
+					SetHintBits(tuple, buffer, HEAP_XMAX_INVALID,
+								InvalidTransactionId);
+			}
+
 			/*
 			 * It'd be possible to discern between INSERT/DELETE in progress
 			 * here by looking at xmax - but that doesn't seem beneficial for
diff --git a/src/test/isolation/expected/cluster-aborted-subxact-update.out b/src/test/isolation/expected/cluster-aborted-subxact-update.out
new file mode 100644
index 0000000..a3448c2
--- /dev/null
+++ b/src/test/isolation/expected/cluster-aborted-subxact-update.out
@@ -0,0 +1,20 @@
+Parsed test spec with 2 sessions
+
+starting permutation: create_and_rollback_alter vacuum_full_pg_class commit check_pg_class
+step create_and_rollback_alter: 
+	BEGIN;
+	CREATE TABLE vacfull_tab (a int);
+	SAVEPOINT sp;
+	ALTER TABLE vacfull_tab ADD COLUMN b int;
+	ROLLBACK TO sp;
+
+step vacuum_full_pg_class: VACUUM FULL pg_class;
+step commit: COMMIT;
+step check_pg_class: 
+	SELECT relname, relnatts FROM pg_class WHERE relname = 'vacfull_tab';
+
+relname    |relnatts
+-----------+--------
+vacfull_tab|       1
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index bf9a203..eee40f5 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -122,6 +122,7 @@ test: plpgsql-toast
 test: cluster-conflict
 test: cluster-conflict-partition
 test: cluster-toast-value-reuse
+test: cluster-aborted-subxact-update
 test: truncate-conflict
 test: serializable-parallel
 test: serializable-parallel-2
diff --git a/src/test/isolation/specs/cluster-aborted-subxact-update.spec b/src/test/isolation/specs/cluster-aborted-subxact-update.spec
new file mode 100644
index 0000000..e2000ed
--- /dev/null
+++ b/src/test/isolation/specs/cluster-aborted-subxact-update.spec
@@ -0,0 +1,32 @@
+# VACUUM FULL must keep a row whose inserting transaction is still running,
+# when a rolled-back subtransaction of that transaction updated the row.
+#
+# VACUUM FULL of a user table would wait for the inserter, so this uses
+# pg_class.  The inserter creates a table and adds a column in a subtransaction
+# that it rolls back, which leaves an updated pg_class row whose updater
+# aborted.
+
+teardown
+{
+	DROP TABLE IF EXISTS vacfull_tab;
+}
+
+session creator
+step create_and_rollback_alter
+{
+	BEGIN;
+	CREATE TABLE vacfull_tab (a int);
+	SAVEPOINT sp;
+	ALTER TABLE vacfull_tab ADD COLUMN b int;
+	ROLLBACK TO sp;
+}
+step commit	{ COMMIT; }
+
+session vacuumer
+step vacuum_full_pg_class	{ VACUUM FULL pg_class; }
+step check_pg_class
+{
+	SELECT relname, relnatts FROM pg_class WHERE relname = 'vacfull_tab';
+}
+
+permutation create_and_rollback_alter vacuum_full_pg_class commit check_pg_class
-- 
2.56.0

