Skip to content

Commit 6b5e1c2

Browse files
bpamiriclaudegithub-actions[bot]
authored
fix(job): guard processQueue job claim with status=pending and affected-row check (#2899)
* fix(job): guard processQueue job claim with status=pending and affected-row check processQueue's $processJob marked a job as processing with an unguarded UPDATE (WHERE id = :id only), so two concurrent workers could both claim and execute the same job. Mirror JobWorker.cfc::$claimJob: add AND status = 'pending' to the claim UPDATE and check the affected-row count via the queryExecute result option on the same statement (a separate verification SELECT breaks on BoxLang + PostgreSQL when the pool hands out a different connection). A lost claim now returns {skipped = true} from $processJob, and processQueue counts it under a new additive 'skipped' key instead of recording a failure. attempts increments only on a successful claim, keeping increment-and-claim atomic. Spec: new "Job Claim Guard" describe in JobQueueSpec proves an already-processing row is not re-executed (status stays 'processing', attempts stays 0), plus a 'skipped' key assertion on the processQueue result shape. Verified red (22 pass / 2 fail pre-fix) then green (24 pass / 0 fail) on Lucee 7 + SQLite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(job): address Reviewer A/B consensus findings (round 1) - vendor/wheels/Job.cfc:238 — drop the redundant StructKeyExists guard on local.jobResult.skipped. $processJob initialises local.result = {success = false, skipped = false, error = ""} unconditionally and every return path returns that struct, so the key is always present. - vendor/wheels/tests/specs/jobs/JobQueueSpec.cfc — move the "Job Claim Guard" describe block's DELETE cleanup from inline at the end of the it block into afterEach (try/catch-wrapped, matching beforeEach), so a throw before the inline DELETE no longer leaks the seeded row. beforeEach still cleans up too (defense in depth). - CHANGELOG.md — add the [Unreleased] block (Keep a Changelog convention) with a ### Fixed entry for the processQueue claim guard. Last [Unreleased] was promoted to 4.0.3 in 08dd480 on 2026-06-09, so this PR is the first change targeting the 4.0.4 snapshot. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
1 parent a391bf4 commit 6b5e1c2

3 files changed

Lines changed: 84 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ All historical references to "CFWheels" in this changelog have been preserved fo
2020

2121
## [Unreleased]
2222

23+
### Fixed
24+
25+
- `Job.processQueue()`'s private `$processJob` now guards the claim `UPDATE` with `AND status = 'pending'` and verifies the affected-row count via the `queryExecute` `result` option on the same statement, mirroring the matrix-proven `JobWorker.cfc::$claimJob` idiom (a separate verification `SELECT` breaks on BoxLang + PostgreSQL when the connection pool hands out a different connection that cannot see the uncommitted UPDATE). Pre-fix, two concurrent claimers — overlapping `processQueue()` callers, or `processQueue` racing the CLI worker — could both claim the same job and both run `perform()` (duplicate emails/charges, with `attempts` double-incremented). A lost claim now early-returns `{success = false, skipped = true}` before job instantiation, tenant-context setup, and `perform()`; `processQueue()` counts lost claims under a new additive `skipped` result key (#2899)
26+
2327
### Security
2428

2529
- `$isSafeRedirectUrl()` rejects backslash-containing URLs (`/\evil.com`, `\/evil.com`, `\\evil.com`) and schemeless-authority URLs (`https:/evil.com`, `javascript:alert(1)`) instead of returning them as safe. Browsers normalize backslashes to forward slashes and single-slash schemes to authority form, so any of those vectors smuggled past the previous check would navigate off-site after `redirectTo()`. Scheme detection now uses the RFC 3986 grammar (`ReFindNoCase("^[a-z][a-z0-9+.-]*:")`) and runs before the relative-URL fast path; backslashes are rejected up front, so the same-domain `ListFirst` no longer needs to treat `\` as a delimiter. Same-origin absolute URLs and genuine relative paths remain allowed (#2898)

vendor/wheels/Job.cfc

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ component {
209209
* @limit Maximum number of jobs to process in this batch.
210210
*/
211211
public struct function processQueue(string queue = "", numeric limit = 10) {
212-
local.result = {processed = 0, failed = 0, errors = []};
212+
local.result = {processed = 0, failed = 0, skipped = 0, errors = []};
213213
local.params = {
214214
runAt = {value = $now(), cfsqltype = "cf_sql_timestamp"}
215215
};
@@ -235,6 +235,11 @@ component {
235235

236236
for (local.row in local.jobs) {
237237
local.jobResult = $processJob(local.row);
238+
if (local.jobResult.skipped) {
239+
// Another worker claimed the job between our SELECT and the claim UPDATE
240+
local.result.skipped++;
241+
continue;
242+
}
238243
if (local.jobResult.success) {
239244
local.result.processed++;
240245
} else {
@@ -250,20 +255,30 @@ component {
250255
* Internal: Process a single job row.
251256
*/
252257
private struct function $processJob(required struct jobRow) {
253-
local.result = {success = false, error = ""};
254-
255-
// Mark as processing
258+
local.result = {success = false, skipped = false, error = ""};
259+
260+
// Mark as processing using optimistic locking: the status guard ensures only
261+
// one concurrent worker can claim the job. Use the result option to get the
262+
// affected-row count from the same connection that executed the UPDATE. A
263+
// separate verification SELECT can fail on BoxLang + PostgreSQL when the
264+
// connection pool hands out a different connection that cannot see the
265+
// uncommitted UPDATE.
256266
try {
257267
queryExecute(
258268
"UPDATE wheels_jobs
259269
SET status = 'processing', attempts = attempts + 1, updatedAt = :updatedAt
260-
WHERE id = :id",
270+
WHERE id = :id AND status = 'pending'",
261271
{
262272
updatedAt = {value = $now(), cfsqltype = "cf_sql_timestamp"},
263273
id = {value = arguments.jobRow.id, cfsqltype = "cf_sql_varchar"}
264274
},
265-
{datasource = variables.$datasource}
275+
{datasource = variables.$datasource, result = "local.updateResult"}
266276
);
277+
if ((local.updateResult.recordCount ?: 0) == 0) {
278+
// Another worker already claimed this job — skip without executing
279+
local.result.skipped = true;
280+
return local.result;
281+
}
267282
} catch (any e) {
268283
local.result.error = "Failed to lock job #arguments.jobRow.id#: #e.message#";
269284
return local.result;

vendor/wheels/tests/specs/jobs/JobQueueSpec.cfc

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ component extends="wheels.WheelsTest" {
147147
expect(local.result).toBeStruct();
148148
expect(local.result).toHaveKey("processed");
149149
expect(local.result).toHaveKey("failed");
150+
expect(local.result).toHaveKey("skipped");
150151
expect(local.result).toHaveKey("errors");
151152
});
152153

@@ -167,6 +168,64 @@ component extends="wheels.WheelsTest" {
167168
});
168169
});
169170

171+
describe("Job Claim Guard", function() {
172+
173+
beforeEach(function() {
174+
// Clean up any leftover test jobs from prior runs
175+
try { queryExecute("DELETE FROM wheels_jobs WHERE queue = 'test_claim_guard'", {}, {datasource = application.wheels.dataSourceName}); }
176+
catch (any e) { /* table may not exist */ }
177+
});
178+
179+
afterEach(function() {
180+
try { queryExecute("DELETE FROM wheels_jobs WHERE queue = 'test_claim_guard'", {}, {datasource = application.wheels.dataSourceName}); }
181+
catch (any e) { /* table may not exist */ }
182+
});
183+
184+
it("does not execute a job already claimed by another worker", function() {
185+
// Enqueue using a concrete subclass so jobClass resolves correctly
186+
local.testJob = new app.jobs.ProcessOrdersJob();
187+
local.enqueued = local.testJob.enqueue(data = {}, queue = "test_claim_guard");
188+
expect(local.enqueued).toHaveKey("persisted");
189+
expect(local.enqueued.persisted).toBeTrue();
190+
191+
// Simulate a concurrent worker having already claimed the job
192+
queryExecute(
193+
"UPDATE wheels_jobs SET status = 'processing' WHERE id = :id",
194+
{id = {value = local.enqueued.id, cfsqltype = "cf_sql_varchar"}},
195+
{datasource = application.wheels.dataSourceName}
196+
);
197+
198+
// Build the job row as processQueue's SELECT would have seen it pre-claim
199+
local.jobRow = {
200+
id = local.enqueued.id,
201+
jobClass = "app.jobs.ProcessOrdersJob",
202+
queue = "test_claim_guard",
203+
data = "{}",
204+
attempts = 0,
205+
maxRetries = 3
206+
};
207+
208+
local.job = new wheels.Job();
209+
prepareMock(local.job);
210+
makePublic(local.job, "$processJob");
211+
local.jobResult = local.job.$processJob(jobRow = local.jobRow);
212+
213+
// The lost claim must be reported as skipped, not executed
214+
expect(local.jobResult).toHaveKey("skipped");
215+
expect(local.jobResult.skipped).toBeTrue();
216+
expect(local.jobResult.success).toBeFalse();
217+
218+
// The already-claimed row must be untouched: still processing, attempts not incremented
219+
local.row = queryExecute(
220+
"SELECT status, attempts FROM wheels_jobs WHERE id = :id",
221+
{id = {value = local.enqueued.id, cfsqltype = "cf_sql_varchar"}},
222+
{datasource = application.wheels.dataSourceName}
223+
);
224+
expect(local.row.status).toBe("processing");
225+
expect(local.row.attempts).toBe(0);
226+
});
227+
});
228+
170229
describe("Job Data Serialization", function() {
171230

172231
it("enqueue handles complex data structures", function() {

0 commit comments

Comments
 (0)