diff --git a/changelog.d/debugbar-externalized-assets.changed.md b/changelog.d/debugbar-externalized-assets.changed.md new file mode 100644 index 0000000000..b6b7dc00dd --- /dev/null +++ b/changelog.d/debugbar-externalized-assets.changed.md @@ -0,0 +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) diff --git a/changelog.d/deploy-warmup-up-endpoint.added.md b/changelog.d/deploy-warmup-up-endpoint.added.md new file mode 100644 index 0000000000..6f5a35f855 --- /dev/null +++ b/changelog.d/deploy-warmup-up-endpoint.added.md @@ -0,0 +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) diff --git a/changelog.d/protected-methods-o1-lookup.performance.md b/changelog.d/protected-methods-o1-lookup.performance.md new file mode 100644 index 0000000000..520c67c693 --- /dev/null +++ b/changelog.d/protected-methods-o1-lookup.performance.md @@ -0,0 +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) diff --git a/changelog.d/schema-column-cache.performance.md b/changelog.d/schema-column-cache.performance.md new file mode 100644 index 0000000000..4f27aae46f --- /dev/null +++ b/changelog.d/schema-column-cache.performance.md @@ -0,0 +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) diff --git a/cli/lucli/templates/app/app/controllers/Up.cfc b/cli/lucli/templates/app/app/controllers/Up.cfc new file mode 100644 index 0000000000..8547cb3069 --- /dev/null +++ b/cli/lucli/templates/app/app/controllers/Up.cfc @@ -0,0 +1,25 @@ +component extends="Controller" { + + /** + * Liveness / warm-up endpoint. + * + * `wheels deploy`'s proxy healthcheck probes `/up` before flipping traffic + * to a freshly deployed node (the default healthcheck path), and load + * balancers can use it as a readiness check. Returning 200 here also + * compiles the dispatch -> controller -> render path on the new host, so the + * first real visitor sees warm latency instead of the one-time first-request + * compile (which is otherwise the bulk of cold-start time). + * + * To warm your hottest ORM metadata too, touch your key models here before + * cutover, e.g.: + * + * model("Post").count(); + * + * Keep this action read-only, cheap, and free of authentication so the probe + * is never blocked. + */ + function index() { + renderText("OK"); + } + +} diff --git a/cli/lucli/templates/app/config/routes.cfm b/cli/lucli/templates/app/config/routes.cfm index 542c3768b8..283b3af077 100644 --- a/cli/lucli/templates/app/config/routes.cfm +++ b/cli/lucli/templates/app/config/routes.cfm @@ -7,6 +7,11 @@ mapper() // CLI-Appends-Here + // Liveness / warm-up endpoint. `wheels deploy`'s proxy healthcheck probes + // `/up` before traffic cutover; a 200 here also compiles the request path + // so the first real visitor gets warm latency. See app/controllers/Up.cfc. + .get(name="up", to="up##index") + // The "wildcard" call below enables automatic mapping of "controller/action" type routes. // This way you don't need to explicitly add a route every time you create a new action in a controller. .wildcard() diff --git a/vendor/wheels/Global.cfc b/vendor/wheels/Global.cfc index 4b68558991..5e0cca6a8c 100644 --- a/vendor/wheels/Global.cfc +++ b/vendor/wheels/Global.cfc @@ -4389,6 +4389,24 @@ return local.$wheels; return protectedMethods; } + /** + * Convert the comma-list returned by `$buildProtectedControllerMethods()` + * into a struct-as-set so `$callAction()` can perform an O(1) + * `StructKeyExists` membership test on the per-request dispatch hot path + * instead of an O(n) `ListFindNoCase` scan over ~100-250 helper names. + * CFML struct keys are case-insensitive by default, preserving the prior + * `ListFindNoCase` semantics (an action named `ENV` is still rejected like + * `env`). Stored on `application.wheels.protectedControllerMethodsLookup` + * alongside the list, which is retained for callers expecting that shape. + */ + public struct function $protectedControllerMethodsLookup(required string methods) { + var lookup = {}; + for (var name in ListToArray(arguments.methods)) { + lookup[name] = true; + } + return lookup; + } + /** * Re-evaluate the given global-includes file into `application.wo`'s * variables/this scope. Invoked from the bare `?reload=true` soft-reload diff --git a/vendor/wheels/controller/processing.cfc b/vendor/wheels/controller/processing.cfc index 2e0fdd8fcc..7cb842d917 100644 --- a/vendor/wheels/controller/processing.cfc +++ b/vendor/wheels/controller/processing.cfc @@ -135,7 +135,7 @@ component { * Internal function. */ public void function $callAction(required string action) { - if (Left(arguments.action, 1) == "$" || ListFindNoCase(application.wheels.protectedControllerMethods, arguments.action)) { + if (Left(arguments.action, 1) == "$" || StructKeyExists(application.wheels.protectedControllerMethodsLookup, arguments.action)) { // A helper-named or $-prefixed action is treated exactly like a // missing action: it 404s (see #2845 and CLAUDE.md Anti-Pattern 8). // Route through $throwErrorOrShow404Page — mirroring RecordNotFound / diff --git a/vendor/wheels/databaseAdapters/Base.cfc b/vendor/wheels/databaseAdapters/Base.cfc index 6daa3e40ab..1f102c4fa4 100755 --- a/vendor/wheels/databaseAdapters/Base.cfc +++ b/vendor/wheels/databaseAdapters/Base.cfc @@ -417,6 +417,32 @@ component output=false extends="wheels.Global"{ local.args.username = variables.username; local.args.password = variables.password; local.args.table = arguments.tableName; + + // Column metadata is a JDBC catalog round-trip (cfdbinfo type="columns") + // fetched once per model class. On remote / wide-schema databases that + // round-trip dominates first-request latency, and it is otherwise re-paid + // on every reload and for every model sharing a table. When + // cacheDatabaseSchema is on, memoize the result per datasource+table in + // application.wheels.schemaColumnCache. A reload rebuilds the application + // scope, so schema changes are still picked up on reload — the same + // contract as the model and controller config caches. The cache lives + // OUTSIDE application.wheels.cache.* on purpose: those categories hold + // {value, expiresAt} envelopes walked by the time-based cull, which would + // throw on a raw query. The read mirrors $getFromCache (try/catch + + // Duplicate) so a concurrent struct read can't surface a partial value and + // a caller can't mutate the cached query in place. (perf) + local.cacheSchema = $get("cacheDatabaseSchema"); + if (local.cacheSchema) { + local.cacheKey = Hash(variables.dataSource & Chr(31) & arguments.tableName); + try { + if (StructKeyExists(application.wheels.schemaColumnCache, local.cacheKey)) { + return Duplicate(application.wheels.schemaColumnCache[local.cacheKey]); + } + } catch (any e) { + // fall through to a fresh catalog lookup on any concurrent-read hiccup + } + } + if ($get("showErrorInformation")) { try { local.rv = $getColumnInfo(argumentCollection = local.args); @@ -430,6 +456,14 @@ component output=false extends="wheels.Global"{ } else { local.rv = $getColumnInfo(argumentCollection = local.args); } + + if (local.cacheSchema) { + // Store an isolated copy so the cached entry can never be mutated via a + // reference handed to an earlier caller. + lock name="wheels.schemaColumnCache" type="exclusive" timeout="10" { + application.wheels.schemaColumnCache[local.cacheKey] = Duplicate(local.rv); + } + } return local.rv; } diff --git a/vendor/wheels/events/onapplicationstart.cfc b/vendor/wheels/events/onapplicationstart.cfc index 2aa07d0751..30195be304 100644 --- a/vendor/wheels/events/onapplicationstart.cfc +++ b/vendor/wheels/events/onapplicationstart.cfc @@ -109,6 +109,13 @@ component { } application.$wheels.controllers = {}; application.$wheels.models = {}; + // Per-app column-metadata cache (see databaseAdapters/Base.cfc $getColumns). + // Deliberately a SIBLING of `cache`, not a `cache.*` category: it stores raw + // query objects that live for the application lifetime, whereas every + // `cache.*` category holds {value, expiresAt} envelopes that the cull/count + // machinery ($addToCache / $cacheCount) walks and dereferences `.expiresAt` + // on. Putting schema queries under `cache.*` makes the cull throw. + application.$wheels.schemaColumnCache = {}; application.$wheels.helperFileCache = {}; application.$wheels.layoutFileCache = {}; application.$wheels.existingObjectFiles = {}; @@ -400,6 +407,13 @@ component { // helpers like env(), model(), redirectTo() are never URL-invokable. application.$wheels.protectedControllerMethods = application.wo.$buildProtectedControllerMethods(); + // Companion struct-as-set for O(1) membership checks on the dispatch hot + // path (see $callAction()); the comma-list above is kept for callers that + // expect that shape. + application.$wheels.protectedControllerMethodsLookup = application.wo.$protectedControllerMethodsLookup( + application.$wheels.protectedControllerMethods + ); + // Enable the main GUI Component if (application.$wheels.enablePublicComponent) { application.$wheels.public = application.wo.$createObjectFromRoot(path = "wheels", fileName = "Public", method = "$init"); diff --git a/vendor/wheels/events/onrequestend/debug.cfm b/vendor/wheels/events/onrequestend/debug.cfm index 00c262b70e..d08fab33e6 100644 --- a/vendor/wheels/events/onrequestend/debug.cfm +++ b/vendor/wheels/events/onrequestend/debug.cfm @@ -83,51 +83,9 @@ OR (StructKeyExists(url, "format") AND ListFindNoCase("json,xml,csv,pdf", url.fo - +
- +
@@ -517,53 +475,7 @@ OR (StructKeyExists(url, "format") AND ListFindNoCase("json,xml,csv,pdf", url.fo
- +
-
+
#ReReplace(local.wdbHtml, "(?m)>\s+<", "><", "all")# diff --git a/vendor/wheels/public/assets/css/debugbar.css b/vendor/wheels/public/assets/css/debugbar.css new file mode 100644 index 0000000000..0c34410bb6 --- /dev/null +++ b/vendor/wheels/public/assets/css/debugbar.css @@ -0,0 +1,41 @@ +#wheels-debugbar *{box-sizing:border-box;margin:0;padding:0;} +#wheels-debugbar{position:fixed;bottom:0;left:0;right:0;z-index:99999;font-size:13px;line-height:1.4;} +#wheels-debugbar .wdb-bar{display:flex;align-items:center;background:#1e1e2e;color:#cdd6f4;height:36px;padding:0 8px;gap:2px;border-top:1px solid #45475a;user-select:none;} +#wheels-debugbar .wdb-bar a,#wheels-debugbar .wdb-bar span{color:#cdd6f4;text-decoration:none;} +#wheels-debugbar .wdb-tab{display:flex;align-items:center;gap:5px;padding:0 10px;height:36px;cursor:pointer;border:none;background:none;color:#cdd6f4;font-size:12px;font-family:inherit;white-space:nowrap;transition:background .15s;} +#wheels-debugbar .wdb-tab:hover{background:#313244;} +#wheels-debugbar .wdb-tab.active{background:#313244;color:#89b4fa;border-top:2px solid #89b4fa;padding-top:2px;} +#wheels-debugbar .wdb-tab svg{width:14px;height:14px;fill:currentColor;flex-shrink:0;} +#wheels-debugbar .wdb-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;border-radius:9px;font-size:10px;font-weight:700;line-height:1;} +#wheels-debugbar .wdb-badge-green{background:#28a74533;color:#a6e3a1;} +#wheels-debugbar .wdb-badge-yellow{background:#ffc10733;color:#f9e2af;} +#wheels-debugbar .wdb-badge-red{background:#dc354533;color:#f38ba8;} +#wheels-debugbar .wdb-badge-blue{background:#89b4fa33;color:#89b4fa;} +#wheels-debugbar .wdb-sep{width:1px;height:20px;background:#45475a;margin:0 4px;} +#wheels-debugbar .wdb-spacer{flex:1;} +#wheels-debugbar .wdb-panel{display:none;position:fixed;bottom:36px;left:0;right:0;max-height:50vh;background:#1e1e2e;border-top:1px solid #45475a;overflow-y:auto;color:#cdd6f4;padding:0;} +#wheels-debugbar .wdb-panel.open{display:block;} +#wheels-debugbar .wdb-panel-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid #313244;position:sticky;top:0;background:#1e1e2e;z-index:1;} +#wheels-debugbar .wdb-panel-header h3{font-size:14px;font-weight:600;color:#cdd6f4;} +#wheels-debugbar .wdb-panel-body{padding:12px 16px;} +#wheels-debugbar .wdb-table{width:100%;border-collapse:collapse;} +#wheels-debugbar .wdb-table th{text-align:left;padding:6px 12px;font-size:11px;font-weight:600;color:#a6adc8;text-transform:uppercase;letter-spacing:.5px;background:#181825;border-bottom:1px solid #313244;} +#wheels-debugbar .wdb-table td{padding:6px 12px;border-bottom:1px solid #313244;font-size:12px;vertical-align:top;} +#wheels-debugbar .wdb-table tr:hover td{background:#31324433;} +#wheels-debugbar .wdb-kv{display:grid;grid-template-columns:180px 1fr;gap:0;} +#wheels-debugbar .wdb-kv dt{padding:6px 12px;font-weight:600;color:#a6adc8;font-size:12px;border-bottom:1px solid #313244;} +#wheels-debugbar .wdb-kv dd{padding:6px 12px;font-size:12px;border-bottom:1px solid #313244;word-break:break-word;} +#wheels-debugbar .wdb-kv dd code{font-family:'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;background:#313244;padding:1px 5px;border-radius:3px;font-size:11px;} +#wheels-debugbar .wdb-timing-row{display:flex;align-items:center;gap:8px;margin-bottom:6px;} +#wheels-debugbar .wdb-timing-label{width:100px;font-size:12px;color:#a6adc8;text-align:right;} +#wheels-debugbar .wdb-timing-bar-bg{flex:1;height:20px;background:#313244;border-radius:3px;overflow:hidden;position:relative;} +#wheels-debugbar .wdb-timing-bar{height:100%;border-radius:3px;display:flex;align-items:center;padding-left:6px;font-size:10px;font-weight:600;color:#1e1e2e;min-width:30px;} +#wheels-debugbar .wdb-close-btn{background:none;border:none;color:#a6adc8;cursor:pointer;font-size:18px;padding:4px 8px;border-radius:4px;line-height:1;} +#wheels-debugbar .wdb-close-btn:hover{background:#45475a;color:#cdd6f4;} +#wheels-debugbar .wdb-link-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px;padding:4px 0;} +#wheels-debugbar .wdb-link-card{display:flex;align-items:center;gap:8px;padding:10px 12px;background:#313244;border-radius:6px;color:#cdd6f4;text-decoration:none;font-size:12px;font-weight:500;transition:background .15s;} +#wheels-debugbar .wdb-link-card:hover{background:#45475a;} +#wheels-debugbar .wdb-link-card svg{width:16px;height:16px;fill:#89b4fa;flex-shrink:0;} +#wheels-debugbar .wdb-env-dot{width:8px;height:8px;border-radius:50%;display:inline-block;} +#wheels-debugbar .wdb-section{margin-bottom:16px;} +#wheels-debugbar .wdb-section-title{font-size:11px;font-weight:700;color:#89b4fa;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid #313244;} diff --git a/vendor/wheels/public/assets/js/debugbar.js b/vendor/wheels/public/assets/js/debugbar.js new file mode 100644 index 0000000000..382881954d --- /dev/null +++ b/vendor/wheels/public/assets/js/debugbar.js @@ -0,0 +1,45 @@ +(function () { + var activePanel = null; + window.wdbToggle = function (name) { + var panels = document.querySelectorAll('#wheels-debugbar .wdb-panel'); + var tabs = document.querySelectorAll('#wheels-debugbar .wdb-tab'); + if (activePanel === name) { + wdbClosePanel(); + return; + } + for (var i = 0; i < panels.length; i++) panels[i].classList.remove('open'); + var p = document.getElementById('wdb-panel-' + name); + if (p) p.classList.add('open'); + for (var j = 0; j < tabs.length; j++) tabs[j].classList.remove('active'); + var t = document.getElementById('wdb-tab-' + name); + if (t) t.classList.add('active'); + activePanel = name; + }; + window.wdbClosePanel = function () { + var panels = document.querySelectorAll('#wheels-debugbar .wdb-panel'); + var tabs = document.querySelectorAll('#wheels-debugbar .wdb-tab'); + for (var i = 0; i < panels.length; i++) panels[i].classList.remove('open'); + for (var j = 0; j < tabs.length; j++) tabs[j].classList.remove('active'); + activePanel = null; + }; + window.wdbMinimize = function () { + wdbClosePanel(); + document.getElementById('wheels-debugbar').style.display = 'none'; + document.getElementById('wdb-minimized').style.display = 'block'; + try { sessionStorage.setItem('wdb-hidden', '1'); } catch (e) {} + }; + window.wdbRestore = function () { + document.getElementById('wheels-debugbar').style.display = ''; + document.getElementById('wdb-minimized').style.display = 'none'; + try { sessionStorage.removeItem('wdb-hidden'); } catch (e) {} + }; + window.wdbEnvSwitch = function (el) { + var target = el.getAttribute('data-wdb-reload'); + if (!target) return false; + var pw = window.prompt('Enter the reload password to switch environments:'); + if (pw === null || pw === '') return false; + window.location.href = target + '&password=' + encodeURIComponent(pw); + return false; + }; + try { if (sessionStorage.getItem('wdb-hidden') === '1') wdbMinimize(); } catch (e) {} +})(); diff --git a/vendor/wheels/tests/specs/controller/protectedMethodsSpec.cfc b/vendor/wheels/tests/specs/controller/protectedMethodsSpec.cfc index fd6502746f..f710d58b2b 100644 --- a/vendor/wheels/tests/specs/controller/protectedMethodsSpec.cfc +++ b/vendor/wheels/tests/specs/controller/protectedMethodsSpec.cfc @@ -35,6 +35,39 @@ component extends="wheels.WheelsTest" { }) }) + // The dispatch hot path checks membership via an O(1) struct-as-set + // (perf) instead of an O(n) ListFindNoCase over the comma-list. The set + // must stay in lockstep with the list it is derived from. + describe("application.wheels.protectedControllerMethodsLookup", () => { + + it("is populated at application start", () => { + expect(StructCount(application.wheels.protectedControllerMethodsLookup)).toBeGT(0) + }) + + it("has one key per entry in the comma-list (case-insensitive)", () => { + for (var item in ListToArray(application.wheels.protectedControllerMethods)) { + expect(StructKeyExists(application.wheels.protectedControllerMethodsLookup, item)).toBeTrue() + } + }) + + it("matches helper names case-insensitively, like the prior ListFindNoCase", () => { + expect(StructKeyExists(application.wheels.protectedControllerMethodsLookup, "model")).toBeTrue() + expect(StructKeyExists(application.wheels.protectedControllerMethodsLookup, "MODEL")).toBeTrue() + expect(StructKeyExists(application.wheels.protectedControllerMethodsLookup, "redirectTo")).toBeTrue() + }) + + it("does not contain entries the list lacks", () => { + expect(StructCount(application.wheels.protectedControllerMethodsLookup)) + .toBe(ListLen(application.wheels.protectedControllerMethods)) + }) + + it("builds an equivalent set from a known list via the helper", () => { + var set = application.wo.$protectedControllerMethodsLookup("env,model,redirectTo") + expect(StructCount(set)).toBe(3) + expect(StructKeyExists(set, "ENV")).toBeTrue() + }) + }) + describe("$callAction action-dispatch gate", () => { beforeEach(() => { diff --git a/vendor/wheels/tests/specs/model/schemaColumnCacheSpec.cfc b/vendor/wheels/tests/specs/model/schemaColumnCacheSpec.cfc new file mode 100644 index 0000000000..6e1f2276a1 --- /dev/null +++ b/vendor/wheels/tests/specs/model/schemaColumnCacheSpec.cfc @@ -0,0 +1,70 @@ +/** + * Coverage for the column-metadata cache (perf). `$getColumns()` issues a + * cfdbinfo type="columns" JDBC catalog round-trip — once per model class and + * re-paid on every reload and for every model sharing a table. On + * remote / wide-schema databases that round-trip dominates first-request + * latency. When `cacheDatabaseSchema` is on, the resolved column query is now + * memoized per datasource+table in `application.wheels.schemaColumnCache` (rebuilt + * on framework reload, so schema changes are still picked up on reload — the + * same contract as the model/controller config caches). + */ +component extends="wheels.WheelsTest" { + + function beforeAll() { + // Snapshot the shared caches/settings so a mid-spec failure can't leave + // the rest of the suite running against a polluted cache. + if (StructKeyExists(application.wheels, "schemaColumnCache")) { + variables.$priorSchemaCache = Duplicate(application.wheels.schemaColumnCache); + } + variables.$priorCacheDatabaseSchema = application.wheels.cacheDatabaseSchema; + } + + function afterAll() { + application.wheels.schemaColumnCache = StructKeyExists(variables, "$priorSchemaCache") + ? variables.$priorSchemaCache + : {}; + application.wheels.cacheDatabaseSchema = variables.$priorCacheDatabaseSchema; + } + + function run() { + + g = application.wo; + + describe("$getColumns column-metadata cache", () => { + + it("memoizes columns per datasource+table when cacheDatabaseSchema is on", () => { + application.wheels.cacheDatabaseSchema = true; + application.wheels.schemaColumnCache = {}; + var m = g.model("author"); + var adapter = m.$assignAdapter(); + var first = adapter.$getColumns(m.tableName()); + expect(StructCount(application.wheels.schemaColumnCache)).toBeGT(0); + }); + + it("returns identical column metadata on the cached path", () => { + application.wheels.cacheDatabaseSchema = true; + application.wheels.schemaColumnCache = {}; + var m = g.model("author"); + var adapter = m.$assignAdapter(); + var fresh = adapter.$getColumns(m.tableName()); + var cached = adapter.$getColumns(m.tableName()); + expect(cached.recordCount).toBe(fresh.recordCount); + expect(cached.columnList).toBe(fresh.columnList); + }); + + it("does not populate the cache when cacheDatabaseSchema is off", () => { + application.wheels.cacheDatabaseSchema = false; + application.wheels.schemaColumnCache = {}; + var m = g.model("author"); + var adapter = m.$assignAdapter(); + var cols = adapter.$getColumns(m.tableName()); + expect(StructCount(application.wheels.schemaColumnCache)).toBe(0); + // columns still resolve correctly with caching disabled + expect(cols.recordCount).toBeGT(0); + }); + + }); + + } + +} diff --git a/web/sites/guides/src/content/docs/v4-0-0/deployment/production-config.mdx b/web/sites/guides/src/content/docs/v4-0-0/deployment/production-config.mdx index e9cf0cee48..1f4f72c0b0 100644 --- a/web/sites/guides/src/content/docs/v4-0-0/deployment/production-config.mdx +++ b/web/sites/guides/src/content/docs/v4-0-0/deployment/production-config.mdx @@ -161,7 +161,10 @@ Run through this before the first production request. Each item maps to a framew 7. **Error email is wired.** `sendEmailOnError` is auto-enabled in production (`debugging.cfm:22-25`). If `errorEmailToAddress` is empty, Wheels still tries to send — `events/EventMethods.cfc:5-13` reads the address at error time — and the mail bounces silently. Set it. 8. **URL rewriting matches the web server.** `set(URLRewriting="On")` only works if nginx/Apache/Tuckey rewrites `/posts/1` to `/index.cfm/posts/1`. If rewrites aren't configured, leave it at `"Partial"` or `"Off"`, or every link breaks. 9. **`allowEnvironmentSwitchViaUrl` is not re-enabled.** Confirm by grepping `config/production/settings.cfm` — don't set it to `true`. If you need to toggle environments, redeploy. -10. **Caches are warmed or warm fast.** First request after deploy compiles every controller/model. For latency-sensitive endpoints, hit them from the deploy script before flipping the load balancer. `cacheControllerConfig`, `cacheModelConfig`, `cacheDatabaseSchema`, and `cacheFileChecking` are always-on in every environment (`events/init/caching.cfm:3-7`), so a single warm-up request per controller is usually enough. +10. **Caches are warmed before traffic cutover.** The very first request to a freshly started node is dominated by one-time CFML-to-bytecode compilation — profiling a stock app puts roughly **85% of a ~1.2s cold start in the engine's compiler**, versus sub-millisecond once warm. That cost is paid once per process, so the goal is to move it off the first *real* visitor and onto the deploy sequence: + - Apps scaffolded with `wheels new` ship a `/up` route (`app/controllers/Up.cfc`). `wheels deploy`'s proxy healthcheck probes `/up` before flipping traffic, so the dispatch → controller → render path is compiled on the new node before it serves anyone. Touch your hottest models in that action (e.g. `model("Post").count()`) to warm their ORM column metadata too — the first query against a model is the other notable one-time cost. + - Not using `wheels deploy`? Curl your latency-sensitive endpoints from the deploy script before enabling the node at the load balancer. A single warm-up request per controller is usually enough, since `cacheControllerConfig`, `cacheModelConfig`, `cacheDatabaseSchema`, and `cacheFileChecking` are always-on in every environment (`events/init/caching.cfm:3-7`). +11. **Template inspection is set to `never` (engine-level).** In development the CFML engine re-checks each template's source timestamp on every request so edits are picked up live; in production that filesystem stat is pure per-request overhead. Set the engine's template-inspection mode to `never` so it trusts the compiled-class cache and skips the check — Lucee: admin → *Performance/Caching* → "Inspect Templates" = `Never` (or `inspectTemplate="never"` in `.CFConfig.json`); Adobe ColdFusion: enable "Trusted Cache". This is engine configuration, not a Wheels setting, so it isn't flipped by `environment="production"`. Pair it with a warm-up (item 10) and a cache-clear/reload step in your deploy so a new release's templates still recompile once. ## What production does not change diff --git a/web/tests/visual-baselines/api.png b/web/tests/visual-baselines/api.png index 4b65188349..507f04ce9e 100644 Binary files a/web/tests/visual-baselines/api.png and b/web/tests/visual-baselines/api.png differ diff --git a/web/tests/visual-baselines/guides.png b/web/tests/visual-baselines/guides.png index e3ec16100e..75ae73d563 100644 Binary files a/web/tests/visual-baselines/guides.png and b/web/tests/visual-baselines/guides.png differ