Skip to content

fix(model): take MySQL and PostgreSQL advisory locks with the shared connection options - #4331

Merged
bpamiri merged 3 commits into
developfrom
peter/advisory-lock-connection-helper
Oct 5, 2026
Merged

bpamiri merged 3 commits into
developfrom
peter/advisory-lock-connection-helper

Conversation

@bpamiri

@bpamiri bpamiri commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4223

Summary

The default (non-transaction) advisory-lock methods on MySQL and PostgreSQL built their connection options by hand: {datasource, username, password}, even when the username and password are empty. Model queries, the transactional path (#4222) and SQL Server's lock methods (#4325) use the shared $advisoryLockConnection() helper instead, which:

  • leaves out an empty username and password, as $performQuery() does;
  • resolves the datasource through $effectiveDataSource().

On Adobe CF the two option forms use different connection pools. The default path never needs the lock on the callback's connection, so the old form wasn't a correctness bug there, but the two paths followed different rules for the same queries.

Change

$acquireAdvisoryLockSession, $releaseAdvisoryLock, $tryReleaseAdvisoryLock and $isAdvisoryLockHeld in MySQLModel.cfc and PostgreSQLModel.cfc now pass $advisoryLockConnection().

Behaviour change: tenant models lock on the tenant database

Because the helper resolves the datasource the way the model's queries do, a tenant (non-shared) model's withAdvisoryLock() now takes the lock on the active tenant's datasource. In 4.1 it took the lock on the application's default datasource. SQL Server and withAdvisoryLock(transaction = true) already worked this way.

  • Consequence: two tenants on different databases that use the same lock name no longer exclude each other.
  • One lock across all tenants: call withAdvisoryLock() through a shared model (sharedModel()), which keeps the default datasource whichever tenant is active.
  • No tenant datasource: nothing changes.

Docs.

  • A changed fragment.
  • A "Behaviour changes to check" entry in the 4.1 to 4.2 upgrade guide, with the shared-model example.
  • A paragraph in the multi-tenancy guide's shared-models section.

The in-application wait follows the lock's datasource too

Review finding: withAdvisoryLock() first waits on an application-server lock ($advisoryLockLocalName, #4197) named after the model's configured datasource. So on one server, two tenants using the same lock name still queued behind each other there, and could hit Wheels.AdvisoryLockTimeout, though their database locks no longer conflict.

The name is now keyed on adapter.$effectiveDataSource(). Another tenant's datasource is another connection pool, which can't borrow the session holding the lock, so #4197's protection still holds. This also applies to SQL Server and transaction = true.

MySQL. GET_LOCK names are server-wide, so tenant databases on the same MySQL server still share a lock name in the database. PostgreSQL and SQL Server locks are per database. The upgrade guide, the multi-tenancy guide and the fragment say so.

Tests

New vendor/wheels/tests/specs/model/advisoryLockTenantSpec.cfc (MySQL, PostgreSQL and SQL Server; it calls skip() elsewhere), with a new SharedAuthor test model (the authors table with sharedModel()):

  • A tenant model locks on the tenant's datasource. With a tenant whose datasource doesn't exist, model("author").withAdvisoryLock() fails naming that datasource, and the callback never runs.
  • Two tenants with the same lock name don't wait for each other on one server. A thread holds tenant A's in-application wait. A tenant-B caller gets past it at once and reaches its own datasource, while a second tenant-A caller times out with Wheels.AdvisoryLockTimeout ("Another caller in this application").
  • The wait is keyed on the lock's datasource. A tenant model waits per tenant datasource; a shared model keeps one wait for the whole application.
  • A shared model locks on the default datasource, whatever the tenant. The callback runs, $advisoryLockConnection().datasource is the default datasource, the lock is held while the callback runs, and it's released afterwards. This is the global-lock pattern the docs show.

advisoryLockExclusionSpec (concurrency, leak, holder-scoped release, load probe) is the regression check.

RED (the new spec on develop's adapters, Lucee 7 + MySQL): the tenant spec fails, because the callback ran with the lock taken on the default datasource. The shared-model spec passes on develop too, as expected, since that behaviour doesn't change.

GREEN, model directory (pass / fail / error; advisoryLockTenantSpec 2/2 on every leg listed):

Engine MySQL PostgreSQL SQL Server
Lucee 7 1423 / 0 / 0 1401 / 0 / 0 1417 / 0 / 0
Lucee 6 1423 / 0 / 0 1401 / 0 / 0
Adobe ColdFusion 2023 1414 / 0 / 0 1398 / 0 / 0
Adobe ColdFusion 2025 1414 / 0 / 0 1398 / 0 / 0
BoxLang 1417 / 0 / 0 1401 / 0 / 0

After the review fix (model directory; advisoryLockTenantSpec 4/4 on each leg):

Run Lucee 7 Lucee 6 Adobe 2023 Adobe 2025 BoxLang
MySQL 1429 / 0 / 0 1429 / 0 / 0 1420 / 0 / 0 1420 / 0 / 0 1423 / 0 / 0
PostgreSQL 1407 / 0 / 0
SQL Server 1428 / 0 / 0
  • RED on the previous head (8795ea2a2, Lucee 7 + MySQL): the two new specs fail. Tenant B waits behind tenant A and times out, and both tenants get the same wait name.
  • SQLite:
    • full core suite, Lucee 7: 6788 passed;
    • internal specs: 113 passed;
    • RustCFML model directory: 1377 / 0 / 0. The thread code compiles there; the database-lock specs skip.
  • Other checks: the complexity gate and the guides build pass.

🤖 Generated with Claude Code

bpamiri and others added 2 commits October 5, 2026 01:11
…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 <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 @ 8795ea2: one MEDIUM (the guide's consequence statement is only half true). Final verdict after matrix 37283279616 and PR CI

What changed

  • All MySQL and PostgreSQL advisory-lock queries now use $advisoryLockConnection(). No raw {datasource: variables.dataSource, …} remains in either adapter's lock code.
  • $advisoryLockConnection() = {datasource = $effectiveDataSource()}, with username and password added only when non-empty.

orch1's checks

  • No change for non-tenant apps. Without a tenant, $effectiveDataSource() returns the model's own datasource, so the lock goes to the same datasource as before. Dropping an empty username and password is how every other model query builds its options. 4.1 passed "" explicitly, which engines treat as "use the datasource's credentials".
  • Shared-model global lock. It works and is spec'd. "takes a shared model's lock on the default datasource, whatever the tenant" asserts the lock datasource is the default, that the lock is held during the callback, and that it is released afterwards.
  • Tenant model. "takes a tenant model's lock on the tenant's datasource" proves the lock query goes to the tenant datasource. It uses a missing tenant datasource, so the acquire fails, the callback doesn't run, and the error names that datasource.

Finding

  • MEDIUM: the in-app lock still serializes tenants that share a lock name.
    • The guide says "two tenants on different databases that use the same lock name no longer exclude each other. Each one gets its own lock". The fragment says the same.
    • That holds across servers, but not within one application. withAdvisoryLock() first takes the in-app named lock $advisoryLockLocalName(name) = Hash(variables.wheels.class.dataSource & "|" & name), keyed on the model's configured datasource, not the tenant's.
    • So on one server, tenant A's and tenant B's withAdvisoryLock("nightly") on the same tenant model still wait for each other. The second one can time out with Wheels.AdvisoryLockTimeout ("Another caller in this application holds the lock") while A's long callback runs, even though their database locks are now separate.
    • Suggested fix: key the local lock on the effective datasource: variables.wheels.class.adapter.$effectiveDataSource() in $advisoryLockLocalName(). The local lock exists to stop a second in-app caller borrowing the idle pooled session that holds the database lock (withAdvisoryLock() lets a second caller in and can leave the lock held on Lucee and BoxLang (MySQL, PostgreSQL) #4197). A different tenant datasource is a different pool, so it can't borrow that session, and keying per effective datasource keeps that protection.
    • This was already true for SQL Server and transaction = true since feat(model): opt-in transaction-scoped advisory locks for withAdvisoryLock #4222/fix(model): take SQL Server advisory locks for the session, outside a transaction #4325, which also key the local lock on the class datasource, so the fix covers all three.
    • A spec with two tenant datasources and the same name, on one server, would pin it. Alternatively, keep the code and reword the guide and fragment: "on the same server they still run one after another".
    • Not a correctness break, since it errs toward over-exclusion. But it's a user-visible claim the code doesn't meet, and it can turn a per-tenant job into a cross-tenant timeout.

On the matrix I'll read advisoryLockTenantSpec, advisoryLockExclusionSpec, advisoryLockTransactionalSpec and lockingSpec on the MySQL, PostgreSQL and SQL Server legs, plus the PR CI tally.

…atasource

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 <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

221 863 tests  +128   216 953 ✅ +77   1h 24m 10s ⏱️ +12s
 17 152 suites + 32     4 910 💤 +51 
     32 files   ±  0         0 ❌ ± 0 

Results for commit 216a0e1. ± Comparison against base commit 727c33b.

@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Final: APPROVE @ 216a0e1 (matrix 37284943699, all 32 legs read; PR CI read)

Delta since 8795ea2. My MEDIUM is fixed.

  • $advisoryLockLocalName() now keys the in-application wait on variables.wheels.class.adapter.$effectiveDataSource().
  • withAdvisoryLock() takes that wait before both the default and the transaction = true paths. So tenants on different databases no longer queue behind each other on one server, on MySQL, PostgreSQL, SQL Server and the transaction path alike.
  • A shared model resolves to the default datasource, so the application-wide lock pattern still serializes everywhere.
  • The fragment and both guides now also note that MySQL GET_LOCK names are server-wide (tenant databases on one MySQL server still share a name), while PostgreSQL and SQL Server locks are per database. That's accurate.

Matrix

  • 32/32 legs have 0 fail, 0 error.

  • advisoryLockTenantSpec passes 4/4 with 0 skipped on all 15 MySQL, PostgreSQL and SQL Server legs (lucee6, lucee7, adobe2023, adobe2025, boxlang). That includes the thread-based "doesn't make two tenants with the same lock name wait for each other on one server" case.

    • No timing flake on any engine. The holder thread holds tenant A's wait for 4 s, tenant B gets through at once, and a second tenant-A caller times out at 1 s with "Another caller in this application".
  • On the other databases (SQLite, H2, Oracle, CockroachDB) it's 1/4: the advisory-lock cases skip as designed, and the datasource-keying unit case runs.

  • Regression checks, on every MySQL / PostgreSQL / SQL Server leg:

    Spec MySQL PostgreSQL SQL Server
    advisoryLockExclusionSpec 10/12 10/12 12/12
    advisoryLockTransactionalSpec 18/20 17/20 16/20
    lockingSpec 11/11 11/11 11/11

    These are the same per-database results as before this PR.

  • Skips. The skipped specs on lucee7-mysql, adobe2025-postgres and boxlang-sqlserver are identical to a develop-based run (feat(model): run long date and timestamp IN lists on SQL Server through STRING_SPLIT #4329's final matrix 37281004808).

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

No open findings.

@bpamiri
bpamiri merged commit c325b7a into develop Oct 5, 2026
70 checks passed
@bpamiri
bpamiri deleted the peter/advisory-lock-connection-helper branch October 5, 2026 09:05
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.

withAdvisoryLock(): use the shared connection-options helper on the default path too

1 participant