fix: a nested transaction does not commit the outer one - #937
Merged
blaipr merged 1 commit intoSep 24, 2026
Merged
Conversation
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
deleted the
fix/a-nested-transaction-does-not-commit-the-outer-one
branch
September 24, 2026 03:46
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Database::beginTransaction()returnedtruewhether it started a transaction or joined onealready running, and
endTransaction()committed whenever the connection was in one.BaseRepository::transactionAware()keeps no depth. So an inner scope committed the outer scope'swork, and a later failure in the outer scope had nothing left to roll back.
Measured against the server rather than reasoned about:
Nesting is the ordinary case here, not an edge
Import::doImport()wraps the whole import in onetransactionAware()and callsAccount::create()per row — which opens its own. The first account created therefore commits theimport. 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 rollsall of it back".
Account::update()opens one, then callsAccountItems::updateItems(), which opens up to five in sequence (replaceUserGroupstwice,replaceUserstwice, tags). The first closes the real transaction;addPresetPermissions()afterwards, or the next account in
updateBulk()'s loop, then runs unprotected.Every repository shares one
Databaseinstance —DatabaseInterfaceis a shared php-di entry and norepository 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 leavesthe 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 itscatch, 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 thesemethods is joined rather than begun again, which PDO would throw on. That also keeps the two existing
beginTransactiontests 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 twonested scopes. Mutation-verified: fails against the old code.
testARollbackFromANestedScopeUndoesAllOfIt— rolls back once from depth 2 and leaves nothing forthe 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 capturesby value — the first attempt did exactly that and
inTransaction()answeredtrueforever, sorollBack()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.