Hi,
I'd like to bring this thread back up. I ran into the same assertion
independently, and it still fails on master (4848fd6752a).
Why it matters to me: I develop an extension (pg_tviews) that keeps
summary tables up to date from statement-level AFTER triggers with
transition tables. The triggers upsert with INSERT ... ON CONFLICT DO
UPDATE, and some run inside PL/pgSQL exception blocks that roll back an
earlier update of the same row. I run my randomized correctness tests
on assert-enabled builds (16, 18 and 19), and this assertion crashes
the backend there. In practice I can't run that suite on cassert
builds until it's fixed. As Luc found, I saw no wrong results on
non-assert builds.
Here is a smaller repro than the original script (assert-enabled build,
fresh database):
CREATE TABLE t (a int, b text);
INSERT INTO t VALUES (1, '');
BEGIN;
SELECT * FROM t FOR NO KEY UPDATE;
SAVEPOINT s;
UPDATE t SET a = 2;
ROLLBACK TO SAVEPOINT s;
RELEASE SAVEPOINT s;
UPDATE t SET b = repeat('x', 3000); -- TRAP
COMMIT;
TRAP: failed Assert("HEAP_XMAX_IS_LOCKED_ONLY(infomask_lock_old_tuple)"),
File: "heapam.c", Line: 3893
It fails on master, REL_19_STABLE, 18.6 and REL_14_STABLE. The original
assert.sql no longer triggers it on REL_19 for me, because the page
never fills up, so the repro above uses a value over the TOAST threshold
instead.
Jasper's multixact.patch still applies cleanly to master, and with it:
- the repro above and the variants I tried pass (FOR UPDATE with a
key-changing update, ON CONFLICT DO UPDATE, MERGE, PL/pgSQL exception
blocks, a full page);
- make check and the isolation tests pass;
- on Luc's cost question: rebuilding a multixact with the same members
returns the existing one from the backend-local cache, so no extra
multixact IDs are used. I ran 500 TOAST updates while another
transaction held FOR KEY SHARE on the row, and both the patched and
unpatched builds used 2 multixacts. What remains is one
TransactionIdIsInProgress() call per member, and only on this path,
which as far as I can tell is reachable only from heap_update()'s
TOAST/new-page branch.
The patch still needs its comments updated: the function header and the
comment above the loop both still say the existing multixact is
returned as-is.
Attached is a regression test for combocid.sql that goes on top of
Jasper's patch. It has three cases, and each one crashes an
assert-enabled build without the fix.
In full disclosure: I'm not a PostgreSQL internals developer. The
analysis and the test were done with an AI assistant (Claude). I checked
the results by running them, but I can't vouch for the reasoning in
depth, so please treat it accordingly. I hope that, together with
Jasper's patch, this is enough to get the fix in.
Regards,
Lionel Hamayon
From 68bc8d3c5b5c6cfca685cf724f099ce22f31c62b Mon Sep 17 00:00:00 2001
From: Lionel Hamayon <[email protected]>
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 <[email protected]>
Discussion: https://postgr.es/m/CAOG+RQ6ECiiFH7bb7e1M0bkd0whprp=T0u=rvarsapogry-...@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