Skip to content

[13.x] Execute rollback callbacks for committed savepoints (follow-up to #55420)#60482

Draft
sulimanbenhalim wants to merge 1 commit into
laravel:13.xfrom
sulimanbenhalim:fix/savepoint-rollback-callbacks-discarded
Draft

[13.x] Execute rollback callbacks for committed savepoints (follow-up to #55420)#60482
sulimanbenhalim wants to merge 1 commit into
laravel:13.xfrom
sulimanbenhalim:fix/savepoint-rollback-callbacks-discarded

Conversation

@sulimanbenhalim

Copy link
Copy Markdown

Hi team

Hit this one in production today. Took me hours to figure out.

Setup: controller wraps its work in DB::transaction. Inside it calls a service that does its own DB::transaction (so a savepoint). Service dispatches a ShouldBeUnique job with after_commit => true. After all the work the controller calls the payment gateway. Gateway throws. Outer rolls back.

Expected: job not queued. Unique lock released. Move on.

What actually happens: job not queued (correct) but the unique lock is NEVER released. Every future dispatch of that job silently does nothing forever.

Took me a while to find why. PR #55420 added the rollback callback mechanism a few months ago which is exactly the right idea. It fires executeCallbacksForRollback() for the OPEN transaction chain. But when an inner savepoint commits its record moves from pendingTransactions to committedTransactions. Then when the outer rolls back removeAllTransactionsForConnection just ->reject()s those committed records without firing their callbacks.

Same gap in removeCommittedTransactionsThatAreChildrenOf (the partial rollback path).

Fix

Fire the callbacks before dropping the committed records. The callbacks already exist on the record. Nobody just calls them in this path.

3 lines in removeAllTransactionsForConnection. 1 line in removeCommittedTransactionsThatAreChildrenOf.

Tests

Added 2 tests next to testRollbackTransactionsExecutesCallbacks:

  • inner savepoint commits then outer rolls back
  • partial rollback with a committed grandchild

All 17 DatabaseTransactionsManagerTest pass. All 52 EloquentTransactionWithAfterCommit* integration tests across the 5 variants pass.

Backward compat

Zero behavior change for code that does not register rollback callbacks on inner savepoints. Code that does (i.e. ShouldBeUnique with after_commit inside nested transactions) finally sees the rollback callbacks it registered actually run.

Same incomplete-fix-completion shape as the recent #60149 cleanup if that helps frame review.

Thanks

@taylorotwell

Copy link
Copy Markdown
Member

Agent Review

Potential breaking change: rollback callback order may change for callbacks registered inside committed nested savepoints.

Today, normal nested rollback callbacks run from the innermost active transaction outward:

DB::beginTransaction(); // level 1
DB::beginTransaction(); // level 2

DB::afterRollBack(fn () => releaseLevel2Thing());

DB::rollBack(); // to level 1

That runs level 2 cleanup before level 1 cleanup. With this PR, when an inner savepoint was already committed/staged and the outer transaction later rolls back, callbacks can run parent-before-child:

DB::transaction(function () {
    DB::afterRollBack(fn () => cleanupOuter()); // level 1

    DB::transaction(function () {
        DB::afterRollBack(fn () => cleanupInner()); // level 2
    }); // inner savepoint commits

    throw new Exception;
});

Expected by existing rollback pattern: cleanupInner() then cleanupOuter().

PR behavior risk: cleanupOuter() may run before cleanupInner() because active transaction callbacks are executed before callbacks on committed savepoint records.

Practical impacts:

  • Unique job locks: if an outer rollback callback clears shared state before an inner committed savepoint releases a unique job lock, cleanup may happen in the wrong dependency order.
  • Debounce locks: same issue for debounced jobs registered after commit inside nested transactions.
  • Userland DB::afterRollBack() callbacks: code that assumes child resources are cleaned before parent resources could break.
  • Observability/auditing: callback logs or metrics may show rollback events in a surprising order compared with non-committed nested rollbacks.

Example:

DB::transaction(function () {
    DB::afterRollBack(fn () => Storage::deleteDirectory("imports/123"));

    DB::transaction(function () {
        DB::afterRollBack(fn () => Storage::delete("imports/123/tmp.csv"));
    });

    throw new Exception;
});

If parent cleanup runs first, the child cleanup may fail, log noisy errors, or try to clean a resource that no longer exists.
A safer implementation would preserve deepest-first rollback semantics for committed savepoint callbacks too, and tests should assert exact order rather than only assertContains.

@taylorotwell taylorotwell marked this pull request as draft June 24, 2026 19:33
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.

2 participants