Skip to content

Commit dd77ff5

Browse files
authored
fix(job): name the queue row when a persisted jobClass cannot be resolved (#3358)
* fix(job): name the queue row when a persisted jobClass cannot be resolved `enqueue()` persists `GetMetadata(this).name` into `wheels_jobs.jobClass`, and the drain re-instantiates with `CreateObject("component", jobRow.jobClass)`. A string produced by engine metadata is stored and later resolved as a component path, so the round trip is only safe if that string keeps the casing of the file on disk — component paths are case-sensitive on Linux and not on macOS or Windows. That is a bug shape which passes in development and fails on a production redeploy, on rows the old instance wrote and the new one drains. I could NOT reproduce a casing drift. Probed on Lucee 7: `GetMetadata().name` comes back canonical (`...jobs.ProbeJob`) even when the component is instantiated through a lowercase path, so Lucee derives it from the file rather than echoing what the caller typed. That explains why this has never bitten — it is a property of the engine, not luck. It is also not a guarantee across the other four engines, and the issue is explicit that it is unverified there. So rather than guess at a normalisation fix for a drift that may not exist, this pins the invariant as a test and improves the failure when it does not hold. JobClassRoundTripSpec asserts, case-sensitively, that the metadata name's last segment equals the .cfc file name; that the name is identical however the component was instantiated (a case-insensitive filesystem resolves both spellings, so an engine that echoed the caller's path would persist whatever casing was typed); and that a job re-instantiates from its own persisted name. Those run on every engine × database leg, so lucee6, adobe2023, adobe2025 and boxlang each answer the open question directly instead of being assumed safe. $instantiateJobClass() replaces the bare CreateObject on both processing paths (Job.$processJob and JobWorker.$executeJob) and throws Wheels.JobClassNotFound naming the class, the queue row id, and the three real causes — casing, rename, delete. The issue's point is that the raw error is `component not found` for a class that plainly exists, which sends investigators to mappings and deployment; the fix is to describe the actual shape of the problem, a string read out of a queue row. It also throws Wheels.InvalidJobClass when the path resolves to a component with no perform(). That narrows but does NOT close the database-string-to-CreateObject shape the issue flags — the real guarantee is still that only $enqueueJob writes that column, and the docstring says so rather than implying the check is a security boundary. JobWorker.$scheduleRetry's CreateObject is deliberately left alone: it is a best-effort backoff lookup already wrapped in a try/catch that falls back to defaults, and it must not start throwing. Red-first, with Job.cfc and JobWorker.cfc reverted: 4734 pass / 2 fail / 1 error. The three invariant specs pass on Lucee 7 — they are meant to, on an engine that holds the invariant. Verification, lucee7 + sqlite, full core suite: develop ab901cf 4732 pass / 0 fail / 0 error this branch 4737 pass / 0 fail / 0 error Exactly +5, the new specs. Closes #3351 Signed-off-by: Peter Amiri <peter@alurium.com> * fix(test): hoist the job-bridge receiver out of Adobe's parser path The compat matrix run for this branch reported tests="0" on EVERY database for adobe2023 and adobe2025 while lucee6, lucee7 and boxlang were all clean at +5. That is the compile-error signature: one bad spec file zeroes the entire engine leg, because the core suite compiles via directory="wheels.tests.specs". From the adobe2023 artifact, not inferred: coldfusion.compiler.CFMLParserBase$MissingNameException: Invalid construct: Either argument or name is missing. snippet: describe("Tests that the persisted jobClass round-trips", () => { TEMPLATE: .../specs/jobs/JobClassRoundTripSpec.cfc LINE 17 TYPE SYNTAX JobClassRoundTripSpec called `(new wheels.Job()).$instantiateJobClass(...)` — a parenthesized `new` in RECEIVER position. Adobe rejects it and, as cross-engine invariant 16 warns, blames the enclosing describe() line rather than the offending statement, so it reads like a broken test-block signature. Hoisting the instance to a variable is the fix. That form has 22 existing spec files as precedent — `adapter.$getType(type = "boolean")` in CockroachDBUnitSpec is the same shape, variable receiver with named arguments — and those files compile on the Adobe legs today. CLAUDE.md invariant 16 is widened rather than left to be rediscovered. It documented only the `application`-scope zero-argument form on Adobe 2025; this is a second shape in the same MissingNameException family, it fails on Adobe 2023 as well, and named arguments do not save it because the receiver is what the parser chokes on. Both are now written up as 16a/16b, with the note that `(new X()).method()` is fine in application code that only runs on Lucee — it appears in this file's own Background Jobs examples — and fatal in the core spec suite, which compiles on all five engines. Local Adobe verification was NOT possible: the adobe2023 container will not start on this machine (`runc create failed ... error during container init`, reported as `engine-down` by tools/test-matrix.sh), from both a worktree and the main checkout. So this rests on the artifact root cause plus the in-repo precedent above, and the re-dispatched matrix is the check. lucee7 + sqlite, full core suite: 4737 pass / 0 fail / 0 error, unchanged from before the hoist. Refs #3351 Signed-off-by: Peter Amiri <peter@alurium.com> * test(job): assert the round-trip property both engine families satisfy The re-dispatched matrix confirmed the parser fix — adobe2023 compiles again, +5 tests on every database — but surfaced a second, more interesting problem: one new ERROR per database on Adobe. Error | reports the same metadata name however the component was instantiated | Could not find the ColdFusion component or interface wheels.tests._assets.jobs.probejob. Adobe's component resolver is CASE-SENSITIVE independently of the filesystem. A miscased path does not resolve on macOS either, where the filesystem happily would. My spec instantiated through a deliberately lowercase path to prove the engine does not echo the caller's casing back into the persisted name — an assumption that only holds where the miscased path is constructible at all. So the spec was failing on Adobe for a reason *safer* than the one it was testing. Two engine families close the same hole differently: Lucee / BoxLang a miscased path RESOLVES, but the metadata name comes back canonical Adobe a miscased path does not resolve at all — nothing can be persisted because nothing can be constructed The spec now asserts the property both satisfy: a caller's miscasing cannot reach `wheels_jobs.jobClass`. Following cross-engine invariant 11, the flag lives on a struct set in the try rather than a local set in the catch, so it survives on BoxLang. This is the answer to the open question in the issue, which is the reason these specs run on every leg instead of being assumed: the round trip is safe on all five engines, for two different reasons, neither of which was verified before. lucee7 + sqlite, full core suite: 4737 pass / 0 fail / 0 error. Refs #3351 Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com>
1 parent e8d094f commit dd77ff5

