Resolve Halcyon DB template mtimes without a query per check - #238
Conversation
This comment was marked as resolved.
This comment was marked as resolved.
There was a problem hiding this comment.
Pull request overview
This PR optimizes Halcyon’s DB-backed template cache validation by avoiding heavyweight row loads when resolving modification times, and by enriching the DB datasource “available paths” manifest so consumers can resolve mtimes without per-template queries (with a companion PR in wintercms/winter consuming the new manifest payload).
Changes:
- Narrow
DbDatasource::lastModified()to select onlyupdated_at(instead ofSELECT *) and avoid exception-driven control flow for missing records. - Version the DB paths cache key (
-v2-) and changegetAvailablePaths()to map live records to theirupdated_attimestamps (deleted records remainfalse). - Add new PHPUnit coverage for DB datasource path manifests, source scoping, event contract compatibility, and query shape.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| tests/Halcyon/DbDatasourceTest.php | Adds regression tests for DB datasource path manifests, event behavior, and query-column selection. |
| src/Halcyon/Datasource/DbDatasource.php | Updates mtime lookup to avoid SELECT *, versions the paths cache key, and stores timestamps in the available-paths manifest. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
`Halcyon\Builder::getCached()` calls `isCacheBusted()` on every warm cache hit, which calls `datasource->lastModified()`. For database-backed templates that meant a full `SELECT *` per template per request just to read `updated_at`, so the object cache removed no database traffic at all -- it only saved the parse. `getAvailablePaths()` already builds a forever-cached manifest of the paths held by the datasource in a single query, and it is invalidated on every insert/update/delete. Recording each live record's modification time in that manifest lets consumers resolve mtimes with no additional query. Timestamps are truthy, so the existence/deletion contract of the map is unchanged and existing consumers keep working; the paths cache key is versioned so stale boolean payloads are rebuilt rather than misread. Because the manifest may also be supplied by the `halcyon.datasource.db.beforeGetAvailablePaths` event, which returns plain booleans, the timestamps are an optimization that consumers must not rely on being present. The docblocks on both the interface and this implementation are updated to describe the widened return shape rather than inheriting the old boolean-only contract. Also narrows `lastModified()` to select only `updated_at` instead of the whole row, which avoids dragging the template content across the wire on the paths that still query. The surrounding try/catch only existed to swallow the null-property error on a missing record, so it is replaced with an explicit check. Out of scope: `updated_at` is nullable, and `Carbon::parse(null)` resolves to "now" in `selectOne()`, `lastModified()` and the manifest alike, so records with a null `updated_at` never agree on an mtime and bust their cache on every request. That is pre-existing and unchanged here; such records cannot be produced through `insert()` or `update()`, which always set the column. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6811f89 to
0f5eec9
Compare
|
Thanks both. Addressed one of the two, and deliberately not the other. Docblock /
|
This comment was marked as resolved.
This comment was marked as resolved.
Neither `DbDatasource` nor `FileDatasource` had any test coverage. Since this branch changes the manifest contract they share, cover both properly rather than only the lines that moved. `FileDatasource::getAvailablePaths()` deliberately still reports `true` rather than a modification time, and there is now a test that says so. Reporting timestamps there would be actively harmful: unlike a database round trip, a filesystem mtime is a cheap local stat, and baking it into the forever-cached paths manifest would mean template edits on disk -- a deploy, for instance -- go unnoticed until that manifest is rebuilt. `AutoDatasource` relies on the `true` value to fall through to a live `filemtime()`. Between them the two suites now cover selectOne/select (column, extension and fileMatch filters), insert/update/delete/forceDelete including renames and extension changes, lastModified, the paths manifest, cache keys and the post processor -- plus the behaviours specific to each: soft deletion, source scoping, reviving deleted records and the beforeInsert/beforeUpdate/extendQuery events for the database, and nested directory creation, path traversal handling and live mtimes for the filesystem. Two behaviours worth noting, both asserted as they actually are rather than as they might be assumed: `FileDatasource::selectOne()` swallows the path traversal exception along with every other read error, so escaping the base path returns null rather than throwing, and `DbDatasource::delete()` throws for an already soft-deleted record because its query excludes them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem
Halcyon\Builder::getCached()validates every warm cache hit by callingisCacheBusted(), which callsdatasource->lastModified(). For database-backed templates that is a fullSELECT *per template per request, purely to readupdated_at:So the Halcyon object cache removes zero database traffic for DB-backed templates — it only saves the parse. On a site taking heavy bot traffic this was the second-largest consumer of database time (717.8K calls, 2h 01m, 10.08ms avg — most of that cost being the
contentblob transfer).Fix
getAvailablePaths()already builds arememberForevermanifest of the paths held by the datasource in a single query, andAutoDatasourceinvalidates it on every insert/update/delete. Recording each live record's modification time in that manifest lets consumers resolve mtimes with no additional query.Timestamps are truthy, so the existence/deletion contract of the map is unchanged and every existing consumer keeps working (
!$paths[$path],array_filter,isset). The paths cache key is versioned (-v2-) so stale boolean payloads are rebuilt rather than misread, and the documentedhalcyon.datasource.db.beforeGetAvailablePathsevent may still return booleans.Also narrows
lastModified()toselect('updated_at')instead of the whole row, for the paths that still query. The surroundingtry/catchonly existed to swallow the null-property error on a missing record, so it is replaced with an explicit check.Docblock
getAvailablePaths()previously documented (and inherited) a boolean-only contract. The return shape is now widened, so bothDatasourceInterfaceand this implementation describe it explicitly asarray<string, int|bool>. Thehalcyon.datasource.db.beforeGetAvailablePathsevent may still supply plain booleans, so the timestamps are an optimization consumers must not rely on being present.Keeping the manifest's truthiness invariant
Consumers decide whether a path can be handled by testing the manifest value for truthiness —
AutoDatasource::getDatasourceForPath()treats a falsy value as deleted,getValidPaths()filters on it,selectOne()retries on it. Storing timestamps breaks that for exactly one value: a live record whoseupdated_atis the Unix epoch yields0.Caught in review. Before the fix, such a record vanished:
0trueselect()listingselectOne()lastModified()00(live query)Note
lastModified()still returned0while everything else treated the record as deleted — because it tests withis_int()while the others test truthiness. That asymmetry between a type test and a truthiness test is the actual defect.Falling back to
truefor the epoch restores the invariant, sofalseis once again the only falsy value the map can hold, and such records simply resolve their mtime with a query as before.DatasourceInterfacenow documents the requirement so other implementations don't reintroduce it.Out of scope: nullable
updated_atupdated_atis nullable, andCarbon::parse(null)resolves to now inselectOne()(which supplies the cachedmtime),lastModified(), and the manifest alike. Those three never agree for such a record, soisCacheBusted()is always true and the cache is busted on every request — before and after this change. Verified:This is pre-existing and deliberately not addressed here. Records with a null
updated_atcannot be produced throughinsert()orupdate(), which always set the column; they only arise from direct SQL or seeding. Fixing it properly means agreeing on one truthy sentinel across all three call sites (0would read as "deleted" in the manifest), which is a separate concern from this PR.Tests
Neither datasource had any coverage before this branch, so both are now covered properly rather than only the lines that moved — 68 tests across the two.
tests/Halcyon/DbDatasourceTest.php(36) —selectOne/selectwith column, extension and fileMatch filters; insert/update/delete/forceDelete including renames, extension changes and reviving soft-deleted records;lastModified; the paths manifest; cache keys; and the DB-specific behaviours: soft deletion, source scoping, and thebeforeInsert/beforeUpdate/extendQuery/beforeGetAvailablePathsevents.tests/Halcyon/FileDatasourceTest.php(32) — the same interface surface, plus nested directory creation, path traversal handling,maxDepthrecursion and live mtimes.The one that matters for reviewers
FileDatasource::getAvailablePaths()deliberately still returnstrue, not a timestamp, andtestGetAvailablePathsReturnsTrueForEveryPathpins that. Reporting timestamps there would be actively harmful — unlike a database round trip, a filesystem mtime is a cheap local stat, and freezing it into the forever-cached manifest would mean template edits on disk (a deploy, say) go unnoticed until the manifest is rebuilt.AutoDatasourcedepends on thetruevalue to fall through to a livefilemtime().Verified by patching
FileDatasourceto returnfilemtime()— the test fails immediately.Regression guards
Run against the pre-change source (
fd673f4f), three fail and the other 33 pass, so the rest genuinely characterize existing behaviour:Two pre-existing behaviours are asserted as they actually are rather than as they might be assumed:
FileDatasource::selectOne()swallows the traversal exception along with every other read error, so escaping the base path returnsnullrather than throwing; andDbDatasource::delete()throws for an already soft-deleted record, because its query excludes them.Full suite: 840 tests. The 1 error / 4 failures that remain are pre-existing (
ConfigWriterTest,ArrayFileTest— PHP config-file formatting) and are present ondevelopwithout this change.Companion PR
Requires wintercms/winter#1508, which consumes the manifest in
Cms\Classes\AutoDatasource::lastModified(). Land together.🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests