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)

Started by RekGRpthabout 6 hours ago3 messagesbugs
Beta feature

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.

appliessuccessCI history

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:t254511
psql -h localhost -U postgres

Built 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.git

In a checkout you already have, add the fork once:

git remote add hackorum https://github.com/hackorum-dev/postgres.git

then, for this patchset and every later one:

git fetch hackorum t254511_2 && git checkout t254511_2

Patchset v2 (message #2) is on t254511_2

Jump to latest
#1RekGRpth
rekgrpth@gmail.com

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).

Attachments:

t254511_1
repro.sqlapplication/sql; name=repro.sqlDownload
fix-multixactidexpand-master.patchtext/x-patch; charset=US-ASCII; name=fix-multixactidexpand-master.patchDownload+95-17
#2Andrey Rachitskiy
pl0h0yp1@gmail.com
In reply to: RekGRpth (#1)
Re: 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)

пн, 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.

Attachments:

t254511_2
v2-0001-Fix-MultiXactIdExpand-reusing-a-multixact-with-dead-members.patchtext/x-patch; charset=US-ASCII; name=v2-0001-Fix-MultiXactIdExpand-reusing-a-multixact-with-dead-members.patchDownload+121-21
#3Heikki Linnakangas
heikki.linnakangas@enterprisedb.com
In reply to: RekGRpth (#1)
Re: 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)

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