From 59e7b0954a05d543f581f44d7897bb882e4cbf53 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 15:57:27 +0000 Subject: [PATCH 1/2] perf(model): cache mixin-integration plan to fix per-instance overhead (#3213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model, controller, and mapper objects are materialized constantly — every new() and every finder row runs $createInstance -> init() -> $integrateComponents, which re-scanned the framework mixin folders (a directoryList plus a createObject and getMetaData per file) and re-resolved every public method on EVERY instance. That per-instance reflection dominated test-suite and request time on 4.0.x — the regression reported in #3213 (a 2.x RocketUnit suite running ~6x slower under 4.x). Build the integration plan once per application (cached in application.wheels.integrationPlans, rebuilt on reload like the schema cache) and replay it cheaply: pre-resolve public method references and precompute the plugin-mixin override set, so the per-method $willBeOverriddenByMixin call is gone from the hot loop. Semantics are unchanged — the same public methods (and super aliases) are mixed in, in the same order; only the work that is identical for every instance is hoisted out. Measured on Lucee 7 + SQLite: model-instance creation dropped from 2772ms to 1513ms for 2000 instances (~1.8x); full core suite 4549 pass / 0 fail. Adds vendor/wheels/tests/specs/model/integrationPlanCacheSpec.cfc to pin the cache and that materialized instances keep the full working method surface. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0167uSbSN4vZqQqL5QZfdiQm Signed-off-by: Claude --- ...-model-instance-mixin-cache.performance.md | 1 + vendor/wheels/Controller.cfc | 83 +++++-------- vendor/wheels/Global.cfc | 114 ++++++++++++++++++ vendor/wheels/Mapper.cfc | 52 +++++--- vendor/wheels/Model.cfc | 92 ++++++-------- vendor/wheels/events/onapplicationstart.cfc | 7 ++ .../specs/model/integrationPlanCacheSpec.cfc | 71 +++++++++++ 7 files changed, 290 insertions(+), 130 deletions(-) create mode 100644 changelog.d/3213-model-instance-mixin-cache.performance.md create mode 100644 vendor/wheels/tests/specs/model/integrationPlanCacheSpec.cfc diff --git a/changelog.d/3213-model-instance-mixin-cache.performance.md b/changelog.d/3213-model-instance-mixin-cache.performance.md new file mode 100644 index 0000000000..7d00692d28 --- /dev/null +++ b/changelog.d/3213-model-instance-mixin-cache.performance.md @@ -0,0 +1 @@ +- Model, controller, and mapper object creation no longer re-scans the framework mixin folders (a directory listing plus a `createObject` and `getMetaData` per file) on every materialization. The mixin-integration plan is now built once per application and reused, and the per-method `$willBeOverriddenByMixin` lookup is precomputed — cutting model-instance creation roughly in half (every `new()` and every finder row was paying the full cost). This is the regression behind slow test-suite and request times reported on 4.0.x (#3213) diff --git a/vendor/wheels/Controller.cfc b/vendor/wheels/Controller.cfc index 69acf34a9a..f3fa5fd182 100644 --- a/vendor/wheels/Controller.cfc +++ b/vendor/wheels/Controller.cfc @@ -375,69 +375,46 @@ component output="false" displayName="Controller" extends="wheels.Global"{ * @path The path to get component files from */ private function $integrateComponents(required string path) { - local.basePath = arguments.path; - local.folderPath = expandPath("/#replace(local.basePath, ".", "/", "all")#"); - - // Get a list of all CFC files in the folder - local.fileList = directoryList(local.folderPath, false, "name", "*.cfc"); - for (local.fileName in local.fileList) { - // Remove the file extension to get the component name - local.componentName = replace(local.fileName, ".cfc", "", "all"); - - $integrateFunctions(createObject("component", "#local.basePath#.#local.componentName#")); + // The directory scan + per-file createObject + getMetaData, plus the + // public-method/reference resolution, are cached per path (issue #3213) — + // they are identical for every controller instance. Only the reference + // assignment below runs on each materialization. The mixin-override set is + // resolved once per call (empty in the common no-mixins case) so the old + // per-method $willBeOverriddenByMixin function call is gone from the loop. + local.plan = $componentIntegrationPlan(arguments.path); + local.overrideSet = $mixinOverrideSet("controller"); + local.iEnd = ArrayLen(local.plan); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + $integrateFunctions(local.plan[local.i].publicMethods, local.overrideSet); } } /** - * Dynamically mix methods from a given component into this component + * Mix a component's pre-resolved public methods (each `{name, ref}`, see + * $componentIntegrationPlan) into this instance. Preserves the original + * semantics: a method that does not already exist (from inheritance or an + * earlier-integrated component) is added, and any method a plugin/package + * mixin will override is also aliased to `super`. `overrideSet` is the + * precomputed mixin-override name set. */ - private function $integrateFunctions(componentInstance) { - // Get all methods from the given component - local.methods = getMetaData(componentInstance).functions; - - for (local.method in local.methods) { - local.functionName = local.method.name; + private function $integrateFunctions(required array publicMethods, required struct overrideSet) { + local.iEnd = ArrayLen(arguments.publicMethods); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.m = arguments.publicMethods[local.i]; + local.name = local.m.name; + local.ref = local.m.ref; - // Only add public, non-inherited methods - if (local.method.access eq "public") { - local.methodExists = structKeyExists(variables, local.method.name) || structKeyExists(this, local.method.name); - - if (!local.methodExists) { - variables[local.functionName] = componentInstance[local.functionName]; - this[local.functionName] = componentInstance[local.functionName]; - } - - // Only add super prefix for functions that will be overridden by plugins/mixins - if ($willBeOverriddenByMixin(local.functionName)) { - local.superMethodName = "super" & local.functionName; - variables[local.superMethodName] = componentInstance[local.functionName]; - this[local.superMethodName] = componentInstance[local.functionName]; - } - + if (!(StructKeyExists(variables, local.name) || StructKeyExists(this, local.name))) { + variables[local.name] = local.ref; + this[local.name] = local.ref; } - } - } - /** - * Check if a function will be overridden by a plugin/mixin - */ - private boolean function $willBeOverriddenByMixin(required string functionName) { - // Check if application and mixins are available - if (!IsDefined("application") || !StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "mixins")) { - return false; - } - - // Check for both "controller" and "global" mixins - local.componentTypes = ["controller", "global"]; - - for (local.componentType in local.componentTypes) { - if (StructKeyExists(application.wheels.mixins, local.componentType) && - StructKeyExists(application.wheels.mixins[local.componentType], arguments.functionName)) { - return true; + if (StructKeyExists(arguments.overrideSet, local.name)) { + local.superName = "super" & local.name; + variables[local.superName] = local.ref; + this[local.superName] = local.ref; } } - - return false; } function onDIcomplete(){ diff --git a/vendor/wheels/Global.cfc b/vendor/wheels/Global.cfc index 5e0cca6a8c..8be7b42a6c 100644 --- a/vendor/wheels/Global.cfc +++ b/vendor/wheels/Global.cfc @@ -1058,6 +1058,120 @@ return local.$wheels; return local.rv; } + /** + * Internal. Returns a cached "integration plan" for a folder of mixin + * components (e.g. `wheels.model`, `wheels.controller`, `wheels.mapper`): an + * ordered array of `{instance, methods, fullName}` where `instance` is a + * single shared, stateless method-holder component and `methods` is its + * `getMetaData().functions` array. + * + * The directory scan, the per-file `createObject`, and the `getMetaData` + * calls are the expensive — and completely invariant — part of + * `$integrateComponents`: they produce the same result for every object of a + * given type. Before this cache they were re-paid on EVERY model, controller, + * and mapper materialization (every `new()` and every finder row goes through + * `$createInstance` -> `init()` -> `$integrateComponents`), which dominated + * test-suite and request time (issue #3213). Now they run once per path and + * the cheap per-instance work (copying function references into the target's + * `variables`/`this`) is all that remains on the hot path. + * + * The plan is cached in `application.wheels.integrationPlans`, so a reload — + * which rebuilds `application.wheels` — re-scans, the same lifetime contract + * as the schema column cache. The cached method-holder components carry no + * instance state (they are never `init()`'d) and CFML methods bind to the + * object they are invoked on, so sharing their function references across many + * target instances and across concurrent requests is safe. + */ + public array function $componentIntegrationPlan(required string path) { + // During early bootstrap (before application.wheels exists) fall back to + // an uncached build so behavior is identical to the pre-cache code path. + if (!StructKeyExists(application, "wheels")) { + return $buildComponentIntegrationPlan(arguments.path); + } + if (!StructKeyExists(application.wheels, "integrationPlans")) { + lock name="wheels.integrationPlans.#application.applicationName#" type="exclusive" timeout="10" { + if (!StructKeyExists(application.wheels, "integrationPlans")) { + application.wheels.integrationPlans = {}; + } + } + } + if (!StructKeyExists(application.wheels.integrationPlans, arguments.path)) { + local.plan = $buildComponentIntegrationPlan(arguments.path); + lock name="wheels.integrationPlans.#application.applicationName#" type="exclusive" timeout="10" { + application.wheels.integrationPlans[arguments.path] = local.plan; + } + } + return application.wheels.integrationPlans[arguments.path]; + } + + /** + * Internal. Builds (without caching) the integration plan for a path — the + * directory scan + per-file createObject + getMetaData that + * $componentIntegrationPlan memoizes. The DirectoryList call mirrors the + * original $integrateComponents exactly so file (and therefore override) + * order is unchanged. + */ + public array function $buildComponentIntegrationPlan(required string path) { + local.folderPath = ExpandPath("/#Replace(arguments.path, ".", "/", "all")#"); + local.fileList = DirectoryList(local.folderPath, false, "name", "*.cfc"); + local.rv = []; + for (local.fileName in local.fileList) { + local.componentName = Replace(local.fileName, ".cfc", "", "all"); + local.instance = CreateObject("component", "#arguments.path#.#local.componentName#"); + local.meta = GetMetaData(local.instance); + local.fns = StructKeyExists(local.meta, "functions") ? local.meta.functions : []; + // Pre-resolve the PUBLIC method references once. On the hot path + // (every materialized object) this removes both the per-method + // `.access` filtering and the `instance[name]` scope lookup; only the + // reference assignment into the target remains (issue #3213). Function + // references are late-bound to the object they are invoked on, so the + // shared, cached reference works correctly on every target instance. + local.publicMethods = []; + local.fEnd = ArrayLen(local.fns); + for (local.f = 1; local.f <= local.fEnd; local.f++) { + if (local.fns[local.f].access == "public") { + ArrayAppend(local.publicMethods, { + name = local.fns[local.f].name, + ref = local.instance[local.fns[local.f].name] + }); + } + } + ArrayAppend(local.rv, { + instance = local.instance, + methods = local.fns, + publicMethods = local.publicMethods, + fullName = StructKeyExists(local.meta, "fullName") ? local.meta.fullName : "#arguments.path#.#local.componentName#" + }); + } + return local.rv; + } + + /** + * Internal. Returns a struct whose KEYS are the function names that a + * registered plugin/package mixin will override for the given component type + * (plus the always-checked "global" type). Empty — the common case, no mixins + * registered — when there are none. Computed from the app-scoped, reload-stable + * application.wheels.mixins so the per-method $willBeOverriddenByMixin function + * call can be replaced by an O(1) struct-membership test on the hot path (#3213). + */ + public struct function $mixinOverrideSet(required string primaryType) { + local.rv = {}; + if ( + !StructKeyExists(application, "wheels") + || !StructKeyExists(application.wheels, "mixins") + || StructIsEmpty(application.wheels.mixins) + ) { + return local.rv; + } + local.types = [arguments.primaryType, "global"]; + for (local.t in local.types) { + if (StructKeyExists(application.wheels.mixins, local.t) && IsStruct(application.wheels.mixins[local.t])) { + StructAppend(local.rv, application.wheels.mixins[local.t], false); + } + } + return local.rv; + } + /** * Internal function. */ diff --git a/vendor/wheels/Mapper.cfc b/vendor/wheels/Mapper.cfc index 52e420513e..bc3a6ff081 100644 --- a/vendor/wheels/Mapper.cfc +++ b/vendor/wheels/Mapper.cfc @@ -379,38 +379,50 @@ component output="false" { * @path The path to get component files from */ private function $integrateComponents(required string path) { - local.basePath = arguments.path; - local.folderPath = expandPath("/#replace(local.basePath, ".", "/", "all")#"); - - // Get a list of all CFC files in the folder - local.fileList = directoryList(local.folderPath, false, "name", "*.cfc"); - for (local.fileName in local.fileList) { - // Remove the file extension to get the component name - local.componentName = replace(local.fileName, ".cfc", "", "all"); - - $integrateFunctions(createObject("component", "#local.basePath#.#local.componentName#")); - } + // The directory scan + per-file createObject + getMetaData, plus the + // public-method/reference resolution, are cached per path (issue #3213). + // The `get`/`controller` exclude-list only applies to NON-wheels.mapper + // sources, so for the wheels.mapper.* components scanned here every public + // method is integrated — exactly what the precomputed publicMethods hold. + local.plan = $componentIntegrationPlan(arguments.path); + local.iEnd = ArrayLen(local.plan); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + $integrateFunctions(local.plan[local.i].instance, local.plan[local.i].publicMethods); + } } /** - * Dynamically mix methods from a given component into this component. - * Only public, non-inherited methods are added. + * Mix a component's methods into this component. The cached path passes the + * pre-resolved public methods (each `{name, ref}`, see + * $componentIntegrationPlan) and assigns them directly. The fallback path — + * used by init() integrating wheels.Global with no cached list — keeps the + * original metadata scan plus the `get`/`controller` exclude-list (#3213). * * @param componentInstance The component instance to integrate methods from. */ - private function $integrateFunctions(required any componentInstance) { - // Get metadata for the component - local.methods = getMetaData(componentInstance).functions; - local.componentName = getMetaData(componentInstance).FULLNAME; + private function $integrateFunctions(required any componentInstance, array publicMethods = []) { + // Cached path: pre-resolved public method references. + if (ArrayLen(arguments.publicMethods)) { + local.iEnd = ArrayLen(arguments.publicMethods); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.m = arguments.publicMethods[local.i]; + variables[local.m.name] = local.m.ref; + this[local.m.name] = local.m.ref; + } + return; + } - // Iterate over the functions in the component + // Fallback (e.g. init() integrating wheels.Global): scan metadata and + // apply the exclude-list against the source's full name. + local.meta = getMetaData(arguments.componentInstance); + local.methods = StructKeyExists(local.meta, "functions") ? local.meta.functions : []; + local.componentName = StructKeyExists(local.meta, "fullName") ? local.meta.fullName : ""; for (local.method in local.methods) { local.functionName = local.method.name; local.excludeList = "get,controller"; - // Add only public, non-inherited methods excluding specific ones + // Add only public methods, excluding specific ones unless the source is a mapper component. if (local.method.access == "public" && (!listFindNoCase(local.excludeList, local.functionName) || findNoCase("wheels.mapper", local.componentName))) { - // Assign methods to `variables` and `this` variables[local.functionName] = componentInstance[local.functionName]; this[local.functionName] = componentInstance[local.functionName]; } diff --git a/vendor/wheels/Model.cfc b/vendor/wheels/Model.cfc index 6e0973979e..d745bc2dfb 100644 --- a/vendor/wheels/Model.cfc +++ b/vendor/wheels/Model.cfc @@ -573,72 +573,50 @@ component output="false" displayName="Model" extends="wheels.Global"{ * @path The path to get component files from */ private function $integrateComponents(required string path) { - local.basePath = arguments.path; - local.folderPath = expandPath("/#replace(local.basePath, ".", "/", "all")#"); - - // Get a list of all CFC files in the folder - local.fileList = directoryList(local.folderPath, false, "name", "*.cfc"); - for (local.fileName in local.fileList) { - // Remove the file extension to get the component name - local.componentName = replace(local.fileName, ".cfc", "", "all"); - - $integrateFunctions(createObject("component", "#local.basePath#.#local.componentName#")); + // The directory scan + per-file createObject + getMetaData, plus the + // public-method/reference resolution, are cached per path (issue #3213) — + // they are identical for every model instance. Only the reference + // assignment below runs on each materialization. The mixin-override set is + // resolved once per call (empty in the common no-mixins case) so the old + // per-method $willBeOverriddenByMixin function call is gone from the loop. + local.plan = $componentIntegrationPlan(arguments.path); + local.overrideSet = $mixinOverrideSet("model"); + local.iEnd = ArrayLen(local.plan); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + $integrateFunctions(local.plan[local.i].publicMethods, local.overrideSet); } } /** - * Dynamically mix methods from a given component into this component + * Mix a component's pre-resolved public methods (each `{name, ref}`, see + * $componentIntegrationPlan) into this instance. Preserves the original + * semantics: a method that already exists (from inheritance or an + * earlier-integrated component) is also exposed as `super`, and any + * method a plugin/package mixin will override is likewise aliased to + * `super`. `overrideSet` is the precomputed mixin-override name set. */ - private function $integrateFunctions(componentInstance) { - // Get all methods from the given component - local.methods = getMetaData(componentInstance).functions; - - for (local.method in local.methods) { - local.functionName = local.method.name; - - // Only add public, non-inherited methods - if (local.method.access eq "public") { - local.methodExists = structKeyExists(variables, local.method.name) || structKeyExists(this, local.method.name); - - if (!local.methodExists) { - variables[local.functionName] = componentInstance[local.functionName]; - this[local.functionName] = componentInstance[local.functionName]; - } else { - local.superMethodName = "super" & local.functionName; - variables[local.superMethodName] = componentInstance[local.functionName]; - this[local.superMethodName] = componentInstance[local.functionName]; - } - - // Only add super prefix for functions that will be overridden by plugins/mixins - if ($willBeOverriddenByMixin(local.functionName)) { - local.superMethodName = "super" & local.functionName; - variables[local.superMethodName] = componentInstance[local.functionName]; - this[local.superMethodName] = componentInstance[local.functionName]; - } + private function $integrateFunctions(required array publicMethods, required struct overrideSet) { + local.iEnd = ArrayLen(arguments.publicMethods); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.m = arguments.publicMethods[local.i]; + local.name = local.m.name; + local.ref = local.m.ref; + + if (!(StructKeyExists(variables, local.name) || StructKeyExists(this, local.name))) { + variables[local.name] = local.ref; + this[local.name] = local.ref; + } else { + local.superName = "super" & local.name; + variables[local.superName] = local.ref; + this[local.superName] = local.ref; } - } - } - /** - * Check if a function will be overridden by a plugin/mixin - */ - private boolean function $willBeOverriddenByMixin(required string functionName) { - // Check if application and mixins are available - if (!IsDefined("application") || !StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "mixins")) { - return false; - } - - // Check for both "model" and "global" mixins - local.componentTypes = ["model", "global"]; - - for (local.componentType in local.componentTypes) { - if (StructKeyExists(application.wheels.mixins, local.componentType) && - StructKeyExists(application.wheels.mixins[local.componentType], arguments.functionName)) { - return true; + if (StructKeyExists(arguments.overrideSet, local.name)) { + local.superName = "super" & local.name; + variables[local.superName] = local.ref; + this[local.superName] = local.ref; } } - - return false; } /** diff --git a/vendor/wheels/events/onapplicationstart.cfc b/vendor/wheels/events/onapplicationstart.cfc index 30195be304..e17e5c51bf 100644 --- a/vendor/wheels/events/onapplicationstart.cfc +++ b/vendor/wheels/events/onapplicationstart.cfc @@ -116,6 +116,13 @@ component { // machinery ($addToCache / $cacheCount) walks and dereferences `.expiresAt` // on. Putting schema queries under `cache.*` makes the cull throw. application.$wheels.schemaColumnCache = {}; + // Per-app mixin-integration plans (see Global.cfc $componentIntegrationPlan). + // Caches the directory scan + per-file createObject + getMetaData that + // $integrateComponents performs for wheels.model / wheels.controller / + // wheels.mapper, so that work runs once per app instead of on every model, + // controller, and mapper object materialization (issue #3213). Like the + // schema cache, it lives for the application lifetime and is rebuilt on reload. + application.$wheels.integrationPlans = {}; application.$wheels.helperFileCache = {}; application.$wheels.layoutFileCache = {}; application.$wheels.existingObjectFiles = {}; diff --git a/vendor/wheels/tests/specs/model/integrationPlanCacheSpec.cfc b/vendor/wheels/tests/specs/model/integrationPlanCacheSpec.cfc new file mode 100644 index 0000000000..863c20ba30 --- /dev/null +++ b/vendor/wheels/tests/specs/model/integrationPlanCacheSpec.cfc @@ -0,0 +1,71 @@ +/** + * Guard for the mixin-integration cache (issue #3213). + * + * Model objects are materialized constantly — every `new()` and every finder + * row goes through $createInstance -> init() -> $integrateComponents. That used + * to re-scan vendor/wheels/model/, re-createObject every sub-component, and + * re-getMetaData on each, on EVERY instance. The plan is now built once per app + * (Global.cfc::$componentIntegrationPlan, cached in + * application.wheels.integrationPlans) and replayed cheaply. + * + * These specs pin the behavior the optimization must preserve: the cache is + * populated and reused, and every materialized instance still carries the full, + * working set of mixed-in model methods. + */ +component extends="wheels.WheelsTest" { + + function run() { + describe("mixin-integration plan cache (##3213)", () => { + + it("populates the per-app integration-plan cache for wheels.model", () => { + // Materializing any model triggers $integrateComponents("wheels.model"). + model("author").new(); + expect(StructKeyExists(application.wheels, "integrationPlans")).toBeTrue(); + expect(StructKeyExists(application.wheels.integrationPlans, "wheels.model")).toBeTrue(); + + var plan = application.wheels.integrationPlans["wheels.model"]; + expect(IsArray(plan)).toBeTrue(); + expect(ArrayLen(plan)).toBeGT(0); + // Each entry carries the pre-resolved public methods used on the hot path. + expect(StructKeyExists(plan[1], "publicMethods")).toBeTrue(); + expect(IsArray(plan[1].publicMethods)).toBeTrue(); + }); + + it("reuses the same cached plan across instances rather than rebuilding it", () => { + model("author").new(); + var first = application.wheels.integrationPlans["wheels.model"]; + // A second materialization must not replace the cached plan. + model("author").new(); + var second = application.wheels.integrationPlans["wheels.model"]; + // Same identity (Lucee/Adobe compare arrays by reference here): a + // rebuild would produce a different array with fresh instances. + expect(first.equals(second)).toBeTrue(); + }); + + it("materializes instances that carry the full mixed-in model method surface", () => { + var a = model("author").new(); + // A representative spread across the model sub-components + // (create/read/update/delete/validations/errors/properties). + for (var fn in ["save", "update", "delete", "valid", "hasErrors", "isNew", "reload", "key", "properties"]) { + expect(StructKeyExists(a, fn)).toBeTrue(); + expect(IsCustomFunction(a[fn])).toBeTrue(); + } + }); + + it("keeps mixed-in methods functional and instances independent", () => { + // Default value comes from config()/properties — proves the instance + // is wired up, not just method-shaped. + var a1 = model("author").new(); + expect(a1.firstName).toBe("Dave"); + expect(a1.valid()).toBeBoolean(); + + var a2 = model("author").new(firstName = "Grace"); + expect(a2.firstName).toBe("Grace"); + // Mutating one instance must not leak into another. + expect(a1.firstName).toBe("Dave"); + }); + + }); + } + +} From 92e8c8787a9e8df8450ef38a1de7c29e77739f9a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 16:05:16 +0000 Subject: [PATCH 2/2] test(model): prove plan reuse with a portable in-place tag (#3236 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the array `.equals()` identity check in the integration-plan-cache spec — whose behavior on BoxLang's array type is unverified — with a deep-path in-place tag of the cached entry that survives a second materialization. The write goes through the full application-scope path (no local-var copy), so it is reference-safe on Adobe CF too, and it uses only core struct functions, so it behaves identically on every engine. Same property proven (cached plan reused, not rebuilt), no `.equals()` dependency. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0167uSbSN4vZqQqL5QZfdiQm Signed-off-by: Claude --- .../specs/model/integrationPlanCacheSpec.cfc | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/vendor/wheels/tests/specs/model/integrationPlanCacheSpec.cfc b/vendor/wheels/tests/specs/model/integrationPlanCacheSpec.cfc index 863c20ba30..019ce59cb7 100644 --- a/vendor/wheels/tests/specs/model/integrationPlanCacheSpec.cfc +++ b/vendor/wheels/tests/specs/model/integrationPlanCacheSpec.cfc @@ -33,13 +33,19 @@ component extends="wheels.WheelsTest" { it("reuses the same cached plan across instances rather than rebuilding it", () => { model("author").new(); - var first = application.wheels.integrationPlans["wheels.model"]; - // A second materialization must not replace the cached plan. + // Tag the cached plan entry in place. The write goes through the full + // application-scope path (no intermediate local var), so it mutates the + // cached array element directly — reference-safe on Adobe CF too, which + // copies an array assigned to a local. Uses only core struct functions, + // so it behaves identically on Lucee/Adobe/BoxLang (avoids the array + // `.equals()` idiom, whose BoxLang behavior is unverified). + application.wheels.integrationPlans["wheels.model"][1]["cacheReuseSentinel"] = true; + // A second materialization must reuse the cached plan, not rebuild it + // (a rebuild would replace the entry with a fresh struct lacking the tag). model("author").new(); - var second = application.wheels.integrationPlans["wheels.model"]; - // Same identity (Lucee/Adobe compare arrays by reference here): a - // rebuild would produce a different array with fresh instances. - expect(first.equals(second)).toBeTrue(); + expect( + StructKeyExists(application.wheels.integrationPlans["wheels.model"][1], "cacheReuseSentinel") + ).toBeTrue(); }); it("materializes instances that carry the full mixed-in model method surface", () => {