Skip to content

Per-request / per-row mixin re-integration and reflective dispatch tax on the hottest framework paths #2897

Description

@bpamiri

Problem

The framework re-derives application-lifetime-constant data on its hottest paths, in four compounding ways. All file:line references verified against origin/develop @ 0688d914b.

1. Full mixin re-integration per model object and per HTTP request

  • vendor/wheels/Model.cfc:5 — every model object instantiation calls $integrateComponents("wheels.model"). That runs a directoryList over vendor/wheels/model/ plus one createObject per CFC (~18 files) (Model.cfc:545-557), then a getMetaData().functions scan and a ~245-function-reference copy into variables/this (Model.cfc:562-593). findAll(returnAs="objects") over N rows pays all of this N times.
  • vendor/wheels/Controller.cfc:4-5 — every request does the same twice (wheels.controller, 14 CFCs + wheels.view, 15 CFCs; ~266 functions) via Controller.cfc:148-193.

2. Throwaway wheels.Plugins (+ its 4,038-line wheels.Global parent) constructed per request and per row

  • vendor/wheels/Controller.cfc:216 and vendor/wheels/Model.cfc:650onDIcomplete() runs new wheels.Plugins().$initializeMixins(variables) per controller instance and per materialized model object.
  • vendor/wheels/events/EventMethods.cfc:156$runOnRequestStart constructs local.Mixins = new wheels.Plugins() unconditionally at the top of every request, but only uses it inside the !StructIsEmpty(application.wheels.mixins) guard (EventMethods.cfc:219-222) — 100% wasted for mixin-free apps. Each construction also pays the Global pseudo-constructor work, including the $promoteIncludedGlobalsToThis() variables-scope scan (vendor/wheels/Global.cfc:4017, 4026-4037).
  • Further call sites: vendor/wheels/Dispatch.cfc:809, vendor/wheels/Test.cfc:802.
  • A cached instance already exists — application[appKey].PluginObj (vendor/wheels/Global.cfc:3072) — but it cannot be shared safely today: $initializeMixins (vendor/wheels/Plugins.cfc:792-840) makes unscoped $wheels.* scratch writes into the component's own variables scope (a data race once the instance is shared), and its trailing StructDelete(variablesScope, "$wheels") is a no-op under the current call pattern.

3. Warm model()/controller() lookups pay reflective dispatch on every call

  • vendor/wheels/Global.cfc:1169-1181 (model()) and Global.cfc:1144-1163 (controller()) route every warm-cache hit through $doubleCheckedLock (Global.cfc:3) → $invoke (Global.cfc:289) → cfinvoke — two reflective dispatches just to evaluate the one-line $cachedModelClassExists (Global.cfc:917) / $cachedControllerClassExists (Global.cfc:928). model() is the framework's hottest function: called per association, per validation, per row.

4. $resolveInitArguments metadata scan per transient resolution — and it never matches inherited init()

  • vendor/wheels/Injector.cfc:290-320 (called from Injector.cfc:177, reached per model row via $createObjectFromRoot) runs getMetaData() on every resolution and inspects only the top-level functions array. Inherited init() lives under extends, so for every app controller/model it returns {} — guaranteed-no-op metadata churn, and the advertised init auto-wiring silently never applies to inherited init().

None of these inputs change during the application lifetime (short of ?reload=true).

Impact

This is the dominant avoidable overhead in the framework. Per materialized row: 1 directory listing, ~19 createObject, ~19 getMetaData, a ~245-function copy, a fresh Plugins+Global instantiation, and a $resolveInitArguments metadata scan. Per request: the ~29-CFC controller+view integration plus another throwaway Plugins. Per warm model() call: two reflective cfinvoke dispatches where a StructKeyExists would do.

Suggested approach (staged — each stage independently shippable, ordered by risk)

Stage 1 — lock-free warm fast path in model()/controller() (low risk; candidate first PR). Before calling $doubleCheckedLock, add a guarded direct lookup, e.g. for model():

if (
    StructKeyExists(application, "wheels")
    && StructKeyExists(application.wheels, "models")
    && StructKeyExists(application.wheels.models, arguments.name)
) {
    return application.wheels.models[arguments.name];
}

(controllers struct for controller(), preserving the params branch that calls $createControllerObject.) Semantically identical to the existing unlocked first check inside $doubleCheckedLock; removes the reflective dispatches per warm call. The full StructKeyExists chain is required for early-bootstrap/reload windows where application.wheels.models may not exist yet, and the change must not bypass $clearModelInitializationCache semantics.

Stage 2 — memoize the integration product per base path. At first use, under a named lock, run one directoryList + createObject + getMetaData pass per base path (wheels.model, wheels.controller, wheels.view) and store a functionName → function-reference map in application scope (same precedent as application.wheels.mixins, which already stores function refs). Rewrite Model.cfc / Controller.cfc $integrateComponents to copy from the cached map into variables/this, preserving the existing collision rules exactly — note they differ per class:

  • Model.cfc:562-593 assigns super<name> into variables/this when the method already exists (else-branch), and super-prefixes under $willBeOverriddenByMixin.
  • Controller.cfc:165-193 silently skips on collision and only super-prefixes under $willBeOverriddenByMixin (Controller.cfc:195-213).

