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)

From: RekGRpth <rekgrpth(at)gmail(dot)com>
To: PostgreSQL mailing lists <pgsql-bugs(at)lists(dot)postgresql(dot)org>
Subject: 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)
Date: 2026-11-02 07:14:24
Message-ID: CAPgh2mJhTo2yq_vLrT6OZPn54hDxJpDVu=9L7Ay_M5qogNFuTw@mail.gmail.com
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-bugs

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

Attachment Content-Type Size
repro.sql application/sql 1.1 KB
fix-multixactidexpand-master.patch text/x-patch 6.3 KB

Responses

Browse pgsql-bugs by date

  From Date Subject
Next Message ssye1697 2026-11-02 07:55:44 Correction: 17.11/18.6 slot_getsomeattrs_int crash is not a d260d7d backpatch gap; request for triage direction
Previous Message Richard Guo 2026-11-02 07:06:43 Re: Wrong results: hashed SubPlan referenced twice after OR-qual extraction reuses a stale hash table (13 to 19beta4)