Reduce front-end database queries from CMS templates, theme data and request logs - #1508
Merged
Conversation
…request logs
Production query stats on a site taking heavy bot traffic showed three hot
paths dominating database time, all of them avoidable without giving up any
functionality.
**CMS templates.** `CmsObject::loadCached()` caches parsed objects, but
`Halcyon\Builder::getCached()` validates every warm cache hit by calling
`datasource->lastModified()`, so each database-backed template still cost a
query per request. `AutoDatasource` already holds a forever-cached, correctly
invalidated manifest of the paths in each datasource; paired with the
companion Storm change, that manifest now carries modification times, so the
lookup is answered without touching the database. Filesystem datasources are
untouched and keep resolving mtimes live, so editing templates on disk still
takes effect immediately.
**Theme data.** `ThemeData::forTheme()` was memoised only in a static array,
making it one uncached query per request. The dominant caller is not
`{{ theme.* }}` in templates but the asset combiner: `cms.combiner.getCacheKey`
is registered for front-end requests only and fires inside
`CombineAssets::getCacheKey()`, which runs before the combiner cache is
consulted, so a warm combiner cache did not avoid it. The record is now cached
following the same pattern as `SettingsModel`, invalidated in `afterSave()`
and a new `afterDelete()` (`Theme::removeCustomData()` deletes the record and
had no hook). `Theme::resetCache()` also clears the in-memory instances, which
it previously left stale for long-running workers.
**Request logs.** `system_request_logs` had no index beyond its primary key,
so every 404 full-scanned a table that only ever grows -- and since `add()` is
what grows it, the cost accelerated on its own. This was 376ms average and by
far the largest share of database time. Adds the missing `(url, status_code)`
index, matching the columns `RequestLog::add()` looks up.
Also adds a composite `(source, path, deleted_at)` index to
`cms_theme_templates`, replacing the redundant standalone `source` index --
every datasource query filters on source and path together, but the two
independent single-column indexes meant only one could be used.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WalkthroughThe CMS now caches theme data and datasource modification timestamps. Cache invalidation occurs after theme-data saves, deletes, and theme resets. A composite index supports theme-template lookups. New tests cover cache persistence, invalidation, datasource timestamp behavior, and filesystem fallback. The system module adds a composite request-log index and tests request aggregation, status separation, index presence, and disabled logging. Estimated code review effort: 4 (Complex) | ~45 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Freezing the value in the path cache makes lastModified() report a consistent result, but selectOne() still resolves a null updated_at live, so the two disagree and the Halcyon cache is still busted on every request for those records. The assertions were already correct; only the comment overstated what they prove. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The path cache carries modification times for database-backed templates, but every consumer of it tests the value for truthiness to decide whether a path can be handled. A live record whose updated_at is the Unix epoch produces a timestamp of 0, which made it read as deleted: absent from select() listings and null from selectOne(). Covers the AutoDatasource side of the accompanying Storm fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This reverts commit b600759.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Production query stats from a site taking heavy bot/crawler traffic:
select * from cms_theme_templates where source = ? and deleted_at is null and path = ? limit 1select * from cms_theme_data where (theme = ?) limit 1select * from system_request_logs where (url = ? and status_code = ?) limit 1update system_request_logs set count = count + 1, updated_at = ? where id = ?Reading the ratios:
717.8K / 358.9Kis exactly 2.00 and theme data is memoised per process, so this is ~358.9K front-end requests, each doing 2 template lookups and 1 theme-data lookup — with ~33% of all requests being 404s (bots probing/wp-login.php,/.env, etc. all funnel through the CMS catch-all route).system_request_logsalone is 80% of the listed database time despite being 25% of the calls.Changes
1. CMS templates — the object cache removed no queries
CmsObject::loadCached()caches parsed objects, butHalcyon\Builder::getCached()validates every warm cache hit viaisCacheBusted()→datasource->lastModified(), so each database-backed template still cost a query per request.AutoDatasourcealready holds a forever-cached, correctly-invalidated manifest of which paths live in which datasource. Paired with wintercms/storm#238 that manifest now carries modification times, soAutoDatasource::lastModified()answers from memory.Filesystem datasources are untouched — they report
truein the manifest and fall through to livefilemtime(), so editing templates on disk still takes effect immediately. Deleted paths reportfalseand keep raising the existing "is deleted" exception.Behaviour tradeoff
Template content edits made by direct SQL, outside the application, are no longer picked up until the paths cache is cleared. Edits through the backend,
theme:sync, or any datasource API call callpopulateCache(true)and remain immediate. The paths manifest already had exactly these semantics for added/removed templates, so this widens an existing characteristic rather than introducing a new class of staleness.2. Theme data — no persistent cache
ThemeData::forTheme()was memoised only in a static array, so it was one uncached query per request. The dominant caller is not{{ theme.* }}in templates — it is the asset combiner:cms.combiner.getCacheKeyis registered for front-end requests only and fires insideCombineAssets::getCacheKey(), which runs before the combiner cache is consulted, so a warm combiner cache did not avoid it.The record is now cached following the existing
SettingsModelpattern, invalidated inafterSave()and a newafterDelete()—Theme::removeCustomData()deletes the record and had no hook.Theme::resetCache()also clears the in-memory instances, which it previously left stale for long-running workers (Octane, queue workers).3. Request logs — no index at all
system_request_logshad no index beyond its primary key, so every 404 full-scanned a table that only ever grows. Sinceadd()is what grows it, the cost accelerated on its own. Adds the(url, status_code)index matching whatRequestLog::add()looks up.Also adds a composite
(source, path, deleted_at)index tocms_theme_templates, replacing the now-redundant standalonesourceindex — every datasource query filters on source and path together, but the two independent single-column indexes meant only one could be used.Tests
AutoDatasourceTest(had one test) — mtime served from the path cache with zero queries, filesystem fall-through returns livefilemtime(), deleted paths still throw, path-cache value shapes (int /false/true), stability for nullupdated_at, and that an update through the datasource is reflected immediately.ThemeDataTest(new) — cached across requests with zero queries, record created only once, defaults applied to cached rows,afterSave()/afterDelete()invalidation, and dynamic (jsonable) attributes surviving the cache.RequestLogTest(new — there was no coverage) — the index exists (driver-aware introspection), create/increment/status-code separation, and thelog_requestssetting being honoured.ThemeTest—resetCache()clears theme data instances.modules/cms/tests/fixtures/themes/themedatawith aform:section; no existing fixture declared customization fields.All new assertions were verified as genuine regression guards by reverting each fix and confirming the corresponding test fails.
Full suite result
711 tests. The 13 errors that remain are pre-existing and unrelated —
Assetic\Filter\CssImportFilter::setImportValidator()is undefined with the currently-installedassetic, following the asset combiner security change. Confirmed identical with these changes stashed.Companion PR
wintercms/storm#238 — merged. The manifest mtimes this PR consumes come from there.
Summary by CodeRabbit
Performance
Bug Fixes