Skip to content

perf(model): cache mixin-integration plan to fix per-instance overhead (#3213)#3236

Merged
bpamiri merged 2 commits into
developfrom
claude/funny-wozniak-gxsh2l
Jun 20, 2026
Merged

perf(model): cache mixin-integration plan to fix per-instance overhead (#3213)#3236
bpamiri merged 2 commits into
developfrom
claude/funny-wozniak-gxsh2l

Conversation

@bpamiri

@bpamiri bpamiri commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

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 by findAll/findOne/findByKeymodel/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:

  • a directoryList of vendor/wheels/model/
  • a createObject per file (18)
  • a getMetaData() per file (18)
  • iterated all 231 public methods, and called $willBeOverriddenByMixin (which did a slow IsDefined) per method

Controller.cfc and Mapper.cfc carried 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-file createObject + getMetaData in application.wheels.integrationPlans (a sibling of the schema cache — rebuilt on reload, so framework edits are still picked up).
  • Public method references are pre-resolved into the plan, removing the per-method .access check and instance[name] scope lookup from the hot path.
  • The plugin-mixin override set is precomputed ($mixinOverrideSet), so the per-method $willBeOverriddenByMixin function call is gone from the loop.

Semantics are unchanged: the same public methods (and super<name> aliases) are mixed into variables/this, in the same order. Model keeps its else → super<name> branch, Controller keeps its no-else form, and Mapper keeps the get/controller exclude-list on its wheels.Global integration (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)

before after
2,000 × model().new() 2772 ms 1513 ms (~1.8×)
full core suite duration 33.3 s ~22–24 s
full core suite result 4545 pass / 0 fail 4549 pass / 0 fail / 0 error

(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.cfc pinning 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's onDIcomplete does variables.this = this, which Duplicate would 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

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

@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 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.cfc keeps the else → super<name> collision branch; Controller.cfc keeps the no-else form; both replace the per-method $willBeOverriddenByMixin call 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"] in Global.cfc:1163).
  • Mapper exclude-list intact. Mapper.cfc:403 keeps a fallback path that re-applies the get,controller exclude-list against getMetaData().fullName for the wheels.Global integration in init() (called with one arg → publicMethods empty → fallback), while the cached path handles wheels.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: Mapper does not extend wheels.Global, so it can only obtain $componentIntegrationPlan because it is a public method mixed in via $integrateFunctions(globalComponent) in init() 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 as application.wheels.integrationPlans — the same struct reference after application.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.md uses the valid performance type and is a fragment (not a direct CHANGELOG.md edit).

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 under vendor/wheels/tests/specs/ (verified by git 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>

bpamiri commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the non-blocking test note in 92e8c87: dropped the array .equals() idiom from integrationPlanCacheSpec.cfc.

I went with a slightly different mechanism than the suggested ArrayAppend(plan, …), because assigning the cached array to a local first (var plan = …integrationPlans["wheels.model"]) can hit Adobe CF's array-copy-by-value behavior — the append would mutate the copy, leaving the cache untouched and the assertion failing on Adobe. Instead I tag the cached entry through the full application-scope path so the write lands in place:

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

@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 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:40 now tags the cached entry directly through the full application-scope path —
    application.wheels.integrationPlans["wheels.model"][1]["cacheReuseSentinel"] = true;
    then re-materializes and asserts 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 $componentIntegrationPlan only rebuilds when the path key is absent (Global.cfc), a second model("author").new() reuses the same plan[1] struct and the sentinel survives. A per-call rebuild would replace plan[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(' under vendor/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:11 still defaults firstName to "Dave", matching the toBe("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 at integrationPlanCacheSpec.cfc:40 described 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.

@bpamiri bpamiri merged commit 8f73b77 into develop Jun 20, 2026
10 checks passed
@bpamiri bpamiri deleted the claude/funny-wozniak-gxsh2l branch June 20, 2026 16:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants