Skip to content

perf(controller): apply a per-class cached method integration instead of the per-method loop - #4166

Merged
bpamiri merged 3 commits into
developfrom
peter/controller-integration-cache
Oct 5, 2026
Merged

bpamiri merged 3 commits into
developfrom
peter/controller-integration-cache

Conversation

@bpamiri

@bpamiri bpamiri commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4149

Summary

Every request builds a controller instance, and Controller.init() mixed the framework's controller and view methods into it one at a time: about 550 {name, ref} entries, each with two StructKeyExists checks and two writes. On RustCFML that was most of a bare dispatch (#4149).

Which names that loop adds, and which super<name> aliases it writes, depend only on the controller class:

  • the integration plans and the mixin-override set are per application;
  • the names already present before integration come from the class's own inheritance chain.

So each class now records the outcome once, and every later instance applies it:

StructAppend(variables, result.adds, false);   // never overwrite a name the instance has
StructAppend(this, result.adds, false);
StructAppend(variables, result.supers, true);  // super<name> aliases always overwrite, as the loop did
StructAppend(this, result.supers, true);

Details:

Measurements

controller("test", {controller: "test", action: "index"}), which builds a full instance, timed per call. Each figure is the median of 3 runs of 200 calls after 20 warm-up calls, with the cache off (the loop) and on, in the same process on the same machine (Apple M3 Ultra, SQLite):

Engine Loop Cached Speed-up
RustCFML v0.693.0 (CI pin) 4,760 µs 1,420 µs 3.4×
RustCFML v0.708.1 4,620 µs 1,230 µs 3.8×
Adobe ColdFusion 2023 2,260 µs 860 µs 2.6×
Adobe ColdFusion 2025 1,710 µs 780 µs 2.2×
Lucee 7 580 µs 325 µs 1.8×
BoxLang 4,685 µs 3,630 µs 1.3×

The RustCFML loop figure matches #4149's 4.47 ms.

Test plan

  • New vendor/wheels/tests/specs/controller/ControllerIntegrationCacheSpec.cfc:
    • the same public keys with the cache on and off, for a controller that overrides linkTo() (superOverride), one that uses super.linkTo() (superLinkDot) and a plain one (test);
    • an override and its superLinkTo() delegation give identical output on and off;
    • super.linkTo() from an override gives the identical outcome (result or error) on and off;
    • one record per class, reused, and recording linkTo as an override with superLinkTo as an alias;
    • a missing override falls back to the loop and rebuilds the record;
    • no record with the cache off.
  • Controller specs, all five engines (wheels.tests.specs.controller, SQLite): 597 pass / 0 fail / 0 error on Lucee 7, Adobe 2023, Adobe 2025, BoxLang and RustCFML v0.693.0; the new spec is 6/6 everywhere.
  • Full core suite, Lucee 7 + SQLite: all specs pass, including the 6 new ones, apart from one spec unrelated to this change that fails the same way on a clean develop checkout on this machine.
  • After merging develop (for 4.2): controller specs (SQLite) 609 / 0 / 0 on Lucee 7, Lucee 6, Adobe 2023, Adobe 2025 and BoxLang; full core suite, Lucee 7 + SQLite: 6668 passed; guides build passes.
  • compat-matrix on the PR head.
  • Complexity gate.

🤖 Generated with Claude Code

… of the per-method loop

Controller.init() mixed about 550 controller and view methods into every new
instance, one StructKeyExists pair and two writes at a time. Which names it
adds and which super<name> aliases it writes depend only on the class, so each
class now records that once and later instances apply it with StructAppend:
adds without overwriting, super<name> aliases with overwriting, as the loop
did. If a name the record treats as an override is missing on an instance,
that instance takes the loop and the record is rebuilt.

The record lives in application.wheels.controllerIntegration and is cleared
on reload, with the controller cache, and when an integration plan is rebuilt
for null refs (#3457). Development keeps the loop (cacheControllerIntegration
is false there), so methods edited without a reload still apply.

Refs #4149

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri

bpamiri commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

