Skip to content

fix(job): name the queue row when a persisted jobClass cannot be resolved - #3358

Merged
bpamiri merged 3 commits into
developfrom
fix/3351-jobclass-casing
Aug 4, 2026
Merged

fix(job): name the queue row when a persisted jobClass cannot be resolved#3358
bpamiri merged 3 commits into
developfrom
fix/3351-jobclass-casing

Conversation

@bpamiri

@bpamiri bpamiri commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Closes #3351.

What I could and could not reproduce

enqueue() persists GetMetadata(this).name into wheels_jobs.jobClass; the drain resolves that string as a component path. Component paths are case-sensitive on Linux and not on macOS or Windows — the classic shape that passes in development and fails on a production redeploy.

I could not reproduce a casing drift. Probed on Lucee 7:

instantiatedCorrect -> name=[wheels.tests._assets.jobs.ProbeJob]  path=[ProbeJob.cfc]
instantiatedLower   -> name=[wheels.tests._assets.jobs.ProbeJob]  path=[ProbeJob.cfc]

Instantiating through an all-lowercase path still yields the canonical metadata name. Lucee derives it from the file, not from 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 for the other four engines, and the issue is explicit that the invariant is unverified there.

So I pinned the invariant instead of guessing at a fix

JobClassRoundTripSpec asserts, case-sensitively (Compare(), not CompareNoCase()):

  1. the metadata name's last segment equals the .cfc file name;
  2. 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 silently persist whatever casing was typed;
  3. a job re-instantiates from its own persisted name.

These run on every engine × database leg, so lucee6, adobe2023, adobe2025 and boxlang each answer the open question directly rather than being assumed safe. If any engine reports a non-canonical name, this fails there and names it — which is a better outcome than a speculative normalisation that might paper over it.

And improved the failure when it does not hold

$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.

This is the issue's actual pain: the raw error is component not found for a class that plainly exists, so investigators go looking at mappings and deployment. The fix is to describe the real shape of the problem — a string read back out of a queue row.

On the CreateObject gadget

The issue asks whether an arbitrary jobClass can be written. Wheels.InvalidJobClass now fires when the path resolves to a component with no perform().

To be straight about what that is worth: it narrows but does not close the database-string-to-CreateObject shape. The real guarantee is still that only $enqueueJob writes that column, and the docstring says exactly that rather than implying the check is a security boundary. Its practical value is diagnostic — a renamed job class fails with a sentence instead of a stack trace.

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 two error-handling specs fail (Expected [Wheels.JobClassNotFound] but received [expression]) and the round-trip spec errors on the missing helper. The three invariant specs pass — as they should on an engine that holds the invariant. That is the point of shipping them.

Verification

lucee7 + sqlite, full core suite:

result
develop ab901cff7 4732 pass / 0 fail / 0 error
this branch 4737 pass / 0 fail / 0 error

Exactly +5 — the new specs, nothing else moved.

Compat matrix dispatched. For this PR the matrix is not just a regression check — it is the experiment.

🤖 Generated with Claude Code

…lved

`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>

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wheels Bot — Reviewer

TL;DR — This PR (#3351) addresses an unresolvable persisted jobClass by (a) pinning the metadata-name round-trip invariant across every engine with JobClassRoundTripSpec, and (b) replacing the bare CreateObject on both drain paths with $instantiateJobClass(), which throws a self-describing Wheels.JobClassNotFound / Wheels.InvalidJobClass instead of the engine's component not found`. The framework change is correct, cross-engine-safe, and red-first tested. My verdict is comment — the only issue is 8 unrelated generated TestBox mock stubs that appear to have been committed by accident.

Docs

  • 8 stray MockBox stub artifacts unrelated to this fix. The PR adds 8 files under public/testbox/system/stubs/ (139184C0..., 16CA3942..., 30942F4D..., 754BEF48..., B7681C49..., C383511C..., CDCB73D6..., plus one more). These are MockBox-generated mock-decorator caches for SSE/channels helpers (closeSSEStream, sendSSEComment, sendSSEEvent, initSSEStream, poll, checkError, $getChannelEngine) — none of them touch jobs. I confirmed they do not exist on origin/develop (git show origin/develop:public/testbox/system/stubs/139184C0543CCDB338AEFB643110CB89.cfm reports "exists on disk, but not in origin/develop"), so this PR introduces all 8. The PR body itself says "Exactly +5 — the new specs, nothing else moved," which is inconsistent with committing these. Please drop them from the diff. There is already one such file tracked on develop (F952D54F...cfm); rather than accreting more, consider .gitignore-ing public/testbox/system/stubs/ so generated mock caches stop landing in commits.

