Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions vendor/wheels/databaseAdapters/Base.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,44 @@ component output=false extends="wheels.Global"{
return local.rv;
}

/**
* Acquire a database advisory lock with the given name.
* Advisory locks are application-level locks that don't lock rows or tables.
* Individual database adapters override this with their specific implementation.
*
* @name A unique name identifying the lock.
* @timeout Maximum seconds to wait for the lock.
*/
public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) {
Throw(
type = "Wheels.AdvisoryLockNotSupported",
message = "Advisory locks are not supported for this database adapter.",
extendedInfo = "The #GetMetaData(this).name# adapter does not implement advisory locking. Use a database that supports advisory locks (PostgreSQL, MySQL, or SQL Server) or implement application-level locking."
);
}

/**
* Release a previously acquired advisory lock.
* Individual database adapters override this with their specific implementation.
*
* @name The name of the lock to release.
*/
public void function $releaseAdvisoryLock(required string name) {
Throw(
type = "Wheels.AdvisoryLockNotSupported",
message = "Advisory locks are not supported for this database adapter.",
extendedInfo = "The #GetMetaData(this).name# adapter does not implement advisory locking."
);
}

/**
* Returns the SQL clause for pessimistic row locking (e.g., "FOR UPDATE").
* Individual database adapters override this when the default is not appropriate.
*/
public string function $forUpdateClause() {
return "FOR UPDATE";
}

/**
* Remove the maxRows argument and add a limit argument instead.
* The args argument is the original arguments passed in by reference so we just modify it without passing it back.
Expand Down
23 changes: 23 additions & 0 deletions vendor/wheels/databaseAdapters/CockroachDB/CockroachDBModel.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,29 @@ component extends="wheels.databaseAdapters.PostgreSQL.PostgreSQLModel" output=fa
return local.rv;
}

/**
* CockroachDB does not support advisory locks.
* Use forUpdate() for row-level locking instead.
*/
public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) {
Throw(
type = "Wheels.AdvisoryLockNotSupported",
message = "CockroachDB does not support advisory locks.",
extendedInfo = "Use forUpdate() for row-level locking instead. CockroachDB supports SELECT ... FOR UPDATE for pessimistic locking within transactions."
);
}

/**
* CockroachDB does not support advisory locks.
*/
public void function $releaseAdvisoryLock(required string name) {
Throw(
type = "Wheels.AdvisoryLockNotSupported",
message = "CockroachDB does not support advisory locks.",
extendedInfo = "Use forUpdate() for row-level locking instead."
);
}

/**
* Override query setup to append RETURNING clause to INSERTs.
* CockroachDB does not support pg_get_serial_sequence()/currval(),
Expand Down
22 changes: 22 additions & 0 deletions vendor/wheels/databaseAdapters/H2/H2Model.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,28 @@ component extends="wheels.databaseAdapters.Base" output=false {
return $performQuery(argumentCollection = arguments);
}

/**
* H2 does not support advisory locks.
*/
public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) {
Throw(
type = "Wheels.AdvisoryLockNotSupported",
message = "H2 does not support advisory locks.",
extendedInfo = "Advisory locks are not available in H2. Consider using a different database for features that require advisory locking."
);
}

/**
* H2 does not support advisory locks.
*/
public void function $releaseAdvisoryLock(required string name) {
Throw(
type = "Wheels.AdvisoryLockNotSupported",
message = "H2 does not support advisory locks.",
extendedInfo = "Advisory locks are not available in H2."
);
}

/**
* Override Base adapter's function.
* When using H2, cfdbinfo incorrectly returns information_schema tables.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,38 @@ component extends="wheels.databaseAdapters.Base" output=false {
return $performQuery(argumentCollection = arguments);
}

/**
* Acquire a SQL Server application lock using sp_getapplock.
* The lock is scoped to the current session.
*/
public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) {
queryExecute(
"EXEC sp_getapplock @Resource = ?, @LockMode = 'Exclusive', @LockTimeout = ?",
[arguments.name, arguments.timeout * 1000],
{datasource: variables.dataSource, username: variables.username, password: variables.password}
);
}

/**
* Release a SQL Server application lock.
*/
public void function $releaseAdvisoryLock(required string name) {
queryExecute(
"EXEC sp_releaseapplock @Resource = ?",
[arguments.name],
{datasource: variables.dataSource, username: variables.username, password: variables.password}
);
}

/**
* SQL Server uses table hints (WITH (UPDLOCK)) instead of trailing FOR UPDATE.
* Table hints require modifying the FROM clause which is too complex for initial implementation.
* Returns empty string to no-op.
*/
public string function $forUpdateClause() {
return "";
}

