perf(model): cache mixin-integration plan to fix per-instance overhead (#3213)#3236
Conversation
#3213) 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<name> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0167uSbSN4vZqQqL5QZfdiQm Signed-off-by: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This PR caches the mixin-integration plan (directory scan + per-file createObject + getMetaData) once per application instead of re-paying it on every model/controller/mapper materialization (#3213). I traced the refactor against the original code and it preserves the existing semantics carefully — Model's collision→super<name> aliasing, Controller's no-else form, and Mapper's get/controller exclude-list are all intact, and the per-method $willBeOverriddenByMixin call is removed with no orphaned callers. I found no blocking correctness, cross-engine, or security issue in the framework code. Verdict: comment — one non-blocking test-robustness suggestion below.
Conventions
Solid throughout — no issues. Worth recording what I verified so a human can trust the audit:
- Semantics preserved per type.
Model.cfckeeps theelse → super<name>collision branch;Controller.cfckeeps the no-elseform; both replace the per-method$willBeOverriddenByMixincall with an O(1)StructKeyExists(overrideSet, name)test built once via$mixinOverrideSet. The override-set type lists match the originals exactly (["model","global"]/["controller","global"]→[primaryType, "global"]inGlobal.cfc:1163). - Mapper exclude-list intact.
Mapper.cfc:403keeps a fallback path that re-applies theget,controllerexclude-list againstgetMetaData().fullNamefor thewheels.Globalintegration ininit()(called with one arg →publicMethodsempty → fallback), while the cached path handleswheels.mapper.*where the exclude never applied. Behavior is equivalent. - New helpers are correctly
public $-prefixed (Global.cfc$componentIntegrationPlan/$buildComponentIntegrationPlan/$mixinOverrideSet). This is required, not incidental:Mapperdoes notextend wheels.Global, so it can only obtain$componentIntegrationPlanbecause it is a public method mixed in via$integrateFunctions(globalComponent)ininit()before$integrateComponents("wheels.mapper")calls it (CLAUDE.md cross-engine invariant #7). - Cache lifetime mirrors the schema cache: seeded in
onapplicationstart.cfc:125(application.$wheels.integrationPlans = {}) and read asapplication.wheels.integrationPlans— the same struct reference afterapplication.wheels = application.$wheels(onapplicationstart.cfc:460), so the two names stay consistent. Rebuilt on reload, as documented. - Changelog fragment
changelog.d/3213-model-instance-mixin-cache.performance.mduses the validperformancetype and is a fragment (not a directCHANGELOG.mdedit).
Cross-engine
No blocking issue. The shared-reference design is sound: even the original code copied function references (componentInstance[name]) into variables/this rather than binding to the holder, so caching one stateless holder per path and reusing its references across instances relies on the same late-binding CFML already guaranteed. The holders are never init()'d and carry no mutable state, so cross-request sharing is safe.
Tests
integrationPlanCacheSpec.cfc is a good BDD spec (extends="wheels.WheelsTest"), exercises real materialization, and its fixture assumptions check out (Author.cfc:11 sets firstName default "Dave"; the .new(firstName=...) named-arg and toBeGT/toBeBoolean matchers all have prior art).
-
Non-blocking —
integrationPlanCacheSpec.cfc:42,expect(first.equals(second)).toBeTrue(). The.equals()array member-function call is the one idiom in this PR with no prior art undervendor/wheels/tests/specs/(verified bygit grep '\.equals('). It works on Lucee/Adobe (same reference → identity/value-equal both return true), but its behavior on BoxLang's array type is unverified — and the PR body itself notes BoxLang was not run locally. It is test-only and CI will surface any failure, so this does not block. If you want a portable identity proof that cannot depend on.equals(), mutate the live cache and confirm the next read reflects it, e.g.:var plan = application.wheels.integrationPlans["wheels.model"]; ArrayAppend(plan, {sentinel = true}); // mutate the cached array model("author").new(); // must NOT rebuild var after = application.wheels.integrationPlans["wheels.model"]; expect(after[ArrayLen(after)].sentinel ?: false).toBeTrue();This proves "same array reused, not rebuilt" using only core array functions that behave identically on every engine.
Commits
Single commit perf(model): cache mixin-integration plan to fix per-instance overhead (#3213) is a clean conventional-commit header (type perf, scope model, well under 100 chars) and describes the "why."
…iew) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0167uSbSN4vZqQqL5QZfdiQm Signed-off-by: Claude <noreply@anthropic.com>
|
Addressed the non-blocking test note in I went with a slightly different mechanism than the suggested application.wheels.integrationPlans["wheels.model"][1]["cacheReuseSentinel"] = true;
model("author").new(); // must reuse the cached plan, not rebuild it
expect(
StructKeyExists(application.wheels.integrationPlans["wheels.model"][1], "cacheReuseSentinel")
).toBeTrue();Same property proven (entry reused, not rebuilt), only core struct functions, so it's portable across Lucee/Adobe/BoxLang. Verified green on Lucee 7 (model suite 917 pass / 0 fail). Generated by Claude Code |
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This re-review covers the new commit 92e8c878 on top of the previously-vetted mixin-integration-plan cache (#3213). The commit is test-only: it replaces the array .equals() identity check in integrationPlanCacheSpec.cfc — which I flagged last pass as the one idiom with no prior art and unverified BoxLang behavior — with a portable in-place tag of the cached plan entry. I traced the new assertion against the cache implementation and it proves the same property (plan reused, not rebuilt) using only core struct functions that behave identically on every engine. No blocking correctness, cross-engine, or security issue. Verdict: approve.
Tests
The replacement is a strict improvement and the prior suggestion is resolved:
integrationPlanCacheSpec.cfc:40now tags the cached entry directly through the full application-scope path —then re-materializes and assertsapplication.wheels.integrationPlans["wheels.model"][1]["cacheReuseSentinel"] = true;
StructKeyExists(...["wheels.model"][1], "cacheReuseSentinel"). Because the write indexes into the cached array element struct in place (no intermediate local-var array copy, which is what would trip Adobe's by-value array semantics), and$componentIntegrationPlanonly rebuilds when the path key is absent (Global.cfc), a secondmodel("author").new()reuses the sameplan[1]struct and the sentinel survives. A per-call rebuild would replaceplan[1]with a fresh struct and the assertion would fail — so the guard still pins the exact regression behind #3213.- The change drops the
.equals()dependency entirely (git grep '\.equals('undervendor/wheels/tests/specs/now finds only the explanatory comment at line 41), so the BoxLang uncertainty I raised is gone. - Fixture assumptions remain valid:
Author.cfc:11still defaultsfirstNameto"Dave", matching thetoBe("Dave")assertions in the unchanged specs.
Commits
test(model): prove plan reuse with a portable in-place tag (#3236 review) is a clean conventional-commit header (type test, scope model, well under 100 chars), carries a Signed-off-by trailer, and the body explains the "why" (portability over .equals()).
For the audit trail, the prior review's single non-blocking finding is now resolved:
- Resolved —
integrationPlanCacheSpec.cfc:42(old).equals()array identity check. Replaced with the engine-portable in-place struct-key tag atintegrationPlanCacheSpec.cfc:40described above.
The substantive perf change (Global.cfc, Model.cfc, Controller.cfc, Mapper.cfc, onapplicationstart.cfc) is unchanged from the commit I vetted last pass and remains clean.
What & why
Fixes the per-instance overhead behind #3213 (a 2.x RocketUnit suite running ~6× slower under 4.0.x).
Root cause
A RocketUnit suite runs the whole suite in one request (
Test.cfc::$wheelsRunner), so application-scoped caches (models, routes, schema) are built once — they are not the per-test cost. The cost is per model-instance creation, which a test suite does constantly:model("X").new()and every row returned byfindAll/findOne/findByKey→model/create.cfc::$createInstance→$createObjectFromRoot(method="$initModelObject")→Injector.getInstance(). Model components aren't singletons, so the injector takes the transient path:createObject+init()+onDIcomplete()every time.Model.cfc::init()runs$integrateComponents("wheels.model"), which on every instance did:directoryListofvendor/wheels/model/createObjectper file (18)getMetaData()per file (18)$willBeOverriddenByMixin(which did a slowIsDefined) per methodController.cfcandMapper.cfccarried the identical pattern.The fix
Build the integration plan once per application and replay it cheaply:
Global.cfc::$componentIntegrationPlan(path)memoizes the directory scan + per-filecreateObject+getMetaDatainapplication.wheels.integrationPlans(a sibling of the schema cache — rebuilt on reload, so framework edits are still picked up)..accesscheck andinstance[name]scope lookup from the hot path.$mixinOverrideSet), so the per-method$willBeOverriddenByMixinfunction call is gone from the loop.Semantics are unchanged: the same public methods (and
super<name>aliases) are mixed intovariables/this, in the same order. Model keeps itselse → super<name>branch, Controller keeps its no-elseform, and Mapper keeps theget/controllerexclude-list on itswheels.Globalintegration (via a fallback path). Function references are late-bound to the object they're invoked on, so sharing the cached references across instances is safe.Measurements (Lucee 7 + SQLite, this branch)
model().new()(The full suite is only partly instance-creation, so its delta understates the win for an instance-heavy suite like the reporter's.)
Tests
Adds
vendor/wheels/tests/specs/model/integrationPlanCacheSpec.cfcpinning that the cache is populated and reused, and that materialized instances keep the full, working mixed-in method surface (and stay independent).Follow-up (not in this PR)
Profiling showed a larger ceiling: cloning a lean pre-mixed prototype with
Duplicate()materializes an instance in ~0.13 ms vs ~0.76 ms — ~5.8×. I did not take that here because it's a deep change to the hottest path with real cross-engine hazards (e.g. BoxLang'sonDIcompletedoesvariables.this = this, whichDuplicatewould leave pointing at the prototype), and I could only validate Lucee 7 in this environment. Worth its own PR with the full engine matrix.Verified on Lucee 7 + SQLite; the cross-engine matrix (Adobe/BoxLang) should run in CI.
🤖 Generated with Claude Code
https://claude.ai/code/session_0167uSbSN4vZqQqL5QZfdiQm
Generated by Claude Code