Add element-level opt-out for page caching - #3980
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3980 +/- ##
=======================================
Coverage 98.28% 98.28%
=======================================
Files 352 352
Lines 9276 9281 +5
=======================================
+ Hits 9117 9122 +5
Misses 159 159
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
19a8a0b to
e5c19c5
Compare
tvdeyen
left a comment
There was a problem hiding this comment.
I like that feature. Would you mind to fix the remaining specs? We plan to make significant changes to the repo and this will break this PR. Also we plan to release 8.4 soon and we would include in that release
e5c19c5 to
8572837
Compare
Fixed. The expectation was wrong. The dynamically created |
tvdeyen
left a comment
There was a problem hiding this comment.
Nice feature — the no-store reasoning is spot on. 👍 One thing to flag: page_cache_disabled_by_elements? runs on every show/index, and because it calls @page.find_elements, it now loads and instantiates the full published element set on every request — including conditional GETs that return 304. Previously a 304 only ran the lightweight EtagGenerator pluck and never materialized elements, so this adds real work to the hot cache path. It's also evaluated up to 3× per request (twice in set_expiration_headers, once via render_fresh_page?) with no memoization.
Two small things make it nearly free again:
- Short-circuit statically — in the common case no definition opts out, so we can skip the DB entirely; and when one does, an
exists?is far cheaper than loading full records (it also naturally covers nested/fixed elements, whichfind_elements' defaultvisible.not_nested.unfixedscope currently misses). - Memoize per request (guarding with
defined?since the result can befalse).
def page_cache_disabled_by_elements?
return @page_cache_disabled_by_elements if defined?(@page_cache_disabled_by_elements)
opt_out_names = Alchemy::Element.definitions.filter_map { |d| d.name if d.page_cache == false }
@page_cache_disabled_by_elements = !!(
opt_out_names.any? &&
@page&.public_version&.elements&.published&.exists?(name: opt_out_names)
)
endThis keeps behavior identical for the specs here, stays zero-cost for apps that don't use the opt-out, and closes the nested/fixed-element gap for free. 🙂
|
Followed up on my suggestion above — applied it locally and it checks out green:
Behavior is identical; it just skips the element load on the 304 hot path. Happy to push the commit to this branch if that's easier for you. 🙂 |
Allow element definitions to declare page_cache: false so pages that contain or can render those elements skip HTTP page-shell caching. Use no-store for this opt-out because no-cache still permits stored responses and conditional revalidation, which can return 304 before element-level cache variants are rendered. Keep the option as definition-only metadata and cover the new behavior with model and request specs.
555daa2 to
a584eeb
Compare
|
@robinboening I would like to take this over if you don't mind? |
The element-level page cache opt-out ran on every show/index request and, via find_elements, loaded and instantiated the full published element set even for conditional GETs that return 304 — the exact hot path page caching exists to keep cheap. It was also evaluated up to three times per request without memoization. Short-circuit on the statically known opt-out element definitions so apps that don't use the feature never touch the database, and fall back to a single exists? query instead of materializing every element when they do. Memoize the result per request. Querying published elements directly also covers nested and fixed elements, which the find_elements default scope silently skipped.
Disabling page caching — whether globally, per page definition, per element definition, or because a flash message is present — must guarantee that no stored copy is ever served. no-cache did not: it still permits caches to store the response and satisfy later requests with a conditional 304, which is why stale flash messages kept surfacing and why element-level cache variants could be skipped. Serve no-store for the whole must_not_cache? path instead of no-cache. Since the element opt-out is already one of its conditions, the dedicated branch collapses into it. This restores the intended "caching disabled means never stale" behavior rather than introducing a new cache mode.
The previous wording ("element-level cache variants" / "per-request
caching") was inaccurate. Elements are fragment-cached in the views via
<% cache element %>, not per request. Reword the comment to describe the
concrete reason for no-store: a stored response reused after a 304 skips
re-rendering elements that opted out of page caching.
|
@tvdeyen thanks for taking this over, I was not at my computer for a few days, and am still very busy with some other things so I probably won't be able to get to it this week or the next. 👍 |
tvdeyen
left a comment
There was a problem hiding this comment.
I added a fix for the performance regression we would have hit and changed that we always return no-store for uncachable pages. Thanks for the contribution, this is a very welcome change.
|
Thanks! I also have some other features in mind that I believe could be nice additions. I'll open the issues when I get some more time. |
Closes #3973.
This adds a
page_cacheoption to element definitions, allowing individual elements to opt a page out of HTTP page caching:When a page contains an element definition with
page_cache: false, Alchemy now always renders the page instead of relying on the page-cache. This lets dynamic or permission-sensitive elements execute and apply their own fragment cache keys.Uncacheable page responses now use
Cache-Control: no-storeinstead ofno-cache.no-cachestill lets a cache store the response and reuse it after a 304, which serves stale content (this is why stale flash messages could appear) and skips re-rendering elements.no-storeguarantees the page is rendered fresh. This applies to every reason a page is uncacheable — global config, page-layoutcache: false, a flash message, and the new element-level opt-out.The
page_cacheoption defaults to true and is kept as definition-only metadata, so element definitions behave as before unless one explicitly opts out. Note that theno-cache→no-storeswitch does change the cache-control header for pages that were already uncacheable.Checklist