/**
* Override Base adapter's function.
*/
Expand Down
31 changes: 31 additions & 0 deletions vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,37 @@ component extends="wheels.databaseAdapters.Base" output=false {
return $performQuery(argumentCollection = arguments);
}

/**
* Acquire a MySQL advisory lock using GET_LOCK.
* Returns after the lock is acquired or the timeout expires.
* Throws if the lock could not be acquired within the timeout.
*/
public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) {
local.result = queryExecute(
"SELECT GET_LOCK(?, ?) AS lockResult",
[arguments.name, arguments.timeout],
{datasource: variables.dataSource, username: variables.username, password: variables.password}
);
if (!IsQuery(local.result) || local.result.lockResult != 1) {
Throw(
type = "Wheels.AdvisoryLockTimeout",
message = "Could not acquire advisory lock '#arguments.name#' within #arguments.timeout# seconds.",
extendedInfo = "The MySQL GET_LOCK function returned a non-1 result, indicating the lock could not be acquired."
);
}
}

/**
* Release a MySQL advisory lock.
*/
public void function $releaseAdvisoryLock(required string name) {
queryExecute(
"SELECT RELEASE_LOCK(?)",
[arguments.name],
{datasource: variables.dataSource, username: variables.username, password: variables.password}
);
}

/**
* Override Base adapter's function.
*/
Expand Down
22 changes: 22 additions & 0 deletions vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,28 @@ component extends="wheels.databaseAdapters.Base" output=false {
return local.rv;
}

/**
* Oracle advisory locks require DBMS_LOCK package setup which is not available by default.
*/
public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) {
Throw(
type = "Wheels.AdvisoryLockNotSupported",
message = "Oracle advisory locks require DBMS_LOCK package setup.",
extendedInfo = "Oracle supports advisory locks via the DBMS_LOCK package, but this requires DBA-level setup and is not supported by Wheels out of the box. Use forUpdate() for row-level locking instead."
);
}

/**
* Oracle advisory locks require DBMS_LOCK package setup.
*/
public void function $releaseAdvisoryLock(required string name) {
Throw(
type = "Wheels.AdvisoryLockNotSupported",
message = "Oracle advisory locks require DBMS_LOCK package setup.",
extendedInfo = "Oracle supports advisory locks via the DBMS_LOCK package, but this requires DBA-level setup and is not supported by Wheels out of the box."
);
}

/**
* Call functions to make adapter specific changes to arguments before executing query.
*/
Expand Down
24 changes: 24 additions & 0 deletions vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,30 @@ component extends="wheels.databaseAdapters.Base" output=false {
return "random()";
}

/**
* Acquire a PostgreSQL advisory lock using pg_advisory_lock.
* This is a session-level lock that blocks until acquired.
* The lock name is hashed to an integer using hashtext().
*/
public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) {
queryExecute(
"SELECT pg_advisory_lock(hashtext(?))",
[arguments.name],
{datasource: variables.dataSource, username: variables.username, password: variables.password}
);
}

/**
* Release a PostgreSQL advisory lock.
*/
public void function $releaseAdvisoryLock(required string name) {
queryExecute(
"SELECT pg_advisory_unlock(hashtext(?))",
[arguments.name],
{datasource: variables.dataSource, username: variables.username, password: variables.password}
);
}

/**
* Override Base adapter's function.
* PostgreSQL uses double-quotes to quote identifiers (ANSI SQL standard).
Expand Down
24 changes: 24 additions & 0 deletions vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,30 @@ component extends="wheels.databaseAdapters.Base" output=false {
}


/**
* SQLite uses file-level locking and does not support advisory locks.
* This is a no-op to allow code that uses advisory locks to run without errors on SQLite.
*/
public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) {
// No-op: SQLite has file-level locking only.
// Advisory locks are not meaningful for SQLite.
}

/**
* No-op release for SQLite.
*/
public void function $releaseAdvisoryLock(required string name) {
// No-op
}

/**
* SQLite does not support SELECT ... FOR UPDATE.
* Returns empty string to no-op.
*/
public string function $forUpdateClause() {
return "";
}

