diff --git a/changelog.d/4220-sqlserver-standalone-advisory-lock.fixed.md b/changelog.d/4220-sqlserver-standalone-advisory-lock.fixed.md new file mode 100644 index 0000000000..1c8a6dddf1 --- /dev/null +++ b/changelog.d/4220-sqlserver-standalone-advisory-lock.fixed.md @@ -0,0 +1 @@ +- On SQL Server, `withAdvisoryLock()` works outside a transaction. It failed with "The statement or function must be executed in the context of a user transaction", because `sp_getapplock` took the lock for a transaction; the lock now belongs to the database session, as on MySQL and PostgreSQL, with the same check that the release freed it (#4220) diff --git a/vendor/wheels/databaseAdapters/Base.cfc b/vendor/wheels/databaseAdapters/Base.cfc index 3c54ac3dae..bfb58043b5 100755 --- a/vendor/wheels/databaseAdapters/Base.cfc +++ b/vendor/wheels/databaseAdapters/Base.cfc @@ -1217,11 +1217,9 @@ component output=false extends="wheels.Global"{ * connection pinned by an enclosing transaction, so it covers the callback's own queries and * (where the lock is transaction-scoped) auto-releases when that transaction ends. * - * Distinct from `$supportsAdvisoryLocks()`, which reports standalone (session) support. An - * adapter may support one and not the other: SQL Server reports `$supportsAdvisoryLocks()` false - * (its standalone `sp_getapplock @LockOwner = 'Transaction'` needs an open transaction) but - * overrides this to true, taking a session-owned `sp_getapplock @LockOwner = 'Session'` lock on - * the transaction-pinned connection. + * Distinct from `$supportsAdvisoryLocks()`, which reports standalone (session) support; an + * adapter may support one and not the other. SQL Server supports both with a session-owned + * `sp_getapplock @LockOwner = 'Session'` lock, on the transaction-pinned connection here. */ public boolean function $supportsTransactionalAdvisoryLock() { return false; diff --git a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc index 5ae7b6048d..e131b9cd75 100755 --- a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc +++ b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc @@ -330,37 +330,184 @@ component extends="wheels.databaseAdapters.Base" output=false { } /** - * Acquire a SQL Server application lock using sp_getapplock. - * The lock is scoped to the current session. + * Acquire a SQL Server application lock using sp_getapplock, owned by the database session + * (#4220), so no transaction is needed. */ public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) { - queryExecute( - "EXEC sp_getapplock @Resource = ?, @LockMode = 'Exclusive', @LockTimeout = ?", + $acquireAdvisoryLockSession(name = arguments.name, timeout = arguments.timeout); + } + + /** + * Internal function. Acquires a session-owned application lock (sp_getapplock @LockOwner = + * 'Session') and returns the holding session's id, @@SPID read in the same batch (#4220, #4197). + * The default 'Transaction' owner needs an open transaction, so withAdvisoryLock() used to fail + * outside one. + * + * sp_getapplock RETURNS a status rather than throwing: >= 0 granted (0 immediately, 1 after + * waiting), < 0 failed (-1 timeout, -2 canceled, -3 deadlock victim, -999 parameter/other). The + * batch opens with SET NOCOUNT ON so the driver does not surface a leading update count ahead of + * the SELECT, which would leave QueryExecute without a result set. + */ + public string function $acquireAdvisoryLockSession(required string name, numeric timeout = 10) { + local.result = queryExecute( + "SET NOCOUNT ON; DECLARE @r int; EXEC @r = sp_getapplock @Resource = ?, @LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = ?; SELECT @r AS lockResult, @@SPID AS sessionId", [arguments.name, arguments.timeout * 1000], - {datasource: variables.dataSource, username: variables.username, password: variables.password} + $advisoryLockConnection() ); + local.status = (IsQuery(local.result) && local.result.recordCount) ? Val(local.result.lockResult) : -999; + if (local.status < 0) { + if (local.status == -1) { + Throw( + type = "Wheels.AdvisoryLockTimeout", + message = "Could not acquire advisory lock '#arguments.name#' within #arguments.timeout# seconds.", + extendedInfo = "SQL Server sp_getapplock returned -1 (timeout), indicating another session holds the lock." + ); + } + Throw( + type = "Wheels.AdvisoryLockError", + message = "Could not acquire advisory lock '#arguments.name#' (sp_getapplock returned #local.status#).", + extendedInfo = "SQL Server sp_getapplock returned a negative status: -2 canceled, -3 deadlock victim, -999 parameter or other error." + ); + } + return local.result.sessionId; } /** - * Release a SQL Server application lock. + * Release a SQL Server application lock held by this session. A session that doesn't hold it + * releases nothing. */ public void function $releaseAdvisoryLock(required string name) { - queryExecute( - "EXEC sp_releaseapplock @Resource = ?", + $tryReleaseAdvisoryLock(name = arguments.name); + } + + /** + * Internal function. Releases the lock when this pooled session holds it and reports whether it + * did (#4197). APPLOCK_MODE is checked first: sp_releaseapplock on a session that doesn't hold + * the lock raises an error instead of returning a status. + */ + public boolean function $tryReleaseAdvisoryLock(required string name) { + local.result = queryExecute( + "SET NOCOUNT ON; DECLARE @n nvarchar(255) = ?; DECLARE @r int = -1; IF APPLOCK_MODE('public', @n, 'Session') <> 'NoLock' EXEC @r = sp_releaseapplock @Resource = @n, @LockOwner = 'Session'; SELECT @r AS released", [arguments.name], - {datasource: variables.dataSource, username: variables.username, password: variables.password} + $advisoryLockConnection() ); + return IsQuery(local.result) && local.result.recordCount && Val(local.result.released) >= 0; + } + + /** + * Internal function. True while the named lock is held: by the session `holder` when given, by + * any session otherwise (#4197). The holder is read from sys.dm_tran_locks, where an application + * lock appears as `:[]:()`. That view needs the VIEW + * SERVER (PERFORMANCE) STATE permission; without it the check falls back to whether ANY session + * holds the lock (APPLOCK_MODE for this session, APPLOCK_TEST for the others), which can only + * make a release wait longer, never report a held lock as free. + */ + public boolean function $isAdvisoryLockHeld(required string name, string holder = "") { + if (!StructKeyExists(variables, "$applockViewDenied")) { + try { + return $isAdvisoryLockHeldBySession(name = arguments.name, holder = arguments.holder); + } catch (any e) { + // Only a missing permission switches this adapter to the fallback for good. + if (!$isPermissionDenied(e)) { + rethrow; + } + variables.$applockViewDenied = true; + } + } + return $isAdvisoryLockHeldByAnySession(name = arguments.name); + } + + /** + * Internal function. True when a database error is SQL Server refusing a permission: error 300 + * (VIEW ... STATE permission was denied), 297 (the user does not have permission to perform this + * action) or 229 (permission denied on an object). Keyed on the error number, so it holds on a + * server whose messages aren't in English; the message text is a fallback for an engine that + * gives no number. + */ + public boolean function $isPermissionDenied(required any exception) { + if (ListFind("229,297,300", $sqlServerErrorNumber(arguments.exception))) { + return true; + } + local.text = ""; + for (local.key in ["message", "detail"]) { + try { + local.text &= " " & arguments.exception[local.key]; + } catch (any e) { + } + } + return FindNoCase("permission", local.text) > 0; } /** - * SQL Server's sp_getapplock requires an active user transaction; calling - * `withAdvisoryLock` outside one raises "The statement or function must be - * executed in the context of a user transaction." Until the locking path - * grows an implicit transaction wrapper, report as unsupported so the test - * suite (and any capability-aware callers) skip rather than error. + * Internal function. SQL Server's error number for a database error, or 0. Each engine keeps it + * somewhere else: Lucee and Adobe in nativeErrorCode, BoxLang only on the driver's + * SQLServerException in the Java cause chain, and a thrown error (cfthrow) in errorCode. + */ + public numeric function $sqlServerErrorNumber(required any exception) { + try { + local.native = arguments.exception.nativeErrorCode; + if (IsNumeric(local.native) && local.native > 0) { + return Val(local.native); + } + } catch (any e) { + } + try { + local.cause = arguments.exception.getCause(); + local.depth = 0; + while (!IsNull(local.cause) && local.depth < 5) { + local.code = local.cause.getErrorCode(); + if (IsNumeric(local.code) && local.code > 0) { + return Val(local.code); + } + local.cause = local.cause.getCause(); + local.depth++; + } + } catch (any e) { + } + try { + local.code = arguments.exception.errorCode; + if (IsNumeric(local.code) && local.code > 0) { + return Val(local.code); + } + } catch (any e) { + } + return 0; + } + + /** + * Internal function. The sys.dm_tran_locks form of $isAdvisoryLockHeld(); throws without the VIEW + * SERVER (PERFORMANCE) STATE permission. + */ + public boolean function $isAdvisoryLockHeldBySession(required string name, string holder = "") { + local.sql = "SELECT COUNT(*) AS n FROM sys.dm_tran_locks WHERE resource_type = 'APPLICATION' AND request_owner_type = 'SESSION' AND CHARINDEX('[' + LEFT(?, 32) + ']', resource_description) > 0"; + local.params = [arguments.name]; + if (Len(arguments.holder)) { + local.sql &= " AND request_session_id = ?"; + ArrayAppend(local.params, {value = arguments.holder, cfsqltype = "cf_sql_integer"}); + } + local.result = queryExecute(local.sql, local.params, $advisoryLockConnection()); + return IsQuery(local.result) && local.result.recordCount && Val(local.result.n) > 0; + } + + /** + * Internal function. True when any session holds the lock, read without special permissions: + * APPLOCK_MODE for this session (APPLOCK_TEST grants a session its own lock), APPLOCK_TEST for the + * others. + */ + public boolean function $isAdvisoryLockHeldByAnySession(required string name) { + local.result = queryExecute( + "SET NOCOUNT ON; DECLARE @n nvarchar(255) = ?; SELECT CASE WHEN APPLOCK_MODE('public', @n, 'Session') <> 'NoLock' THEN 1 WHEN APPLOCK_TEST('public', @n, 'Exclusive', 'Session') = 0 THEN 1 ELSE 0 END AS held", + [arguments.name], + $advisoryLockConnection() + ); + return IsQuery(local.result) && local.result.recordCount && Val(local.result.held) == 1; + } + + /** + * SQL Server supports standalone advisory locks with a session-owned sp_getapplock (#4220). */ public boolean function $supportsAdvisoryLocks() { - return false; + return true; } /** @@ -390,32 +537,11 @@ component extends="wheels.databaseAdapters.Base" output=false { * the current (pinned) connection (#4198). @LockOwner = 'Session' is required because cftransaction * does not raise @@TRANCOUNT on SQL Server (see $supportsTransactionalAdvisoryLock). * - * sp_getapplock RETURNS a status rather than throwing: >= 0 granted (0 immediately, 1 after - * waiting), < 0 failed (-1 timeout, -2 canceled, -3 deadlock victim, -999 parameter/other). The - * batch opens with SET NOCOUNT ON so the driver does not surface a leading update count ahead of - * the SELECT, which would leave QueryExecute without a result set. + * The same session-owned acquire as the standalone path ($acquireAdvisoryLockSession); the + * enclosing transaction is what pins it to the callback's connection. */ public void function $acquireAdvisoryLockTransactional(required string name, numeric timeout = 10) { - local.result = queryExecute( - "SET NOCOUNT ON; DECLARE @r int; EXEC @r = sp_getapplock @Resource = ?, @LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = ?; SELECT @r AS lockResult", - [arguments.name, arguments.timeout * 1000], - $advisoryLockConnection() - ); - local.status = (IsQuery(local.result) && local.result.recordCount) ? Val(local.result.lockResult) : -999; - if (local.status < 0) { - if (local.status == -1) { - Throw( - type = "Wheels.AdvisoryLockTimeout", - message = "Could not acquire advisory lock '#arguments.name#' within #arguments.timeout# seconds.", - extendedInfo = "SQL Server sp_getapplock returned -1 (timeout), indicating another session holds the lock." - ); - } - Throw( - type = "Wheels.AdvisoryLockError", - message = "Could not acquire advisory lock '#arguments.name#' (sp_getapplock returned #local.status#).", - extendedInfo = "SQL Server sp_getapplock returned a negative status: -2 canceled, -3 deadlock victim, -999 parameter or other error." - ); - } + $acquireAdvisoryLockSession(name = arguments.name, timeout = arguments.timeout); } /** diff --git a/vendor/wheels/model/locking.cfm b/vendor/wheels/model/locking.cfm index 76c8b07942..141c89f544 100644 --- a/vendor/wheels/model/locking.cfm +++ b/vendor/wheels/model/locking.cfm @@ -15,7 +15,8 @@ * Support varies by database: * - PostgreSQL: Full support via pg_advisory_lock/pg_advisory_unlock * - MySQL: Full support via GET_LOCK/RELEASE_LOCK - * - SQL Server: Full support via sp_getapplock/sp_releaseapplock + * - SQL Server: Full support via sp_getapplock/sp_releaseapplock, owned by the database session, so + * no transaction is needed * - SQLite: No-op (file-level locking only) * - CockroachDB: Not supported (throws error, use forUpdate() instead) * - H2: Not supported (throws error) diff --git a/vendor/wheels/tests/specs/database/AdapterSqlGenerationSpec.cfc b/vendor/wheels/tests/specs/database/AdapterSqlGenerationSpec.cfc index f9841ccf74..e4d6c240fb 100644 --- a/vendor/wheels/tests/specs/database/AdapterSqlGenerationSpec.cfc +++ b/vendor/wheels/tests/specs/database/AdapterSqlGenerationSpec.cfc @@ -178,7 +178,7 @@ component extends="wheels.WheelsTest" { var mysql = CreateObject("component", "wheels.databaseAdapters.MySQL.MySQLModel"); expect(mysql.$supportsAdvisoryLocks()).toBeTrue(); var mssql = CreateObject("component", "wheels.databaseAdapters.MicrosoftSQLServer.MicrosoftSQLServerModel"); - expect(mssql.$supportsAdvisoryLocks()).toBeTypeOf("boolean"); + expect(mssql.$supportsAdvisoryLocks()).toBeTrue(); }); }); diff --git a/vendor/wheels/tests/specs/model/advisoryLockExclusionSpec.cfc b/vendor/wheels/tests/specs/model/advisoryLockExclusionSpec.cfc index e94efd58d2..629a6e5072 100644 --- a/vendor/wheels/tests/specs/model/advisoryLockExclusionSpec.cfc +++ b/vendor/wheels/tests/specs/model/advisoryLockExclusionSpec.cfc @@ -1,7 +1,7 @@ /** - * withAdvisoryLock() keeps a second caller out and leaves no lock held (#4197). MySQL and - * PostgreSQL advisory locks belong to a database session and a session can take the same lock - * again, while Wheels runs the acquire and the release as separate pooled queries. On Lucee and + * withAdvisoryLock() keeps a second caller out and leaves no lock held (#4197). MySQL, + * PostgreSQL and SQL Server (#4220) advisory locks belong to a database session and a session can + * take the same lock again, while Wheels runs the acquire and the release as separate pooled queries. On Lucee and * BoxLang a second caller borrowed the idle connection holding the lock, took it too, and the lock * stayed held after both finished. */ @@ -11,7 +11,7 @@ component extends="wheels.WheelsTest" { variables.g = application.wo; variables.ds = variables.g.get("dataSourceName"); variables.adapterName = variables.g.get("adapterName"); - variables.applies = ListFindNoCase("PostgreSQLModel,MySQLModel", variables.adapterName) > 0; + variables.applies = ListFindNoCase("PostgreSQLModel,MySQLModel,MicrosoftSQLServerModel", variables.adapterName) > 0; } // A separate adapter instance on the test datasource, so a mock never touches the model's own. @@ -39,6 +39,14 @@ component extends="wheels.WheelsTest" { ); return q.n > 0; } + if (variables.adapterName == "MicrosoftSQLServerModel") { + var s = QueryExecute( + "SELECT COUNT(*) AS n FROM sys.dm_tran_locks WHERE resource_type = 'APPLICATION' AND CHARINDEX('[' + LEFT(?, 32) + ']', resource_description) > 0", + [arguments.name], + {datasource = variables.ds} + ); + return s.n > 0; + } var m = QueryExecute("SELECT IS_USED_LOCK(?) AS id", [arguments.name], {datasource = variables.ds}); return !IsNull(m.id) && Len(m.id) > 0; } @@ -72,11 +80,11 @@ component extends="wheels.WheelsTest" { function run() { - describe("withAdvisoryLock() on MySQL and PostgreSQL", () => { + describe("withAdvisoryLock() on MySQL, PostgreSQL and SQL Server", () => { it("keeps a second caller out while the first holds the lock, and frees it afterwards", () => { if (!variables.applies) { - skip("Session advisory locks: MySQL and PostgreSQL."); + skip("Session advisory locks: MySQL, PostgreSQL and SQL Server."); } var name = lockName(); var state = {entered = false, type = ""}; @@ -100,7 +108,7 @@ component extends="wheels.WheelsTest" { it("leaves the lock free after it returns and after its callback throws", () => { if (!variables.applies) { - skip("Session advisory locks: MySQL and PostgreSQL."); + skip("Session advisory locks: MySQL, PostgreSQL and SQL Server."); } var name = lockName(); var ok = function() { @@ -123,7 +131,7 @@ component extends="wheels.WheelsTest" { it("frees the lock for the next caller once the first has finished", () => { if (!variables.applies) { - skip("Session advisory locks: MySQL and PostgreSQL."); + skip("Session advisory locks: MySQL, PostgreSQL and SQL Server."); } var name = lockName(); var holder = startHolder(name, 500); @@ -159,7 +167,7 @@ component extends="wheels.WheelsTest" { // gone is not our failure, so only our holder still holding it counts. it("does not report a lock that another session holds once ours is gone", () => { if (!variables.applies) { - skip("Session advisory locks: MySQL and PostgreSQL."); + skip("Session advisory locks: MySQL, PostgreSQL and SQL Server."); } var name = lockName(); var other = startHolder(name, 3000); @@ -179,7 +187,7 @@ component extends="wheels.WheelsTest" { it("records the session that took the lock", () => { if (!variables.applies) { - skip("Session advisory locks: MySQL and PostgreSQL."); + skip("Session advisory locks: MySQL, PostgreSQL and SQL Server."); } var name = lockName(); var adapter = freshAdapter(); @@ -191,6 +199,59 @@ component extends="wheels.WheelsTest" { expect(isHeld(name)).toBeFalse(); }); + // SQL Server identifies the holder through sys.dm_tran_locks, which needs the VIEW SERVER + // (PERFORMANCE) STATE permission. Without it, the check falls back to whether any session + // holds the lock. + it("still sees a held SQL Server lock without the permission to read sys.dm_tran_locks", () => { + if (variables.adapterName != "MicrosoftSQLServerModel") { + skip("SQL Server's sys.dm_tran_locks permission."); + } + var name = lockName(); + var other = startHolder(name, 2000); + var adapter = freshAdapter(); + prepareMock(adapter); + adapter.$(method = "$isAdvisoryLockHeldBySession", throwException = true, throwType = "Wheels.SpecPermissionDenied", throwMessage = "VIEW SERVER PERFORMANCE STATE permission was denied on object 'server', database 'master'."); + var state = {held = adapter.$isAdvisoryLockHeld(name = name, holder = "0")}; + thread action="join" name="#other#" timeout="15000"; + state.heldAfter = adapter.$isAdvisoryLockHeld(name = name); + expect(state.held).toBeTrue(); + expect(state.heldAfter).toBeFalse(); + }); + + it("falls back on the error number, whatever language the message is in", () => { + if (variables.adapterName != "MicrosoftSQLServerModel") { + skip("SQL Server's sys.dm_tran_locks permission."); + } + var name = lockName(); + var other = startHolder(name, 2000); + var adapter = freshAdapter(); + prepareMock(adapter); + adapter.$(method = "$isAdvisoryLockHeldBySession", throwException = true, throwType = "Wheels.SpecPermissionDenied", throwMessage = "Die Berechtigung VIEW SERVER PERFORMANCE STATE wurde für das Objekt 'server' verweigert.", throwErrorCode = "300"); + var state = {held = adapter.$isAdvisoryLockHeld(name = name, holder = "0")}; + thread action="join" name="#other#" timeout="15000"; + expect(state.held).toBeTrue(); + }); + + it("reads SQL Server's error number on this engine and from each field", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.MicrosoftSQLServer.MicrosoftSQLServerModel"); + expect(adapter.$sqlServerErrorNumber({nativeErrorCode = 300})).toBe(300); + expect(adapter.$sqlServerErrorNumber({errorCode = "297"})).toBe(297); + expect(adapter.$sqlServerErrorNumber({message = "no number"})).toBe(0); + expect(adapter.$isPermissionDenied({nativeErrorCode = 229, message = "Zugriff verweigert"})).toBeTrue(); + expect(adapter.$isPermissionDenied({nativeErrorCode = 208, message = "Invalid object name"})).toBeFalse(); + if (variables.adapterName != "MicrosoftSQLServerModel") { + return; + } + // A real driver error: Lucee and Adobe report it in nativeErrorCode, BoxLang on its Java cause. + var state = {number = -1}; + try { + QueryExecute("SELECT * FROM wheels_spec_no_such_table", [], {datasource = variables.ds}); + } catch (any e) { + state.number = adapter.$sqlServerErrorNumber(e); + } + expect(state.number).toBe(208); + }); + it("keeps the callback's error when the release fails too", () => { var adapter = CreateObject("component", "wheels.databaseAdapters.MySQL.MySQLModel"); prepareMock(adapter); diff --git a/web/sites/guides/src/content/docs/v4-2-0/upgrading/4x-1-to-4x-2.mdx b/web/sites/guides/src/content/docs/v4-2-0/upgrading/4x-1-to-4x-2.mdx index 0aad41b11a..da15cd8e38 100644 --- a/web/sites/guides/src/content/docs/v4-2-0/upgrading/4x-1-to-4x-2.mdx +++ b/web/sites/guides/src/content/docs/v4-2-0/upgrading/4x-1-to-4x-2.mdx @@ -354,7 +354,11 @@ A migration that uses a column type the database adapter doesn't map, such as `t ### `withAdvisoryLock()` can throw `Wheels.AdvisoryLockReleaseFailed` -On MySQL and PostgreSQL, `withAdvisoryLock()` now checks that the lock was released ([#4197](https://github.com/wheels-dev/wheels/issues/4197)). These locks belong to the pooled database session that took them, and on Lucee and BoxLang a busy pool can run the release on a different session, which frees nothing. Wheels retries the release for up to 5 seconds and then throws `Wheels.AdvisoryLockReleaseFailed`, naming the lock, if our session still holds it. The callback has already finished when this is thrown. If the callback itself threw, the failed release is logged instead and the callback's error is what you get. A lock left held stays held until that pooled connection closes, so other callers wait or time out on it. Before 4.2 this went unnoticed. The case is rare, but a scheduled job that relies on the lock should catch the exception and alert. Callers in the same application now also wait for each other, within `timeout`, before taking the database lock. To hold one connection for the whole call, which removes this case, pass `transaction = true` to `withAdvisoryLock()` ([#4198](https://github.com/wheels-dev/wheels/issues/4198)): it pins the lock, the callback, and the release to one connection inside a transaction (PostgreSQL, MySQL, and SQL Server; other databases throw). The callback then runs inside a transaction, so prefer it for short critical sections. On MySQL and SQL Server, `transaction = true` inside a transaction that is already open throws `Wheels.AdvisoryLockNotSupported`, because their lock would be released before the outer transaction commits: take the lock outside the transaction. PostgreSQL joins the open transaction and holds the lock until it ends. +On MySQL, PostgreSQL and SQL Server, `withAdvisoryLock()` now checks that the lock was released ([#4197](https://github.com/wheels-dev/wheels/issues/4197)). These locks belong to the pooled database session that took them, and on Lucee and BoxLang a busy pool can run the release on a different session, which frees nothing. Wheels retries the release for up to 5 seconds and then throws `Wheels.AdvisoryLockReleaseFailed`, naming the lock, if our session still holds it. The callback has already finished when this is thrown. If the callback itself threw, the failed release is logged instead and the callback's error is what you get. A lock left held stays held until that pooled connection closes, so other callers wait or time out on it. Before 4.2 this went unnoticed. The case is rare, but a scheduled job that relies on the lock should catch the exception and alert. Callers in the same application now also wait for each other, within `timeout`, before taking the database lock. To hold one connection for the whole call, which removes this case, pass `transaction = true` to `withAdvisoryLock()` ([#4198](https://github.com/wheels-dev/wheels/issues/4198)): it pins the lock, the callback, and the release to one connection inside a transaction (PostgreSQL, MySQL, and SQL Server; other databases throw). The callback then runs inside a transaction, so prefer it for short critical sections. On MySQL and SQL Server, `transaction = true` inside a transaction that is already open throws `Wheels.AdvisoryLockNotSupported`, because their lock would be released before the outer transaction commits: take the lock outside the transaction. PostgreSQL joins the open transaction and holds the lock until it ends. + +### `withAdvisoryLock()` works on SQL Server outside a transaction + +On SQL Server, `withAdvisoryLock()` called outside a transaction failed with "The statement or function must be executed in the context of a user transaction" ([#4220](https://github.com/wheels-dev/wheels/issues/4220)). The lock now belongs to the database session (`sp_getapplock @LockOwner = 'Session'`), as on MySQL and PostgreSQL, so it works anywhere, with the same release check. To identify which session holds a lock, Wheels reads `sys.dm_tran_locks`, which needs the `VIEW SERVER STATE` (SQL Server 2022: `VIEW SERVER PERFORMANCE STATE`) permission. Without it, a release waits until no session holds the lock, so it can report a failure in the rare case where another server takes the lock the moment ours is freed. ### `LocalDisk` storage keys are checked per segment