fix(model): namespace per-request query cache under request.wheels.$queryCache - #3338
Conversation
…ueryCache
The per-request finder cache keyed itself on the bare model name directly in
`request.wheels`. CFML struct keys are case-insensitive, so a model named
`Tenant` — the documented name for the control-plane model in a
database-per-tenant app — shared one key between its query cache and the
framework-owned `request.wheels.tenant`.
Two silent failures followed:
1. Write path. `$clearRequestCache()` runs after every create/update/delete/bulk
operation and set `request.wheels.tenant = {}`, erasing the resolved tenant
for the rest of the request. `databaseAdapters/Base.cfc` only swaps in the
tenant datasource when `request.wheels.tenant.dataSource` exists, so every
tenant-scoped query after that point fell back to the control-plane
datasource and wrote to the wrong database with no exception.
2. Read path. A `Tenant` finder running before `TenantResolver` — which is what
happens when the resolver itself looks tenants up, the obvious shape for a
subdomain to tenant directory — populated `request.wheels.tenant` with
query-cache entries. The struct is non-empty but has no `id`, `dataSource`,
or `config`, so `IsDefined("request.wheels.tenant")` guards read an
unresolved request as resolved.
The cache now lives under the reserved `request.wheels.$queryCache` sub-struct,
confining the blast radius to one key that no model name can reach. Caching
behaviour is otherwise unchanged: same per-model slots, same query keys, same
lifecycle.
Scoped deliberately to the finder cache. `Global.cfc`'s pagination handles are
also dynamic keys in `request.wheels`, but an app author has to actively name a
handle `tenant` to collide; the model-name key aliased automatically.
Regression coverage adds a `Tenant` fixture model mapped onto the existing
authors table (no populate.cfm changes) and covers both failure modes plus the
namespacing invariant.
Verified on Lucee 7 + SQLite: 4707 passed on develop, 4713 passed on this branch
(+6 new specs, no regressions). The Docker matrix could not run locally —
`tools/test-matrix.sh` fails to bind-mount box.json under Docker Desktop
virtiofs on this machine, and fails identically on clean develop.
Refs #3336
Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This PR namespaces the per-request finder cache under request.wheels.$queryCache[ModelName] instead of the bare request.wheels[ModelName], closing a case-insensitive struct-key collision where a model named Tenant aliased onto the framework-owned request.wheels.tenant. The change is a mechanical key-path swap plus an ensure-helper, the migration is complete, and it ships red-first regression tests, a changelog fragment, and doc updates. Verdict: approve.
Correctness
The fix is complete and self-consistent:
- All four model-name cache sites migrated — three in
vendor/wheels/model/read.cfc(read-hit check, read, write-back) and one invendor/wheels/model/miscellaneous.cfc($clearRequestCache()). A grep forrequest.wheels[variables.wheels.class.modelName]now returns zero hits. - The read-path access
request.wheels["$queryCache"][variables.wheels.class.modelName](read.cfc:323, 326) is safe: it is only reached whenlocal.useRequestCacheis true, and$ensureRequestQueryCache()is called unconditionally at the top of that sameif (local.useRequestCache)block (read.cfc:314), so the sub-struct always exists first. $ensureRequestQueryCache()is more defensive than the code it replaces — it now guardsStructKeyExists(request, "wheels"), whereas the old read.cfc assumedrequest.wheelsalready existed.- The collision target is genuinely vacated:
tenant()(Global.cfc:732) and$tenantDataSource()(Global.cfc:745) readrequest.wheels.tenant, which the cache no longer touches. Both referenced framework functions exist, so the specs exercise real code.
Cross-engine
No concerns. Constructs are matrix-safe by inspection: bracket-notation $queryCache keys (no member-function collision), no closures, no attributeCollection, no reserved-scope parameter names, no finally loops. The new $ensureRequestQueryCache() helper is public + $-prefixed (Cross-Engine Invariant 7) so it mixes into the model object correctly, and lives in wheels.model.*, outside the protectedControllerMethods surface. The mixed quoted/unquoted struct-literal keys in the tenant fixture struct ("$locked" = true) are valid on every engine. Noting for the record that the author disclosed the Docker matrix did not run locally (environmental virtiofs mount failure — it fails identically on clean develop); CI's compat matrix remains the real gate, but nothing in this diff reads as engine-sensitive.
Tests
Strong coverage. requestQueryCacheTenantCollisionSpec.cfc reproduces both reported failure modes (write-path $clearRequestCache() wiping tenant context; read-path finder fabricating a resolved tenant), plus a sibling-isolation spec and the namespacing invariant. The write-path spec correctly uses the real datasource with a no-match where to avoid leaving transaction state dirty for later specs — a nice touch. requestQueryCacheSpec.cfc's eight existing white-box assertions are repointed at the namespaced key, which is the right call since they assert on the exact structure being moved. The new Tenant fixture reuses c_o_r_e_authors, so populate.cfm is untouched.
Docs
Changelog fragment present and correctly named (changelog.d/3336-request-query-cache-namespace.fixed.md, fixed type). No direct CHANGELOG.md [Unreleased] edit. Blog post web/content/blog/posts/caching-in-wheels-4.md updated in both places that documented the old path.
Commits
fix(model): namespace per-request query cache under request.wheels.$queryCache — valid type, scoped, subject well under 100 chars, describes the why.
Non-blocking note
The two sibling collision families (pagination handles in Global.cfc:3847/3915; query-hash keys in callbacks.cfc:331) are explicitly and reasonably scoped out — the pagination one requires an app author to actively name a handle tenant, so deferring it is defensible. Likewise the two defensive-hardening items (TenantResolver stale-key delete, tenant() shape validation) change tenancy semantics rather than fix the collision; a follow-up issue for the pagination-handle surface would be worth filing so it is not lost.
Wheels Test Results 32 files + 1 9 716 suites +28 20m 20s ⏱️ -48s For more details on these failures and errors, see this check. Results for commit bc0b7b3. ± Comparison against base commit fc59492. ♻️ This comment has been updated with latest results. |
Cross-engine verification (manual
|
Failures on branch but not develop |
none |
Failures on develop but not branch |
none |
Per-leg failure counts are unchanged (adobe2023 3–5, adobe2025 4–6, boxlang 3–4, lucee6 1–2, lucee7 0–1; adobe2023-oracle 11). Those are pre-existing — the leg debt tracked by #3302. The most frequent ones are HTML-encodes the path in the verb-mismatch message (17×), float() applies its default='' / allowNull=true outlier defaults (11×), is including partial with query grouped by a column (11×), and updateAll with multiple includes updates matching rows (5×, which is #3294).
Unrelated observation for #3302
Both runs report conclusion: success while all six engine jobs report failure — continue-on-error: true means six red engines roll up green. Additionally the oracle legs on adobe2025 / boxlang / lucee6 emitted 0 testcases (engine never came up), which SOFT_FAIL_DBS="oracle" then absorbs. Two stacked layers of invisibility, exactly what #3302 describes.
Follow-on hardening for #3336, folded in at maintainer request. The namespacing commit stops the framework from ever writing a query cache onto `request.wheels.tenant`. These two changes stop anything else that lands there from being read as a resolved tenant. `tenant()` now requires a non-empty `dataSource` before treating the key as an active tenant, which is the same test `$tenantDataSource()` already applies before it routes a query. Previously it returned whatever occupied the key, so a malformed value satisfied every `IsDefined("request.wheels.tenant")` or truthiness guard in application code while carrying no usable routing information — wrong behaviour rather than a no-op. `TenantResolver` now deletes any pre-existing value on the key when its resolver returns no match. Before, an unresolved request only looked unresolved after the `finally` block ran, so a stale or foreign value could be read as resolved for the whole downstream request. The two agree by construction: both use the non-empty `dataSource` test, so `tenant()` and `$tenantDataSource()` can no longer disagree about whether a request is tenanted. All four framework producers of the key — `switchTenant()` (throws without a dataSource), `TenantResolver`, `Job.$restoreTenantContext()` and `TenantMigrator` (both guard) — already guarantee a non-empty `dataSource`, so correctly-resolved tenants see no change. Only foreign values are filtered. Specs written red first: 4 new specs fail with these two changes reverted and the specs kept, confirming they exercise the hardening rather than restating existing behaviour. Lucee 7 + SQLite full core suite: 4713 passed before, 4717 passed after, zero failures. Refs #3336 Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This PR fixes #3336, where the per-request finder cache keyed itself on the bare model name directly in request.wheels, letting a model named Tenant alias onto the framework-owned request.wheels.tenant (CFML struct keys are case-insensitive). The fix namespaces the cache under a reserved request.wheels.$queryCache sub-struct and adds tenant-shape hardening in tenant() / TenantResolver. The change is well-scoped, red-first tested, and matrix-safe by inspection. I found no correctness, cross-engine, or security issues — only one docs nit (a stale PR description). Verdict: comment.
Correctness
The namespacing is complete and internally consistent. All four bare-model-name sites are migrated and every read of request.wheels["$queryCache"][modelName] is dominated by an $ensureRequestQueryCache() call:
vendor/wheels/model/read.cfc:314calls the ensure helper inside thelocal.useRequestCacheguard, and the subsequent reads at:324/:326and the store at:358are all short-circuited behindlocal.useRequestCache— no unguarded key access.vendor/wheels/model/miscellaneous.cfc:27(\$clearRequestCache()) calls the ensure helper first, so it no longer assumesrequest.wheelsexists.
A repo-wide grep for request.wheels[ confirms only the two documented out-of-scope families remain (pagination handles in Global.cfc:3859/3927, the query-hash map in callbacks.cfc:331) — no dangling reader of the old bare-model-name path.
The tenant() hardening in Global.cfc:766 now gates on a non-empty dataSource, matching \$tenantDataSource() (Global.cfc:758) exactly, so the two agree on what "resolved" means. Every framework producer (switchTenant() throws on empty dataSource at Global.cfc:778; TenantResolver.handle() requires Len(dataSource) at :57) already guarantees a non-empty dataSource, so no legitimately-resolved tenant regresses.
TenantResolver.cfc:76-81 correctly drops a stale/foreign request.wheels.tenant on a no-match, guarded by StructKeyExists(request, "wheels").
Cross-engine
Clean. The new code uses only IsDefined, IsStruct, StructKeyExists, StructDelete, Len, and bracket-notation struct keys — no closures-as-constructor-args, no attributeCollection, no reserved-scope params, no finally loops, no Left(str,0), no obj.map(). The new \$ensureRequestQueryCache() is public + \$-prefixed per cross-engine invariant #7, so it integrates via \$integrateComponents(), and it lives in wheels.model.* (outside the protectedControllerMethods surface), so it can't shadow a controller action.
Tests
Strong coverage. requestQueryCacheTenantCollisionSpec.cfc exercises both reported failure modes (write-path erasure via \$clearRequestCache() and a real updateAll(); read-path fabrication via a Tenant finder) plus the namespacing invariant and per-model isolation. requestQueryCacheSpec.cfc's 8 white-box assertions are correctly repointed at the namespaced key. MultiTenantSpec.cfc and TenantResolverSpec.cfc gain matching hardening specs that follow the existing pipeline/closure-scope idioms in those files. The new Tenant.cfc fixture reuses c_o_r_e_authors, so populate.cfm is untouched.
Docs
- Nit — stale PR description. The PR body's "Also not included" section states the two defensive-hardening items (
tenant()shape validation,TenantResolverstale-tenant deletion) are not in this PR, but commite689e193b("harden tenant context against malformed values") ships exactly those, andchangelog.d/3336-tenant-shape-hardening.fixed.mddocuments them. Worth reconciling the description before merge so reviewers/changelog-readers aren't misled — no code change needed.
Changelog fragments (changelog.d/*.fixed.md) use the correct fragment mechanism rather than a direct CHANGELOG.md edit, and the published blog post's two request.wheels[ModelName] references are updated.
Commits
Both commits conform to commitlint.config.js — fix(model): …, subject non-empty, not ALL-CAPS, ≤100 chars.
Nice work — the red-first tests and the deliberately semantics-free key-path swap (avoiding the by-reference StructClear() draft that would have leaned on Adobe struct-reference semantics) are exactly right.
… StructKeyExists
Caught by the compat matrix on Adobe 2025 — every TenantResolver "no match" spec
errored with `Element WHEELS is undefined in REQUEST` across all five of its
database legs. Lucee 6, Lucee 7, Adobe 2023 and BoxLang were unaffected, so the
local suite and PR CI both reported clean.
`handle()` takes a parameter named `request`. On Adobe 2025 the bare `request`
token does not resolve consistently between the two positions used by the guard
added in the previous commit: passed as an argument to
`StructKeyExists(request, "wheels")` it saw the key and returned true, but the
`request.wheels` member-access expression in the guarded `StructDelete` then
resolved somewhere without it and threw.
Switch the guard to `IsDefined("request.wheels.tenant")`, which string-resolves
the whole dotted path in a single evaluation and therefore cannot disagree with
itself. This is the same guard the `finally` block in this function already
uses, so it is proven on Adobe 2025 rather than merely plausible.
Behaviour is unchanged: deleting a key that isn't there was already a no-op, so
narrowing the guard from "request.wheels exists" to "request.wheels.tenant
exists" drops no work.
Lucee 7 + SQLite full core suite: 4717 passed, 0 failed.
Refs #3336
Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This PR fixes #3336: the per-request finder cache keyed on the bare model name in request.wheels, so a model named Tenant aliased onto the framework-owned request.wheels.tenant (CFML struct keys are case-insensitive). The fix namespaces the cache under a reserved request.wheels.$queryCache sub-struct, adds shape-hardening in tenant() / TenantResolver, and the new head commit bc0b7b3e3 narrows a no-match guard that the Adobe 2025 compat leg had surfaced as a real error. I found no correctness, cross-engine, or security issues — only one optional docs nit. Verdict: comment.
Correctness
The namespacing is complete and self-consistent. Every read of request.wheels["$queryCache"][modelName] is dominated by an $ensureRequestQueryCache() call:
vendor/wheels/model/read.cfc:314calls the ensure helper at the top of thelocal.useRequestCacheblock; the reads at:324/:326and the store at:358are all gated behind the samelocal.useRequestCache, so no unguarded key access.vendor/wheels/model/miscellaneous.cfc:27($clearRequestCache()) calls the ensure helper first, so it no longer assumesrequest.wheelspre-exists.- A repo-wide grep for
request.wheels[variables.wheels.class.modelName]now returns zero hits — all four bare-model-name sites are migrated, and the two out-of-scope families (Global.cfcpagination handles, thecallbacks.cfc:331query-hash map) are correctly left for #3339.
The new-head fix is right. Previously the no-match branch was else if (StructKeyExists(request, "wheels")) and then StructDelete(request.wheels, "tenant"). Because handle() declares a parameter named request (a reserved scope — CLAUDE.md anti-pattern #11), Adobe 2025 resolved the bare request inconsistently between the StructKeyExists argument and the request.wheels member-access, throwing Element WHEELS is undefined in REQUEST. Switching to IsDefined("request.wheels.tenant") (TenantResolver.cfc:76) string-resolves the whole dotted path in one evaluation — and it is the same guard the finally block at :95 already uses, so it is proven on Adobe 2025 rather than merely plausible. The narrowing is behaviour-preserving: IsDefined("request.wheels.tenant") being true implies request.wheels exists, so the guarded StructDelete(request.wheels, "tenant") at :88 is safe, and deleting an absent key was already a no-op.
The tenant() hardening at Global.cfc:739-748 now gates on IsStruct + StructKeyExists(...,"dataSource") + Len(dataSource), matching $tenantDataSource() (Global.cfc:757-764) exactly, so the two agree on what "resolved" means. Every framework producer already guarantees a non-empty dataSource (switchTenant() throws at :778; TenantResolver.handle() requires Len(dataSource) at :57), so no legitimately-resolved tenant regresses.
Cross-engine
No concerns. The changed constructs are all matrix-safe: bracket-notation ["$queryCache"] keys (no .map()-style member collision), IsDefined / StructKeyExists / StructDelete, no closures on the hot path, no attributeCollection, no finally loops. $ensureRequestQueryCache() is public + $-prefixed per Cross-Engine Invariant 7, so it integrates via $integrateComponents(), and lives in wheels.model.* (outside protectedControllerMethods). The reserved-scope hazard that the flat guard hit is exactly what the new commit resolves.
Tests
Well-covered and red-first. TenantResolverSpec.cfc:296 ("drops a pre-existing request.wheels.tenant when the resolver finds no match") genuinely exercises the new else if branch, not the finally: the handler runs inside try before finally, and it observes IsDefined("request.wheels.tenant") as false — which can only be true if the pre-try delete ran. requestQueryCacheTenantCollisionSpec.cfc covers both failure modes plus per-model isolation, backed by a Tenant fixture mapped onto the existing authors table (no populate.cfm change). MultiTenantSpec.cfc adds the shape-hardening cases and has an afterEach that clears request.wheels.tenant, so no state leaks.
Docs
Optional nit only: the top-of-function comment at TenantResolver.cfc:48-52 asserts "bare request inside a function always refers to the built-in request scope, even when a parameter is named request," while the new comment at :81-87 documents the Adobe 2025 case where bare request did not resolve consistently. The two read as being in tension. Consider scoping the older comment ("on most engines") or cross-referencing the #3336 exception, so a future reader does not trust the absolute wording. Non-blocking.
Changelog fragments (changelog.d/3336-*.fixed.md) and the blog-post update are handled correctly (no direct CHANGELOG.md edit). Commit messages conform to commitlint. Cross-engine matrix limitation is disclosed and was in fact manually dispatched — which is how the Adobe 2025 issue was caught and fixed.
…nt 15 Documents the cross-engine bug the compat matrix caught in bc0b7b3, so the next person writing middleware doesn't rediscover it the same way. In a function that declares a parameter named `request`, Adobe CF 2025 does not resolve the bare `request` token consistently across expression positions — it can mean the built-in scope as a function argument and `arguments.request` in a member-access expression within the same function. A guard written one way therefore cannot protect an access written the other way. This is not a niche shape: `wheels.middleware.MiddlewareInterface` mandates `handle(required struct request, required any next)`, so every middleware component declares the parameter and is exposed. Anti-pattern 11's advice — never name a parameter after a reserved scope — is unavailable there, so the two safe forms (IsDefined on the full dotted path, or assign-before-use) are the only options and must not be mixed with a bare-token guard. Adds CLAUDE.md cross-engine invariant 15 and a matching deep-reference entry in .ai/wheels/cross-engine-compatibility.md, both noting that Lucee 6/7, BoxLang and Adobe 2023 resolve consistently — so a green local run and green Adobe 2023 smokes do not cover this, and compat-matrix.yml does not run on PRs. Also rewrites the misleading note at the top of TenantResolver.handle(), which asserted that bare `request` "always refers to the built-in request scope, even when a parameter is named `request`". That is true on Lucee and Adobe 2023 and false on Adobe 2025, and it is the assumption that produced the bug. Audited the rest of the framework while here: no other function that declares a `request` parameter pairs a `StructKeyExists(request, ...)` guard with a `request.x` access. The two existing sites (TenantResolver.handle, RequestId.handle) both use the safe assign-before-use form. Docs and comments only — no behaviour change. Lucee 7 + SQLite full core suite: 4717 passed, 0 failed. Refs #3336 Signed-off-by: Peter Amiri <peter@alurium.com>
Adobe 2025 regression, caught and fixedThe Cause: Fixed in Matrix re-run on
|
| The PR's 10 specs | 280/280 passing across 28 engine×DB legs |
Testcase delta vs develop |
exactly +10 on every leg |
Failures on branch but not develop |
none |
All five Adobe 2025 legs now match develop exactly (6/6, 5/5, 4/4, 4/4, 4/4). Remaining per-leg failures are unchanged pre-existing #3302 debt.
- Branch: https://github.com/wheels-dev/wheels/actions/runs/30780625771
developbaseline: https://github.com/wheels-dev/wheels/actions/runs/30773074179
Documented as invariant 15 (8d9e20a)
Added to CLAUDE.md and .ai/wheels/cross-engine-compatibility.md, since every middleware component declares a request parameter and is exposed to this. Anti-pattern 11 ("never name a parameter after a reserved scope") can't apply there — the interface mandates the name — so the two safe forms are the only options:
// safe (a) — one evaluation of the whole dotted path
if (IsDefined("request.wheels.tenant")) { StructDelete(request.wheels, "tenant"); }
// safe (b) — assign before use
if (!StructKeyExists(request, "wheels")) { request.wheels = {}; }
request.wheels.tenant = local.tenant;Also rewrote the note at the top of TenantResolver.handle(), which asserted bare request "always refers to the built-in request scope, even when a parameter is named request" — true on Lucee and Adobe 2023, false on Adobe 2025, and the assumption that produced this bug.
Audited the rest of the framework: no other function declaring a request parameter pairs a StructKeyExists(request, …) guard with a request.x access. The two existing sites (TenantResolver.handle, RequestId.handle) both use safe form (b).
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This PR fixes #3336: the per-request finder cache keyed on the bare model name directly in request.wheels, so a case-insensitive struct-key collision let a model named Tenant alias onto framework-owned request.wheels.tenant — silently wiping (write path) or fabricating (read path) tenant context. The fix namespaces the cache under request.wheels.$queryCache[ModelName], and two folded-in commits harden tenant() and TenantResolver so a malformed value on the key degrades to a no-op instead of wrong behaviour. Every source change is correct, cross-engine-safe, and backed by a dedicated red-first spec. Verdict: approve.
Correctness
Verified each of the three behavioural changes against the current source:
model/read.cfc:314calls$ensureRequestQueryCache()inside theif (local.useRequestCache)block, so therequest.wheels["$queryCache"][modelName]path is guaranteed to exist before the reads atread.cfc:324/:326and the write at:358— all three gated on the samelocal.useRequestCache. No unguarded deref.model/miscellaneous.cfc:170—$ensureRequestQueryCache()builds the namespace defensively (request.wheelsthen$queryCachethen model slot), and$clearRequestCache()resets only its own model's slot, matching the "clears only its own model's cache" spec.Global.cfc:739tenant()now requiresIsDefined+IsStruct+ a non-emptydataSource, exactly the$tenantDataSource()predicate — the "agrees with $tenantDataSource()" spec pins the two together.TenantResolver.handle()line 86 else-if drops a pre-existing/foreign value on a no-match beforenext(), so the request looks unresolved for its whole duration, not just after thefinally— covered by the new "drops a pre-existing request.wheels.tenant" spec.
Cross-engine
Clean, and notably self-aware: the no-match delete at TenantResolver.cfc:86 uses IsDefined("request.wheels.tenant") rather than the StructKeyExists(request, "wheels") + request.wheels member-access shape this very PR documents as broken on Adobe 2025 (CLAUDE.md invariant 15 / .ai/wheels/cross-engine-compatibility.md). $ensureRequestQueryCache() is public + $-prefixed per invariant 7 and lives in wheels.model.*, so it mixes in via $integrateComponents() and stays outside the protectedControllerMethods surface. No closures-as-constructor-args, no attributeCollection, no Left(str,0), no finally loops.
Tests
requestQueryCacheTenantCollisionSpec.cfc (6 specs) covers both failure modes plus the namespacing invariant and sibling-isolation; MultiTenantSpec.cfc and TenantResolverSpec.cfc gain the hardening cases; requestQueryCacheSpec.cfc's white-box assertions are repointed at the new key. All BDD, extending wheels.WheelsTest. The Tenant fixture reuses c_o_r_e_authors so populate.cfm is untouched. The one caveat the author discloses honestly: compat-matrix.yml does not run on PRs, so the Adobe 2025 legs that would exercise invariant 15 are not gated here — the manual dispatch link is provided.
Docs
Changelog fragments present and correctly typed (changelog.d/3336-*.fixed.md), CLAUDE.md invariant 15 and the deep-reference entry added, blog post caching-in-wheels-4.md updated. No direct CHANGELOG.md [Unreleased] edit.
Commits
All four conform to commitlint.config.js (fix(model), fix(middleware), docs), subjects under 100 chars, bodies explain the why (the invariant-15 commit body is exemplary).
Nice work — thorough, well-scoped, and the deferred-collision surfaces (pagination handles to #3339, callback query hash no-collide) are correctly triaged out rather than bundled in.
Fixes #3336.
The collision
The per-request finder cache keyed itself on the bare model name directly in
request.wheels. CFML struct keys are case-insensitive, so a model namedTenant— the natural name for the control-plane model in a database-per-tenant app, and the one used inTenantResolver's own docblock — shared a single key between its query cache and the framework-ownedrequest.wheels.tenant.The fix moves the cache under a reserved
request.wheels.$queryCachesub-struct. Caching behaviour is unchanged: same per-model slots, same query keys, same lifecycle.model/miscellaneous.cfcgains$ensureRequestQueryCache(), which owns namespace creation for the four call sites (three inread.cfc, one in$clearRequestCache()).Scope
The sweep found three independent families of dynamic keys in
request.wheels. Only the first is fixed here:tenant?model/read.cfc×3,model/miscellaneous.cfc×1Global.cfcpagination()/setPagination()"query")tenant— filed as #3339model/callbacks.cfc:331The pagination handles are a real-but-latent collision surface; they need an app author to actively pick the name, so they're tracked separately in #3339 rather than bundled in.
Tenant-context hardening (second commit)
Folded in at maintainer request. The first commit stops the framework from ever writing a query cache onto
request.wheels.tenant; these two stop anything else that lands there from being read as a resolved tenant.tenant()requires a non-emptydataSourcebefore treating the key as an active tenant — the same test$tenantDataSource()already applies before routing a query. It previously returned whatever occupied the key, so a malformed value satisfied everyIsDefined("request.wheels.tenant")or truthiness guard while carrying no usable routing information. The two now agree by construction and can't disagree about whether a request is tenanted.TenantResolverdeletes any pre-existing value on the key when its resolver returns no match. Previously an unresolved request only looked unresolved after thefinallyblock, so a stale or foreign value could read as resolved for the whole downstream request.All four framework producers —
switchTenant()(throws without adataSource),TenantResolver,Job.$restoreTenantContext()andTenantMigrator(both guard) — already guarantee a non-emptydataSource, so correctly-resolved tenants are unaffected. Only foreign values are filtered.Net effect: a malformed tenant context degrades from wrong behaviour to a no-op.
Tests
vendor/wheels/tests/specs/model/requestQueryCacheTenantCollisionSpec.cfc— 6 specs covering both failure modes plus the namespacing invariant. Backed by a newTenantfixture model mapped onto the existingc_o_r_e_authorstable, sopopulate.cfmis untouched.Written red first. The failures before the fix were exactly the two reported behaviours:
key [ID] doesn't existreadingrequest.wheels.tenant.idafter aTenantwrite → failure mode 1, tenant context wiped to{}IsDefined("request.wheels.tenant")returning true after aTenantfinder → failure mode 2, tenant context fabricatedrequestQueryCacheSpec.cfc's 8 existing assertions are repointed at the namespaced key — they're white-box assertions on the exact structure being moved.Verification
Lucee 7 + SQLite via
bash tools/test-local.sh(full suite, not scoped):develop@fc59492Delta is exactly the 10 new specs (6 + 4).
Both sets were written red first. Reverting just the two hardening files while keeping their specs yields exactly 4 failures, confirming those specs exercise the change rather than restating existing behaviour.
tools/test-matrix.sh lucee7 mysqlfails on this machine before any test executes — Docker Desktop can't bind-mounttools/docker/lucee7/box.jsononto/wheels-test-suite/box.jsonunder virtiofs:It fails identically on clean
develop, so it's environmental, not this change. Because I couldn't verify Adobe/BoxLang locally, the implementation was deliberately rewritten to avoid anything engine-sensitive: an earlier draft returned the cache struct by reference and usedStructClear(), which would have depended on struct-reference write semantics holding on Adobe (cf. cross-engine invariant #6, where Adobe copies arrays by value in struct literals). What's here instead is a literal key-path swap of the original code plus an ensure-helper — no new semantics on any path.What PR CI does and does not cover
To be precise about the engine coverage this PR actually carries:
Lucee 7 + SQLite (LuCLI), plus my local runsmoke-env.ymlcold-boots the demo app underproduction/testingand asserts clean 404s / no stack traces / reload behaviour. Per its own comments the probes never query the database, so no model spec runs. It does prove the new$-prefixed public mixin compiles and integrates via$integrateComponents()on Adobe.compat-matrix.yml— the workflow that runs the full suite across all five engines — is weekly cron +workflow_dispatchonly, andcontinue-on-error: true. It does not trigger on pull requests. I've manually dispatched it against this branch: https://github.com/wheels-dev/wheels/actions/runs/30772173443Constructs used are all matrix-safe by inspection:
StructKeyExists, bracket-notation struct keys, no closures, noattributeCollection, no reserved scope names, nofinallyloops. The new helper ispublic+$-prefixed per cross-engine invariant #7 so it mixes in correctly, and lives inwheels.model.*, which is outside theprotectedControllerMethodssurface — so it can't shadow a controller action name.Docs
web/content/blog/posts/caching-in-wheels-4.mddocuments the oldrequest.wheels[ModelName]path in two places; both are updated. Drop that hunk if you'd rather not touch a published post.