Apply the identical public-only access filter (cross-engine invariant 7). Must prove per engine that a function reference harvested from instance A and assigned into instance B's variables/this binds to B's variables at call time — the current code already relies on this (refs come from a fresh throwaway component), so behavior should be identical, but verify on Lucee 5/6/7, Adobe 2018/2021/2023/2025, BoxLang.

Stage 3 — reuse the cached application[appKey].PluginObj (Global.cfc:3072) instead of new wheels.Plugins() at EventMethods.cfc:156, Controller.cfc:216, Model.cfc:650, Dispatch.cfc:809, Test.cfc:802. Prerequisites: convert the unscoped $wheels.* scratch writes in $initializeMixins (Plugins.cfc:792-840) to local.-scoped so a shared instance is thread-safe; remove the now-no-op StructDelete(variablesScope, "$wheels"); hoist the EventMethods construction inside the mixins-nonempty guard. Keep the IsDefined("application") guards for Test.cfc/CLI call sites where application may be undefined.

Stage 4 — memoize Injector.cfc $resolveInitArguments (Injector.cfc:290) per component dot-path (init metadata is static per class); invalidate on reload. Whether to also fix the inherited-init() blind spot (walk extends) is a separate behavioral decision — it makes auto-wiring start applying where it silently never did (see review finding DI13).

Benchmark harness (for the issue thread, not specs): time findAll(returnAs="objects") over 500 rows and a 200-request warm controller loop, before/after, on Lucee 7 + Adobe 2023 via tools/test-matrix.sh containers.

Why this needs a design pass (not a drive-by PR)

This is the framework's hottest path with HIGH cross-engine regression surface:

  1. Invariant 7 — only public $-prefixed methods integrate; the memoized map must apply the identical access filter or BoxLang will pass while Lucee/Adobe break (precedent: 8f35045a0 made $subscribe* public for exactly this).
  2. Function-reference rebinding — copying refs from a cached map into a new instance's scopes must bind to the receiving instance's variables at call time on every engine. Same mechanism the current code uses, but it must be proven, not assumed — especially on BoxLang and Adobe 2021/2023.
  3. Invariant 2 — Adobe CF rejects calling function members on application scope. The cached map may only be a storage vehicle (copy-then-call), never called in place; application.wheels.mixins is the existing precedent.
  4. Shared PluginObj = shared variables scope — the unscoped $wheels.* writes are a data race once shared; they must be local-scoped first.
  5. Collision semantics must not drift — Model's per-instance super-prefix-on-collision differs from Controller's $willBeOverriddenByMixin gate; memoization must preserve each verbatim or plugin override chains (superFoo) break silently.
  6. Bootstrap/reload windows — the Stage 1 fast path must guard the full StructKeyExists chain and not bypass $clearModelInitializationCache.
  7. No inline closures as constructor named args anywhere in new code (kills entire TestBox bundles on Adobe); no reserved-scope parameter names.
  8. Reload must invalidate every new cache (integration map, $resolveInitArguments memo). Lesson from feat(migrator): enrich wheels_migrator_versions with name + applied_at (#2780) #2800: never trust a we-did-this-once flag for state that can be reset out-of-band — re-probe.

Acceptance criteria

  • Stage 1: warm model()/controller() return identical objects as before; cold path, bootstrap, and reload windows unaffected; the params branch of controller() still calls $createControllerObject.
  • Stage 2: integration product built once per base path under a named lock; per-class collision rules (Model super-prefix-on-collision vs Controller skip + $willBeOverriddenByMixin) preserved verbatim; public-only filter identical to today; map stored as data only (copy-then-call).
  • Stage 2: function-reference rebinding proven by spec on Lucee 5/6/7, Adobe 2018/2021/2023/2025, BoxLang before merge.
  • Stage 3: $initializeMixins scratch writes local.-scoped; shared PluginObj reused at all five call sites; EventMethods construction hoisted inside the mixins guard; IsDefined("application") guards retained.
  • Stage 4: $resolveInitArguments memoized per dot-path; memo invalidated on reload.
  • ?reload=true invalidates every new cache.
  • Full matrix green: tools/test-matrix.sh --all (and the targeted Adobe 2023 + Lucee 7 MySQL runs during development).
  • Before/after benchmark numbers (500-row findAll(returnAs="objects"), 200-request warm controller loop) posted on this issue for Lucee 7 and Adobe 2023.

Source

Internal multi-agent framework review, 2026-06-09 — finding T9 (mixin-integration-memoize), i.e. Top 10 item #9 "Per-request / per-row mixin re-integration and reflective dispatch tax", consolidating detail findings M10, C14, DC12, DC14, DI4, and DI13. Triage classified this as needing design/discussion rather than an autonomous PR; Stage 1 is the one low-risk separable piece that could become a small first PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    performancePerformance work (profiling, hot-path optimization)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions