From 68bc8d3c5b5c6cfca685cf724f099ce22f31c62b Mon Sep 17 00:00:00 2001
From: Lionel Hamayon <lionel.hamayon@evolution-digitale.fr>
Date: Wed, 28 Oct 2026 16:47:46 +0100
Subject: [PATCH] Add regression tests for lock-only Xmax after a rolled-back
 update

When heap_update() has to release the buffer lock on the old tuple,
because the new version needs toasting or does not fit on the page, it
first marks the old tuple with a lock-only Xmax and asserts that it is
lock-only.  If the transaction already holds the same row lock in a
multixact that also contains the update of a rolled-back subtransaction,
MultiXactIdExpand() used to return that multixact unchanged, aborted
updater included, and the assertion failed:

    TRAP: failed Assert("HEAP_XMAX_IS_LOCKED_ONLY(infomask_lock_old_tuple)")

Add three cases to combocid.sql: FOR NO KEY UPDATE followed by a TOAST
update, FOR UPDATE followed by a key-changing TOAST update, and a TOAST
update of a version that inherited such an Xmax.  They use a value over
the TOAST threshold rather than a full page, so they do not depend on
how much space pruning frees.  Each case fails on an assert-enabled
build without the fix in MultiXactIdExpand().

Reported-by: Oleksii Kozlov
Reported-by: Jasper Smit <jbsmit@gmail.com>
Discussion: https://postgr.es/m/CAOG+RQ6ECiiFH7bb7e1M0bkd0whprp=T0u=rvARSaPOGrY-NDQ@mail.gmail.com
Backpatch-through: 14
---
 src/test/regress/expected/combocid.out | 72 ++++++++++++++++++++++++++
 src/test/regress/sql/combocid.sql      | 43 +++++++++++++++
 2 files changed, 115 insertions(+)

diff --git a/src/test/regress/expected/combocid.out b/src/test/regress/expected/combocid.out
index 2bf080b2ae2..ba9d5d30fdb 100644
--- a/src/test/regress/expected/combocid.out
+++ b/src/test/regress/expected/combocid.out
@@ -167,3 +167,75 @@ SELECT * FROM testcase WHERE id = 1 FOR UPDATE;
 
 ROLLBACK;
 DROP TABLE testcase;
+-- An UPDATE that must release the buffer lock (because the new tuple needs
+-- toasting or does not fit on the page) first marks the old tuple as locked.
+-- That lock-only Xmax must not keep the update of a rolled-back
+-- subtransaction, even when the updating transaction already holds the
+-- same row lock.
+CREATE TABLE testcase(
+	id int PRIMARY KEY,
+	val text
+);
+INSERT INTO testcase VALUES (1, '');
+BEGIN;
+SELECT id FROM testcase WHERE id = 1 FOR NO KEY UPDATE;
+ id 
+----
+  1
+(1 row)
+
+SAVEPOINT subxact;
+UPDATE testcase SET val = 'subxact' WHERE id = 1;
+ROLLBACK TO SAVEPOINT subxact;
+RELEASE SAVEPOINT subxact;
+UPDATE testcase SET val = repeat('x', 3000) WHERE id = 1;
+SELECT id, length(val) FROM testcase;
+ id | length 
+----+--------
+  1 |   3000
+(1 row)
+
+COMMIT;
+-- same with FOR UPDATE and a key update
+BEGIN;
+SELECT id FROM testcase WHERE id = 1 FOR UPDATE;
+ id 
+----
+  1
+(1 row)
+
+SAVEPOINT subxact;
+UPDATE testcase SET val = 'subxact' WHERE id = 1;
+ROLLBACK TO SAVEPOINT subxact;
+RELEASE SAVEPOINT subxact;
+UPDATE testcase SET id = 2, val = repeat('y', 3000) WHERE id = 1;
+SELECT id, length(val) FROM testcase;
+ id | length 
+----+--------
+  2 |   3000
+(1 row)
+
+COMMIT;
+-- the new tuple version inherits the Xmax of the old one; a later update of
+-- that version must not trip over the rolled-back update either
+BEGIN;
+SELECT id FROM testcase WHERE id = 2 FOR NO KEY UPDATE;
+ id 
+----
+  2
+(1 row)
+
+SAVEPOINT subxact;
+UPDATE testcase SET val = 'subxact' WHERE id = 2;
+ROLLBACK TO SAVEPOINT subxact;
+RELEASE SAVEPOINT subxact;
+UPDATE testcase SET val = 'small' WHERE id = 2;
+UPDATE testcase SET val = repeat('z', 3000) WHERE id = 2;
+SELECT id, length(val) FROM testcase;
+ id | length 
+----+--------
+  2 |   3000
+(1 row)
+
+COMMIT;
+DROP TABLE testcase;
diff --git a/src/test/regress/sql/combocid.sql b/src/test/regress/sql/combocid.sql
index a5cdf6dba29..2e7d638a204 100644
--- a/src/test/regress/sql/combocid.sql
+++ b/src/test/regress/sql/combocid.sql
@@ -109,3 +109,46 @@ ROLLBACK TO SAVEPOINT subxact;
 SELECT * FROM testcase WHERE id = 1 FOR UPDATE;
 ROLLBACK;
 DROP TABLE testcase;
+
+-- An UPDATE that must release the buffer lock (because the new tuple needs
+-- toasting or does not fit on the page) first marks the old tuple as locked.
+-- That lock-only Xmax must not keep the update of a rolled-back
+-- subtransaction, even when the updating transaction already holds the
+-- same row lock.
+CREATE TABLE testcase(
+	id int PRIMARY KEY,
+	val text
+);
+INSERT INTO testcase VALUES (1, '');
+BEGIN;
+SELECT id FROM testcase WHERE id = 1 FOR NO KEY UPDATE;
+SAVEPOINT subxact;
+UPDATE testcase SET val = 'subxact' WHERE id = 1;
+ROLLBACK TO SAVEPOINT subxact;
+RELEASE SAVEPOINT subxact;
+UPDATE testcase SET val = repeat('x', 3000) WHERE id = 1;
+SELECT id, length(val) FROM testcase;
+COMMIT;
+-- same with FOR UPDATE and a key update
+BEGIN;
+SELECT id FROM testcase WHERE id = 1 FOR UPDATE;
+SAVEPOINT subxact;
+UPDATE testcase SET val = 'subxact' WHERE id = 1;
+ROLLBACK TO SAVEPOINT subxact;
+RELEASE SAVEPOINT subxact;
+UPDATE testcase SET id = 2, val = repeat('y', 3000) WHERE id = 1;
+SELECT id, length(val) FROM testcase;
+COMMIT;
+-- the new tuple version inherits the Xmax of the old one; a later update of
+-- that version must not trip over the rolled-back update either
+BEGIN;
+SELECT id FROM testcase WHERE id = 2 FOR NO KEY UPDATE;
+SAVEPOINT subxact;
+UPDATE testcase SET val = 'subxact' WHERE id = 2;
+ROLLBACK TO SAVEPOINT subxact;
+RELEASE SAVEPOINT subxact;
+UPDATE testcase SET val = 'small' WHERE id = 2;
+UPDATE testcase SET val = repeat('z', 3000) WHERE id = 2;
+SELECT id, length(val) FROM testcase;
+COMMIT;
+DROP TABLE testcase;
-- 
2.56.0

