diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 0bb9cbc48a6..64702f1cddb 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -3592,14 +3592,25 @@ l2: else update_xact = InvalidTransactionId; - /* - * There was no UPDATE in the MultiXact; or it aborted. No - * TransactionIdIsInProgress() call needed here, since we called - * MultiXactIdWait() above. - */ - if (!TransactionIdIsValid(update_xact) || - TransactionIdDidAbort(update_xact)) + if (!TransactionIdIsValid(update_xact)) + { + /* no UPDATE in the MultiXact */ can_continue = true; + } + else + { + /* + * There was another UPDATE. Did it abort? + * + * Note: We check !TransactionIdDidCommit() instead of + * TransactionIdDidAbort(), to treat crashed transactions as + * aborted. No TransactionIdIsInProgress() call is needed + * here, because the MultiXactIdWait() call above already + * waited for it to finish. + */ + if (!TransactionIdDidCommit(update_xact)) + can_continue = true; + } } else if (TransactionIdIsCurrentTransactionId(xwait)) { @@ -7512,8 +7523,9 @@ DoesMultiXactIdConflict(MultiXactId multi, uint16 infomask, if (ISUPDATE_from_mxstatus(members[i].status)) { - /* ignore aborted updaters */ - if (TransactionIdDidAbort(memxid)) + /* ignore aborted (or crashed) updaters */ + if (!TransactionIdIsInProgress(memxid) && + !TransactionIdDidCommit(memxid)) continue; } else diff --git a/src/test/recovery/t/060_multixact_crashed_updater.pl b/src/test/recovery/t/060_multixact_crashed_updater.pl new file mode 100644 index 00000000000..85f25722fd1 --- /dev/null +++ b/src/test/recovery/t/060_multixact_crashed_updater.pl @@ -0,0 +1,75 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Test that a crashed updating transaction that is part of a multixid is treated +# as aborted. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf('postgresql.conf', 'max_prepared_transactions = 10'); +$node->start; + +$node->safe_psql( + 'postgres', q( + CREATE TABLE mx_crashed (id int PRIMARY KEY, payload text); + INSERT INTO mx_crashed VALUES (1, 'initial'); +)); + +# Keep the updater open while another transaction locks the same row. This +# creates a multixid with the updating and locking xids as members. +my $updater = $node->background_psql('postgres'); +$updater->query_safe('BEGIN'); +$updater->query_safe( + q(UPDATE mx_crashed SET payload = 'crashed' WHERE id = 1)); + +$node->safe_psql( + 'postgres', q( + BEGIN; + SELECT id FROM mx_crashed WHERE id = 1 FOR KEY SHARE; + PREPARE TRANSACTION 'mx_crashed_locker'; +)); + +# The PREPARE TRANSACTION flushed the preceding heap update and multixact WAL +# records, too. Kill the server to leave the updating transaction as crashed, +# ie. it is marked neither as committed nor aborted in pg_xact. +$node->stop('immediate'); +$updater->{run}->finish; +$node->start; + +# Update the row again after restart. The crashed updater should be ignored, but +# the FOR KEY SHARE lock should remain. +my ($stdout, $stderr); +my $ret = $node->psql( + 'postgres', q( + BEGIN ISOLATION LEVEL REPEATABLE READ; + UPDATE mx_crashed SET payload = 'after crash' WHERE id = 1; + COMMIT; +), + stdout => \$stdout, + stderr => \$stderr); +is($ret, 0, 'repeatable read update ignores the crashed updater') + or diag($stderr); + +is( $node->safe_psql( + 'postgres', q(SELECT payload FROM mx_crashed WHERE id = 1)), + 'after crash', + 'row was updated'); + +# Check that the row remains locked for the locking transaction +$node->psql( + 'postgres', + 'SELECT * FROM mx_crashed FOR UPDATE NOWAIT', + stderr => \$stderr); +like( + $stderr, + qr/could not obtain lock on row in relation/, + "prepared share locker survives after crash"); + +$node->stop(); +done_testing();