[PATCH] Discard aborted updaters when expanding a multixact
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.
This thread has been committed, so CI has stopped here. Anything below is the last result it produced.
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:t253801psql -h localhost -U postgresBuilt from patchset v5 (message #5), October 14, 2026 at 09:19 PM.
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 t253801_5 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 t253801_5 && git checkout t253801_5Patchset v5 (message #5) is on t253801_5
Hi,
An updating member of a multixact can be marked aborted in pg_xact while
its backend is still present in ProcArray. A concurrent update need not
wait for that member, but MultiXactIdExpand() currently retains it because
TransactionIdIsInProgress() still returns true. Adding the concurrent
updater then fails with:
ERROR: new multixact has more than one updating member
The relevant interleaving is:
1. One transaction updates a row.
2. A second transaction takes a compatible key-share lock, changing the
row's xmax into a multixact containing the updater and locker.
3. The updater records its abort in pg_xact, but has not yet been removed
from ProcArray.
4. A third transaction updates the row. The conflict check ignores the
aborted updater, but multixact expansion retains it as apparently
running and tries to add another updater.
The first patch discards an updating member when pg_xact already records
its abort, even if it still appears to be running. An abort status is
final. The existing checks for running and committed members remain in
place, so an updater whose backend crashed without recording an abort is
still discarded.
I reproduced this problem with a TPC-C test. The same failure was reported
in the 2022 thread "Fix for visibility check on 14.5 fails on tpcc with high
concurrency":
/messages/by-id/CO2PR0801MB2310579F65529380A4E5EDC0E20A9@CO2PR0801MB2310.namprd08.prod.outlook.com
That report showed the same error and WAL records for an updater that
aborted before the next update.
Patch 0002 adds an injection point and an isolation test that reproduce
the race deterministically.
Regards,
Chee Wooson
Attachments:
t253801_1v1-0001-Discard-aborted-updaters-in-MultiXactIdExpand.patchapplication/octet-stream; name=v1-0001-Discard-aborted-updaters-in-MultiXactIdExpand.patchDownload+10-1
v1-0002-Test-multixact-expansion-during-updater-abort.patchapplication/octet-stream; name=v1-0002-Test-multixact-expansion-during-updater-abort.patchDownload+97-2
Hi,
Since v1 no longer applied cleanly due to a conflict, I rebased it onto current master at fcc0e27f45e. Attached is v2.
Regards,
Chee Wooson
Attachments:
t253801_2v2-0001-Discard-aborted-updaters-in-MultiXactIdExpand.patchapplication/octet-stream; name=v2-0001-Discard-aborted-updaters-in-MultiXactIdExpand.patchDownload+10-1
v2-0002-Test-multixact-expansion-during-updater-abort.patchapplication/octet-stream; name=v2-0002-Test-multixact-expansion-during-updater-abort.patchDownload+97-2
Hi,
Attached is v3, rebased onto master at fcc0e27f45e2d8dc6046435908a48626c91d3d84.
The v2 change in MultiXactIdExpand() fixes the two-updater ERROR, but
I found a case it cannot reach. T1 performs a non-key update while T2
takes a compatible FOR KEY SHARE lock and prepares its transaction.
The server then crashes before T1 commits. After recovery, T1's update
has not committed and the original row is still visible. However, a
REPEATABLE READ update by T3 gets "could not serialize access due to
concurrent update": TransactionIdDidAbort(T1) is false because the
crashed updater has no abort record, so heap_update() returns TM_Updated
before MultiXactIdExpand() is reached. Filtering members in Expand
cannot fix this result.
Patch 0001 addresses the original abort-window race at its earlier
conflict check: an updater is skipped only when it is no longer in
progress and did not commit. A conflicting operation therefore waits
until the abort finishes. MultiXactIdExpand()'s existing filter then
discards the aborted member, so the v2 change there is no longer needed.
heap_update() has a similar problem in its own updater-status check.
Like the conflict check changed by patch 0001, it uses
TransactionIdDidAbort(), which cannot identify an updater that crashed
without recording an abort. Such an updater is no longer running and
never committed, but TransactionIdDidAbort() still returns false.
Patch 0001 skips that updater when checking conflicts, but does not fix
heap_update()'s subsequent decision. The surviving prepared locker
makes HeapTupleSatisfiesUpdate() return TM_BeingModified, so
heap_update() still reaches its own updater-status check. Its
can_continue flag remains false and it reports TM_Updated for an update
that never committed. This causes the REPEATABLE READ error above and
can also trigger the executor's tmfd.traversed assertion at READ
COMMITTED.
Patch 0002 changes that heap_update() check to !TransactionIdDidCommit(),
after the updater has been established as no longer running.
I also checked the MultiXact-related TransactionIdDidAbort()/
TransactionIdDidCommit() calls for missing TransactionIdIsInProgress()
checks or equivalent completion guarantees, leaving
heap_lock_updated_tuple_rec() as the only remaining case to examine.
In this function, TransactionIdDidAbort() checks the successor's xmin.
An explicit abort correctly stops the chain walk. If the creator
crashed without an abort record, the walk can instead lock a dead
successor and generate extra WAL. In the cases analyzed and reproduced,
the live predecessor was still locked correctly, and subsequent updates
and pruning handled the dead successor correctly. I found no
user-visible correctness issue in those cases, so I have left this
path unchanged in v3.
Patch 0003 adds an isolation test for the abort-window race and a
recovery test for heap_update()'s crashed-updater case.
I ran the isolation test with assertions and injection points enabled on
fcc0e27f45e2d8dc6046435908a48626c91d3d84. All four permutations passed.
With only the v2 MultiXactIdExpand() change, the DELETE and key-changing
UPDATE cases reproduce assertion failures.
Regards,
Chee Wooson
Attachments:
t253801_3v3-0001-Wait-for-updaters-still-in-progress-when-checking.patchapplication/octet-stream; name=v3-0001-Wait-for-updaters-still-in-progress-when-checking.patchDownload+12-7
v3-0002-Judge-a-finished-multixact-updater-by-commit-stat.patchapplication/octet-stream; name=v3-0002-Judge-a-finished-multixact-updater-by-commit-stat.patchDownload+13-9
v3-0003-Test-abort-window-conflicts-and-crashed-multixact.patchapplication/octet-stream; name=v3-0003-Test-abort-window-conflicts-and-crashed-multixact.patchDownload+267-2
Hi,
Since v3 no longer applied cleanly due to conflicts, I rebased it onto current master at 061065e28f0. Attached is v4.
Regards,
Chee Wooson
Attachments:
t253801_4v4-0001-Wait-for-updaters-still-in-progress-when-checking.patchapplication/octet-stream; name=v4-0001-Wait-for-updaters-still-in-progress-when-checking.patchDownload+12-7
v4-0002-Judge-a-finished-multixact-updater-by-commit-stat.patchapplication/octet-stream; name=v4-0002-Judge-a-finished-multixact-updater-by-commit-stat.patchDownload+13-9
v4-0003-Test-abort-window-conflicts-and-crashed-multixact.patchapplication/octet-stream; name=v4-0003-Test-abort-window-conflicts-and-crashed-multixact.patchDownload+267-2
On 08/10/2026 12:04, Chee Wooson wrote:
Hi,
Since v3 no longer applied cleanly due to conflicts, I rebased it onto current master at 061065e28f0. Attached is v4.
Thanks, the fix looks correct to me.
I spend some time rewriting all the comments; I found them pretty
difficult to read. Notable, they used many words to explain that a
transaction might not be marked as committed or aborted but we have
already checked that it is not running hence it cannot commit in the
future. While that's all true, I think it's still OK, and more
understandable to a human, to just call it an "aborted transaction", or
"aborted (or crashed) transaction" if you want to call out crashed
transactions separately. That's a concept that's explained in more
detail in existing comments in heapam_visibility.c.
See attached version with my comment changes.
- Heikki
Hi Heikki,
Thanks for reviewing and revising the comments. I reviewed v5, and it looks good to me.
Regards,
Chee Wooson
Hi,
I ran into "new multixact has more than one updating member" while load
testing a trigger-based extension, and found this thread. In case it
helps with committing and back-patching, here is a plain-SQL reproducer
that needs no injection points and no crash, at READ COMMITTED with
default settings.
Schema:
CREATE TABLE tb_user (pk_user bigint PRIMARY KEY, name text);
CREATE TABLE tb_post (pk_post bigserial PRIMARY KEY,
fk_user bigint NOT NULL REFERENCES tb_user,
title text);
INSERT INTO tb_user SELECT g, 'u' FROM generate_series(1, 10) g;
ins.pgb (the FK check takes FOR KEY SHARE on the parent):
\set u random(1, 10)
INSERT INTO tb_post (fk_user, title) VALUES (:u, 't');
ren.pgb (a non-key update of the parent that always rolls back):
\set u random(1, 10)
BEGIN;
UPDATE tb_user SET name = md5(random()::text) WHERE pk_user = :u;
ROLLBACK;
pgbench -n -c 48 -j 8 -T 30 -f ins.pgb@8 -f ren.pgb@2
On REL_18_STABLE (a85b552), 24 cores, 43 of the 48 clients failed with
"ERROR: new multixact has more than one updating member" within 30
seconds, in each of three runs. With the heapam.c and xact.c changes of
v5 applied to the same tree: no errors over about 16 million
transactions in three runs, and no other errors. I also hit it on 18.1
and 18.4 under REPEATABLE READ.
Tested-by: Lionel Hamayon <lionel.hamayon@evolution-digitale.fr>
Regards,
Lionel
Hi,
Thanks for testing this and for the simple SQL reproducer!
I also reproduced this issue under TPC-C workloads without using
injection points. It's nice to have a smaller, self-contained
reproducer that makes the issue easier to investigate and verify.
The results with v5 are encouraging. The reproductions on 18.1 and
18.4 also confirm that released versions are affected, which will help
make the case for back-patching once the fix is committed.
Regards,
Chee Wooson
On 13/10/2026 04:47, Chee Wooson wrote:
Hi,
Thanks for testing this and for the simple SQL reproducer!
+1
I also reproduced this issue under TPC-C workloads without using
injection points. It's nice to have a smaller, self-contained
reproducer that makes the issue easier to investigate and verify.The results with v5 are encouraging. The reproductions on 18.1 and
18.4 also confirm that released versions are affected, which will help
make the case for back-patching once the fix is committed.
Ok, committed and backported to all supported versions. Thanks!
- Heikki