5 files changed

Lines changed: 182 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,19 @@ The framework must run on Lucee 5/6/7, Adobe CF 2018/2021/2023/2025, and BoxLang
5454

5555
15. **A parameter named `request` makes the bare `request` token resolve inconsistently on Adobe 2025.** In a function declaring a parameter named `request`, Adobe CF 2025 can resolve bare `request` to the built-in scope in one expression position and to `arguments.request` in another *within the same function* — so a guard written one way cannot protect an access written the other way. `if (StructKeyExists(request, "wheels")) { StructDelete(request.wheels, "tenant"); }` passed the guard and then threw `Element WHEELS is undefined in REQUEST`. Use `IsDefined("request.wheels.tenant")`, which string-resolves the whole dotted path in one evaluation, or assign before use (`if (!StructKeyExists(request, "wheels")) { request.wheels = {}; }` then write) — never mix the two forms. This hits **every middleware component**, because `wheels.middleware.MiddlewareInterface` mandates the signature `handle(required struct request, required any next)`; anti-pattern 11's "never name a parameter after a reserved scope" is unavailable there. Lucee 6/7, BoxLang and Adobe 2023 all resolve consistently, so **local Lucee green and Adobe 2023 smokes do NOT cover this** — only the Adobe 2025 matrix legs catch it, and `compat-matrix.yml` does not run on PRs (weekly cron + `workflow_dispatch`, `continue-on-error: true`). Hit by `TenantResolver.handle()` in [#3338](https://github.com/wheels-dev/wheels/pull/3338).
5656

57-
16. **A zero-argument call through the `application` scope breaks Adobe 2025's parser in statement position.** Inside a closure, `application.wo.$someMethod()` with an **empty** argument list — used as a bare statement or as the whole right-hand side of an assignment — throws at COMPILE time: `coldfusion.compiler.CFMLParserBase$MissingNameException: Invalid construct: Either argument or name is missing` ("When using named parameters to a function, each parameter must have a name"). Adobe appears to parse it as a script-style tag call and demand at least one attribute. This is the `application`-scope sibling of invariant 2. Verified boundaries — each of these compiles, so **do not "fix" them**:
57+
16. **Two receiver shapes break Adobe's parser at COMPILE time with the same `MissingNameException`.** Both throw `coldfusion.compiler.CFMLParserBase$MissingNameException: Invalid construct: Either argument or name is missing` ("When using named parameters to a function, each parameter must have a name"). Adobe appears to parse the construct as a script-style tag call and demand at least one attribute.
58+
59+
**16a — a parenthesized `new` in receiver position, on EVERY Adobe engine.** `(new wheels.Job()).$someMethod(arg = "x")` fails to compile on Adobe **2023 and 2025**; Lucee 6/7 and BoxLang accept it. The argument list is irrelevant here — named arguments do not save it, because the receiver is what the parser chokes on. Hoist the instance to a variable first:
60+
```cfm
61+
// WRONG — zeroes out both Adobe legs
62+
revived = (new wheels.Job()).$instantiateJobClass(jobClass = persisted);
63+
// RIGHT — variable receiver; 22 spec files already do this and pass on Adobe
64+
var bridge = new wheels.Job();
65+
revived = bridge.$instantiateJobClass(jobClass = persisted);
66+
```
67+
Note the `(new X()).method()` form appears in this file's own Background Jobs examples and in user-facing docs — it is fine in **application** code that only ever runs on Lucee, and fatal in the **core spec suite**, which compiles on all five engines. Hit by `JobClassRoundTripSpec` in [#3351](https://github.com/wheels-dev/wheels/issues/3351).
68+
69+
**16b — a zero-argument call through the `application` scope, Adobe 2025.** Inside a closure, `application.wo.$someMethod()` with an **empty** argument list — used as a bare statement or as the whole right-hand side of an assignment — fails the same way. This is the `application`-scope sibling of invariant 2. Verified boundaries — each of these compiles, so **do not "fix" them**:
5870
- any argument at all: `application.wo.$get("showErrorInformation")`
5971
- nested inside another call: `expect(application.wo.$statusCode()).toBe(418)` (long-standing in `renderingSpec`)
6072
- chained further: `application.wo.mapper().resources("posts")` (`RoutePrecedenceSpec`)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- A background job whose `jobClass` cannot be resolved now throws `Wheels.JobClassNotFound` naming the class, the queue row, and the likely causes, instead of the engine's bare `component not found`. `wheels_jobs.jobClass` is written from `GetMetadata(this).name` on enqueue and resolved as a component path on drain, so the failure appears as "component not found" for a class that plainly exists on disk — which sends people to look at mappings and deployment rather than at the persisted string. Component paths are case-sensitive on Linux but not on macOS or Windows, so a casing mismatch resolves in development and fails on a production redeploy, long after the row was written. A path that resolves to something without a `perform()` method now throws `Wheels.InvalidJobClass` rather than failing later inside job execution. Both processing paths (`Job.$processJob` and `JobWorker.$executeJob`) share the check (#3351)
2+
- Verified across every engine: the `jobClass` string persisted on enqueue always round-trips. Lucee and BoxLang derive the metadata name from the file, so a miscased path still yields the canonical name; Adobe's component resolver is case-sensitive independently of the filesystem, so a miscased path does not construct at all. Either way a caller's miscasing cannot reach `wheels_jobs.jobClass`. Pinned by `JobClassRoundTripSpec`, which runs on all five engines rather than assuming the invariant (#3351)

vendor/wheels/Job.cfc

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,48 @@ component {
268268
return local.result;
269269
}
270270

271+
/**
272+
* Internal: Turn a persisted `jobClass` string back into a job instance.
273+
*
274+
* `jobClass` is written on enqueue from `GetMetadata(this).name` and read back here as a
275+
* component path, so the round trip depends on that string still resolving — including its
276+
* casing, on a case-sensitive filesystem. Lucee derives the metadata name from the file
277+
* rather than from how the component was instantiated, so it is canonical there; the
278+
* cross-engine guarantee is pinned by JobClassRoundTripSpec rather than assumed.
279+
*
280+
* When it does not resolve, the raw engine error is `component not found` for a class that
281+
* plainly exists on disk, which sends people to look at mappings and deployment. Name the
282+
* real shape of the problem instead: a string read out of a queue row (issue #3351).
283+
*
284+
* @jobClass The component path as persisted in wheels_jobs.
285+
* @jobId The queue row's id, for the error message. Optional.
286+
*/
287+
public any function $instantiateJobClass(required string jobClass, string jobId = "") {
288+
local.rowLabel = Len(arguments.jobId) ? " named by queue row [#arguments.jobId#]" : "";
289+
try {
290+
local.rv = CreateObject("component", arguments.jobClass);
291+
} catch (any e) {
292+
Throw(
293+
type = "Wheels.JobClassNotFound",
294+
message = "The job class `#arguments.jobClass#`#local.rowLabel# could not be instantiated: #e.message#",
295+
extendedInfo = "This path was persisted to `wheels_jobs.jobClass` when the job was enqueued and is resolved as a component path now. If the file exists, compare its name and directories to the string above CHARACTER BY CHARACTER — component paths are case-sensitive on Linux but not on macOS or Windows, so a casing mismatch resolves in development and fails in production. It also fails if the job class was renamed, moved, or deleted while rows referencing it were still queued."
296+
);
297+
}
298+
// A job row names something to instantiate and then call perform() on. Anything without
299+
// perform() is not a job, and failing here says so rather than failing later inside the
300+
// job's own execution where it reads as a job bug. Note this narrows but does not close
301+
// the database-string-to-CreateObject shape the issue flags: the actual guarantee is that
302+
// only $enqueueJob writes this column.
303+
if (!StructKeyExists(local.rv, "perform")) {
304+
Throw(
305+
type = "Wheels.InvalidJobClass",
306+
message = "The component `#arguments.jobClass#`#local.rowLabel# is not a job — it has no `perform()` method.",
307+
extendedInfo = "`wheels_jobs.jobClass` must name a component extending `wheels.Job`. Only the framework writes this column; a value that names something else means the row was written by something other than `enqueue()`."
308+
);
309+
}
310+
return local.rv;
311+
}
312+
271313
/**
272314
* Internal: Process a single job row.
273315
*/
@@ -315,7 +357,7 @@ component {
315357

316358
try {
317359
// Instantiate and execute the job
318-
local.jobInstance = CreateObject("component", arguments.jobRow.jobClass);
360+
local.jobInstance = $instantiateJobClass(jobClass = arguments.jobRow.jobClass, jobId = arguments.jobRow.id);
319361
if (StructKeyExists(local.jobInstance, "baseDelay")) {
320362
local.backoffBaseDelay = local.jobInstance.baseDelay;
321363
}

vendor/wheels/JobWorker.cfc

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,12 @@ component {
449449
local.hasTenantContext = false;
450450

451451
try {
452-
local.jobInstance = CreateObject("component", arguments.jobRow.jobClass);
452+
// Shared with Job.$processJob so both processing paths report an unresolvable
453+
// jobClass the same way (issue #3351)
454+
local.jobInstance = $jobBridge().$instantiateJobClass(
455+
jobClass = arguments.jobRow.jobClass,
456+
jobId = arguments.jobRow.id
457+
);
453458
local.jobData = DeserializeJSON(arguments.jobRow.data);
454459

455460
// Restore tenant context if the job was enqueued within a tenant scope and
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
component extends="wheels.WheelsTest" {
2+
3+
function run() {
4+
5+
// Issue #3351. `enqueue()` persists `GetMetadata(this).name` into
6+
// `wheels_jobs.jobClass`, and the drain re-instantiates with
7+
// `CreateObject("component", jobRow.jobClass)`. So a string produced by ENGINE
8+
// METADATA is stored and later resolved as a component path, and the round trip is
9+
// only safe if that string keeps the casing of the file on disk — component paths are
10+
// case-sensitive on Linux and not on macOS or Windows, which is exactly the shape of
11+
// bug that passes locally and fails on a production redeploy.
12+
//
13+
// The issue calls the invariant unverified across engines. Rather than guess at a fix,
14+
// these specs assert it. They run on every engine × database leg, so lucee6, lucee7,
15+
// adobe2023, adobe2025 and boxlang each answer the question directly: if any engine
16+
// reports a name that does not match the file, this fails there and names it.
17+
describe("Tests that the persisted jobClass round-trips", () => {
18+
19+
it("reports a metadata name whose last segment matches the .cfc file name exactly", () => {
20+
job = CreateObject("component", "wheels.tests._assets.jobs.ProbeJob")
21+
meta = GetMetadata(job)
22+
23+
fileName = ListFirst(ListLast(Replace(meta.path, "\", "/", "all"), "/"), ".")
24+
25+
// case-sensitive comparison — Compare(), not CompareNoCase()
26+
expect(Compare(ListLast(meta.name, "."), fileName)).toBe(0)
27+
})
28+
29+
it("never persists a caller's miscased path", () => {
30+
// The risk is an engine ECHOING BACK the path it was handed instead of
31+
// deriving the name from the file: the persisted string would then carry
32+
// whatever casing the caller happened to type, and that string is what a
33+
// Linux worker later has to resolve.
34+
//
35+
// Engines close that off two different ways, and either is sufficient:
36+
//
37+
// Lucee/BoxLang — a miscased path RESOLVES (the filesystem is
38+
// case-insensitive here) but the metadata name comes back canonical.
39+
// Adobe — a miscased path does not resolve AT ALL. Its component
40+
// resolver is case-sensitive independently of the filesystem, throwing
41+
// "Could not find the ColdFusion component ... probejob". Nothing can be
42+
// persisted because nothing can be constructed.
43+
//
44+
// Asserting only the first would fail on Adobe for a reason that is *safer*
45+
// than the one being tested, so assert the property both satisfy.
46+
canonical = GetMetadata(CreateObject("component", "wheels.tests._assets.jobs.ProbeJob")).name
47+
resolved = {miscasedConstructed = false, name = ""}
48+
49+
try {
50+
resolved.name = GetMetadata(CreateObject("component", "wheels.tests._assets.jobs.probejob")).name
51+
resolved.miscasedConstructed = true
52+
} catch (any e) {
53+
// case-sensitive resolver — the stronger guarantee
54+
}
55+
56+
if (resolved.miscasedConstructed) {
57+
expect(Compare(resolved.name, canonical)).toBe(0)
58+
} else {
59+
expect(resolved.name).toBe("")
60+
}
61+
})
62+
63+
it("re-instantiates from its own persisted metadata name", () => {
64+
// the actual enqueue -> drain round trip, without touching the queue table
65+
original = CreateObject("component", "wheels.tests._assets.jobs.ProbeJob")
66+
persisted = GetMetadata(original).name
67+
68+
// Hoisted receiver. A parenthesized `new` in receiver position — `(new X()).m()`
69+
// — is rejected by Adobe's parser with `Invalid construct: Either argument or
70+
// name is missing`, the same MissingNameException family as cross-engine
71+
// invariant 16. Adobe blames the enclosing describe() line and the whole engine
72+
// leg reports tests=0. Caught by the compat matrix; Lucee and BoxLang accept it.
73+
bridge = new wheels.Job()
74+
revived = bridge.$instantiateJobClass(jobClass = persisted)
75+
76+
expect(Compare(GetMetadata(revived).name, persisted)).toBe(0)
77+
})
78+
})
79+
80+
describe("Tests that an unresolvable jobClass", () => {
81+
82+
it("throws Wheels.JobClassNotFound naming the row and the class", () => {
83+
thrown = {type: "", message: ""}
84+
85+
bridge = new wheels.Job()
86+
87+
try {
88+
bridge.$instantiateJobClass(jobClass = "app.jobs.NoSuchJob", jobId = "abc-123")
89+
} catch (any e) {
90+
thrown.type = e.type
91+
thrown.message = e.message
92+
}
93+
94+
// the raw engine error is "component not found" for a class that plainly
95+
// exists, which points investigators at mappings and deployment
96+
expect(thrown.type).toBe("Wheels.JobClassNotFound")
97+
expect(thrown.message).toInclude("app.jobs.NoSuchJob")
98+
expect(thrown.message).toInclude("abc-123")
99+
})
100+
101+
it("throws Wheels.InvalidJobClass when the path resolves to something that is not a job", () => {
102+
thrown = {type: ""}
103+
104+
bridge = new wheels.Job()
105+
106+
try {
107+
// a real component with no perform()
108+
bridge.$instantiateJobClass(jobClass = "wheels.tests._assets.models.Post")
109+
} catch (any e) {
110+
thrown.type = e.type
111+
}
112+
113+
expect(thrown.type).toBe("Wheels.InvalidJobClass")
114+
})
115+
})
116+
}
117+
118+
}

0 commit comments

Comments
 (0)