Skip to content

fix(model): roll back a Wheels transaction when the request ends with abort inside it - #4335

Merged
bpamiri merged 3 commits into
developfrom
peter/transaction-abort-rollback
Oct 5, 2026
Merged

bpamiri merged 3 commits into
developfrom
peter/transaction-abort-rollback

Conversation

@bpamiri

@bpamiri bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

When a request ends with abort inside a Wheels transaction, the transaction is now rolled back on every supported engine. That covers invokeWithTransaction(), a nested call that joins it, a savepoint unit, and withAdvisoryLock(transaction = true), which runs through invokeWithTransaction(). A transaction that completes normally (it returns, returns false, or throws) behaves exactly as before.

The rollback for a failed method lived in the finally/catch of a try that also has a catch clause. On BoxLang, abort skips a finally whose try has a catch clause, so on that path the transaction was closed with the writes made before the abort still in it. Lucee and Adobe ColdFusion already rolled back.

Change

  • vendor/wheels/model/transactions.cfm:
    • $transactionBody() now runs the method through a new $invokeTransactionMethod().
    • It wraps the call in a try/finally with no catch clause, around an inner try/catch that only records a normal throw and rethrows it.
    • When the method neither returned nor threw (the request ended with abort), the outer finally runs transaction action="rollback".
    • The existing return-value checks, rollback-on-failure and afterRollback handling are unchanged.
  • vendor/wheels/model/locking.cfm: the comments on fix(model): release withAdvisoryLock(transaction=true) lock when the callback aborts #4330's release finally now name the actual condition: the finally must belong to a try with no catch clause. They also note that invokeWithTransaction() now performs the abort rollback for this path. Comment-only, in its own commit.
  • CLAUDE.md: a new cross-engine invariant (22) records the rule and the nested try pattern.
  • Changelog: a fixed fragment.

Tests

New vendor/wheels/tests/specs/model/transactionAbortRollbackSpec.cfc. Each scenario runs as its own request through $testClient, via a test controller (TransactionAbortProbe) and a route. Each aborting scenario checks a marker set right before the abort, so it can't pass if the request never reached the write.

  • abort inside invokeWithTransaction() leaves 0 rows;
  • abort in a nested call that joins the transaction leaves 0 rows;
  • abort inside a savepoint unit leaves 0 rows;
  • a normal commit keeps its row;
  • a savepoint unit that returns false keeps the outer row and drops its own.

RED (this spec on develop, BoxLang + MySQL): the three abort scenarios fail with the writes kept. The two normal-completion scenarios pass. On develop the abort scenarios pass on Lucee and Adobe ColdFusion.

GREEN, model directory after merging develop (pass / fail / error):

Engine SQLite MySQL PostgreSQL SQL Server
BoxLang 1392 / 0 / 0 1435 / 0 / 0 1418 / 0 / 0 1440 / 0 / 0
Lucee 7 1392 / 0 / 0 1441 / 0 / 0 1418 / 0 / 0 1440 / 0 / 0
Lucee 6 1392 / 0 / 0 1441 / 0 / 0 1418 / 0 / 0 1440 / 0 / 0
Adobe ColdFusion 2023 1389 / 0 / 0 1432 / 0 / 0 1415 / 0 / 0 1437 / 0 / 0
Adobe ColdFusion 2025 1389 / 0 / 0 1432 / 0 / 0 1415 / 0 / 0 1437 / 0 / 0

Raw transaction {} blocks aren't managed by Wheels and aren't changed here.

🤖 Generated with Claude Code

bpamiri and others added 3 commits October 5, 2026 04:02
… abort inside it

The rollback for a failed transaction method lived in the finally/catch
of a try that also has a catch clause. On BoxLang, abort skips a finally
whose try has a catch clause, so a request that ended with abort inside
invokeWithTransaction() (or a nested call, a savepoint unit, or
withAdvisoryLock(transaction = true)) closed the transaction with the
writes made before the abort. Lucee and Adobe ColdFusion rolled back.

