From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: RekGRpth <rekgrpth@gmail.com>
Date: Mon, 2 Nov 2026 12:00:00 +0500
Subject: [PATCH] Prune dead members of a multixact before the same-status
 early return in MultiXactIdExpand
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

MultiXactIdExpand has an early return that hands the given multixact back
as-is when the transaction to add is already a member of it with the same
status.  That check ran before the pruning of members that are of no
interest anymore, so the returned multixact could still contain, for
example, the aborted update of a subtransaction.

In heap_update, the temporary locking of the old tuple, taken when the new
version needs TOAST or does not fit its page, then computed a multixact
that is not locked-only, failing Assert(HEAP_XMAX_IS_LOCKED_ONLY) in an
assertion-enabled build, once a transaction that had locked a row FOR NO
KEY UPDATE updated it in a subtransaction that was rolled back and
released, and updated the row again itself.  A release build just stored
the multixact with the dead member instead.

Prune the members first, and return the given multixact unchanged only if
nothing was pruned, so that the early return never hands back a multixact
with dead members; when something was pruned, create a new one from the
members that remain.

Found by running the pg_task extension, whose worker locks the task row
FOR NO KEY UPDATE and re-runs its bookkeeping UPDATE in the parent
transaction after a trigger of the owner fails the one in a
subtransaction.  An isolation test reproducing the crash is added.
---
 src/backend/access/transam/multixact.c                 | 49 +++++++++++++++--------------
 .../isolation/specs/multixact-expand-savepoint.spec    | 37 +++++++++++++++++++++
 .../expected/multixact-expand-savepoint.out            | 29 +++++++++++++++++++
 3 files changed, 98 insertions(+), 17 deletions(-)

--- a/src/backend/access/transam/multixact.c	2026-11-02 11:33:07.735438037 +0500
+++ b/src/backend/access/transam/multixact.c	2026-11-02 12:06:50.582067256 +0500
@@ -454,22 +454,6 @@
 	}
 
 	/*
-	 * If the TransactionId is already a member of the MultiXactId with the
-	 * same status, just return the existing MultiXactId.
-	 */
-	for (i = 0; i < nmembers; i++)
-	{
-		if (TransactionIdEquals(members[i].xid, xid) &&
-			(members[i].status == status))
-		{
-			debug_elog4(DEBUG2, "Expand: %u is already a member of %u",
-						xid, multi);
-			pfree(members);
-			return multi;
-		}
-	}
-
-	/*
 	 * Determine which of the members of the MultiXactId are still of
 	 * interest. This is any running transaction, and also any transaction
 	 * that grabbed something stronger than just a lock and was committed. (An
@@ -495,6 +479,37 @@
 		}
 	}
 
+	/*
+	 * If the TransactionId is already a member of the MultiXactId with the
+	 * same status, we need not add it again.  Return the existing
+	 * MultiXactId as-is if no member was dropped above; otherwise create a
+	 * new one from the members that remain, because handing back the
+	 * existing one would preserve members that are of no interest anymore.
+	 * (For instance, an aborted update of a subtransaction kept there made
+	 * the temporary locking of the old tuple in heap_update fail its
+	 * assertion that the xmax it computes be locked-only.)
+	 */
+	for (i = 0; i < j; i++)
+	{
+		if (TransactionIdEquals(newMembers[i].xid, xid) &&
+			(newMembers[i].status == status))
+		{
+			if (j == nmembers)
+			{
+				debug_elog4(DEBUG2, "Expand: %u is already a member of %u",
+							xid, multi);
+				pfree(members);
+				pfree(newMembers);
+				return multi;
+			}
+			newMulti = MultiXactIdCreateFromMembers(j, newMembers);
+			pfree(members);
+			pfree(newMembers);
+			debug_elog3(DEBUG2, "Expand: returning new multi %u", newMulti);
+			return newMulti;
+		}
+	}
+
 	newMembers[j].xid = xid;
 	newMembers[j++].status = status;
 	newMulti = MultiXactIdCreateFromMembers(j, newMembers);

--- /dev/null	2026-11-02 07:02:07.009483604 +0500
+++ b/src/test/isolation/specs/multixact-expand-savepoint.spec	2026-11-02 12:05:39.653275417 +0500
@@ -0,0 +1,36 @@
+# This test verifies that MultiXactIdExpand does not hand back a multixact
+# that still contains members which are of no interest anymore.  A
+# transaction that locked a row FOR NO KEY UPDATE updates it in a
+# subtransaction which is then rolled back and released, and updates the row
+# again by itself, with a new version that needs TOAST, so that heap_update
+# temporarily locks the old tuple: the multixact it computes there must be
+# locked-only, which an aborted update kept in it used to violate, failing
+# Assert(HEAP_XMAX_IS_LOCKED_ONLY) in an assertion-enabled build.
+
+setup
+{
+  DROP TABLE IF EXISTS multixact_expand;
+  CREATE TABLE multixact_expand (id int primary key, val text);
+  INSERT INTO multixact_expand VALUES (1, 'x');
+}
+
+teardown
+{
+  DROP TABLE multixact_expand;
+}
+
+session s1
+step s1b	{ BEGIN; }
+step s1l	{ SELECT id FROM multixact_expand WHERE id = 1 FOR NO KEY UPDATE; }
+step s1sv	{ SAVEPOINT s; }
+step s1u	{ UPDATE multixact_expand SET val = 'sub' WHERE id = 1; }
+step s1rbs	{ ROLLBACK TO SAVEPOINT s; }
+step s1rel	{ RELEASE SAVEPOINT s; }
+step s1u2	{ UPDATE multixact_expand SET val = repeat('md5', 4000) WHERE id = 1; }
+step s1len	{ SELECT length(val) FROM multixact_expand WHERE id = 1; }
+step s1c	{ COMMIT; }
+
+session s2
+step s2sel	{ SELECT id FROM multixact_expand; }
+
+permutation s1b s1l s1sv s1u s1rbs s1rel s1u2 s1len s1c s2sel

--- /dev/null	2026-11-02 07:02:07.009483604 +0500
+++ b/src/test/isolation/expected/multixact-expand-savepoint.out	2026-11-02 12:06:11.774181271 +0500
@@ -0,0 +1,28 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1b s1l s1sv s1u s1rbs s1rel s1u2 s1len s1c s2sel
+step s1b: BEGIN;
+step s1l: SELECT id FROM multixact_expand WHERE id = 1 FOR NO KEY UPDATE;
+id
+--
+ 1
+(1 row)
+
+step s1sv: SAVEPOINT s;
+step s1u: UPDATE multixact_expand SET val = 'sub' WHERE id = 1;
+step s1rbs: ROLLBACK TO SAVEPOINT s;
+step s1rel: RELEASE SAVEPOINT s;
+step s1u2: UPDATE multixact_expand SET val = repeat('md5', 4000) WHERE id = 1;
+step s1len: SELECT length(val) FROM multixact_expand WHERE id = 1;
+length
+------
+ 12000
+(1 row)
+
+step s1c: COMMIT;
+step s2sel: SELECT id FROM multixact_expand;
+id
+--
+ 1
+(1 row)
+
-- 
2.39.5
