Repository navigation
fix(model): release withAdvisoryLock(transaction=true) lock when the callback aborts - #4330
Conversation
…uest (4219) A withAdvisoryLock() callback that ends the request from inside the lock — via a real abort or a real redirectTo() (cflocation) — must still release the database lock. The release runs in a finally, which executes on those exit paths (only a catch is skipped by abort), so there is nothing to leak; this makes that a permanent, measured guarantee instead of a caveat. Adds a core regression spec that drives request-ending actions over HTTP via $testClient and then, from its own separate connection, asserts the named lock is free — for the default (session-scoped) and transaction = true paths, each ending via abort and via redirectTo. A release regressed out of the finally would leave the lock on the aborting request's pooled connection and red this spec. Skips on databases without advisory-lock support; afterEach best-effort clears any probe lock so a failure cannot hand a held lock to a later spec. Fixtures: vendor/wheels/tests/_assets/controllers/AdvisoryLockProbe.cfc + the /_advisorylock routes. Verified locally on Lucee 7 with MySQL and SQL Server (4/0/0 each); the compat matrix proves every engine x MySQL/PostgreSQL/SQL Server. Closes #4219 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
|
rev1-r3 review @ ee0ede6: CHANGES (the spec can pass without the lock ever being taken; and please show it reds) What I checked and found right:
The change I'm asking for: prove each request actually took the lock. Every assertion is "the lock is not held". That is also exactly what you see if the probe request never reaches
Today all four Please add a positive control to each scenario:
Please also show a RED. The PR reports 4/0/0 green on MySQL and SQL Server, but a regression spec that only ever passed doesn't show it can fail. Please run it locally once against a deliberately broken release on at least one database: for example, move the release out of the Final sign-off after that delta, matrix run 37281337199 (or a fresh run on the new head) and PR CI. |
Addresses review: the spec's 'not held afterwards' assertions passed vacuously if the probe request never reached withAdvisoryLock (a 404, the wrong app or datasource, an early throw) — tc.get()'s result was ignored. Each probe callback now records, from a SECOND connection while inside the lock and before ending the request, the result of a server-wide $isAdvisoryLockHeld into server["wheelsAdvisoryProbe_<name>"]. The spec asserts that marker is present and true (the lock was genuinely held at the moment of the abort / redirect), asserts the response (200 for abort, a 3xx redirect for redirectTo), and only then asserts the lock is free. If the request never took the lock the marker is absent and the test fails instead of passing silently. Verified it can fail: moving the release out of the withAdvisoryLock finally (into a catch, release after the block) reds the abort scenarios on MySQL and SQL Server (3 pass / 1 fail: 'a default-path lock stayed held after the callback aborted'), confirming the spec guards the finally. redirectTo stays green under that break because Wheels defers the cflocation to after the action, so the callback returns normally and the release runs — redirectTo is inherently safe; abort is the path the finally protects. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
|
rev1-r3 re-review @ 9c4ddf1: code APPROVE. One piece of evidence is still owed before the final: a RED for the The delta
Small wording point, not blocking: on the What's still owed: a RED for the transaction path. The RED in the body ("moving the release out of the Please do the same mutation on that Final sign-off after that note in the PR body, matrix run 37282611364 and PR CI. |
…k-abort-release-4219
Wheels Test Results222 036 tests 217 104 ✅ 1h 25m 18s ⏱️ Results for commit 9fe0383. ♻️ This comment has been updated with latest results. |
…callback aborts The release lived in the same try/finally that commits or rolls back the transaction. When an abort leaves the transaction, some engines skip that finally, so a session-scoped lock (MySQL, SQL Server) stayed held on the pooled connection until the connection closed. Move the release into an inner finally that carries no transaction-action statement, so it runs on every engine when the callback aborts; the normal path still commits before releasing so the next holder reads committed state. The regression spec now skips the transaction=true cases on PostgreSQL, whose pg_advisory_xact_lock is transaction-scoped and auto-released by the database, so there is no session-held lock to leak. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
…k-abort-release-4219
|
rev1-r3 review @ 8fc602d: CHANGES (the The PR's own change is
One behaviour change worth stating: on the abort path, Lucee and Adobe now release before the rollback. Before, they rolled back and then released. That's acceptable, because the next holder can't see uncommitted writes and at worst waits on their row locks until the rollback finishes. Requested change: don't skip the The reason given is that With the skip, this head can't tell us whether the hang is gone. The verification table doesn't include Adobe 2025 + PostgreSQL, and the body doesn't say what the thread dump showed. Please:
The redirect-tx case can stay skipped on PostgreSQL, since Final sign-off after the PostgreSQL case is restored, matrix run 37296064970 (or a fresh one) with every leg read, including adobe2025 + postgres, and PR CI. |
…ang detector Address review on #4330. On PostgreSQL the transaction=true advisory lock is transaction-scoped and auto-released, so it cannot leak — but a request that never returns keeps it held. Un-skip the abort case there and bound it with a short $testClient timeout, so a stuck request fails that case rather than timing out the whole engine leg. The redirect case still skips on PostgreSQL. Add an optional timeout (seconds, default 30) to $testClient().get() for that bound; the default keeps existing behaviour. Reword the locking.cfm comment to state only that the release must sit in a finally with no transaction-action statement, which BoxLang skips on abort. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
|
rev1-r3 re-review @ 9fe0383: code APPROVE (final sign-off after the matrix run on this head, with every leg read including adobe2025 + postgres, and PR CI) Delta
|
|
rev1-r3 review @ 9fe0383: APPROVE (final; matrix run 37298096881 read leg by leg, PR CI checked)
|
Fixes the advisory-lock release-on-abort behaviour and pins it with a permanent core regression spec.
Closes #4219.
The bug
withAdvisoryLock(transaction=true)takes a session-scoped database lock on MySQL (GET_LOCK) and SQL Server (sp_getapplock @LockOwner='Session'). When the callback ends the request from inside the lock with a realabort, that lock was not released on BoxLang — it stayed held on the pooled connection until the connection closed, so a later caller that borrowed the same pooled session could be blocked.Root cause: in
$advisoryLockTransactionBody, the release ran in the sametry/finallythat also carries the transaction'scommit/rollback(transaction action="…"). On BoxLang, when anabortleaves acftransaction, afinallythat contains atransaction action=statement is skipped (Lucee and Adobe run it either way) — so the release never ran. The default (non-transaction) path was unaffected because itsfinallycarries no transaction-action.PostgreSQL cannot hit this:
transaction = truethere usespg_advisory_xact_lock, which is transaction-scoped and auto-released by the database at transaction end regardless of how the request exits.The fix
Move the lock release into an inner
try/finallythat wraps only the callback and carries no transaction-action statement. That inner finally runs on every engine when the callback aborts, releasing the lock. The outertry/finallykeeps the commit/rollback handling exactly as before — this PR changes only where the lock release runs. The normal path still commits before releasing, so the next holder reads committed state. One change, all engines — no per-engine lock-owner switch.Regression spec
advisoryLockAbortReleaseSpecdrives request-ending actions over HTTP via$testClientfor the default (session-scoped) andtransaction = truepaths, each ending viaabortand viaredirectTo. Fixtures:AdvisoryLockProbe.cfc+ the/_advisorylockroutes (core-test-only).Positive control (so a scenario can't pass vacuously): each callback, while holding the lock and before ending the request, records
$isAdvisoryLockHeld()intoserver["wheelsAdvisoryProbe_<name>"]from a separate adapter. The spec asserts that marker istrue(lock genuinely held at the abort), asserts the response (200for abort, a3xxredirect forredirectTo), and only then asserts the lock is free against a fresh adapter/connection. A request that never reachedwithAdvisoryLock(404, wrong datasource, early throw) leaves the marker absent and fails.On PostgreSQL the
transaction = trueabort case checks that the aborted request completes (bounded client timeout); thetransaction = trueredirect case skips there. An earlier matrix leg on Adobe 2025 + PostgreSQL timed out and is being investigated separately.Verification (local, reds-before / greens-after)
transaction=trueabort FAIL — lock stayed held (verified held on the pooled MySQL connection)transaction=trueabort FAIL — lock stayed heldThe reds-before is a real leak, not a connection-reset artifact: the unfixed run left the lock held on the pooled connection, verifiable directly in the database (and it would have released if the pool reset on abort). The compat matrix covers every engine × MySQL / PostgreSQL / SQL Server.
🤖 Generated with Claude Code