Skip to content

fix(model): take SQL Server advisory locks for the session, outside a transaction - #4325

Merged
bpamiri merged 2 commits into
developfrom
peter/sqlserver-standalone-advisory-lock
Oct 5, 2026
Merged

bpamiri merged 2 commits into
developfrom
peter/sqlserver-standalone-advisory-lock

Conversation

@bpamiri

@bpamiri bpamiri commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Closes #4220.

On SQL Server, withAdvisoryLock() called outside a transaction failed with "The statement or function must be executed in the context of a user transaction." sp_getapplock used its default @LockOwner = 'Transaction'. The adapter reported $supportsAdvisoryLocks() as false, which only the specs read, and the withAdvisoryLock() docblock claimed "Full support".

The lock is now owned by the database session (sp_getapplock @LockOwner = 'Session'), as on MySQL and PostgreSQL. It goes through the same verified release (#4197):

  • Acquire: $acquireAdvisoryLockSession() returns @@SPID, read in the same batch, as the holder. The status sp_getapplock returns is mapped as before:
    • -1 throws Wheels.AdvisoryLockTimeout;
    • other negative values throw Wheels.AdvisoryLockError.
  • Release: $tryReleaseAdvisoryLock() releases only when APPLOCK_MODE shows this pooled session holds the lock. sp_releaseapplock raises an error on any other session.
  • Held check: $isAdvisoryLockHeld() reads the holder from sys.dm_tran_locks. An application lock appears there as <db>:[<first 32 characters of the name>]:(<hash>); I measured this on SQL Server 17.
    • That view needs VIEW SERVER STATE (SQL Server 2022: VIEW SERVER PERFORMANCE STATE). I measured that a login without it gets error 300.
    • In that case the check falls back, for that adapter, to whether any session holds the lock: APPLOCK_MODE for this session, since APPLOCK_TEST grants a session its own lock, and APPLOCK_TEST for the others.
    • The fallback can only make a release wait longer; it never reports a held lock as free. Only a permission error switches to it; other errors are rethrown.
    • A permission error is recognised by SQL Server's error number (300, 297, 229), so it works on a server whose messages aren't in English. The number is read where each engine keeps it: nativeErrorCode on Lucee and Adobe, the driver exception's cause chain on BoxLang, and errorCode for a thrown error. Message and detail text are only a fallback; on Adobe, message is just "Error Executing Database Query."
  • Support flag: $supportsAdvisoryLocks() is now true. The transaction path (withAdvisoryLock(): hold one connection for the call, using transaction-scoped locks #4198, feat(model): opt-in transaction-scoped advisory locks for withAdvisoryLock #4222) shares the session acquire, so the two can't drift.
  • Docblocks: corrected in locking.cfm, Base.cfc and the adapter.

Docs

  • changelog.d/4220-sqlserver-standalone-advisory-lock.fixed.md.
  • A 4.1 to 4.2 upgrade-guide section, "withAdvisoryLock() works on SQL Server outside a transaction", including the permission note.
  • The release-check section now names SQL Server alongside MySQL and PostgreSQL.

Tests

  • advisoryLockExclusionSpec now runs its session-lock checks on SQL Server too:
    • a second caller is kept out;
    • the lock is free after the callback returns or throws;
    • the next caller gets it;
    • another session's lock isn't counted as ours;
    • the holder session is recorded.
  • New check: with sys.dm_tran_locks unreadable (mocked permission error), a lock held by another session is still seen as held, and as free once it's released.
  • Error-number checks: a German permission message carrying error 300 still triggers the fallback. $sqlServerErrorNumber() reads each field shape, and a real driver error reports 208 on each engine.
  • AdapterSqlGenerationSpec expects SQL Server's $supportsAdvisoryLocks() to be true. lockingSpec, which skipped when support was false, now runs on SQL Server.

RED on develop, Lucee 7 × SQL Server, only the specs copied in: 7 checks fail or error. The withAdvisoryLock() checks error with "The statement or function must be executed in the context of a user transaction."

GREEN locally, all with 0 fail / 0 error:

Engine Database internal model database
Lucee 7 SQL Server 114 1401 178
Lucee 7 MySQL 114 1415 180
Lucee 7 PostgreSQL 114 1393 178
BoxLang SQL Server — 1401 178
Adobe 2025 SQL Server — 1398 177

On Adobe, the mock's exception type had to be custom, because Adobe won't cfthrow its built-in Database type. The message is still the real permission-denied text, which is what the adapter checks.

After rebasing onto develop (which brought #4323's guide edit), I reran internal and model on Lucee 7 × SQL Server; both pass. The guides build passes.

🤖 Generated with Claude Code

… transaction

On SQL Server, withAdvisoryLock() outside a transaction failed with "The
statement or function must be executed in the context of a user
transaction": sp_getapplock used its default Transaction owner. The
docblock claimed full support and the adapter reported none.

The lock is now owned by the database session (sp_getapplock @LockOwner =
'Session'), as on MySQL and PostgreSQL, and goes through the same verified
release (#4197):
- the acquire returns @@spid from the same batch;
- a release runs only on the session that holds the lock (APPLOCK_MODE),
  since sp_releaseapplock errors on any other;
- the held check reads the holder from sys.dm_tran_locks. Without the VIEW
  SERVER (PERFORMANCE) STATE permission it falls back to whether any session
  holds the lock.
$supportsAdvisoryLocks() is true. The transaction path (#4198) shares the
session acquire.

Fixes #4220

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 @ 163b681: source looks good, one LOW. Final verdict after matrix 37273906928

Release lands on the holding session. This reuses #4197's verified release unchanged.

  • $runWithAdvisoryLock() records the holder returned by $acquireAdvisoryLockSession(), which is now @@SPID read in the same batch as sp_getapplock @LockOwner = 'Session'.
  • $releaseAdvisoryLockVerified() loops for up to 5 s:
    • $tryReleaseAdvisoryLock() releases only when APPLOCK_MODE('public', @n, 'Session') <> 'NoLock' on the pooled session it lands on, so a non-holder session is a no-op rather than an error;
    • then $isAdvisoryLockHeld(name, holder) asks whether our session still holds it.
    • So a release that lands on another pooled session retries until it reaches the holder, or throws Wheels.AdvisoryLockReleaseFailed. That is the same contract as MySQL and PostgreSQL.
  • The in-app named lock in withAdvisoryLock() is held until that release finishes, so another request in this app can't borrow the holder session and re-enter the lock in the meantime.
  • Both acquire and release now use $advisoryLockConnection(). That means the effective, tenant-routed datasource with empty credentials omitted, replacing the old raw {datasource, username, password}, which is an improvement in itself.

The #4222 transaction path still works.

  • $acquireAdvisoryLockTransactional() now delegates to the same session acquire. It's the same SQL plus @@SPID, on $advisoryLockConnection(), so the enclosing transaction still pins it.
  • $releaseAdvisoryLockTransactional() is unchanged.
  • I'll confirm advisoryLockTransactionalSpec on the SQL Server legs. Its "runs the nested callback and fully releases on the default path" case, previously skipped there for lack of standalone support, should now run.

Holder read. sys.dm_tran_locks filters on resource_type = 'APPLICATION', request_owner_type = 'SESSION' and the [<first 32 chars>] fragment of resource_description, plus request_session_id when a holder is known. The holder is bound as an integer, and the name only ever as a parameter.

Findings

  • LOW: the permission fallback is keyed on the message text, not on the error number. $isAdvisoryLockHeld() switches to the APPLOCK fallback when FindNoCase("permission", e.message), and rethrows otherwise.
    • On an English-language server that covers error 300 (VIEW SERVER STATE permission was denied…) and also 229-style "SELECT permission was denied". That's a reasonable family, but it isn't "only error 300".
    • On a server running another language (SET LANGUAGE, or a localized login default), the denial message is translated, the match misses, and every release check rethrows. That would make withAdvisoryLock() fail after the callback for an app without the permission.
    • Suggested: key on the native error number where the engine exposes it (e.nativeErrorCode / e.errorCode = 300, or 229 / 297 if you want the family), with the English text as a fallback.
    • This doesn't block, because English servers and servers that have the permission are unaffected.
  • INFO: names whose first 32 characters are the same share a resource_description fragment. With a holder given, the session filter keeps that harmless. In the no-holder fallback it can only over-report "held", which makes a release wait longer, as the guide says.
  • INFO, cosmetic: the locking.cfm docblock continuation line * no transaction is needed lost its tab indent.

Docs and guide. They match the code:

  • SQL Server joins the "checks that the lock was released" sentence.
  • The new section names sp_getapplock @LockOwner = 'Session', the VIEW SERVER STATE / VIEW SERVER PERFORMANCE STATE (2022) permission, and the fallback's rare false failure.
  • The $supportsTransactionalAdvisoryLock() docblock no longer says SQL Server lacks standalone support.

On the matrix I'll read advisoryLockExclusionSpec (now applied to SQL Server, plus the new permission-fallback case), advisoryLockTransactionalSpec and AdapterSqlGenerationSpec on all five SQL Server legs.

…language

The advisory-lock held check fell back only when the error message contained
"permission": never on a server whose messages aren't in English, and never on
Adobe, whose message is just "Error Executing Database Query.". It now keys on
SQL Server's error number (300, 297, 229), read where each engine keeps it:
nativeErrorCode on Lucee and Adobe, the driver exception's cause chain on BoxLang,
errorCode for a thrown error. The message and detail text remain a fallback.

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

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Wheels Test Results

219 815 tests   215 280 ✅  1h 20m 30s ⏱️
 16 992 suites    4 535 💤
     32 files          0 ❌

Results for commit 2a8bdd0.

@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Final: APPROVE @ 2a8bdd0 (matrix 37275206192, all 32 legs read; PR CI read)

Delta since 163b681. My LOW is fixed.

  • $isPermissionDenied() keys on SQL Server error 229, 297 or 300, read by $sqlServerErrorNumber(): nativeErrorCode (Lucee, Adobe), then the Java cause chain's getErrorCode() (BoxLang), then errorCode (cfthrow). The message and detail text are a secondary check.
  • The specs cover a German permission message carrying error 300, each field shape, and a real driver error (208) read on whichever engine runs the test.

Matrix

  • 32/32 legs have 0 fail, 0 error.
  • All five SQL Server legs (lucee6, lucee7, adobe2023, adobe2025, boxlang):
    • advisoryLockExclusionSpec passes 12/12 with 0 skipped. That covers exclusion, freeing the lock for the next caller, release after a throw, recording the holder session, both permission-fallback cases (English and German), and reading the error number from a real driver error, which confirms the extraction works on each engine.
    • lockingSpec's four standalone cases now run and pass (they were skipped as "Adapter does not support standalone advisory locks").
    • advisoryLockTransactionalSpec is 16/20, up from 15/20 at feat(model): opt-in transaction-scoped advisory locks for withAdvisoryLock #4222's matrix: "runs the nested callback and fully releases on the default path" now runs. So the feat(model): opt-in transaction-scoped advisory locks for withAdvisoryLock #4222 transaction path still passes everywhere.
    • AdapterSqlGenerationSpec passes 45/45.
  • MySQL and PostgreSQL legs: unchanged apart from skipping the two SQL Server-only fallback cases. Exclusion 10/12, transactional 18/20 (MySQL) and 17/20 (PostgreSQL), as before.
  • Skips. On the legs I compared, the only skip changes against develop are SQL Server cases that now run instead of skipping, and the two new SQL Server-only cases skipping elsewhere.

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

No open findings. The two INFOs (the text fallback alongside a known number, and ending the cause walk on the first cause without getErrorCode()) stand as possible follow-ups.

@bpamiri
bpamiri merged commit 9c540f9 into develop Oct 5, 2026
70 checks passed
@bpamiri
bpamiri deleted the peter/sqlserver-standalone-advisory-lock branch October 5, 2026 07:19
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.

4.3: support standalone advisory locks on SQL Server via sp_getapplock @LockOwner='Session'

1 participant