Hi, Andrey, Jonathan, Bryan! v2 addresses the comments: savedParentNChildXids is initialized in StartTransaction() alongside the other childXids fields (and still in PushTransaction()).
The attached test uses a SUBXACT_EVENT_COMMIT_SUB callback so it does not need --enable-injection-points and can be backported. The TAP case adapts Andrey Borodin's demonstration to that callback. ср, 12 авг. 2026 г. в 19:39, Andrey Borodin <[email protected]>: > > > > On 12 Aug 2026, at 16:36, Jonathan Gonzalez V. < > [email protected]> wrote: > > > > Probably this requires to have this test in the patch, reproducing this > > issue it's not easy. Using a test that use the callback or an injection > > point, don't know if this it's expects to be backported, if that's not > > the case probably an injection point plus a regression will be enough. > > It's relatively easy with injection point generating an error in the > window. PFA. > However it was not clear to me that error in this window is possible. > I did not dig into Andrey Rachitskiy's analysis yet. > > > Best regards, Andrey Borodin. > > -- Regards, Rachitskiy Andrey
From: Andrey Rachitskiy <[email protected]> Date: Wed, 12 Aug 2026 20:46:33 +0500 Subject: [PATCH v2] Fix pg_xact corruption from subtransaction abort after subcommit CommitSubTransaction() calls AtSubCommit_childXids() to copy this subtransaction's XID and its committed grandchildren into the parent's list of committed children. Several fallible steps run after that while the subtransaction is still in TRANS_COMMIT state. If one of them throws (for example, out of memory), the subtransaction aborts while in COMMIT state. RecordTransactionAbort() marks its XID aborted in pg_xact, but nothing removes that XID from the parent's committed-child list; AtSubAbort_childXids() only frees the child's own array. When the parent commits, TransactionIdSetTreeStatus() walks the list and tries to mark the aborted XID committed. That trips the assertion in TransactionIdSetStatusBit() in an assert build, and writes the wrong pg_xact status otherwise. The same list is stored in the parent's commit WAL record, so replay hits it too and recovery cannot complete. Have AtSubCommit_childXids() save the parent's child count before it appends, and have AbortSubTransaction() restore it when the subtransaction aborts after the transfer. The appended entries are at the tail of the parent's array, so restoring the saved length removes exactly this subtransaction's XID and its grandchildren; the grandchildren revert to implicitly aborted, which is correct because the whole subtree is rolling back. The restore runs ahead of the curTransactionOwner-guarded cleanup, so it happens whenever AtSubCommit_childXids() ran. Initialize savedParentNChildXids in StartTransaction() and PushTransaction(), per review. Add a small test module that raises from a SUBXACT_EVENT_COMMIT_SUB callback after the child-XID transfer. t/001_subxact_resurrect_pk.pl adapts Andrey Borodin's demonstration to that callback (HOT update, second session, amcheck, REINDEX, recovery) and asserts correct behaviour. No --enable-injection-points is required, so the same test approach can be used on back branches. Author: Bryan Green <[email protected]> Co-authored-by: Andrey Borodin <[email protected]> Co-authored-by: Andrey Rachitskiy <[email protected]> --- src/backend/access/transam/xact.c | 28 ++++ src/test/modules/Makefile | 1 + src/test/modules/meson.build | 1 + .../modules/test_subxact_commit/.gitignore | 4 + src/test/modules/test_subxact_commit/Makefile | 25 +++ .../modules/test_subxact_commit/meson.build | 33 ++++ .../t/001_subxact_resurrect_pk.pl | 146 ++++++++++++++++++ .../test_subxact_commit--1.0.sql | 11 ++ .../test_subxact_commit/test_subxact_commit.c | 68 ++++++++ .../test_subxact_commit.control | 4 + 10 files changed, 321 insertions(+) create mode 100644 src/test/modules/test_subxact_commit/.gitignore create mode 100644 src/test/modules/test_subxact_commit/Makefile create mode 100644 src/test/modules/test_subxact_commit/meson.build create mode 100644 src/test/modules/test_subxact_commit/t/001_subxact_resurrect_pk.pl create mode 100644 src/test/modules/test_subxact_commit/test_subxact_commit--1.0.sql create mode 100644 src/test/modules/test_subxact_commit/test_subxact_commit.c create mode 100644 src/test/modules/test_subxact_commit/test_subxact_commit.control diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c index 3a89149016f..4cfc0b64b52 100644 --- a/src/backend/access/transam/xact.c +++ b/src/backend/access/transam/xact.c @@ -208,6 +208,8 @@ typedef struct TransactionStateData TransactionId *childXids; /* subcommitted child XIDs, in XID order */ int nChildXids; /* # of subcommitted child XIDs */ int maxChildXids; /* allocated size of childXids[] */ + int savedParentNChildXids; /* parent's nChildXids before + * subcommit transfer, or -1 */ Oid prevUser; /* previous CurrentUserId setting */ int prevSecContext; /* previous SecurityRestrictionContext */ bool prevXactReadOnly; /* entry-time xact r/o state */ @@ -1710,6 +1712,12 @@ AtSubCommit_childXids(void) Assert(s->parent != NULL); + /* + * Remember the parent's child count so a later abort can undo this + * transfer (see AbortSubTransaction). + */ + s->savedParentNChildXids = s->parent->nChildXids; + /* * The parent childXids array will need to hold my XID and all my * childXids, in addition to the XIDs already there. @@ -2143,6 +2151,7 @@ StartTransaction(void) s->childXids = NULL; s->nChildXids = 0; s->maxChildXids = 0; + s->savedParentNChildXids = -1; /* * Once the current user ID and the security context flags are fetched, @@ -5343,6 +5352,24 @@ AbortSubTransaction(void) s->state = TRANS_ABORT; + /* + * If AtSubCommit_childXids() moved our XID and childXids up to the + * parent, undo that here. An error escaping the later steps of + * CommitSubTransaction() aborts us in COMMIT state; leaving our aborted + * XID in the parent's committed-child list would make the parent's commit + * mark it committed and corrupt pg_xact. Our entries are the tail of the + * parent's array, so restoring the saved length drops exactly them. Done + * before the curTransactionOwner check below so it runs whenever + * AtSubCommit_childXids() did. + */ + if (s->savedParentNChildXids >= 0) + { + Assert(s->parent != NULL); + Assert(s->parent->nChildXids >= s->savedParentNChildXids); + s->parent->nChildXids = s->savedParentNChildXids; + s->savedParentNChildXids = -1; + } + /* * Reset user ID which might have been changed transiently. (See notes in * AbortTransaction.) @@ -5515,6 +5542,7 @@ PushTransaction(void) s->parallelModeLevel = 0; s->parallelChildXact = (p->parallelModeLevel != 0 || p->parallelChildXact); s->topXidLogged = false; + s->savedParentNChildXids = -1; CurrentTransactionState = s; diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile index 098bb8142ae..446c514b720 100644 --- a/src/test/modules/Makefile +++ b/src/test/modules/Makefile @@ -52,6 +52,7 @@ SUBDIRS = \ test_shmem \ test_shm_mq \ test_slru \ + test_subxact_commit \ test_tidstore \ unsafe_tests \ worker_spi \ diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build index 4bca42bb370..3bbe09c06d2 100644 --- a/src/test/modules/meson.build +++ b/src/test/modules/meson.build @@ -53,6 +53,7 @@ subdir('test_saslprep') subdir('test_shmem') subdir('test_shm_mq') subdir('test_slru') +subdir('test_subxact_commit') subdir('test_tidstore') subdir('typcache') subdir('unsafe_tests') diff --git a/src/test/modules/test_subxact_commit/.gitignore b/src/test/modules/test_subxact_commit/.gitignore new file mode 100644 index 00000000000..5dcb3ff9723 --- /dev/null +++ b/src/test/modules/test_subxact_commit/.gitignore @@ -0,0 +1,4 @@ +# Generated subdirectories +/log/ +/results/ +/tmp_check/ diff --git a/src/test/modules/test_subxact_commit/Makefile b/src/test/modules/test_subxact_commit/Makefile new file mode 100644 index 00000000000..b3db1bc52f8 --- /dev/null +++ b/src/test/modules/test_subxact_commit/Makefile @@ -0,0 +1,25 @@ +# src/test/modules/test_subxact_commit/Makefile + +MODULE_big = test_subxact_commit +OBJS = \ + $(WIN32RES) \ + test_subxact_commit.o +PGFILEDESC = "test_subxact_commit - force errors at subtransaction commit" + +EXTENSION = test_subxact_commit +DATA = test_subxact_commit--1.0.sql + +TAP_TESTS = 1 + +EXTRA_INSTALL = contrib/amcheck contrib/pageinspect + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = src/test/modules/test_subxact_commit +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/src/test/modules/test_subxact_commit/meson.build b/src/test/modules/test_subxact_commit/meson.build new file mode 100644 index 00000000000..b354452efc9 --- /dev/null +++ b/src/test/modules/test_subxact_commit/meson.build @@ -0,0 +1,33 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +test_subxact_commit_sources = files( + 'test_subxact_commit.c', +) + +if host_system == 'windows' + test_subxact_commit_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'test_subxact_commit', + '--FILEDESC', 'test_subxact_commit - force errors at subtransaction commit',]) +endif + +test_subxact_commit = shared_module('test_subxact_commit', + test_subxact_commit_sources, + kwargs: pg_test_mod_args, +) +test_install_libs += test_subxact_commit + +test_install_data += files( + 'test_subxact_commit.control', + 'test_subxact_commit--1.0.sql', +) + +tests += { + 'name': 'test_subxact_commit', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'tap': { + 'tests': [ + 't/001_subxact_resurrect_pk.pl', + ], + }, +} diff --git a/src/test/modules/test_subxact_commit/t/001_subxact_resurrect_pk.pl b/src/test/modules/test_subxact_commit/t/001_subxact_resurrect_pk.pl new file mode 100644 index 00000000000..3d8c2e41b7d --- /dev/null +++ b/src/test/modules/test_subxact_commit/t/001_subxact_resurrect_pk.pl @@ -0,0 +1,146 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Demonstrate that a subtransaction which aborts after it has already +# subcommitted would corrupt a primary key without the childXids restore +# in AbortSubTransaction(). +# +# AtSubCommit_childXids() copies the subtransaction's XID into the parent's +# committed-children array before CommitSubTransaction() is finished. The +# test_subxact_commit.force_error GUC makes the COMMIT_SUB callback raise +# after that transfer. Control longjmps into AbortSubTransaction(), which +# records the XID aborted but, without the fix, leaves it in the parent's +# array. The parent's commit then marks that aborted XID COMMITTED, so a +# row the subtransaction rolled back becomes live. +# +# To make that reach the index we use a HOT update. The rolled-back row sits +# in the HOT chain behind a live one, so it has no index entry of its own and +# nobody looks at its xmin while the XID is still aborted -- had anyone done +# so, the HEAP_XMIN_INVALID hint bit would have masked the resurrection. A +# second session then updates the row, which retires the version the +# rolled-back one was chained behind. Once the culprit commits, both the +# resurrected row and the second session's row would be live under the same +# key without the fix. +# +# The checks below assert correct behaviour, so on unpatched non-cassert +# builds they fail and print the corruption. A build with --enable-cassert +# trips the assertion in clog.c on the culprit COMMIT instead. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('subxact_resurrect_pk'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +# Extension is installed with this test module. +$node->safe_psql('postgres', 'CREATE EXTENSION test_subxact_commit;'); +$node->safe_psql('postgres', 'CREATE EXTENSION amcheck;'); +$node->safe_psql('postgres', 'CREATE EXTENSION pageinspect;'); +$node->safe_psql('postgres', q[ +CREATE TABLE t (id int PRIMARY KEY, note text); +INSERT INTO t VALUES (1, 'original'); +]); + +# The offending session. force_error raises from COMMIT_SUB after the XID +# has been handed to the parent, so the subtransaction aborts while already +# in TRANS_COMMIT state. The PL/pgSQL EXCEPTION block swallows that error +# and the surrounding transaction lives on. +my $culprit = $node->background_psql('postgres', on_error_stop => 0); + +# Load the module in this backend. SET of a custom GUC alone only creates a +# placeholder if the .so is not loaded, so the COMMIT_SUB callback would never +# run. +$culprit->query_safe(q[SELECT test_subxact_commit_init()]); + +$culprit->query_safe(q[BEGIN]); + +# note is not indexed and the page has room, so this is a HOT update. +$culprit->query(q[ +DO $$ +BEGIN + BEGIN + SET LOCAL test_subxact_commit.force_error = on; + UPDATE t SET note = 'rolled-back-subxact' WHERE id = 1; + EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'subxact aborted: %', SQLERRM; + END; +END $$; +]); + +# The WARNING and NOTICE above arrive on stderr; take them and clear it, so +# that the later query_safe() calls do not mistake them for a failure. +my $subxact_err = $culprit->{stderr}; +$culprit->{stderr} = ''; + +like( + $subxact_err, + qr/AbortSubTransaction while in COMMIT state/, + 'subtransaction aborted after it had already subcommitted'); + +# A second session updates the same row. It sees the original version as +# live, since the subtransaction that tried to supersede it is aborted, and +# never examines the rolled-back version behind it. +$node->safe_psql('postgres', "UPDATE t SET note = 'other-session' WHERE id = 1;"); + +# Releasing the culprit is what does the damage without the fix: its commit +# record carries the aborted subtransaction in the child list, so that XID +# is marked COMMITTED. +$culprit->query_safe(q[COMMIT]); +$culprit->quit; + +diag("heap page after commit:\n" + . $node->safe_psql('postgres', q[ +SELECT lp, t_xmin, t_xmax, t_ctid, + (t_infomask & 256) <> 0 AS xmin_committed, + (t_infomask & 512) <> 0 AS xmin_invalid, + (t_infomask & 1024) <> 0 AS xmax_committed, + (t_infomask & 2048) <> 0 AS xmax_invalid +FROM heap_page_items(get_raw_page('t', 0)) ORDER BY lp;])); + +# A sequential scan and an index scan must agree on how many rows carry the key. +my $seqscan = $node->safe_psql('postgres', q[ +SET enable_indexscan = off; SET enable_bitmapscan = off; SET enable_indexonlyscan = off; +SELECT count(*) FROM t WHERE id = 1;]); +my $idxscan = $node->safe_psql('postgres', q[ +SET enable_seqscan = off; +SELECT count(*) FROM t WHERE id = 1;]); + +diag("rows with id = 1: seqscan $seqscan, index scan $idxscan"); + +is($seqscan, '1', 'primary key must hold a single row for the key'); +is($seqscan, $idxscan, 'sequential and index scan must agree'); + +# amcheck compares the index against the heap under a snapshot, so a live +# heap tuple with no index entry, or two live entries under a unique key, is +# reported as corruption. +my ($rc, $stdout, $stderr) = $node->psql('postgres', + "SELECT bt_index_check(index => 't_pkey'::regclass, heapallindexed => true, checkunique => true);" +); + +diag("amcheck says: $stderr") if $stderr ne ''; +is($rc, 0, 'amcheck must find the primary key intact'); + +# And rebuilding the index must not stumble over a duplicate. +my ($rc2, $stdout2, $stderr2) = + $node->psql('postgres', 'REINDEX TABLE t;'); + +diag("reindex says: $stderr2") if $stderr2 ne ''; +is($rc2, 0, 'the primary key must be rebuildable'); + +# The damage reached WAL in the parent's commit record, so recovery would +# apply the same wrong status without the fix. +$node->stop('immediate'); +$node->start; + +my $seqscan_after = $node->safe_psql('postgres', q[ +SET enable_indexscan = off; SET enable_bitmapscan = off; SET enable_indexonlyscan = off; +SELECT count(*) FROM t WHERE id = 1;]); + +is($seqscan_after, '1', 'the key must still hold a single row after recovery'); + +done_testing(); diff --git a/src/test/modules/test_subxact_commit/test_subxact_commit--1.0.sql b/src/test/modules/test_subxact_commit/test_subxact_commit--1.0.sql new file mode 100644 index 00000000000..165870a02ec --- /dev/null +++ b/src/test/modules/test_subxact_commit/test_subxact_commit--1.0.sql @@ -0,0 +1,11 @@ +/* src/test/modules/test_subxact_commit/test_subxact_commit--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_subxact_commit" to load this file. \quit + +-- Forces the shared library to load so _PG_init can register the callback +-- and define test_subxact_commit.force_error. +CREATE FUNCTION test_subxact_commit_init() + RETURNS void + AS 'MODULE_PATHNAME', 'test_subxact_commit_init' + LANGUAGE C; diff --git a/src/test/modules/test_subxact_commit/test_subxact_commit.c b/src/test/modules/test_subxact_commit/test_subxact_commit.c new file mode 100644 index 00000000000..326f6884ce4 --- /dev/null +++ b/src/test/modules/test_subxact_commit/test_subxact_commit.c @@ -0,0 +1,68 @@ +/*------------------------------------------------------------------------- + * + * test_subxact_commit.c + * Helpers to test subtransaction commit failure handling. + * + * Loading this module registers a SubXactCallback. With + * test_subxact_commit.force_error = on, the COMMIT_SUB callback raises + * ERROR after AtSubCommit_childXids() has already published the subxact + * XID into the parent's childXids list. That is the window that used to + * corrupt pg_xact when the error was caught and the outer transaction + * later committed. See t/001_subxact_resurrect_pk.pl. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/test/modules/test_subxact_commit/test_subxact_commit.c + * + *------------------------------------------------------------------------- + */ + +#include "postgres.h" + +#include "access/xact.h" +#include "fmgr.h" +#include "utils/guc.h" + +PG_MODULE_MAGIC; + +static bool force_commit_error = false; + +static void +test_subxact_commit_cb(SubXactEvent event, + SubTransactionId mySubid, + SubTransactionId parentSubid, + void *arg) +{ + if (force_commit_error && event == SUBXACT_EVENT_COMMIT_SUB) + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("forced error on subtransaction commit"))); +} + +/* + * Dummy SQL entry point so CREATE EXTENSION loads the shared library + * (and thus runs _PG_init) without needing shared_preload_libraries. + */ +PG_FUNCTION_INFO_V1(test_subxact_commit_init); +Datum +test_subxact_commit_init(PG_FUNCTION_ARGS) +{ + PG_RETURN_VOID(); +} + +void +_PG_init(void) +{ + DefineCustomBoolVariable("test_subxact_commit.force_error", + "Raise ERROR from SUBXACT_EVENT_COMMIT_SUB callback.", + NULL, + &force_commit_error, + false, + PGC_USERSET, + 0, + NULL, NULL, NULL); + + RegisterSubXactCallback(test_subxact_commit_cb, NULL); +} diff --git a/src/test/modules/test_subxact_commit/test_subxact_commit.control b/src/test/modules/test_subxact_commit/test_subxact_commit.control new file mode 100644 index 00000000000..1c5c2792d4e --- /dev/null +++ b/src/test/modules/test_subxact_commit/test_subxact_commit.control @@ -0,0 +1,4 @@ +comment = 'Test code for subtransaction commit error handling' +default_version = '1.0' +module_pathname = '$libdir/test_subxact_commit' +relocatable = true -- 2.53.0