/**
* Default VALUES syntax (same as MySQL).
*/
Expand Down
52 changes: 52 additions & 0 deletions vendor/wheels/model/locking.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Provides advisory lock and pessimistic row locking support for Wheels models.
*
* Advisory locks are database-level, application-coordinated locks that don't lock any rows or tables.
* They are useful for coordinating exclusive access to shared resources (e.g., preventing duplicate
* background job processing, serializing access to external APIs).
*
* Pessimistic row locking (SELECT ... FOR UPDATE) is handled via the QueryBuilder's `forUpdate()` method.
*/
component {

/**
* Executes a callback while holding a database advisory lock.
* The lock is automatically released when the callback completes, even if an exception is thrown.
*
* Advisory locks are database-level locks that don't lock rows or tables. They are useful for
* coordinating exclusive access to shared resources across application instances.
*
* 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
* - SQLite: No-op (file-level locking only)
* - CockroachDB: Not supported (throws error, use forUpdate() instead)
* - H2: Not supported (throws error)
* - Oracle: Not supported by default (requires DBMS_LOCK package setup)
*
* [section: Model Class]
* [category: Locking Functions]
*
* @name A unique name for the lock. Different callers using the same name will contend for the same lock.
* @timeout Maximum number of seconds to wait when acquiring the lock (supported by MySQL and SQL Server).
* @callback A function or closure to execute while holding the lock. Its return value is returned by this method.
*/
public any function withAdvisoryLock(
required string name,
numeric timeout = 10,
required any callback
) {
local.adapter = variables.wheels.class.adapter;
local.adapter.$acquireAdvisoryLock(name = arguments.name, timeout = arguments.timeout);
try {
local.result = arguments.callback();
} finally {
local.adapter.$releaseAdvisoryLock(name = arguments.name);
}
if (StructKeyExists(local, "result")) {
return local.result;
}
}

}
22 changes: 21 additions & 1 deletion vendor/wheels/model/query/QueryBuilder.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ component output="false" {
variables.offsetValue = 0;
variables.distinctValue = false;
variables.groupClause = "";
variables.forUpdateValue = false;
return this;
}

Expand Down Expand Up @@ -211,6 +212,20 @@ component output="false" {
return this;
}

/**
* Add a FOR UPDATE clause to the query for pessimistic row locking.
* The locked rows will be held until the current transaction commits or rolls back.
* Must be used within a transaction to be effective.
*
* Support varies by database:
* - PostgreSQL, MySQL, CockroachDB, H2, Oracle: Appends FOR UPDATE
* - SQL Server, SQLite: No-op (MSSQL uses table hints, SQLite has file-level locking)
*/
public any function forUpdate() {
variables.forUpdateValue = true;
return this;
}

/**
* Build the accumulated arguments into a struct suitable for finder methods.
*/
Expand Down Expand Up @@ -281,6 +296,11 @@ component output="false" {
local.args.maxRows = variables.limitValue;
}

// Add FOR UPDATE flag if set
if (variables.forUpdateValue) {
local.args.$forUpdate = true;
}

// Merge in any extra arguments passed to the terminal method
StructAppend(local.args, arguments.extraArgs, false);

Expand Down Expand Up @@ -390,7 +410,7 @@ component output="false" {
Throw(
type = "Wheels.MethodNotFound",
message = "The method `#arguments.missingMethodName#` was not found on the query builder for `#variables.modelReference.$classData().modelName#`.",
extendedInfo = "Available methods: where, orWhere, whereNull, whereNotNull, whereBetween, whereIn, whereNotIn, orderBy, limit, offset, select, include, group, distinct, get, first, findAll, findOne, count, exists, updateAll, deleteAll, findEach, findInBatches."
extendedInfo = "Available methods: where, orWhere, whereNull, whereNotNull, whereBetween, whereIn, whereNotIn, orderBy, limit, offset, select, include, group, distinct, forUpdate, get, first, findAll, findOne, count, exists, updateAll, deleteAll, findEach, findInBatches."
);
}

Expand Down
9 changes: 8 additions & 1 deletion vendor/wheels/model/read.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ component {
struct useIndex = {},
string dataSource = variables.wheels.class.dataSource,
numeric $limit = "0",
numeric $offset = "0"
numeric $offset = "0",
boolean $forUpdate = "false"
) {
$args(name = "findAll", args = arguments);
$setDebugName(name = "findAll", args = arguments);
Expand Down Expand Up @@ -269,6 +270,12 @@ component {
if (Len(local.orderBy)) {
ArrayAppend(local.sql, local.orderBy);
}
if (arguments.$forUpdate) {
local.forUpdateClause = variables.wheels.class.adapter.$forUpdateClause();
if (Len(local.forUpdateClause)) {
ArrayAppend(local.sql, local.forUpdateClause);
}
}
local.lockName = "findAllSQLLock" & application.applicationName;
local.executeArgs = {key = local.queryShellKey, value = local.sql, category = "sql"};
$simpleLock(
Expand Down
Loading
Loading