Correctness

No issues. $instantiateJobClass() (vendor/wheels/Job.cfc:287) wraps only the CreateObject in try/catch, re-throws with Throw(type="Wheels.JobClassNotFound", ...), then guards the result with StructKeyExists(local.rv, "perform") before returning — the same instance-method-existence idiom already used a few lines down (StructKeyExists(local.jobInstance, "baseDelay"), Job.cfc:361). Both throws land inside the existing $processJob / $executeJob try blocks, so control flow (retry/backoff/permanent-fail) is unchanged; only the logged message improves. JobWorker.$scheduleRetry's best-effort CreateObject is correctly left untouched.

Cross-engine

No issues. $instantiateJobClass is public with a $ prefix, satisfying the mixin-integration rule (Cross-Engine Invariant 7). No map(), application-scope function members, bracket-notation calls, reserved-scope parameters, or in-finally loops. JobWorker.$executeJob correctly reaches the shared helper through the lazy $jobBridge() singleton (JobWorker.cfc:641), which returns a new wheels.Job().

Tests

Solid and red-first. JobClassRoundTripSpec uses case-sensitive Compare() (not CompareNoCase()) to pin the canonical-name invariant, and covers both error paths (Wheels.JobClassNotFound naming class + row id, Wheels.InvalidJobClass for a perform()-less component). I verified the fixtures the specs depend on: ProbeJob.cfc exists with a perform() method, and wheels.tests._assets.models.Post has no perform() (no perform anywhere in Model.cfc/model/), so the InvalidJobClass assertion is sound.

Commits

fix(job): name the queue row when a persisted jobClass cannot be resolved — valid type/scope, subject well under 100 chars, describes the "why." Conforms to commitlint.config.js.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Wheels Test Results

     31 files    9 800 suites   19m 44s ⏱️
131 201 tests 130 730 ✅ 397 💤 39 ❌ 35 🔥
133 133 runs  132 662 ✅ 397 💤 39 ❌ 35 🔥

For more details on these failures and errors, see this check.

Results for commit 576bbbb.

♻️ This comment has been updated with latest results.

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>
@github-actions github-actions Bot added the dependencies Pull requests that update a dependency file label Aug 4, 2026

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wheels Bot — Reviewer

