diff --git a/vendor/wheels/databaseAdapters/Base.cfc b/vendor/wheels/databaseAdapters/Base.cfc index 7bd184b02..1c04f3eb3 100755 --- a/vendor/wheels/databaseAdapters/Base.cfc +++ b/vendor/wheels/databaseAdapters/Base.cfc @@ -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. diff --git a/vendor/wheels/databaseAdapters/CockroachDB/CockroachDBModel.cfc b/vendor/wheels/databaseAdapters/CockroachDB/CockroachDBModel.cfc index e1c35fd37..e1bbc55de 100644 --- a/vendor/wheels/databaseAdapters/CockroachDB/CockroachDBModel.cfc +++ b/vendor/wheels/databaseAdapters/CockroachDB/CockroachDBModel.cfc @@ -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(), diff --git a/vendor/wheels/databaseAdapters/H2/H2Model.cfc b/vendor/wheels/databaseAdapters/H2/H2Model.cfc index cd987e5b1..55f9b9c2d 100755 --- a/vendor/wheels/databaseAdapters/H2/H2Model.cfc +++ b/vendor/wheels/databaseAdapters/H2/H2Model.cfc @@ -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. diff --git a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc index 750c15bc6..50b7d678a 100755 --- a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc +++ b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc @@ -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. */ diff --git a/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc b/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc index 3d6fdfbc8..611c63307 100755 --- a/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc +++ b/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc @@ -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. */ diff --git a/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc b/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc index 0ad78c10c..42a6e4e6c 100755 --- a/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc +++ b/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc @@ -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. */ diff --git a/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc b/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc index 6c11b1853..f3c0788a5 100755 --- a/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc +++ b/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc @@ -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). diff --git a/vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc b/vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc index ee04ddc09..1b44629ef 100755 --- a/vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc +++ b/vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc @@ -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). */ diff --git a/vendor/wheels/model/locking.cfc b/vendor/wheels/model/locking.cfc new file mode 100644 index 000000000..e6059b570 --- /dev/null +++ b/vendor/wheels/model/locking.cfc @@ -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; + } + } + +} diff --git a/vendor/wheels/model/query/QueryBuilder.cfc b/vendor/wheels/model/query/QueryBuilder.cfc index ac7b454a2..633051a2f 100644 --- a/vendor/wheels/model/query/QueryBuilder.cfc +++ b/vendor/wheels/model/query/QueryBuilder.cfc @@ -32,6 +32,7 @@ component output="false" { variables.offsetValue = 0; variables.distinctValue = false; variables.groupClause = ""; + variables.forUpdateValue = false; return this; } @@ -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. */ @@ -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); @@ -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." ); } diff --git a/vendor/wheels/model/read.cfc b/vendor/wheels/model/read.cfc index cee79ad13..787427986 100644 --- a/vendor/wheels/model/read.cfc +++ b/vendor/wheels/model/read.cfc @@ -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); @@ -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( diff --git a/vendor/wheels/tests/specs/model/lockingSpec.cfc b/vendor/wheels/tests/specs/model/lockingSpec.cfc new file mode 100644 index 000000000..8b678db6f --- /dev/null +++ b/vendor/wheels/tests/specs/model/lockingSpec.cfc @@ -0,0 +1,114 @@ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo + + describe("Tests that withAdvisoryLock", () => { + + it("executes callback and returns result", () => { + local.result = g.model("author").withAdvisoryLock(name="test_lock_1", callback=function() { + return 42; + }); + expect(local.result).toBe(42); + }) + + it("releases lock even when callback throws an exception", () => { + local.exceptionThrown = false; + try { + g.model("author").withAdvisoryLock(name="test_lock_2", callback=function() { + Throw(type="TestException", message="deliberate error"); + }); + } catch (TestException e) { + local.exceptionThrown = true; + } + expect(local.exceptionThrown).toBeTrue(); + + // Verify the lock was released by successfully acquiring it again + local.result = g.model("author").withAdvisoryLock(name="test_lock_2", callback=function() { + return "reacquired"; + }); + expect(local.result).toBe("reacquired"); + }) + + it("accepts a custom timeout argument", () => { + local.result = g.model("author").withAdvisoryLock(name="test_lock_timeout", timeout=5, callback=function() { + return "locked"; + }); + expect(local.result).toBe("locked"); + }) + + it("safely handles lock names with single quotes", () => { + // Regression guard: verify lock names are parameterized, not interpolated. + // On SQLite this is a no-op, but the call must not throw a SQL syntax error + // regardless of adapter. With proper parameterization, "O'Brien" is fine. + local.result = g.model("author").withAdvisoryLock(name="O'Brien's lock", callback=function() { + return "safe"; + }); + expect(local.result).toBe("safe"); + }) + + }) + + describe("Tests that forUpdate on QueryBuilder", () => { + + it("sets the forUpdate flag in built finder args", () => { + local.builder = g.model("author").where("firstName", "Per").forUpdate(); + local.args = local.builder.$buildFinderArgs(); + expect(local.args).toHaveKey("$forUpdate"); + expect(local.args.$forUpdate).toBeTrue(); + }) + + it("does not set forUpdate flag when not called", () => { + local.builder = g.model("author").where("firstName", "Per"); + local.args = local.builder.$buildFinderArgs(); + expect(local.args).notToHaveKey("$forUpdate"); + }) + + it("works in a QueryBuilder chain with other methods", () => { + local.builder = g.model("author") + .where("firstName", "Per") + .orderBy("lastName") + .forUpdate() + .limit(1); + local.args = local.builder.$buildFinderArgs(); + expect(local.args).toHaveKey("$forUpdate"); + expect(local.args.$forUpdate).toBeTrue(); + expect(local.args).toHaveKey("where"); + expect(local.args).toHaveKey("order"); + expect(local.args).toHaveKey("maxRows"); + }) + + it("executes a findAll with forUpdate without error", () => { + // On SQLite this is a no-op (empty FOR UPDATE clause), but should not throw + local.result = g.model("author").where("firstName", "Per").forUpdate().get(); + expect(local.result.recordCount).toBeGTE(0); + }) + + it("executes findAll with dollar forUpdate argument without error", () => { + // Direct findAll with $forUpdate argument + local.result = g.model("author").findAll(where="firstName = 'Per'", $forUpdate=true); + expect(local.result.recordCount).toBeGTE(0); + }) + + }) + + describe("Tests that adapter forUpdateClause", () => { + + it("returns correct clause for the current adapter", () => { + local.adapter = g.model("author").$classData().adapter; + local.adapterName = g.model("author").get("adapterName"); + local.clause = local.adapter.$forUpdateClause(); + + if (local.adapterName == "SQLiteModel" || local.adapterName == "MicrosoftSQLServerModel") { + expect(local.clause).toBe(""); + } else { + expect(local.clause).toBe("FOR UPDATE"); + } + }) + + }) + + } + +}