Server crash on UPDATE after savepoint rollback when the row is locked by the same transaction (Assert HEAP_XMAX_IS_LOCKED_ONLY in heap_update)
Hackorum builds and tests every patch posted to the lists, not only commitfest submissions. This is Hackorum's own CI rather than the PostgreSQL project's, and it is still under testing - please report anything that looks wrong.
You can run a PostgreSQL built from this patch straight from Docker, with no checkout and no build:
docker run --rm -p 5432:5432 ghcr.io/hackorum-dev/postgres-patch:t254511psql -h localhost -U postgresBuilt from patchset v2 (message #2), November 02, 2026 at 09:04 AM.
Every patchset is also pushed to a branch of our PostgreSQL fork, so you can check out the same tree CI built. Without a PostgreSQL checkout:
git clone --branch t254511_2 https://github.com/hackorum-dev/postgres.gitIn a checkout you already have, add the fork once:
git remote add hackorum https://github.com/hackorum-dev/postgres.gitthen, for this patchset and every later one:
git fetch hackorum t254511_2 && git checkout t254511_2Patchset v2 (message #2) is on t254511_2
PostgreSQL version: PostgreSQL 19rc1; also reproduced on 10.23. The relevant
code in MultiXactIdExpand/compute_new_xmax_infomask is identical on every
branch I checked (9.4 through master), so all maintained versions appear
affected.
Operating system: Linux x86_64, gcc 15.2. Only --enable-cassert builds crash;
in release builds the Assert is compiled out (the invariant is still violated).
Description
A backend crashes (SIGABRT via ExceptionalCondition) on an UPDATE when all of
the following hold:
1. transaction T has previously locked the row with SELECT ... FOR NO KEY
UPDATE;
2. a subtransaction of T updated the row - the old version's xmax becomes the
multixact {T: ForNoKeyUpdate, S: <update status>} - and that subtransaction
was rolled back (its update aborted);
3. the subtransaction was released (RELEASE SAVEPOINT /
RollbackAndReleaseCurrentSubTransaction), so subsequent statements run in T
itself;
4. T updates the same row again and the new tuple needs TOASTing (or does not
fit the page), so heap_update takes the "temporarily lock the old tuple"
path.
Steps to reproduce (any assert build):
CREATE TABLE tr (id int primary key, val text);
INSERT INTO tr VALUES (1, 'x');
BEGIN;
SELECT id FROM tr WHERE id = 1 FOR NO KEY UPDATE;
SAVEPOINT s;
UPDATE tr SET val = 'sub' WHERE id = 1;
ROLLBACK TO SAVEPOINT s;
RELEASE SAVEPOINT s;
UPDATE tr SET val = repeat('md5', 4000) WHERE id = 1;
COMMIT;
The last UPDATE crashes the server:
TRAP: failed Assert("HEAP_XMAX_IS_LOCKED_ONLY(infomask_lock_old_tuple)"),
File: "heapam.c", Line: 3886 (19rc1; 4046 in 10.23)
ExceptionalCondition + heap_update + ...
Analysis
- After step 2 the row's xmax is a multixact M containing the (now aborted)
subtransaction's update.
- The retry UPDATE reaches the temporary-lock step and calls
compute_new_xmax_infomask(..., is_update=false), which in the IS_MULTI branch
calls MultiXactIdExpand(M, T, ForNoKeyUpdate).
- MultiXactIdExpand returns early - "If the TransactionId is already a member
of the MultiXactId with the same status, just return the existing
MultiXactId" - because T itself is a member with status ForNoKeyUpdate.
- That early return happens before the pruning of dead members ("An update
that aborted is of no interest here"), so the aborted updater stays in M;
GetMultiXactIdHintBits leaves HEAP_XMAX_LOCK_ONLY unset and the assertion
fails.
Without step 3 (a plain ROLLBACK TO SAVEPOINT, which re-establishes the
savepoint in a new subtransaction), the retry's xid is not a member of M, the
pruning runs, and everything works - which is probably why this has gone
unnoticed.
We hit this in practice with the pg_task extension: its worker locks the task
row FOR NO KEY UPDATE SKIP LOCKED, runs a bookkeeping UPDATE in an internal
subtransaction, and when the table owner's AFTER UPDATE trigger raises,
records the failure by re-running the same UPDATE in the parent transaction
after RollbackAndReleaseCurrentSubTransaction - reliably crashing the server
on assert builds whenever the retry needs TOAST or the page is full.
Suggested fix
In MultiXactIdExpand, prune dead members ("still of interest") before the
same-status early return, and only return the existing MultiXactId unchanged
when nothing was pruned; otherwise recreate the multi from the remaining
members. A patch against master is attached; I verified it fixes both the
repro above and the pg_task scenario on 19rc1, with the multixact now created
without the aborted updater (HEAP_XMAX_LOCK_ONLY set as expected).
пн, 2 нояб. 2026 г. в 12:14, RekGRpth <rekgrpth@gmail.com>:
Suggested fix
In MultiXactIdExpand, prune dead members ("still of interest") before the
same-status early return, and only return the existing MultiXactId
unchanged
when nothing was pruned; otherwise recreate the multi from the remaining
members. A patch against master is attached; I verified it fixes both the
repro above and the pg_task scenario on 19rc1, with the multixact now
created
without the aborted updater (HEAP_XMAX_LOCK_ONLY set as expected).
Hi Georgy!
Thanks for the report.
v2 is attached. A few comments were adjusted, and the test was added
to src/test/isolation/isolation_schedule.
On 02/11/2026 09:14, RekGRpth wrote:
Steps to reproduce (any assert build):
CREATE TABLE tr (id int primary key, val text);
INSERT INTO tr VALUES (1, 'x');
BEGIN;
SELECT id FROM tr WHERE id = 1 FOR NO KEY UPDATE;
SAVEPOINT s;
UPDATE tr SET val = 'sub' WHERE id = 1;
ROLLBACK TO SAVEPOINT s;
RELEASE SAVEPOINT s;
UPDATE tr SET val = repeat('md5', 4000) WHERE id = 1;
COMMIT;The last UPDATE crashes the server:
TRAP: failed Assert("HEAP_XMAX_IS_LOCKED_ONLY(infomask_lock_old_tuple)"),
File: "heapam.c", Line: 3886 (19rc1; 4046 in 10.23)
ExceptionalCondition + heap_update + ...
This was previously reported at
/messages/by-id/CAOG+RQ6ECiiFH7bb7e1M0bkd0whprp=T0u=rvARSaPOGrY-NDQ@mail.gmail.com
- Heikki