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
14 changes: 13 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
2 changes: 2 additions & 0 deletions changelog.d/3351-jobclass-round-trip.fixed.md
Original file line number Diff line number Diff line change
@@ -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)
44 changes: 43 additions & 1 deletion vendor/wheels/Job.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
}
Expand Down
7 changes: 6 additions & 1 deletion vendor/wheels/JobWorker.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions vendor/wheels/tests/specs/jobs/JobClassRoundTripSpec.cfc
Original file line number Diff line number Diff line change
@@ -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")
})
})
}

}
Loading