Skip to content

fix: a nested transaction does not commit the outer one - #937

Merged
blaipr merged 1 commit into
mainfrom
fix/a-nested-transaction-does-not-commit-the-outer-one
Sep 24, 2026
Merged

blaipr merged 1 commit into
mainfrom
fix/a-nested-transaction-does-not-commit-the-outer-one

Conversation

@blaipr

@blaipr blaipr commented Sep 24, 2026

Copy link
Copy Markdown
Member

Database::beginTransaction() returned true whether it started a transaction or joined one
already running, and endTransaction() committed whenever the connection was in one.
BaseRepository::transactionAware() keeps no depth. So an inner scope committed the outer scope's
work
, and a later failure in the outer scope had nothing left to roll back.

Measured against the server rather than reasoned about:

after inner commit, still in transaction: no
outer rollback: There is no active transaction
rows surviving the outer rollback: [1,2]

Nesting is the ordinary case here, not an edge

  • Import. Import::doImport() wraps the whole import in one transactionAware() and calls
    Account::create() per row — which opens its own. The first account created therefore commits the
    import. An import that failed on row 40 kept rows 1–39. That contradicts what this repo's own notes
    claim for the accounts path: "the whole import runs inside transactionAware, so a failure rolls
    all of it back"
    .
  • Account edit and bulk edit. Account::update() opens one, then calls
    AccountItems::updateItems(), which opens up to five in sequence (replaceUserGroups twice,
    replaceUsers twice, tags). The first closes the real transaction; addPresetPermissions()
    afterwards, or the next account in updateBulk()'s loop, then runs unprotected.

Every repository shares one Database instance — DatabaseInterface is a shared php-di entry and no
repository overrides the constructor — so "inner" and "outer" are always the same connection.

The change

A depth counter. Only the outermost scope commits; an inner endTransaction() decrements and leaves
the work in the open transaction. A rollback undoes all of it from whatever depth and zeroes the
depth, which also makes the outer scope's own rollback — transactionAware() rolls back in its
catch, so it runs again as the exception passes through — a no-op rather than the
"no active transaction" error above.

inTransaction() is still consulted alongside the counter, so a transaction opened outside these
methods is joined rather than begun again, which PDO would throw on. That also keeps the two existing
beginTransaction tests meaningful.

Savepoints were the alternative and are not what the callers want: every one of them wants
all-or-nothing, and a savepoint would let an inner scope keep half the work.

Tests

  • testANestedTransactionDoesNotCommitTheOuterOne — one real begin, one real commit, across two
    nested scopes. Mutation-verified: fails against the old code.
  • testARollbackFromANestedScopeUndoesAllOfIt — rolls back once from depth 2 and leaves nothing for
    the outer scope. This one is not a distinguisher: the old code also rolled back once. It pins
    that the second rollback is harmless.

Both model the connection's state through a by-reference closure rather than fn(), which captures
by value — the first attempt did exactly that and inTransaction() answered true forever, so
rollBack() ran twice and the test failed for the wrong reason.

Full unit and integration suites pass, which matters more than usual here: transactions now genuinely
span the nested work, so anything that had come to rely on the early commit would have surfaced.

Database::beginTransaction() returned true whether it started a transaction or joined one
already running, and endTransaction() committed whenever the connection was in one, while
BaseRepository::transactionAware() keeps no depth. So an inner scope committed the outer
scope's work and a later failure had nothing left to roll back. Measured against the server:
the inner commit leaves inTransaction() false, the outer rollback fails with 'There is no
active transaction', and both rows survive.

Nesting is the ordinary case. Import::doImport() wraps the whole import and calls
Account::create() per row, which opens its own — so an import failing on row 40 kept rows
1-39, contradicting what this repo's notes claim for that path. Account::update() calls
AccountItems::updateItems(), which opens up to five in sequence, so addPresetPermissions()
afterwards and the next account in updateBulk()'s loop ran unprotected. Every repository
shares one Database instance, so inner and outer are always the same connection.

A depth counter: only the outermost scope commits, an inner endTransaction() leaves the work
in the open transaction, and a rollback undoes all of it from any depth and zeroes the depth
so the outer scope's own rollback is a no-op rather than an error. inTransaction() is still
consulted alongside the counter, so a transaction opened outside these methods is joined
rather than begun again.

Savepoints were the alternative and are not what the callers want: every one of them wants
all-or-nothing.
@blaipr
blaipr merged commit a9f1029 into main Sep 24, 2026
8 checks passed
@blaipr
blaipr deleted the fix/a-nested-transaction-does-not-commit-the-outer-one branch September 24, 2026 03:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant