diff --git a/CLAUDE.md b/CLAUDE.md index c6f7beaa3b..7bdf4b7ef1 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,19 @@ The framework must run on Lucee 5/6/7, Adobe CF 2018/2021/2023/2025, and BoxLang 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). -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**: +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. + + **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: + ```cfm + // WRONG — zeroes out both Adobe legs + revived = (new wheels.Job()).$instantiateJobClass(jobClass = persisted); + // RIGHT — variable receiver; 22 spec files already do this and pass on Adobe + var bridge = new wheels.Job(); + revived = bridge.$instantiateJobClass(jobClass = persisted); + ``` + 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). + + **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**: - any argument at all: `application.wo.$get("showErrorInformation")` - nested inside another call: `expect(application.wo.$statusCode()).toBe(418)` (long-standing in `renderingSpec`) - chained further: `application.wo.mapper().resources("posts")` (`RoutePrecedenceSpec`) diff --git a/changelog.d/3351-jobclass-round-trip.fixed.md b/changelog.d/3351-jobclass-round-trip.fixed.md new file mode 100644 index 0000000000..c55528ef67 --- /dev/null +++ b/changelog.d/3351-jobclass-round-trip.fixed.md @@ -0,0 +1,2 @@ +- 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) +- 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) diff --git a/vendor/wheels/Job.cfc b/vendor/wheels/Job.cfc index 5d18a25350..6e9f3e93e3 100644 --- a/vendor/wheels/Job.cfc +++ b/vendor/wheels/Job.cfc @@ -268,6 +268,48 @@ component { return local.result; } + /** + * Internal: Turn a persisted `jobClass` string back into a job instance. + * + * `jobClass` is written on enqueue from `GetMetadata(this).name` and read back here as a + * component path, so the round trip depends on that string still resolving — including its + * casing, on a case-sensitive filesystem. Lucee derives the metadata name from the file + * rather than from how the component was instantiated, so it is canonical there; the + * cross-engine guarantee is pinned by JobClassRoundTripSpec rather than assumed. + * + * When it does not resolve, the raw engine error is `component not found` for a class that + * plainly exists on disk, which sends people to look at mappings and deployment. Name the + * real shape of the problem instead: a string read out of a queue row (issue #3351). + * + * @jobClass The component path as persisted in wheels_jobs. + * @jobId The queue row's id, for the error message. Optional. + */ + public any function $instantiateJobClass(required string jobClass, string jobId = "") { + local.rowLabel = Len(arguments.jobId) ? " named by queue row [#arguments.jobId#]" : ""; + try { + local.rv = CreateObject("component", arguments.jobClass); + } catch (any e) { + Throw( + type = "Wheels.JobClassNotFound", + message = "The job class `#arguments.jobClass#`#local.rowLabel# could not be instantiated: #e.message#", + 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." + ); + } + // A job row names something to instantiate and then call perform() on. Anything without + // perform() is not a job, and failing here says so rather than failing later inside the + // job's own execution where it reads as a job bug. Note this narrows but does not close + // the database-string-to-CreateObject shape the issue flags: the actual guarantee is that + // only $enqueueJob writes this column. + if (!StructKeyExists(local.rv, "perform")) { + Throw( + type = "Wheels.InvalidJobClass", + message = "The component `#arguments.jobClass#`#local.rowLabel# is not a job — it has no `perform()` method.", + 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()`." + ); + } + return local.rv; + } + /** * Internal: Process a single job row. */ @@ -315,7 +357,7 @@ component { try { // Instantiate and execute the job - local.jobInstance = CreateObject("component", arguments.jobRow.jobClass); + local.jobInstance = $instantiateJobClass(jobClass = arguments.jobRow.jobClass, jobId = arguments.jobRow.id); if (StructKeyExists(local.jobInstance, "baseDelay")) { local.backoffBaseDelay = local.jobInstance.baseDelay; } diff --git a/vendor/wheels/JobWorker.cfc b/vendor/wheels/JobWorker.cfc index 96abbe334e..4f8336f985 100644 --- a/vendor/wheels/JobWorker.cfc +++ b/vendor/wheels/JobWorker.cfc @@ -449,7 +449,12 @@ component { local.hasTenantContext = false; try { - local.jobInstance = CreateObject("component", arguments.jobRow.jobClass); + // Shared with Job.$processJob so both processing paths report an unresolvable + // jobClass the same way (issue #3351) + local.jobInstance = $jobBridge().$instantiateJobClass( + jobClass = arguments.jobRow.jobClass, + jobId = arguments.jobRow.id + ); local.jobData = DeserializeJSON(arguments.jobRow.data); // Restore tenant context if the job was enqueued within a tenant scope and diff --git a/vendor/wheels/tests/specs/jobs/JobClassRoundTripSpec.cfc b/vendor/wheels/tests/specs/jobs/JobClassRoundTripSpec.cfc new file mode 100644 index 0000000000..ee129ee249 --- /dev/null +++ b/vendor/wheels/tests/specs/jobs/JobClassRoundTripSpec.cfc @@ -0,0 +1,118 @@ +component extends="wheels.WheelsTest" { + + function run() { + + // Issue #3351. `enqueue()` persists `GetMetadata(this).name` into + // `wheels_jobs.jobClass`, and the drain re-instantiates with + // `CreateObject("component", jobRow.jobClass)`. So a string produced by ENGINE + // METADATA is stored and later resolved as a component path, and 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, which is exactly the shape of + // bug that passes locally and fails on a production redeploy. + // + // The issue calls the invariant unverified across engines. Rather than guess at a fix, + // these specs assert it. They run on every engine × database leg, so lucee6, lucee7, + // adobe2023, adobe2025 and boxlang each answer the question directly: if any engine + // reports a name that does not match the file, this fails there and names it. + describe("Tests that the persisted jobClass round-trips", () => { + + it("reports a metadata name whose last segment matches the .cfc file name exactly", () => { + job = CreateObject("component", "wheels.tests._assets.jobs.ProbeJob") + meta = GetMetadata(job) + + fileName = ListFirst(ListLast(Replace(meta.path, "\", "/", "all"), "/"), ".") + + // case-sensitive comparison — Compare(), not CompareNoCase() + expect(Compare(ListLast(meta.name, "."), fileName)).toBe(0) + }) + + it("never persists a caller's miscased path", () => { + // The risk is an engine ECHOING BACK the path it was handed instead of + // deriving the name from the file: the persisted string would then carry + // whatever casing the caller happened to type, and that string is what a + // Linux worker later has to resolve. + // + // Engines close that off two different ways, and either is sufficient: + // + // Lucee/BoxLang — a miscased path RESOLVES (the filesystem is + // case-insensitive here) but the metadata name comes back canonical. + // Adobe — a miscased path does not resolve AT ALL. Its component + // resolver is case-sensitive independently of the filesystem, throwing + // "Could not find the ColdFusion component ... probejob". Nothing can be + // persisted because nothing can be constructed. + // + // Asserting only the first would fail on Adobe for a reason that is *safer* + // than the one being tested, so assert the property both satisfy. + canonical = GetMetadata(CreateObject("component", "wheels.tests._assets.jobs.ProbeJob")).name + resolved = {miscasedConstructed = false, name = ""} + + try { + resolved.name = GetMetadata(CreateObject("component", "wheels.tests._assets.jobs.probejob")).name + resolved.miscasedConstructed = true + } catch (any e) { + // case-sensitive resolver — the stronger guarantee + } + + if (resolved.miscasedConstructed) { + expect(Compare(resolved.name, canonical)).toBe(0) + } else { + expect(resolved.name).toBe("") + } + }) + + it("re-instantiates from its own persisted metadata name", () => { + // the actual enqueue -> drain round trip, without touching the queue table + original = CreateObject("component", "wheels.tests._assets.jobs.ProbeJob") + persisted = GetMetadata(original).name + + // Hoisted receiver. A parenthesized `new` in receiver position — `(new X()).m()` + // — is rejected by Adobe's parser with `Invalid construct: Either argument or + // name is missing`, the same MissingNameException family as cross-engine + // invariant 16. Adobe blames the enclosing describe() line and the whole engine + // leg reports tests=0. Caught by the compat matrix; Lucee and BoxLang accept it. + bridge = new wheels.Job() + revived = bridge.$instantiateJobClass(jobClass = persisted) + + expect(Compare(GetMetadata(revived).name, persisted)).toBe(0) + }) + }) + + describe("Tests that an unresolvable jobClass", () => { + + it("throws Wheels.JobClassNotFound naming the row and the class", () => { + thrown = {type: "", message: ""} + + bridge = new wheels.Job() + + try { + bridge.$instantiateJobClass(jobClass = "app.jobs.NoSuchJob", jobId = "abc-123") + } catch (any e) { + thrown.type = e.type + thrown.message = e.message + } + + // the raw engine error is "component not found" for a class that plainly + // exists, which points investigators at mappings and deployment + expect(thrown.type).toBe("Wheels.JobClassNotFound") + expect(thrown.message).toInclude("app.jobs.NoSuchJob") + expect(thrown.message).toInclude("abc-123") + }) + + it("throws Wheels.InvalidJobClass when the path resolves to something that is not a job", () => { + thrown = {type: ""} + + bridge = new wheels.Job() + + try { + // a real component with no perform() + bridge.$instantiateJobClass(jobClass = "wheels.tests._assets.models.Post") + } catch (any e) { + thrown.type = e.type + } + + expect(thrown.type).toBe("Wheels.InvalidJobClass") + }) + }) + } + +}