rev1-r1 review @ 4fed451 (draft, 4.3): APPROVE on code; merge gated on compat-matrix run 37103968629 and on 4.2.0 shipping.

  • $simulateIntegration() reproduces $integrateFunctions() decision for decision:

    • an absent name → adds, written to both variables and this;
    • a present name → super<name>, recorded as a collision;
    • an override-set name → super<name> as well;
    • a name added earlier in the same pass counts as present, as it would after the loop's own write.

    Applying adds with StructAppend(…, false) and supers with StructAppend(…, true) gives the same keys, refs and last-write-wins order as the per-method loop.

  • Correct reuse across instances: the plans, the mixin-override set and the names a class defines before integration are fixed per class, so keying the result by the class's full name is sound. $integrationCollisionsPresent() catches the one way an instance can differ from its class's record (an override no longer present): that instance takes the loop and the record is dropped. The record is also cleared on reload, on a controller-cache clear and whenever an integration plan is rebuilt, so it never holds refs from an old plan.

  • Development keeps the loop (cacheControllerIntegration is false there), so edited controllers behave exactly as today. Building outside the lock and storing under it is a benign race: two builders produce the same record.

  • Specs: ControllerIntegrationCacheSpec compares cached against loop results key for key, including an override plus super.method() with the cache on and off.

LOW (edge, doc-worthy rather than code): an instance that gains an extra method name at construction, beyond what its class defines (e.g. a pseudo-constructor that conditionally assigns a function to variables), keeps that function, because adds is applied without overwrite. But it won't get the super<name> alias the loop would have written, since the class record saw that name as absent. That's rare, and only matters if the controller calls super<name>(). A one-line note next to cacheControllerIntegration ("turn off if your controllers define methods conditionally at construction") would cover it.

@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Wheels Test Results

216 807 tests   212 683 ✅  1h 6m 52s ⏱️
 16 576 suites    4 124 💤
     32 files          0 ❌

Results for commit 9d662d6.

♻️ This comment has been updated with latest results.

bpamiri and others added 2 commits October 4, 2026 20:55
…ng guide

Adds a "Controller method integration cache" section: what the setting
does, that it is on outside development, and when to turn it off (a
controller that defines a method conditionally at construction and calls
super<name>() for it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri bpamiri added this to the v4.2.0 milestone Oct 5, 2026
@bpamiri
bpamiri marked this pull request as ready for review October 5, 2026 04:03
@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Final: APPROVE @ 9d662d6 (lifted into 4.2; matrix 37261944917, all 32 legs read)

Delta since my approval at 4fed451

  • The code is identical. The develop merge is clean.
  • onapplicationstart.cfc keeps fix(test): default coreTestDataSourceName after the settings load #4298's post-settings coreTestDataSourceName default, with no early default.
  • The new v4-2-0 caching-guide section "Controller method integration cache" covers my earlier LOW accurately:
    • cacheControllerIntegration defaults to environment != "development" (events/init/caching.cfm:27);
    • a reload clears the per-class records;
    • the turn-it-off case is a method defined conditionally at construction plus a super<name>() call.

Matrix

  • 32/32 legs have 0 fail, 0 error, with 518 bundles each.
  • ControllerIntegrationCacheSpec passes 6/6 with 0 skipped in every leg (lucee6/7, adobe2023/2025, boxlang × every database).
  • The set of skipped specs is identical to a develop-based run (perf(router): index dynamic routes by method and first two path segments #4183's matrix 37261921912) on the legs I compared: lucee7-mysql, adobe2025-sqlite and boxlang-postgres.

No open findings.

@bpamiri
bpamiri merged commit 2b223c2 into develop Oct 5, 2026
75 checks passed
@bpamiri
bpamiri deleted the peter/controller-integration-cache branch October 5, 2026 05:04
bpamiri added a commit that referenced this pull request Oct 5, 2026
…cache and exact wide numbers (#4319)

Add 4.1 to 4.2 entries for #4166 (cacheControllerIntegration) and for
#4173/#4190 (wide numbers bound exactly), and end four fragments with a
period.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

perf(controller): apply a per-class precomputed method struct instead of the per-instance mixin loop

1 participant