Skip to content

Commit ef02be9

Browse files
bpamiriPeter Amirigithub-actions[bot]
authored
perf: cold-start warm-up, O(1) protected-method gate, schema cache, debug-bar slimming (#3210)
* perf: cold-start warm-up, O(1) protected-method gate, schema cache, debug-bar slimming Profiling-driven optimizations. JFR + ab profiling showed cold start is ~85% Lucee CFML->bytecode compilation (bootstrap logic is only ~20ms) while warm serving is already ~0.4ms; these target the cold path and a couple of clean hot-path/dev wins, none changing public APIs. - Dispatch gate (controller/processing.cfc): test the protected-helper set with an O(1) StructKeyExists lookup instead of an O(n) ListFindNoCase scan. A companion application.wheels.protectedControllerMethodsLookup struct-as-set is built once at app start alongside the retained comma-list; case-insensitive matching is unchanged. - Column metadata (databaseAdapters/Base.cfc): memoize cfdbinfo "columns" per datasource+table in application.wheels.cache.schema when cacheDatabaseSchema is on. Rebuilt on reload, so schema changes are still picked up on reload. - Dev debug bar (events/onrequestend/debug.cfm): externalize the static CSS/JS to vendor/wheels/public/assets/{css,js}/debugbar.* (eliminating the CFML ##-escaping footgun) and collapse inter-tag whitespace. Dev-only; production unaffected. - Scaffold (cli templates): ship a /up liveness/warm-up endpoint that `wheels deploy`'s healthcheck probes before cutover, plus production-config warm-up recipe and inspectTemplate=never guidance. Signed-off-by: Peter Amiri <petera@pai.com> * docs(changelog): reference PR #3210 in perf fragments Signed-off-by: Peter Amiri <petera@pai.com> * chore(web): refresh visual baseline(s) (all) Manually triggered baseline refresh via .github/workflows/refresh-visual-baselines.yml on branch peter/perf-cold-start-serving-followups. Run when an intentional content/layout change makes the visual-regression check fail. The new PNG(s) under web/tests/visual-baselines/ are now the expected rendering; re-run the failing visual-regression job to flip the check green. * fix(model): move schema column cache out of the time-based cache namespace Addresses the bot reviewer on #3210. The column-metadata cache stored raw query objects under application.wheels.cache.schema, but every application.wheels.cache.* category is owned by the time-based cull/count machinery: $cacheCount() sums StructCount across all categories toward maximumItemsToCache, and the cull dereferences `.expiresAt` on every entry with no type guard (Global.cfc:904). A raw query has no expiresAt, so once the global cache filled (page/partial/query caching, on by default in production) and a cull was due, it would throw when it reached a schema entry. - Move the cache to a sibling top-level key application.wheels.schemaColumnCache (set in onapplicationstart next to, not under, cache) so the cull/count never walk it. Matches the stated intent: persists for the app lifetime, rebuilt on reload. - Mirror $getFromCache's defensive shape: wrap the read in try/catch and Duplicate() the cached query on store and on return, so a concurrent struct read can't surface a partial value and no caller can mutate the cached query in place (the first review's non-blocking note). - Update schemaColumnCacheSpec and the changelog fragment for the new key. Signed-off-by: Peter Amiri <petera@pai.com> --------- Signed-off-by: Peter Amiri <petera@pai.com> Co-authored-by: Peter Amiri <petera@pai.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 35f5ebe commit ef02be9

18 files changed

Lines changed: 298 additions & 94 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- The development debug bar's static CSS and JavaScript are now maintained as standalone files (`vendor/wheels/public/assets/css/debugbar.css`, `vendor/wheels/public/assets/js/debugbar.js`) and included into the bar, eliminating the CFML `##`-escaped inline blocks (a documented "unescaped `#` crashes the suite" hazard) and trimming the per-response debug payload via inter-tag whitespace collapse. The bar remains development-only and unchanged in production (#3210)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- New apps scaffolded with `wheels new` now ship a `/up` liveness/warm-up endpoint (`app/controllers/Up.cfc` + route). `wheels deploy`'s proxy healthcheck already probes `/up` before traffic cutover, so the dispatch → controller → render path is compiled on a freshly deployed node before the first real visitor — moving the one-time cold-start compile (the bulk of first-request latency) off user traffic. The production-config guide documents the warm-up recipe and recommends setting the engine's template-inspection mode to `never` in production (#3210)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- The per-request action-dispatch gate now tests the protected-helper list with an O(1) `StructKeyExists` lookup instead of an O(n) `ListFindNoCase` scan over the ~100-250 framework helper names. A companion `application.wheels.protectedControllerMethodsLookup` struct-as-set is built once at application start alongside the existing comma-list (which is retained); case-insensitive matching is unchanged (#3210)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Database column metadata is now memoized per datasource+table in `application.wheels.schemaColumnCache` when `cacheDatabaseSchema` is on (the default). Previously every model class init issued a fresh `cfdbinfo type="columns"` JDBC catalog round-trip — a significant first-request cost on remote or wide-schema databases, re-paid on every reload and for every model sharing a table. The cache is rebuilt on reload, so schema changes are still picked up on reload (the same contract as the model and controller config caches) (#3210)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
component extends="Controller" {
2+
3+
/**
4+
* Liveness / warm-up endpoint.
5+
*
6+
* `wheels deploy`'s proxy healthcheck probes `/up` before flipping traffic
7+
* to a freshly deployed node (the default healthcheck path), and load
8+
* balancers can use it as a readiness check. Returning 200 here also
9+
* compiles the dispatch -> controller -> render path on the new host, so the
10+
* first real visitor sees warm latency instead of the one-time first-request
11+
* compile (which is otherwise the bulk of cold-start time).
12+
*
13+
* To warm your hottest ORM metadata too, touch your key models here before
14+
* cutover, e.g.:
15+
*
16+
* model("Post").count();
17+
*
18+
* Keep this action read-only, cheap, and free of authentication so the probe
19+
* is never blocked.
20+
*/
21+
function index() {
22+
renderText("OK");
23+
}
24+
25+
}

cli/lucli/templates/app/config/routes.cfm

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
mapper()
88
// CLI-Appends-Here
99
10+
// Liveness / warm-up endpoint. `wheels deploy`'s proxy healthcheck probes
11+
// `/up` before traffic cutover; a 200 here also compiles the request path
12+
// so the first real visitor gets warm latency. See app/controllers/Up.cfc.
13+
.get(name="up", to="up##index")
14+
1015
// The "wildcard" call below enables automatic mapping of "controller/action" type routes.
1116
// This way you don't need to explicitly add a route every time you create a new action in a controller.
1217
.wildcard()

vendor/wheels/Global.cfc

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4389,6 +4389,24 @@ return local.$wheels;
43894389
return protectedMethods;
43904390
}
43914391

4392+
/**
4393+
* Convert the comma-list returned by `$buildProtectedControllerMethods()`
4394+
* into a struct-as-set so `$callAction()` can perform an O(1)
4395+
* `StructKeyExists` membership test on the per-request dispatch hot path
4396+
* instead of an O(n) `ListFindNoCase` scan over ~100-250 helper names.
4397+
* CFML struct keys are case-insensitive by default, preserving the prior
4398+
* `ListFindNoCase` semantics (an action named `ENV` is still rejected like
4399+
* `env`). Stored on `application.wheels.protectedControllerMethodsLookup`
4400+
* alongside the list, which is retained for callers expecting that shape.
4401+
*/
4402+
public struct function $protectedControllerMethodsLookup(required string methods) {
4403+
var lookup = {};
4404+
for (var name in ListToArray(arguments.methods)) {
4405+
lookup[name] = true;
4406+
}
4407+
return lookup;
4408+
}
4409+
43924410
/**
43934411
* Re-evaluate the given global-includes file into `application.wo`'s
43944412
* variables/this scope. Invoked from the bare `?reload=true` soft-reload

vendor/wheels/controller/processing.cfc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ component {
135135
* Internal function.
136136
*/
137137
public void function $callAction(required string action) {
138-
if (Left(arguments.action, 1) == "$" || ListFindNoCase(application.wheels.protectedControllerMethods, arguments.action)) {
138+
if (Left(arguments.action, 1) == "$" || StructKeyExists(application.wheels.protectedControllerMethodsLookup, arguments.action)) {
139139
// A helper-named or $-prefixed action is treated exactly like a
140140
// missing action: it 404s (see #2845 and CLAUDE.md Anti-Pattern 8).
141141
// Route through $throwErrorOrShow404Page — mirroring RecordNotFound /

vendor/wheels/databaseAdapters/Base.cfc

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,32 @@ component output=false extends="wheels.Global"{
417417
local.args.username = variables.username;
418418
local.args.password = variables.password;
419419
local.args.table = arguments.tableName;
420+
421+
// Column metadata is a JDBC catalog round-trip (cfdbinfo type="columns")
422+
// fetched once per model class. On remote / wide-schema databases that
423+
// round-trip dominates first-request latency, and it is otherwise re-paid
424+
// on every reload and for every model sharing a table. When
425+
// cacheDatabaseSchema is on, memoize the result per datasource+table in
426+
// application.wheels.schemaColumnCache. A reload rebuilds the application
427+
// scope, so schema changes are still picked up on reload — the same
428+
// contract as the model and controller config caches. The cache lives
429+
// OUTSIDE application.wheels.cache.* on purpose: those categories hold
430+
// {value, expiresAt} envelopes walked by the time-based cull, which would
431+
// throw on a raw query. The read mirrors $getFromCache (try/catch +
432+
// Duplicate) so a concurrent struct read can't surface a partial value and
433+
// a caller can't mutate the cached query in place. (perf)
434+
local.cacheSchema = $get("cacheDatabaseSchema");
435+
if (local.cacheSchema) {
436+
local.cacheKey = Hash(variables.dataSource & Chr(31) & arguments.tableName);
437+
try {
438+
if (StructKeyExists(application.wheels.schemaColumnCache, local.cacheKey)) {
439+
return Duplicate(application.wheels.schemaColumnCache[local.cacheKey]);
440+
}
441+
} catch (any e) {
442+
// fall through to a fresh catalog lookup on any concurrent-read hiccup
443+
}
444+
}
445+
420446
if ($get("showErrorInformation")) {
421447
try {
422448
local.rv = $getColumnInfo(argumentCollection = local.args);
@@ -430,6 +456,14 @@ component output=false extends="wheels.Global"{
430456
} else {
431457
local.rv = $getColumnInfo(argumentCollection = local.args);
432458
}
459+
460+
if (local.cacheSchema) {
461+
// Store an isolated copy so the cached entry can never be mutated via a
462+
// reference handed to an earlier caller.
463+
lock name="wheels.schemaColumnCache" type="exclusive" timeout="10" {
464+
application.wheels.schemaColumnCache[local.cacheKey] = Duplicate(local.rv);
465+
}
466+
}
433467
return local.rv;
434468
}
435469

vendor/wheels/events/onapplicationstart.cfc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ component {
109109
}
110110
application.$wheels.controllers = {};
111111
application.$wheels.models = {};
112+
// Per-app column-metadata cache (see databaseAdapters/Base.cfc $getColumns).
113+
// Deliberately a SIBLING of `cache`, not a `cache.*` category: it stores raw
114+
// query objects that live for the application lifetime, whereas every
115+
// `cache.*` category holds {value, expiresAt} envelopes that the cull/count
116+
// machinery ($addToCache / $cacheCount) walks and dereferences `.expiresAt`
117+
// on. Putting schema queries under `cache.*` makes the cull throw.
118+
application.$wheels.schemaColumnCache = {};
112119
application.$wheels.helperFileCache = {};
113120
application.$wheels.layoutFileCache = {};
114121
application.$wheels.existingObjectFiles = {};
@@ -400,6 +407,13 @@ component {
400407
// helpers like env(), model(), redirectTo() are never URL-invokable.
401408
application.$wheels.protectedControllerMethods = application.wo.$buildProtectedControllerMethods();
402409

410+
// Companion struct-as-set for O(1) membership checks on the dispatch hot
411+
// path (see $callAction()); the comma-list above is kept for callers that
412+
// expect that shape.
413+
application.$wheels.protectedControllerMethodsLookup = application.wo.$protectedControllerMethodsLookup(
414+
application.$wheels.protectedControllerMethods
415+
);
416+
403417
// Enable the main GUI Component
404418
if (application.$wheels.enablePublicComponent) {
405419
application.$wheels.public = application.wo.$createObjectFromRoot(path = "wheels", fileName = "Public", method = "$init");

0 commit comments

Comments
 (0)