perf: cold-start warm-up, O(1) protected-method gate, schema cache, debug-bar slimming#3210
Conversation
…ebug-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>
Signed-off-by: Peter Amiri <petera@pai.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — Profiling-driven perf PR: O(1) struct-as-set gate for the protected-method dispatch check, an app-scoped column-metadata cache, debug-bar asset externalization, and a scaffolded /up warm-up endpoint. The diff is well-scoped, well-tested (protectedMethodsSpec + new schemaColumnCacheSpec), and well-documented (4 valid changelog.d fragments + production-config guide). I verified the load-bearing assumptions and found no blocking correctness, cross-engine, or security issue. Verdict: comment — two non-blocking robustness/maintainability notes below.
Conventions
1. Schema cache: unlocked read isn't guarded the way the established cache pattern is (databaseAdapters/Base.cfc:431-435).
local.cacheKey = Hash(variables.dataSource & Chr(31) & arguments.tableName);
if (StructKeyExists(application.wheels.cache.schema, local.cacheKey)) {
return application.wheels.cache.schema[local.cacheKey];
}The write is wrapped in an exclusive lock, but the read is unlocked — so the lock provides no mutual exclusion against readers, and the write is idempotent anyway (the lock is nearly moot). The framework's own cache accessor $getFromCache() (Global.cfc:929-945) deliberately wraps its unlocked read in try { … } catch (any e) {} and Duplicate()s non-simple cached values before handing them out. This new path does neither.
Not a proven bug today: I confirmed no current caller mutates the returned query — Model.cfc:115 calls .filter(...) (returns a new query) and H2Model.cfc:167-181 builds a fresh QueryNew() rather than mutating its super.$getColumns() result. So the missing Duplicate() is latent, not active. But matching $getFromCache's defensive shape (try/catch on the read; Duplicate() on store-or-return) would make this robust against the cross-engine concurrent-struct-read footgun the existing code already guards against, and against a future caller that mutates the column query in place. Worth considering before merge; not blocking.
Cross-engine
No blocker. Spot-checks that passed: $protectedControllerMethodsLookup is correctly public + $-prefixed in Global.cfc (mixin invariant #7); application.wheels is the alias of application.$wheels set at onapplicationstart.cfc:447, after both protectedControllerMethodsLookup (:407) and cache.schema (:131) are populated, so the request-time reads in processing.cfc:138 and Base.cfc:433 resolve; the cache key preserves table-name case (no lCase), matching the verbatim-case contract noted in migrator/CLAUDE.md; cfsavecontent/ReReplace/cflock are all engine-neutral.
Nit — debug.cfm:478 whitespace-collapse regex runs over the embedded <script>/<style> too.
#ReReplace(local.wdbHtml, "(?m)>\s+<", "><", "all")#Because the <cfinclude> of debugbar.css/debugbar.js happens inside the cfsavecontent, this >\s+< collapse also scans the inlined JS/CSS. It's safe as written — the bundled JS contains no > character (comparisons use < only) and the CSS uses no child combinator or < — so nothing matches inside those blocks. But it's a latent footgun: a future > arrow/child-combinator or a >-then-newline-then-< sequence in either asset could be silently mangled. Dev-only, so low stakes; flagging for awareness.
Tests
Good coverage and good hygiene. schemaColumnCacheSpec snapshots/restores cache.schema + cacheDatabaseSchema in beforeAll/afterAll so a mid-spec failure can't poison the shared suite, and covers memoization, cached-path parity, and the off-switch. protectedMethodsSpec adds list↔set parity, case-insensitivity, and a no-extra-entries (StructCount == ListLen) invariant. Both extend wheels.WheelsTest (BDD), not legacy RocketUnit.
Docs
changelog.d/ fragments use valid types (changed, added, performance×2) — no direct CHANGELOG.md edit. production-config.mdx items 10–11 document the warm-up recipe and inspectTemplate=never guidance. Complete.
Commits
Single commit / PR title perf: cold-start warm-up, O(1) protected-method gate, schema cache, debug-bar slimming — valid type, header well under 100 chars, not ALL-CAPS. Conforms to commitlint.config.js.
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — Profiling-driven perf PR: an O(1) struct-as-set gate for the protected-method dispatch check, an app-scoped column-metadata cache, debug-bar asset externalization, and a /up warm-up endpoint for wheels new. The protected-method gate, the debug-bar externalization, and the warm-up endpoint are all correct and well-tested. The column-metadata cache, however, stores raw query objects under application.wheels.cache.schema — a namespace owned by the global cache cull/count machinery, which assumes every entry is a {value, expiresAt} envelope and unconditionally dereferences .expiresAt. Once the global cache fills to maximumItemsToCache (5000) and a cull is due, the cull loop throws when it reaches a schema entry. Verdict: request changes on that one blocking correctness issue; the rest is solid.
Correctness
Blocking — schema cache poisons the global cache cull/count machinery. The new cache puts bare query objects directly into the shared application.wheels.cache struct:
vendor/wheels/events/onapplicationstart.cfc—application.\$wheels.cache.schema = {};registersschemaas a category under.cache.vendor/wheels/databaseAdapters/Base.cfc:451-455—if (local.cacheSchema) { lock name="wheels.cache.schema" type="exclusive" timeout="10" { application.wheels.cache.schema[local.cacheKey] = local.rv; // raw query, no {value, expiresAt} envelope } }
But every other producer in application.wheels.cache.* (page, partial, query, …) writes through \$addToCache(), which wraps the value as {value, expiresAt} (Global.cfc:914-922). The cull and count machinery relies on that invariant:
Global.cfc:963-964—\$cacheCount()with no category sumsStructCountacross all categories, so schema entries inflatecurrentCounttoward the 5000 cap.Global.cfc:893-909— whencurrentCount >= maximumItemsToCacheand a cull is due, the cull iteratesStructKeyArray(application.wheels.cache)(all categories, includingschema) and for every key evaluates:There is no type guard. For a&& local.now > application.wheels.cache[local.cacheCategory][local.cacheKey].expiresAtschemaentry the value is a query object with noexpiresAtcolumn, so.expiresAtthrows (column EXPIRESAT not foundon Lucee/Adobe) — and even if an engine returned null,local.now > nullis a cast error.
This is reachable in any app using page/partial/query caching (on by default per the production-config guide). maxItemsToDelete = Ceiling(5000 * 10/100) = 500, but the loop only deletes expired items; in steady state most entries have a future expiresAt, so the loop never hits 500 deletions and scans every category — including schema — before exiting, throwing inside \$addToCache() during render of cacheable content. StructKeyArray category order is non-deterministic on Lucee/Adobe, so the throw isn't reliably avoided even when few items are expired.
Fix: don't store raw values under application.wheels.cache.*. Either (a) move the schema cache to a sibling top-level key the cull doesn't walk, e.g. application.wheels.schemaColumnCache (set it in onapplicationstart next to — not under — cache), or (b) route writes/reads through \$addToCache/\$getFromCache with the standard envelope (less ideal here, since schema should persist for the app lifetime rather than expire). Option (a) matches the stated intent ("rebuilt on reload, same contract as the config caches") without colluding with the time-based cull.
Tests
protectedMethodsSpec.cfc— good coverage: list/set parity, case-insensitivity, and the helper round-trip. No gaps.schemaColumnCacheSpec.cfc— exercises memoize / cached-path parity / off-switch, but never drives the cache to themaximumItemsToCachecull path, which is exactly why the blocking issue above slipped through. After the fix, consider a spec that seeds the schema cache alongside a near-full global cache and calls\$addToCacheto assert the cull doesn't throw on schema entries.
Cross-engine
Verified clean. Spot-checked the two patterns most likely to break:
- The debug-bar
cfincludeof raw.css/.js(debug.cfm:88,478) is the same mechanism already shipping invendor/wheels/public/layout/_header_simple.cfm:58forsemantic.min.css(which is full of#hex colors). Included templates don't inherit the caller's<cfoutput>expression-parsing mode, so the#literals are emitted verbatim — this genuinely removes the##-escaping footgun as claimed. to="up##index"in the route template resolves to the literalup#indexcontroller#action form. Correct.
Conventions
Minor (non-blocking): debug.cfm:481 applies ReReplace(local.wdbHtml, "(?m)>\s+<", "><", "all") over the entire assembled bar, including dynamically-rendered panel content (params, SQL, request values). A logged value containing a literal >-whitespace-< sequence would have that whitespace collapsed in the display. It's dev-only and cosmetic, so fine to ship — just flagging that the collapse isn't limited to the static markup.
Docs / Commits
- Four
changelog.d/fragments with valid types (added,changed,performance×2) — correct fragment workflow, no direct[Unreleased]edit. production-config.mdxwarm-up + trusted-cache guidance reads well and is accurate.- Both commits conform to
commitlint.config.js(perf:anddocs(changelog):, subjects ≤ 100 chars, not ALL-CAPS).
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.
…space 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>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — Profiling-driven perf PR: O(1) struct-as-set gate for the protected-method dispatch check, an app-scoped column-metadata cache, debug-bar asset externalization, and a /up warm-up endpoint for wheels new. Re-review at head 55df686. The only new commit since the prior round (chore(web): refresh visual baseline(s)) refreshes web/tests/visual-baselines/*.png and does not touch the cache code. The blocking correctness issue from the previous round therefore still stands: the column-metadata cache stores raw query objects under application.wheels.cache.schema, a namespace the global cache cull/count machinery walks while unconditionally dereferencing .expiresAt. Verdict: request changes on that one blocking issue; everything else remains solid.
Correctness — BLOCKING (unchanged from prior round): schema cache poisons the global cache cull/count machinery. The new cache writes bare query objects into the shared application.wheels.cache struct with no {value, expiresAt} envelope:
vendor/wheels/events/onapplicationstart.cfc:131—application.$wheels.cache.schema = {};registersschemaas a sibling category alongsidepage,partial,query, etc.vendor/wheels/databaseAdapters/Base.cfc:451-453stores the raw cfdbinfo query:application.wheels.cache.schema[local.cacheKey] = local.rv;insidelock name="wheels.cache.schema" type="exclusive".local.rvis the unwrapped query from$getColumnInfo()(Base.cfc:437/449).
Every other producer under application.wheels.cache.* writes through $addToCache(), which wraps the value as {value, expiresAt} (Global.cfc:914-922). The cull and count machinery relies on that invariant and has no type guard:
Global.cfc:961-967—$cacheCount()with no category sumsStructCountacross all categories (for (local.key in application.wheels.cache)), so schema entries inflatecurrentCounttoward the cap.Global.cfc:893-906— whencurrentCount >= maximumItemsToCacheand a cull is due, the loop iteratesStructKeyArray(application.wheels.cache)(line 893 — every category,schemaincluded) and at line 904 evaluateslocal.now > application.wheels.cache[local.cacheCategory][local.cacheKey].expiresAt. For aschemaentry the value is a query with noexpiresAtcolumn, so.expiresAtthrows (column [EXPIRESAT] not foundon Lucee/Adobe). NoIsStruct/IsQueryguard precedes it.
Reachable with stock defaults (vendor/wheels/events/init/caching.cfm): cacheDatabaseSchema = true (schema entries exist after first model touch), maximumItemsToCache = 5000, cacheCullPercentage = 10, so maxItemsToDelete = Ceiling(5000 * 10/100) = 500. The cull deletes only expired items, so in steady state (most entries un-expired) the loop scans entire categories without reaching 500 deletions and walks schema before exiting — throwing inside $addToCache() mid-render of cacheable page/partial/query content. StructKeyArray category order is non-deterministic on Lucee/Adobe, so the throw is not reliably dodged even when few items are expired.
Fix (same as prior round): do not store raw values under application.wheels.cache.*. Preferred — move the schema cache to a sibling top-level key the cull never walks, e.g. set application.$wheels.schemaColumnCache = {} in onapplicationstart next to (not under) cache, and read/write/lock against that. This matches the stated "rebuilt on reload, app-lifetime" intent without colluding with the time-based cull. (Routing through $addToCache/$getFromCache is the alternative, but schema should persist for the app lifetime rather than expire, so the sibling-key approach fits better.)
Tests.
protectedMethodsSpec.cfc— solid: list/set parity, case-insensitivity,StructCount == ListLenno-extra-entries invariant. Extendswheels.WheelsTest(BDD). No gaps.schemaColumnCacheSpec.cfc— exercises memoize / cached-path parity / off-switch and cleanly snapshots+restorescache.schema/cacheDatabaseSchemainbeforeAll/afterAll. But it never drives the global cache to themaximumItemsToCachecull path — which is exactly why the blocking issue is invisible to the suite. After the fix, add a spec that seeds the schema cache alongside a near-full global cache and calls$addToCacheto assert the cull does not throw on schema entries (a regression pin for this bug).
Cross-engine. Verified clean otherwise. $protectedControllerMethodsLookup is public + $-prefixed (mixin invariant #7); application.wheels aliases application.$wheels set after both lookups are populated, so the request-time reads resolve; the schema cache key preserves table-name case (no lCase), matching the verbatim-case contract in migrator/CLAUDE.md; the debug-bar cfinclude of raw .css/.js is the same mechanism already shipping for semantic.min.css in _header_simple.cfm, so the ##-escaping footgun is genuinely removed; to="up##index" resolves to the literal up#index form.
Minor (non-blocking): debug.cfm applies a whitespace-collapse ReReplace over the entire assembled bar, including the inlined JS/CSS and dynamically-rendered panel content. Safe as written (bundled assets contain no >, dev-only), but a future >-then-whitespace-then-< sequence in an asset or logged value could be silently mangled. Flagging for awareness.
Conventions / Docs / Commits.
- Route addition
.get(name="up", to="up##index")is correctly placed before.wildcard()— custom named route ahead of the wildcard, per the documented order. Up.cfcindex()is a clean read-onlyrenderText("OK")warm-up action; comments accurately describe the deploy healthcheck probe.- Four
changelog.d/fragments with valid types (changed,added,performancex2) — correct fragment workflow, no direct[Unreleased]edit.production-config.mdxwarm-up +inspectTemplate=neverguidance is accurate. - Commits conform to
commitlint.config.js(perf:,docs(changelog):,chore(web):; subjects <= 100 chars, not ALL-CAPS).
Once the schema cache is moved off the culled application.wheels.cache.* namespace (and ideally pinned by a cull-path spec), this is good to merge.
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — Profiling-driven perf PR: an O(1) struct-as-set protected-method dispatch gate, an app-scoped column-metadata cache, debug-bar asset externalization, and a /up warm-up endpoint for wheels new. Re-review at head 47d1e8c. The sole new commit since the last round — fix(model): move schema column cache out of the time-based cache namespace — resolves the one blocking correctness issue the previous two rounds flagged. With that fixed and no new blocking finding, verdict: approve. This --approve supersedes my still-active CHANGES_REQUESTED (at 55df686); the audit trail below is the evidence.
Correctness — previously-blocking issue RESOLVED
Prior rounds blocked on the schema cache storing raw query objects under application.wheels.cache.schema, a namespace the time-based cull/count machinery walks while unconditionally dereferencing .expiresAt (Global.cfc:904) — guaranteed to throw once the global cache filled and a cull was due. Commit 47d1e8c fixes this by structural separation:
vendor/wheels/events/onapplicationstart.cfc:118— the cache is now a sibling top-level key:application.$wheels.schemaColumnCache = {};, set next to (not under)cache. The oldapplication.$wheels.cache.schema = {};line is removed (confirmed gone from the diff).vendor/wheels/Global.cfc:893— the cull iteratesStructKeyArray(application.wheels.cache), which now contains onlypage/partial/query.vendor/wheels/Global.cfc:963-966—$cacheCount()sumsStructCountover the same set. Neither ever walksschemaColumnCache, so a raw query's missing.expiresAtcan no longer be dereferenced. The bug is now impossible by construction, not merely avoided.vendor/wheels/databaseAdapters/Base.cfc:438-439, 463-464— read and write now targetapplication.wheels.schemaColumnCacheunderlock name="wheels.schemaColumnCache". The read is alias-safe:application.wheels = application.$wheelsis assigned atonapplicationstart.cfc:453, after the key is initialized at line 118.
I also confirmed local.rv is always defined when the store block runs (the showErrorInformation branch re-throws rather than falling through), so the Duplicate(local.rv) store can't hit an undefined variable.
Cross-engine
Clean. The fix commit only adds Duplicate() (universal on query objects), a try/catch (any e), and a renamed lock — no engine-specific idioms. The defensive read shape (try/catch + Duplicate) mirrors $getFromCache (Global.cfc:928-948). The non-fix portions remain as verified in prior rounds: $protectedControllerMethodsLookup is public + $-prefixed (mixin invariant #7); the debug-bar cfinclude of raw .css/.js matches the semantic.min.css mechanism in _header_simple.cfm, genuinely removing the ##-escaping footgun; to="up##index" resolves to the literal up#index form.
Tests
schemaColumnCacheSpec.cfcupdated to snapshot/restore and assert againstapplication.wheels.schemaColumnCache(lines 16-23, 37-61) — memoize / cached-path parity / off-switch all retargeted to the new key.protectedMethodsSpec.cfc— unchanged, still solid (list/set parity, case-insensitivity, no-extra-entries invariant).- Minor (non-blocking): the suggested cull-path regression pin from prior rounds is now far less necessary — the schema cache no longer shares the culled namespace, so the cull cannot reach it. A pin would only guard against a future re-merge of the namespaces; nice-to-have, not required.
Docs / Commits
changelog.d/schema-column-cache.performance.mdupdated to name the newapplication.wheels.schemaColumnCachekey — fragment workflow intact, no direct[Unreleased]edit.fix(model): move schema column cache out of the time-based cache namespaceconforms tocommitlint.config.js(validfixtype,modelscope, header <= 100 chars, not ALL-CAPS, DCO sign-off present). The commit body correctly explains the why (cull dereferences.expiresAtwith no type guard).
Good to merge. Thanks for the thorough fix and the explanatory commit body.
Profiling-driven optimizations. I profiled Wheels startup and page-serving with JDK21 JFR (attach-to-PID, 1ms sampler) + Apache Bench against the demo app (Lucee 7, SQLite).
What the profiling showed
lucee.transformer.*+ ASM)Two static-analysis "HIGH" candidates were empirically refuted (per-request
$integrateComponents/directoryList/getMetaDataare all 0% — cached at app start), so they are deliberately not touched.Changes
controller/processing.cfc,Global.cfc,onapplicationstart.cfc) — replace the per-requestListFindNoCaseover ~100–250 helper names with aStructKeyExistslookup against a companionapplication.wheels.protectedControllerMethodsLookupstruct-as-set built once at app start. The comma-list is retained; case-insensitive matching unchanged.databaseAdapters/Base.cfc,onapplicationstart.cfc) — memoizecfdbinfo type="columns"per datasource+table inapplication.wheels.cache.schemawhencacheDatabaseSchemais on (default). Rebuilt on reload, so schema changes are still picked up on reload (same contract as the model/controller config caches). Mainly helps remote/wide-schema databases.events/onrequestend/debug.cfm+ newvendor/wheels/public/assets/{css,js}/debugbar.*) — externalize the static CSS/JS to standalone files (eliminating the CFML##-escaping footgun) and collapse inter-tag whitespace. Dev-only; production unaffected. Note:##is resolved at compile time, so throughput is unchanged — this is a maintainability + footgun-removal + payload-trim change, not a throughput win.cliscaffold + docs) — new apps ship a/upliveness/warm-up endpoint (app/controllers/Up.cfc+ route) thatwheels deploy's proxy healthcheck already probes before traffic cutover, compiling the request path on a fresh node before the first real visitor. Production-config guide gains a warm-up recipe andinspectTemplate=neverguidance.Tests
protectedMethodsSpec(lookup parity + case-insensitivity),schemaColumnCacheSpec(memoization, cached-path parity, off-switch).cfincludeof.css/.jsis the same pattern as the GUI'ssemantic.min.css).No public API changes. Four
changelog.dfragments included.