Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/3213-model-instance-mixin-cache.performance.md
Original file line number Diff line number Diff line change
@@ -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)
83 changes: 30 additions & 53 deletions vendor/wheels/Controller.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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<name>`. `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(){
Expand Down
114 changes: 114 additions & 0 deletions vendor/wheels/Global.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
52 changes: 32 additions & 20 deletions vendor/wheels/Mapper.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down
Loading
Loading