From 52d939c10e1dfc67d07265de2ff17e40c9ded076 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 01:11:52 -0700 Subject: [PATCH 1/2] fix(model): take MySQL and PostgreSQL advisory locks with the shared connection options The default (non-transaction) withAdvisoryLock() methods on MySQL and PostgreSQL built {datasource, username, password} by hand, even with an empty username and password, while model queries and the transactional path use $advisoryLockConnection(). They now use it too, like SQL Server. That helper resolves the datasource like $performQuery: a tenant (non-shared) model now takes its lock on the active tenant's datasource instead of the application's default one. Two tenants on different databases using the same lock name no longer exclude each other; a lock taken through a shared model stays on the default datasource. Fixes #4223 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- ...advisory-lock-tenant-datasource.changed.md | 1 + .../databaseAdapters/MySQL/MySQLModel.cfc | 8 +- .../PostgreSQL/PostgreSQLModel.cfc | 8 +- .../tests/_assets/models/SharedAuthor.cfc | 12 +++ .../specs/model/advisoryLockTenantSpec.cfc | 92 +++++++++++++++++++ .../v4-2-0/digging-deeper/multi-tenancy.mdx | 9 ++ .../docs/v4-2-0/upgrading/4x-1-to-4x-2.mdx | 14 +++ 7 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 changelog.d/4223-advisory-lock-tenant-datasource.changed.md create mode 100644 vendor/wheels/tests/_assets/models/SharedAuthor.cfc create mode 100644 vendor/wheels/tests/specs/model/advisoryLockTenantSpec.cfc diff --git a/changelog.d/4223-advisory-lock-tenant-datasource.changed.md b/changelog.d/4223-advisory-lock-tenant-datasource.changed.md new file mode 100644 index 0000000000..de37029b15 --- /dev/null +++ b/changelog.d/4223-advisory-lock-tenant-datasource.changed.md @@ -0,0 +1 @@ +- On MySQL and PostgreSQL, `withAdvisoryLock()` on a tenant (non-shared) model takes its lock on the active tenant's datasource instead of the application's default one, like the model's queries, SQL Server and `withAdvisoryLock(transaction = true)`. Two tenants on different databases using the same lock name no longer exclude each other; for one lock across all tenants, call `withAdvisoryLock()` through a shared model. The lock queries also build their connection options like other model queries, leaving out an empty username and password (#4223) diff --git a/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc b/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc index fdbbc5317b..3e5e141a55 100755 --- a/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc +++ b/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc @@ -145,7 +145,7 @@ component extends="wheels.databaseAdapters.Base" output=false { local.result = queryExecute( "SELECT GET_LOCK(?, ?) AS lockResult, CONNECTION_ID() AS sessionId", [arguments.name, arguments.timeout], - {datasource: variables.dataSource, username: variables.username, password: variables.password} + $advisoryLockConnection() ); if (!IsQuery(local.result) || local.result.lockResult != 1) { Throw( @@ -164,7 +164,7 @@ component extends="wheels.databaseAdapters.Base" output=false { queryExecute( "SELECT RELEASE_LOCK(?)", [arguments.name], - {datasource: variables.dataSource, username: variables.username, password: variables.password} + $advisoryLockConnection() ); } @@ -175,7 +175,7 @@ component extends="wheels.databaseAdapters.Base" output=false { local.result = queryExecute( "SELECT RELEASE_LOCK(?) AS released", [arguments.name], - {datasource: variables.dataSource, username: variables.username, password: variables.password} + $advisoryLockConnection() ); return IsQuery(local.result) && IsNumeric(local.result.released) && local.result.released == 1; } @@ -188,7 +188,7 @@ component extends="wheels.databaseAdapters.Base" output=false { local.result = queryExecute( "SELECT IS_USED_LOCK(?) AS holder", [arguments.name], - {datasource: variables.dataSource, username: variables.username, password: variables.password} + $advisoryLockConnection() ); if (!IsQuery(local.result) || !IsNumeric(local.result.holder)) { return false; diff --git a/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc b/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc index ae5d8a2069..87ee314287 100755 --- a/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc +++ b/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc @@ -195,7 +195,7 @@ component extends="wheels.databaseAdapters.Base" output=false { local.result = queryExecute( "SELECT pg_try_advisory_lock(hashtext(?)) AS lockresult, pg_backend_pid() AS sessionid", [arguments.name], - {datasource: variables.dataSource, username: variables.username, password: variables.password} + $advisoryLockConnection() ); if (IsQuery(local.result) && IsBoolean(local.result.lockresult) && local.result.lockresult) { return local.result.sessionid; @@ -218,7 +218,7 @@ component extends="wheels.databaseAdapters.Base" output=false { queryExecute( "SELECT pg_advisory_unlock(hashtext(?))", [arguments.name], - {datasource: variables.dataSource, username: variables.username, password: variables.password} + $advisoryLockConnection() ); } @@ -229,7 +229,7 @@ component extends="wheels.databaseAdapters.Base" output=false { local.result = queryExecute( "SELECT pg_advisory_unlock(hashtext(?)) AS released", [arguments.name], - {datasource: variables.dataSource, username: variables.username, password: variables.password} + $advisoryLockConnection() ); return IsQuery(local.result) && IsBoolean(local.result.released) && local.result.released; } @@ -254,7 +254,7 @@ component extends="wheels.databaseAdapters.Base" output=false { local.result = queryExecute( local.sql, local.params, - {datasource: variables.dataSource, username: variables.username, password: variables.password} + $advisoryLockConnection() ); return IsQuery(local.result) && Val(local.result.holders) > 0; } diff --git a/vendor/wheels/tests/_assets/models/SharedAuthor.cfc b/vendor/wheels/tests/_assets/models/SharedAuthor.cfc new file mode 100644 index 0000000000..a7403a9011 --- /dev/null +++ b/vendor/wheels/tests/_assets/models/SharedAuthor.cfc @@ -0,0 +1,12 @@ +component extends="Model" { + + /* + * Backs advisoryLockTenantSpec: the authors table as a shared (non-tenant) model, which keeps + * the application's default datasource when a tenant datasource is active. + */ + function config() { + table("c_o_r_e_authors"); + sharedModel(); + } + +} diff --git a/vendor/wheels/tests/specs/model/advisoryLockTenantSpec.cfc b/vendor/wheels/tests/specs/model/advisoryLockTenantSpec.cfc new file mode 100644 index 0000000000..dd6cf8207b --- /dev/null +++ b/vendor/wheels/tests/specs/model/advisoryLockTenantSpec.cfc @@ -0,0 +1,92 @@ +/** + * Which datasource withAdvisoryLock() takes its lock on, by default (no transaction), when a tenant + * datasource is active (#4223). A tenant (non-shared) model locks on the tenant's datasource, like + * its queries; a shared model keeps the application's default datasource, so a lock taken through a + * shared model is one lock for every tenant. + */ +component extends="wheels.WheelsTest" { + + function beforeAll() { + variables.g = application.wo; + variables.adapterName = variables.g.get("adapterName"); + variables.applies = ListFindNoCase("MySQLModel,PostgreSQLModel,MicrosoftSQLServerModel", variables.adapterName) > 0; + variables.missingDataSource = "wheels_lock_tenant_missing"; + } + + function lockName() { + return "wheels_tenant_lock_" & Replace(CreateUUID(), "-", "", "all"); + } + + function activateMissingTenant() { + request.wheels.tenant = {id = "lock-probe", dataSource = variables.missingDataSource, config = {}, "$locked" = true}; + } + + function restoreTenant(required any saved) { + if (IsStruct(arguments.saved)) { + request.wheels.tenant = arguments.saved; + } else { + StructDelete(request.wheels, "tenant"); + } + } + + function run() { + + describe("withAdvisoryLock() under a tenant datasource", () => { + + it("takes a tenant model's lock on the tenant's datasource", () => { + if (!variables.applies) { + skip("Advisory locks: MySQL, PostgreSQL and SQL Server."); + } + var state = {ran = false, error = ""}; + var saved = IsDefined("request.wheels.tenant") ? Duplicate(request.wheels.tenant) : ""; + activateMissingTenant(); + try { + variables.g.model("author").withAdvisoryLock( + name = lockName(), + callback = function() { + state.ran = true; + } + ); + } catch (any e) { + state.error = e.message & " " & e.detail & " " & (StructKeyExists(e, "extendedInfo") ? e.extendedInfo : ""); + } finally { + restoreTenant(saved); + } + // the tenant's datasource doesn't exist, so the lock can only fail there + expect(state.ran).toBeFalse("the callback ran: the lock was not taken on the tenant datasource"); + expect(state.error).toInclude(variables.missingDataSource); + }); + + it("takes a shared model's lock on the default datasource, whatever the tenant", () => { + if (!variables.applies) { + skip("Advisory locks: MySQL, PostgreSQL and SQL Server."); + } + var name = lockName(); + var shared = variables.g.model("sharedAuthor"); + var adapter = shared.$classData().adapter; + var state = {ran = false, held = false, lockDataSource = ""}; + var saved = IsDefined("request.wheels.tenant") ? Duplicate(request.wheels.tenant) : ""; + activateMissingTenant(); + try { + shared.withAdvisoryLock( + name = name, + callback = function() { + state.ran = true; + state.lockDataSource = adapter.$advisoryLockConnection().datasource; + state.held = adapter.$isAdvisoryLockHeld(name); + } + ); + } finally { + restoreTenant(saved); + } + expect(state.ran).toBeTrue(); + expect(state.lockDataSource).toBe(variables.g.get("dataSourceName")); + expect(state.held).toBeTrue("the lock is held on the default datasource while the callback runs"); + expect(adapter.$isAdvisoryLockHeld(name)).toBeFalse("released afterwards"); + }); + + }); + + } + +} diff --git a/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/multi-tenancy.mdx b/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/multi-tenancy.mdx index 3addd53d93..78c4fa78fe 100644 --- a/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/multi-tenancy.mdx +++ b/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/multi-tenancy.mdx @@ -204,6 +204,15 @@ component extends="Model" { } ``` +Advisory locks follow the same rule. `withAdvisoryLock()` on a tenant model takes the lock in that tenant's database, so it only excludes callers working on the same tenant database. For one lock across every tenant, such as a nightly job that must not run twice, call it through a shared model, which takes the lock on the default datasource: + +```cfm {test:compile} title="anywhere you call a model: controller, job or model" +// one lock for the whole application, whichever tenant is active +model("Plan").withAdvisoryLock(name = "nightly-billing", callback = function() { + // ... +}); +``` + Rule of thumb: if the table exists in every tenant database, don't share it. If it exists only in the central database, share it. `User` is the classic borderline case — share it if users span tenants (one account, many tenants), scope it per tenant if accounts belong to exactly one tenant. ## Accessing the tenant inside your app 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 172b3c0fc0..320ad4ea77 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 @@ -360,6 +360,20 @@ On MySQL, PostgreSQL and SQL Server, `withAdvisoryLock()` now checks that the lo 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. +### A tenant model's `withAdvisoryLock()` locks on the tenant database + +On MySQL and PostgreSQL, `withAdvisoryLock()` on a tenant (non-shared) model now takes the lock on the active tenant's datasource, the database its queries use ([#4223](https://github.com/wheels-dev/wheels/issues/4223)). 4.1 took it on the application's default datasource. SQL Server and `withAdvisoryLock(transaction = true)` already worked this way. + +The consequence: two tenants on different databases that use the same lock name no longer exclude each other. Each one gets its own lock. If a lock must cover every tenant, call `withAdvisoryLock()` through a shared model (one with `sharedModel()` in its `config()`), which keeps the default datasource whichever tenant is active: + +```cfm +model("Plan").withAdvisoryLock(name = "nightly-billing", callback = function() { + // runs once across all tenants +}); +``` + +Without a tenant datasource, nothing changes. + ### `LocalDisk` storage keys are checked per segment `LocalDisk` now validates an object key one path segment at a time ([#3912](https://github.com/wheels-dev/wheels/issues/3912)): From 216a0e172f1be74d2c6f5232d1f7840a3584687b Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 01:38:45 -0700 Subject: [PATCH 2/2] fix(model): key the in-application advisory-lock wait on the lock's datasource withAdvisoryLock() first waits on an application-server lock named after the model's configured datasource and the lock name. With a tenant model's lock now taken on the tenant's datasource, two tenants using the same lock name on one server still queued behind each other there, and could time out. The wait is now keyed on the effective datasource: another tenant's datasource is another connection pool, which can't borrow the session holding the lock (#4197), so its callers needn't wait. The docs note that MySQL lock names are server-wide, so tenant databases on the same MySQL server still share a lock name. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- ...advisory-lock-tenant-datasource.changed.md | 2 +- vendor/wheels/model/locking.cfm | 6 +- .../specs/model/advisoryLockTenantSpec.cfc | 96 ++++++++++++++++++- .../v4-2-0/digging-deeper/multi-tenancy.mdx | 2 +- .../docs/v4-2-0/upgrading/4x-1-to-4x-2.mdx | 2 +- 5 files changed, 101 insertions(+), 7 deletions(-) diff --git a/changelog.d/4223-advisory-lock-tenant-datasource.changed.md b/changelog.d/4223-advisory-lock-tenant-datasource.changed.md index de37029b15..2418edc10f 100644 --- a/changelog.d/4223-advisory-lock-tenant-datasource.changed.md +++ b/changelog.d/4223-advisory-lock-tenant-datasource.changed.md @@ -1 +1 @@ -- On MySQL and PostgreSQL, `withAdvisoryLock()` on a tenant (non-shared) model takes its lock on the active tenant's datasource instead of the application's default one, like the model's queries, SQL Server and `withAdvisoryLock(transaction = true)`. Two tenants on different databases using the same lock name no longer exclude each other; for one lock across all tenants, call `withAdvisoryLock()` through a shared model. The lock queries also build their connection options like other model queries, leaving out an empty username and password (#4223) +- On MySQL and PostgreSQL, `withAdvisoryLock()` on a tenant (non-shared) model takes its lock on the active tenant's datasource instead of the application's default one, like the model's queries, SQL Server and `withAdvisoryLock(transaction = true)`. Two tenants on different databases using the same lock name no longer exclude each other, and no longer wait for each other inside one application (on MySQL, whose lock names are server-wide, tenant databases on the same MySQL server still share them); for one lock across all tenants, call `withAdvisoryLock()` through a shared model. The lock queries also build their connection options like other model queries, leaving out an empty username and password (#4223) diff --git a/vendor/wheels/model/locking.cfm b/vendor/wheels/model/locking.cfm index 141c89f544..ad3a660695 100644 --- a/vendor/wheels/model/locking.cfm +++ b/vendor/wheels/model/locking.cfm @@ -80,10 +80,12 @@ /** * Internal function. The application-server lock name that serialises withAdvisoryLock() callers - * for one database lock (#4197). + * for one database lock (#4197). It is keyed on the datasource the lock is taken on, the active + * tenant's for a tenant model (#4223): another tenant's datasource is another connection pool, + * which can't borrow the session holding this lock, so its callers needn't wait here. */ public string function $advisoryLockLocalName(required string name) { - return "wheels_advisory_" & Hash(variables.wheels.class.dataSource & "|" & arguments.name); + return "wheels_advisory_" & Hash(variables.wheels.class.adapter.$effectiveDataSource() & "|" & arguments.name); } /** diff --git a/vendor/wheels/tests/specs/model/advisoryLockTenantSpec.cfc b/vendor/wheels/tests/specs/model/advisoryLockTenantSpec.cfc index dd6cf8207b..ca38f29b0e 100644 --- a/vendor/wheels/tests/specs/model/advisoryLockTenantSpec.cfc +++ b/vendor/wheels/tests/specs/model/advisoryLockTenantSpec.cfc @@ -17,8 +17,51 @@ component extends="wheels.WheelsTest" { return "wheels_tenant_lock_" & Replace(CreateUUID(), "-", "", "all"); } - function activateMissingTenant() { - request.wheels.tenant = {id = "lock-probe", dataSource = variables.missingDataSource, config = {}, "$locked" = true}; + function activateMissingTenant(string dataSource = variables.missingDataSource) { + request.wheels.tenant = {id = arguments.dataSource, dataSource = arguments.dataSource, config = {}, "$locked" = true}; + } + + // Starts a thread holding the in-application lock named `localName` (what withAdvisoryLock() + // waits on first) for `holdMs`, and waits until it holds it. Started from a method, not a + // closure, and sharing state through the application scope, like advisoryLockExclusionSpec. + function startLocalHolder(required string localName, required numeric holdMs) { + var threadName = "advisoryLockTenantHolder" & Replace(CreateUUID(), "-", "", "all"); + application.advisoryLockTenantSpec = {started = false}; + thread name="#threadName#" action="run" localName="#arguments.localName#" holdMs="#arguments.holdMs#" { + lock name="#attributes.localName#" type="exclusive" timeout="5" { + application.advisoryLockTenantSpec.started = true; + sleep(attributes.holdMs); + } + } + var waited = 0; + while (!application.advisoryLockTenantSpec.started && waited < 5000) { + sleep(50); + waited += 50; + } + return threadName; + } + + // The error type and text withAdvisoryLock() gives under the given tenant datasource, with a + // 1 second timeout, and whether the callback ran. + function lockUnderTenant(required string dataSource, required string name) { + var state = {ran = false, type = "", text = ""}; + var saved = IsDefined("request.wheels.tenant") ? Duplicate(request.wheels.tenant) : ""; + activateMissingTenant(arguments.dataSource); + try { + variables.g.model("author").withAdvisoryLock( + name = arguments.name, + timeout = 1, + callback = function() { + state.ran = true; + } + ); + } catch (any e) { + state.type = e.type; + state.text = e.message & " " & e.detail & " " & (StructKeyExists(e, "extendedInfo") ? e.extendedInfo : ""); + } finally { + restoreTenant(saved); + } + return state; } function restoreTenant(required any saved) { @@ -85,6 +128,55 @@ component extends="wheels.WheelsTest" { expect(adapter.$isAdvisoryLockHeld(name)).toBeFalse("released afterwards"); }); + // Two tenants using the same lock name on one server: while a caller holds tenant A's + // in-application wait, tenant B gets past it at once (and reaches its own datasource, + // which here doesn't exist), and a second tenant-A caller still waits and times out. + it("doesn't make two tenants with the same lock name wait for each other on one server", () => { + if (!variables.applies) { + skip("Advisory locks: MySQL, PostgreSQL and SQL Server."); + } + var name = lockName(); + var tenantA = variables.missingDataSource & "_a"; + var tenantB = variables.missingDataSource & "_b"; + var saved = IsDefined("request.wheels.tenant") ? Duplicate(request.wheels.tenant) : ""; + activateMissingTenant(tenantA); + try { + var localNameA = variables.g.model("author").$advisoryLockLocalName(name); + } finally { + restoreTenant(saved); + } + var holder = startLocalHolder(localNameA, 4000); + var other = lockUnderTenant(tenantB, name); + var same = lockUnderTenant(tenantA, name); + thread action="join" name="#holder#" timeout="15000"; + expect(application.advisoryLockTenantSpec.started).toBeTrue("the holder thread took tenant A's lock"); + expect(other.type).notToBe("Wheels.AdvisoryLockTimeout", "tenant B waited behind tenant A"); + expect(other.text).toInclude(tenantB); + expect(same.type).toBe("Wheels.AdvisoryLockTimeout"); + expect(same.text).toInclude("Another caller in this application"); + expect(other.ran || same.ran).toBeFalse(); + }); + + // The in-application wait (#4197) follows the lock's datasource: callers of another + // tenant's database don't queue behind this one on the same server. + it("keys the in-application wait on the datasource the lock is taken on", () => { + var name = lockName(); + var author = variables.g.model("author"); + var shared = variables.g.model("sharedAuthor"); + var names = {defaultAuthor = author.$advisoryLockLocalName(name), defaultShared = shared.$advisoryLockLocalName(name)}; + var saved = IsDefined("request.wheels.tenant") ? Duplicate(request.wheels.tenant) : ""; + activateMissingTenant(); + try { + names.tenantAuthor = author.$advisoryLockLocalName(name); + names.tenantShared = shared.$advisoryLockLocalName(name); + } finally { + restoreTenant(saved); + } + expect(names.defaultShared).toBe(names.defaultAuthor, "without a tenant, both use the default datasource"); + expect(names.tenantAuthor).notToBe(names.defaultAuthor, "a tenant model waits per tenant datasource"); + expect(names.tenantShared).toBe(names.defaultAuthor, "a shared model keeps one wait for the whole application"); + }); + }); } diff --git a/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/multi-tenancy.mdx b/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/multi-tenancy.mdx index 78c4fa78fe..af56818b33 100644 --- a/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/multi-tenancy.mdx +++ b/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/multi-tenancy.mdx @@ -204,7 +204,7 @@ component extends="Model" { } ``` -Advisory locks follow the same rule. `withAdvisoryLock()` on a tenant model takes the lock in that tenant's database, so it only excludes callers working on the same tenant database. For one lock across every tenant, such as a nightly job that must not run twice, call it through a shared model, which takes the lock on the default datasource: +Advisory locks follow the same rule. `withAdvisoryLock()` on a tenant model takes the lock in that tenant's database, so it only excludes callers working on the same tenant database (on MySQL, lock names are server-wide, so on the same MySQL server). For one lock across every tenant, such as a nightly job that must not run twice, call it through a shared model, which takes the lock on the default datasource: ```cfm {test:compile} title="anywhere you call a model: controller, job or model" // one lock for the whole application, whichever tenant is active 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 320ad4ea77..b289c76cfd 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 @@ -364,7 +364,7 @@ On SQL Server, `withAdvisoryLock()` called outside a transaction failed with "Th On MySQL and PostgreSQL, `withAdvisoryLock()` on a tenant (non-shared) model now takes the lock on the active tenant's datasource, the database its queries use ([#4223](https://github.com/wheels-dev/wheels/issues/4223)). 4.1 took it on the application's default datasource. SQL Server and `withAdvisoryLock(transaction = true)` already worked this way. -The consequence: two tenants on different databases that use the same lock name no longer exclude each other. Each one gets its own lock. If a lock must cover every tenant, call `withAdvisoryLock()` through a shared model (one with `sharedModel()` in its `config()`), which keeps the default datasource whichever tenant is active: +The consequence: two tenants on different databases that use the same lock name no longer exclude each other, on one application server or across several. Each one gets its own lock. On MySQL, lock names are shared by every database on the same MySQL server, so tenant databases hosted on one MySQL server still exclude each other for the same name. If a lock must cover every tenant, call `withAdvisoryLock()` through a shared model (one with `sharedModel()` in its `config()`), which keeps the default datasource whichever tenant is active: ```cfm model("Plan").withAdvisoryLock(name = "nightly-billing", callback = function() {