TL;DR — This PR (#3351) makes an unresolvable persisted jobClass fail loudly: $instantiateJobClass() replaces the bare CreateObject on both drain paths (Job.$processJob, JobWorker.$executeJob) and throws a self-describing Wheels.JobClassNotFound / Wheels.InvalidJobClass instead of the engine's bare component not found, and JobClassRoundTripSpec pins the metadata-name round-trip invariant across every engine. The framework change is correct, cross-engine-safe, and red-first tested. The head commit (9c369c9c4) also correctly resolves an Adobe compile break in the new spec. Verdict is comment — the only issues are stray generated/empty artifacts committed by accident. No blocking findings.

Correctness

No issues. $instantiateJobClass() (vendor/wheels/Job.cfc:283) wraps only the CreateObject in try/catch, re-throws Wheels.JobClassNotFound with extendedInfo naming the persisted string and the three real causes, then guards the result with StructKeyExists(local.rv, "perform") before returning — the same instance-method-existence idiom used a few lines down (StructKeyExists(local.jobInstance, "baseDelay"), Job.cfc:361). Both throws land inside the existing try blocks in $processJob / $executeJob, so retry/backoff/permanent-fail control flow is unchanged; only the message improves. JobWorker.$executeJob reaches the shared helper through the lazy $jobBridge() singleton (JobWorker.cfc:641-645, new wheels.Job()), and $scheduleRetry's best-effort CreateObject is correctly left untouched.

Cross-engine

No issues, and the head commit fixed one. The original spec called (new wheels.Job()).$instantiateJobClass(...) — a parenthesized new in receiver position — which Adobe 2023/2025 reject at compile time (MissingNameException), zeroing the whole engine leg. 9c369c9c4 hoists the receiver to a bridge variable (JobClassRoundTripSpec.cfc:50,62,81), which matches the 22-file precedent, and widens CLAUDE.md invariant 16 into 16a/16b to document the second shape. $instantiateJobClass is public with a $ prefix (mixin-integration rule, Invariant 7). No map(), application-scope function members, reserved-scope params, or in-finally loops.

Tests

Solid and red-first. JobClassRoundTripSpec uses case-sensitive Compare() (not CompareNoCase()) for the canonical-name invariant and covers both error paths. Fixtures verified: ProbeJob.cfc has a perform(); perform appears only in Job.cfc, the job assets, and McpServer.cfc — never in Model.cfc/model/, so wheels.tests._assets.models.Post genuinely has no perform() and the InvalidJobClass assertion (:85,90) is sound.

Docs

Two stray artifacts unrelated to the fix should be dropped from the diff:

  1. Empty box.json at repo root (NEW in 9c369c9c4). git show origin/develop:box.json"exists on disk, but not in origin/develop", and the blob is the empty-file hash (e69de29bb, 0 bytes). An empty file is not valid JSON, so any CommandBox/tooling that reads it could error. This looks accidental — please remove it.

  2. 8 MockBox stub caches under public/testbox/system/stubs/ (139184C0…, 16CA3942…, 30942F4D…, 754BEF48…, B7681C49…, C383511C…, CDCB73D6…, plus one more). These are generated mock-decorator caches for SSE/channels helpers — none touch jobs — and none exist on origin/develop. The PR body says "Exactly +5 — the new specs, nothing else moved," which is inconsistent with committing these. Please drop them, and consider .gitignore-ing public/testbox/system/stubs/ so generated caches stop landing in commits (one, F952D54F…, is already tracked on develop).

Neither is a correctness or runtime concern; both are noise in the tree.

Commits

Both commits conform to commitlint.config.js: fix(job): … and fix(test): … — valid types/scopes, subjects well under 100 chars, describing the "why," DCO-signed. The changelog fragment (changelog.d/3351-jobclass-round-trip.fixed.md) is present and correctly typed.

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>
@bpamiri
bpamiri force-pushed the fix/3351-jobclass-casing branch from a7baa46 to 576bbbb Compare August 4, 2026 06:45

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WHEELS BOT REVIEWER PLACEHOLDER

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wheels Bot — Reviewer

TL;DR — This PR (issue 3351) makes an unresolvable persisted jobClass fail with a diagnostic instead of the engine bare component not found, by routing both drain paths through a new Job.$instantiateJobClass() helper (typed Wheels.JobClassNotFound / Wheels.InvalidJobClass errors plus a perform() guard), and pins the enqueue-to-drain round-trip invariant with JobClassRoundTripSpec on every engine leg. The framework change is correct, well-tested, cross-engine-careful, and honest about what it does and does not close. My only findings are two accidental artifacts checked into the diff, unrelated to the fix. Verdict: comment — no blocking issues; please strip the artifacts before merge.

What is right (kept short)

  • $instantiateJobClass (vendor/wheels/Job.cfc:281) is public and reached from JobWorker.$executeJob via the pre-existing $jobBridge() lazy-instance pattern (vendor/wheels/JobWorker.cfc:454, :641) — consistent with $restoreTenantContext / $ensureJobTable on the same bridge.
  • $scheduleRetry best-effort CreateObject (vendor/wheels/JobWorker.cfc:516) is correctly left inside its defaults-fallback try/catch — it must not start throwing, and the PR calls this out explicitly.
  • The new throw sits inside the same try that already wrapped the old CreateObject on both paths, so no previously-caught failure becomes uncaught.
  • Spec follows cross-engine invariant 11 (flag on a struct set in the try, empty catch, JobClassRoundTripSpec.cfc:47-54) and invariant 16a (hoisted bridge = new wheels.Job() receiver, :73), and CLAUDE.md invariant 16 is widened to 16a/16b to document the second MissingNameException shape.

Docs

  • box.json — a new, empty (0-byte) file is added at the repo root (introduced in 9c369c9c4). It is not on develop and there is no .gitignore entry for it. An empty box.json is not a valid CommandBox descriptor and can trip JSON-parsing tooling; it is unrelated to the jobClass fix. Please git rm box.json before merge.
  • Seven MockBox stub artifacts under public/testbox/system/stubs/ (139184C0…, 16CA3942…, 30942F4D…, 754BEF48…, B7681C49…, C383511C…, CDCB73D6…) are hash-named test-run artifacts, not source. develop already tracks a few of these (e.g. F952D54F…) because the directory is not gitignored, so this is a pre-existing leak the PR widens rather than a new anti-pattern — but they are noise in a fix diff and should be dropped (git rm the seven, and ideally add public/testbox/system/stubs/ to .gitignore in a follow-up).

Tests

  • JobClassRoundTripSpec is a proper BDD spec extending wheels.WheelsTest, covers the round-trip happy path plus both error types (Wheels.JobClassNotFound, Wheels.InvalidJobClass), and its fixtures check out: ProbeJob has perform() and wheels.tests._assets.models.Post deliberately does not, so the InvalidJobClass assertion exercises the real branch. Red-first result (4734 pass / 2 fail / 1 error with the fix reverted) is documented.

Commits

  • All three headers are valid conventional commits within the length limit (fix(job):, fix(test):, test(job):), messages explain the why, and each carries a DCO Signed-off-by. No commitlint concern.

Nice work — pinning the invariant on every leg rather than guessing at a normalization is the right call, and the failure-message rewrite targets the actual investigator pain. Just clear out box.json and the seven stub files.

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wheels Bot — Reviewer

TL;DR — This PR (issue 3351) makes an unresolvable persisted jobClass fail with a diagnostic instead of the engine bare component not found, by routing both drain paths through a new Job.$instantiateJobClass() helper (typed Wheels.JobClassNotFound / Wheels.InvalidJobClass errors plus a perform() guard), and pins the enqueue-to-drain round-trip invariant with JobClassRoundTripSpec on every engine leg. The framework change is correct, well-tested, cross-engine-careful, and honest about what it does and does not close. My only findings are two accidental artifacts checked into the diff, unrelated to the fix. Verdict: comment — no blocking issues; please strip the artifacts before merge.

What is right (kept short)

  • $instantiateJobClass (vendor/wheels/Job.cfc:281) is public and reached from JobWorker.$executeJob via the pre-existing $jobBridge() lazy-instance pattern (vendor/wheels/JobWorker.cfc:454, :641) — consistent with $restoreTenantContext / $ensureJobTable on the same bridge.
  • $scheduleRetry best-effort CreateObject (vendor/wheels/JobWorker.cfc:516) is correctly left inside its defaults-fallback try/catch — it must not start throwing, and the PR calls this out explicitly.
  • The new throw sits inside the same try that already wrapped the old CreateObject on both paths, so no previously-caught failure becomes uncaught.
  • Spec follows cross-engine invariant 11 (flag on a struct set in the try, empty catch, JobClassRoundTripSpec.cfc:47-54) and invariant 16a (hoisted bridge = new wheels.Job() receiver, :73), and CLAUDE.md invariant 16 is widened to 16a/16b to document the second MissingNameException shape.

Docs

  • box.json — a new, empty (0-byte) file is added at the repo root (introduced in 9c369c9c4). It is not on develop and there is no .gitignore entry for it. An empty box.json is not a valid CommandBox descriptor and can trip JSON-parsing tooling; it is unrelated to the jobClass fix. Please git rm box.json before merge.
  • Seven MockBox stub artifacts under public/testbox/system/stubs/ (139184C0…, 16CA3942…, 30942F4D…, 754BEF48…, B7681C49…, C383511C…, CDCB73D6…) are hash-named test-run artifacts, not source. develop already tracks a few of these (e.g. F952D54F…) because the directory is not gitignored, so this is a pre-existing leak the PR widens rather than a new anti-pattern — but they are noise in a fix diff and should be dropped (git rm the seven, and ideally add public/testbox/system/stubs/ to .gitignore in a follow-up).

Tests

  • JobClassRoundTripSpec is a proper BDD spec extending wheels.WheelsTest, covers the round-trip happy path plus both error types (Wheels.JobClassNotFound, Wheels.InvalidJobClass), and its fixtures check out: ProbeJob has perform() and wheels.tests._assets.models.Post deliberately does not, so the InvalidJobClass assertion exercises the real branch. Red-first result (4734 pass / 2 fail / 1 error with the fix reverted) is documented.

Commits

  • All three headers are valid conventional commits within the length limit (fix(job):, fix(test):, test(job):), messages explain the why, and each carries a DCO Signed-off-by. No commitlint concern.

Nice work — pinning the invariant on every leg rather than guessing at a normalization is the right call, and the failure-message rewrite targets the actual investigator pain. Just clear out box.json and the seven stub files.

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wheels Bot — Reviewer

TL;DR — This PR (#3351) makes an unresolvable persisted jobClass fail with a diagnostic instead of the engine's bare component not found, by routing both drain paths through a new $instantiateJobClass() that throws Wheels.JobClassNotFound / Wheels.InvalidJobClass, and pins the metadata round-trip invariant across every engine with JobClassRoundTripSpec. The change is correct, honestly scoped, cross-engine-aware, and well tested. Verdict: comment — clean diff, one non-blocking convention nit.

Correctness

The new throws are safe on both processing paths. In Job.$processJob, $instantiateJobClass() runs inside the existing try (vendor/wheels/Job.cfc:360) whose catch turns a failure into a retry/dead-letter — so an unresolvable class remains a job failure, never a batch abort. local.hasTenantContext = false is set before the try (Job.cfc:350), so the post-catch tenant cleanup can't read an undefined var. JobWorker.$executeJob mirrors this: the call is inside the try (vendor/wheels/JobWorker.cfc:454) and local.hasTenantContext = false is pre-set at JobWorker.cfc:449. The pre-existing regression test "reports an unresolvable job class as a job failure instead of throwing" (JobRobustnessSpec.cfc:40) still holds because the new message includes the job id.

The perform() guard is idiomatic:

if (!StructKeyExists(local.rv, "perform")) {   // Job.cfc:303

matching the same StructKeyExists member check already applied to local.jobInstance two lines up in the sibling path (Job.cfc:361, :364) — and wheels.tests._assets.models.Post has no perform(), so the Wheels.InvalidJobClass spec exercises a real negative.

Cross-engine

Handled well. JobClassRoundTripSpec follows invariant 11 by hanging the miscased-construction flag off a struct set in the try (resolved = {miscasedConstructed = false, ...}) rather than a local set in the catch, so it survives BoxLang. The parenthesized-new receiver was hoisted to a variable (bridge = new wheels.Job()), and JobWorker reaches $instantiateJobClass through $jobBridge() (a variable receiver), both dodging Adobe's MissingNameException. The newly-documented CLAUDE.md invariant 16a captures exactly that gotcha.

Tests

Strong. Three invariant specs (file-name/metadata-name equality via case-sensitive Compare(), miscasing cannot reach the column, self-round-trip) plus both error paths (Wheels.JobClassNotFound, Wheels.InvalidJobClass), all BDD under wheels.WheelsTest. They run on every engine × DB leg, which is the point — they answer the issue's "unverified across engines" question directly.

Conventions

Non-blocking nit: JobClassRoundTripSpec.cfc assigns spec locals unscoped (job = CreateObject(...), meta, bridge, revived, thrown, resolved, …), whereas every sibling in vendor/wheels/tests/specs/jobs/ uses local.-scoped vars (e.g. local.bootstrapJob = new wheels.Job(); in JobRobustnessSpec.cfc:19). It runs green, but the unscoped writes leak into the spec's variables scope; local.-prefixing would match the directory idiom. Purely stylistic — safe to leave.

Docs

Changelog fragment present and correctly named (changelog.d/3351-jobclass-round-trip.fixed.md, <slug>.fixed.md). CLAUDE.md invariant 16 split into 16a/16b documenting the parenthesized-new Adobe crash — a genuinely useful addition.

Commits

All three conform to commitlint (test(job):, fix(test):, fix(job): — valid types, lowercase subjects ≤ 100 chars) and the messages explain the why (the Adobe case-sensitivity discovery, the invariant-11 struct-flag rationale).

Nice work — the honesty about the CreateObject-from-DB-string gadget being narrowed, not closed is the right call, and pinning the invariant rather than guessing at a normalization is the correct instinct.

@bpamiri
bpamiri merged commit dd77ff5 into develop Aug 4, 2026
12 of 18 checks passed
@bpamiri
bpamiri deleted the fix/3351-jobclass-casing branch August 4, 2026 12:46
bpamiri added a commit that referenced this pull request Aug 4, 2026
Second wheels-bot review follow-up from the same batch, on PR #3358.

Every sibling in vendor/wheels/tests/specs/jobs/ declares spec variables with `local.` (e.g.
`local.bootstrapJob = new wheels.Job();` in JobRobustnessSpec). JobClassRoundTripSpec assigned
them unscoped, which runs green but leaks the writes into the spec's `variables` scope.

Purely a convention fix — no behaviour change. Folded in here rather than opened as a third
PR, since it is the same review round on the same merged batch.

lucee7 + sqlite, full core suite: 4756 pass / 0 fail / 0 error, unchanged.

Signed-off-by: Peter Amiri <peter@alurium.com>
bpamiri added a commit that referenced this pull request Aug 4, 2026
…e SQL text (#3360)

* fix(model): read a join's type from association metadata, not from the SQL text

Follow-up to #3334, raised by wheels-bot on PR #3354 and correct.

The grouping decision that PR introduced still asked "is this an INNER join?" by searching the
generated SQL:

    FindNoCase("INNER", local.joins[local.parentPosition])

That misclassifies every table whose name contains the substring — `winners`, `spinners`,
`beginners`. A parent LEFT OUTER JOIN on `c_o_r_e_winners` reads as INNER, so its nested child
is emitted flat instead of grouped, which silently drops the parent rows #3334 exists to
preserve.

Not a regression: the pre-fix code scanned the same way, and no fixture table hits it. Worth
fixing anyway, because it is the exact anti-pattern that PR argues against — its whole thesis
is that structure should come from the association tree rather than from re-parsing the SQL
string, and this was ten lines of new code doing the opposite.

`$associationJoinsInner()` reads the association's declared `joinType`, which is the value
`$expandedAssociations` turns into the leading `INNER JOIN` / `LEFT OUTER JOIN` text in the
first place — so it is the authoritative source by construction. It falls back to the text
scan only when an entry carries no `joinType`, keeping it total for a hand-built struct.

Red-first: with the helper reverted to the bare text scan, the new spec fails
`Expected [true] to be false`.

A note on that red-check, because it nearly fooled me: run against a WARM server the reverted
build still reported green — `tools/test-local.sh` reuses a running server and the reload did
not recompile the changed CFC. The failure only appears after killing the server first. Any
red-check on this suite needs a cold start to mean anything.

lucee7 + sqlite, full core suite:

  develop 2c98644   4755 pass / 0 fail / 0 error
  this branch         4756 pass / 0 fail / 0 error

Exactly +1, the new spec.

Refs #3334

Signed-off-by: Peter Amiri <peter@alurium.com>

* test(job): scope JobClassRoundTripSpec locals with local.

Second wheels-bot review follow-up from the same batch, on PR #3358.

Every sibling in vendor/wheels/tests/specs/jobs/ declares spec variables with `local.` (e.g.
`local.bootstrapJob = new wheels.Job();` in JobRobustnessSpec). JobClassRoundTripSpec assigned
them unscoped, which runs green but leaks the writes into the spec's `variables` scope.

Purely a convention fix — no behaviour change. Folded in here rather than opened as a third
PR, since it is the same review round on the same merged batch.

lucee7 + sqlite, full core suite: 4756 pass / 0 fail / 0 error, unchanged.

Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(test): keep the catch-block struct unscoped — local. breaks it on BoxLang

The compat matrix for this branch came back +24 tests on all 28 legs, which is correct, and
two NEW failures on boxlang for every database:

  Failed | throws Wheels.JobClassNotFound naming the row and the class
         | Expected [Wheels.JobClassNotFound] but received []
  Failed | throws Wheels.InvalidJobClass when the path resolves to something that is not a job
         | Expected [Wheels.InvalidJobClass] but received []

Both are mine, and both were caused by the previous commit — the `local.`-scoping convention
nit from the bot review. `thrown` is written from inside a catch block, and on BoxLang the
catch body runs under a nested `local` that is discarded on exit. Prefixing the struct made
`local.thrown.type = e.type` land on that discarded copy instead of mutating the outer struct,
so the assertion read an empty type.

Cross-engine invariant 11 already covers the scalar case. What it did not say is that the
struct workaround it recommends only works when the struct is accessed WITHOUT the prefix —
`local.state.flag = true` fails exactly like `local.X = ...`. The prefix is what breaks it,
not the assignment shape. Widened the invariant with that, plus a worked example, because
`local.`-scoping spec variables IS the house style everywhere else, which makes tidying a
catch-using spec to match an easy and completely invisible way to break it.

So the original unscoped form was correct and the "nit" was wrong. Reverted for `thrown` only
— every other variable in the file stays `local.`-scoped, since those are written from try
bodies and are unaffected — with a comment at both sites explaining why, so it does not get
tidied back.

Worth noting the failure mode: green on Lucee, green on Adobe, wrong only on BoxLang, and
silent rather than an error. Nothing local would have caught it.

lucee7 + sqlite, full core suite: 4756 pass / 0 fail / 0 error, unchanged.

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug dependencies Pull requests that update a dependency file docs enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Job.cfc persists jobClass from GetMetadata(this).name, whose casing is not guaranteed stable across reloads/engines

1 participant