$transactionBody() now runs the method through $invokeTransactionMethod():
a catch-free try/finally around an inner try/catch that records a normal
throw. When the method neither returned nor threw, the outer finally runs
transaction action="rollback". Normal completion is unchanged.

CLAUDE.md gains cross-engine invariant 22 for the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
…abort-rollback

Signed-off-by: Peter Amiri <peter@alurium.com>

# Conflicts:
#	vendor/wheels/tests/routes.cfm
…ly on abort

The release comments in $advisoryLockTransactionBody() said a transaction-action
statement in a finally makes BoxLang skip it on abort. Measured: the trigger is a
catch clause on the try; transaction actions make no difference. The comments now
say so and point at cross-engine invariant 22, and note that invokeWithTransaction()
performs the abort rollback on BoxLang.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri bpamiri added this to the v4.2.0 milestone Oct 5, 2026
@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Review @ b0645e0: source looks good. Final verdict after matrix 37304944278 (BoxLang legs especially) and PR CI

  1. $invokeTransactionMethod(). The method call sits in an inner try / catch (any) that sets exitState.threw and rethrows. On a normal return it sets exitState.completed.
    • The outer try has no catch clause, and its finally rolls back only when neither flag is set, which is exactly the abort path.
    • The flags are fields of a var struct written without the local. prefix, so they survive the catch on BoxLang (invariant 11).
    • Everything else in $transactionBody() is unchanged:
      • a normal return, and the false / invalid-return / transaction = "rollback" handling, still run after $invokeTransactionMethod() returns;
      • a throw still reaches $transactionBody()'s own catch: rollback, marker reset, afterRollback for the owner, rethrow;
      • $runInTransaction()'s afterCommit / afterRollback resolution is untouched.
  2. Nested calls and savepoint units.
    • A nested invokeWithTransaction() that joins takes the alreadyopen path, which has no $transactionBody(). Its abort leaves through the owner's $invokeTransactionMethod(), whose finally rolls the whole transaction back.
    • $invokeWithSavepoint() doesn't go through $invokeTransactionMethod(). A false return or a throw still rolls back to the savepoint only: savepointFalse keeps the outer row and drops the inner one.
    • An abort inside a savepoint unit ends the request, and the owner's finally then rolls back the whole transaction, which is the right outcome for a request that never finished. The spec asserts no rows remain for that marker.
  3. Lucee and Adobe. There's no finally anywhere up the chain that commits: $beginTransaction() has only transaction action="begin" { … } blocks and a catch for the isolation retry. On those engines the explicit rollback in the new finally is the first and only action on abort, and the block then exits with nothing pending. The matrix runs the same abort scenarios through $testClient() on every engine, so a double-rollback error would show there.
  4. The spec has a positive control.
    • Each abort scenario asserts reachedAbort(marker), a server-scope flag the probe sets right before abort, so "0 rows" can't pass because the write never ran.
    • The commit scenario asserts the write survives (1 row), which proves the rollback isn't firing on the normal path.
    • INFO: the server flag keys aren't removed afterwards. They're unique per run, so it's a small leak only.
  5. Invariant 22 and the locking.cfm comments.
    • The rule as written is "abort skips a finally whose try has a catch clause, even an unreached catch (any) { rethrow; }; transaction actions make no difference".
    • The pattern it shows is exactly $invokeTransactionMethod().
    • It corrects the earlier locking.cfm comments, which attributed the skip to a transaction action= inside the finally. The two updated comments now say the catch clause decides, and point to $invokeTransactionMethod() for the rollback on BoxLang.
    • The numbering is 22, after develop's 21.
  6. The fragment ("… now rolls the transaction back on every engine, as it already did on Lucee and Adobe ColdFusion. On BoxLang the writes made before the abort were kept") is accurate and neutral, and the type is right (fixed).

Branch freshness. The branch is behind develop, but develop hasn't touched any of the PR's files since its base, and git merge-tree reports a clean merge.

On the matrix I'll read transactionAbortRollbackSpec on every leg, the five BoxLang legs closest, plus the transaction and locking specs, and the PR CI tally.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Wheels Test Results

