Greetings,
The subtransaction commit path can corrupt pg_xact, and the offending code
is all stock. A subtransaction that has already subcommitted can still be
forced to abort, and when that happens its XID is left in the parent's list
of committed children while pg_xact records it as aborted. When the parent
commits, TransactionIdSetTreeStatus() tries to mark that aborted XID
committed. With assertions on, that's a TRAP in clog.c; with them off, it
writes the wrong status, and since the parent's commit record carries the
same child list, replay hits it too and recovery never finishes.
The window is in CommitSubTransaction():
s->state = TRANS_COMMIT;
...
if (FullTransactionIdIsValid(s->fullTransactionId))
AtSubCommit_childXids();
AfterTriggerEndSubXact(true);
AtSubCommit_Portals(...);
...
By the time AfterTriggerEndSubXact() runs, AtSubCommit_childXids() has
already copied our XID and any committed grandchildren into the parent's
array. If one of the later steps throws (OOM being the obvious case),
control longjmps into AbortSubTransaction() with the subtransaction still in
TRANS_COMMIT state. We record the XID aborted and leave it sitting in the
parent's list; AtSubAbort_childXids() only frees our own array, not the
parent's.
An error thrown at a subtransaction's commit inside a PL/pgSQL EXCEPTION
block is caught right there, so the subtransaction aborts while the
surrounding transaction goes on to commit. That is the shape that bites.
A subtransaction-commit callback that raises on
SUBXACT_EVENT_COMMIT_SUB reproduces it with no core changes and no
injection points; an injection point just after AtSubCommit_childXids()
does too:
BEGIN;
DO $$
BEGIN
BEGIN
INSERT INTO t VALUES (1); -- subtransaction acquires an XID
EXCEPTION WHEN OTHERS THEN
NULL; -- swallow the commit-time error
END;
END $$;
COMMIT; -- crashes here
TRAP: failed Assert("curval == 0 || ... || curval == status"),
File: "clog.c", Line: 702
TransactionIdSetStatusBit
TransactionIdSetTreeStatus
TransactionIdCommitTree
RecordTransactionCommit
The fix is small and stays in xact.c. AtSubCommit_childXids() records the
parent's child count before it appends, and AbortSubTransaction() restores
that count when the subtransaction aborts after the transfer. The entries
we added are the tail of the parent's array, so restoring the length drops
exactly them; the grandchildren revert to aborted, which is correct because
the whole subtree is rolling back.
I confirmed on current master that the reproduction crashes without the
patch and commits cleanly with it, that the aborted row is gone, that a
committed sibling savepoint survives, and that the regression tests pass.
--
Bryan Green
EDB: https://www.enterprisedb.com
From f6110b2337f226866c5d246df4407c2c5e051f5a Mon Sep 17 00:00:00 2001
From: Bryan Green <[email protected]>
Date: Tue, 4 Aug 2026 11:05:15 -0500
Subject: [PATCH] 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.
The failure can be reproduced with an error thrown from a
subtransaction-commit callback (or an injection point) after
AtSubCommit_childXids() has run, inside a PL/pgSQL block whose EXCEPTION
handler catches it so the surrounding transaction commits.
Co-authored-by: Mark Dilger <[email protected]>
---
src/backend/access/transam/xact.c | 28 ++++++++++++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/src/backend/access/transam/xact.c
b/src/backend/access/transam/xact.c
index 3a89149016..e73944fded 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 */
@@ -250,6 +252,7 @@ static TransactionStateData TopTransactionStateData = {
.state = TRANS_DEFAULT,
.blockState = TBLOCK_DEFAULT,
.topXidLogged = false,
+ .savedParentNChildXids = -1,
};
/*
@@ -1710,6 +1713,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.
@@ -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;
--
2.49.0