Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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