222 196 tests   217 264 ✅  1h 22m 31s ⏱️
 17 248 suites    4 932 💤
     32 files          0 ❌

Results for commit b0645e0.

@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Final: APPROVE @ b0645e0 (matrix 37304944278, all 32 legs read; PR CI read)

Correction to point 3 of my source review. I said nothing up the chain issues a second rollback. That's true of the plain invokeWithTransaction() path but not of withAdvisoryLock(transaction = true).

  • On Lucee and Adobe, an abort there runs fix(model): release withAdvisoryLock(transaction=true) lock when the callback aborts #4330's rollback in $advisoryLockTransactionBody()'s finally first. It's the inner frame, guarded by try / catch + WriteLog.
  • It then runs $invokeTransactionMethod()'s rollback second, and that one is unguarded.
  • The matrix shows the second is a no-op on every engine (below). Folding the two into one is an optional later cleanup.

Matrix

  • 32/32 legs have 0 fail, 0 error, and every job succeeded, including rustcfml and the Compatibility Matrix Gate.
  • transactionAbortRollbackSpec passes 5/5 with 0 skipped on all 32 legs, including all five BoxLang legs. That covers:
    • an abort leaving none of the transaction's writes;
    • an abort in a nested call that joins;
    • an abort in a savepoint unit;
    • the normal commit keeping its write;
    • a savepoint unit returning false keeping the outer write.
  • transactionsSpec passes 25/25 on every leg.
  • The double-rollback path. fix(model): release withAdvisoryLock(transaction=true) lock when the callback aborts #4330's advisoryLockAbortReleaseSpec "transaction = true: frees the lock after the callback aborts" (and its default-path twin) passes on all 15 MySQL, PostgreSQL and SQL Server legs (lucee6, lucee7, adobe2023, adobe2025, boxlang). So on Lucee and Adobe the second rollback raises no error.
  • advisoryLockTransactionalSpec gives the same per-database results as before: MySQL 18/20, PostgreSQL 17/20, SQL Server 16/20, and 5/20 elsewhere.
  • Skips. The only differences from develop's latest matrix come from this branch predating fix(model): compare SQL Server date and time conditions in the column's type #4333 (its SQL Server temporal specs aren't in this tree). There's no new skip from this PR.

PR CI: 18 pass, 3 skipping, 0 fail, 0 pending.

No open findings. The INFOs stand: the probe's server-scope flags aren't cleaned up, and the lock path's coverage comes from #4330's spec.

@bpamiri
bpamiri merged commit c9722ed into develop Oct 5, 2026
59 checks passed
@bpamiri
bpamiri deleted the peter/transaction-abort-rollback branch October 5, 2026 12:08
bpamiri added a commit that referenced this pull request Oct 5, 2026
…Lang (#4336)

Add the 4.1 to 4.2 entry for #4335: on BoxLang a request that ends with
abort inside invokeWithTransaction() now rolls back, as on Lucee and
Adobe ColdFusion, where 4.1 kept the writes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
bpamiri added a commit that referenced this pull request Oct 5, 2026
…onal advisory-lock body (#4338)

* refactor(model): drop the abort rollback from the transactional advisory-lock body

$advisoryLockTransactionBody() rolled the transaction back in its outer
finally when the callback neither committed nor threw, i.e. when the
request ended with abort. Since #4335, invokeWithTransaction() itself
rolls back on abort ($invokeTransactionMethod()), and this method only
runs through invokeWithTransaction(), so that rollback is redundant.

Removing it also drops the two flags only it read and the outer catch
that only set one of them, which leaves the outer try catch-free
(cross-engine invariant 22). The inner catch-free release on abort or
throw and the release after a failed commit are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* test(core): drop the locking.cfm exception from the try/catch/finally guard

$advisoryLockTransactionBody() no longer has a try with both a catch and a finally,
so the guard needs no exceptions. With develop's locking.cfm and an empty allowlist
the guard fails at the old outer try; with this branch it passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant