Skip to content

Purge the rendered HTML when a site-wide resource changes (#232) - #235

Merged
silverbackdan merged 1 commit into
mainfrom
feature/232-purge-rendered-html
Sep 21, 2026
Merged

silverbackdan merged 1 commit into
mainfrom
feature/232-purge-rendered-html

Conversation

@silverbackdan

Copy link
Copy Markdown
Collaborator

Closes #232. Front-end counterpart: components-web-app/cwa-nuxt-module#289

The gap

The module tags each cached page with the resource IRIs the render touched, so a write to any of them already drops the affected pages. That misses resources which shape every page without ever entering the module's resource store — site config above all. Change siteName and every cached page kept the old one until its TTL lapsed.

The fix

A write to a class listed in the new http_cache.purge_rendered_html_classes also purges the constant surrogate key cwa-html, which the module puts on every cacheable page. No new endpoint: the bundle already holds the purge connection, it just needed to know which resource types invalidate all rendered pages rather than only their own IRI.

silverback_api_components:
    http_cache:
        personalised_resource_classes: [...]   # existing
        purge_rendered_html_classes:           # new, default [SiteConfigParameter::class]
            - Silverback\ApiComponentsBundle\Entity\Core\SiteConfigParameter

The node mirrors personalised_resource_classes beside it, including is_a(..., true) so subclasses match.

Once per purge, by construction

HttpCachePurger carries a single bool set in collectResource() and appended in propagate(). A bool cannot be added twice, so N listed resources written in one flush produce exactly one tag — a structural guarantee rather than a filter.

Across requests it is once per write. propagate() is bound to postFlush and SiteConfigParameter has no batch operation, so saving four parameters is four requests and four purges. That is harmless — the key is already gone after the first — and coalescing would need cross-request state on a shared service, which is the FrankenPHP worker-mode bug class this codebase has been bitten by before. Deliberately not done.

One correction to the original design during implementation: propagate()'s early return now tests the assembled list rather than $this->tags. With the original ordering, a listed resource whose IRI resolution threw would leave tags empty, return early, and never reset the flag — so the next unrelated flush would spuriously drop the whole HTML cache. Covered by a regression test.

POST on SiteConfigParameter now requires the admin permission

This is a behaviour change for existing applications. #[Post] carried no security: at all — only the application firewall gated it — so any authenticated user could create site config. With the purge tag attached to that write, any authenticated user could also flush the entire HTML cache. It now requires publishable.permission, matching Put/Patch/Delete.

It is securityPostDenormalize:, not security:. AP4 evaluates a plain security expression in the provider stage with no object yet, and SiteConfigParameterVoter::supports() requires $subject instanceof SiteConfigParameter — so a null subject makes every voter abstain, and AffirmativeStrategy denies on all-abstain, locking out admins as well. securityPostDenormalize runs with the denormalized object available.

The existing features/main/security.feature scenarios (anonymous POST → 401, admin POST → 201) both still pass unedited.

The tag is a cross-repo contract

cwa-html, as HttpCachePurger::RENDERED_HTML_TAG. The module asserts the same literal in http-cache.spec.ts. A mismatch fails silently by matching nothing, so it gets the same written-down treatment as explicitAllowOnly and SouinPurger::SEPARATOR.

It is deliberately not namespaced in the kind:value style #227 proposes for manifest keys — those prefixes are a kind qualifying a value, and this tag has no value part, so cwa:html would only look consistent. Collision with a resource IRI is impossible: every collected tag is a path beginning with /.

List only leaf resources — ones not reachable as a Doctrine association of another resource. collectResource() is also reached for associated entities, so a listed class that is reachable as an association would let unrelated writes drop the whole HTML cache. Inert for SiteConfigParameter, which has no associations.

Tests

  • PHPUnit: 678 (was 671) — new tests/HttpCache/HttpCachePurgerTest.php, which did not exist before
  • Behat: 527 scenarios, 3854 steps (was 522) — new features/main/purge_rendered_html.feature

ProfilerContext::collectPurgedTags() is extracted as a single parser and the existing purge step rebuilt on it, so there is one implementation rather than two. It reads whichever of xkey/surrogate-key is present and splits on /[,\s]+/ — the test harness uses the Varnish xkey purger with space glue while production uses Souin with ', ', so the steps are deliberately header- and separator-agnostic.

🤖 Generated with Claude Code

A write to a configured resource class now appends the constant surrogate
key `cwa-html` to the purge the bundle already sends, so resources that
shape every page without entering the front end's resource store -
SiteConfigParameter above all - invalidate the rendered HTML rather than
leaving it stale until its TTL lapses.

The tag is a cross-repo interface contract held as
HttpCachePurger::RENDERED_HTML_TAG and mirrored by the module's
RENDERED_HTML_SURROGATE_KEY; a mismatch fails silently by matching
nothing. It cannot collide with a resource IRI, which is always an
ABS_PATH beginning with `/`.

The tag is sent once per purge, not once per written resource: a single
bool set during collection and appended once in propagate(), cleared by
reset(). A multi-parameter save remains N requests, N flushes, N purges -
the bundle cannot coalesce across requests without state the worker-mode
rule forbids, and purging an already-purged key is a no-op at the edge.

SiteConfigParameter::Post carried no security expression at all, leaving
creation gated only by the application firewall, so any authenticated
user could have flushed the whole HTML cache. It now matches its four
sibling operations, via securityPostDenormalize because a plain security
expression is evaluated on POST while `object` is still null, which the
voter's subject check would reject.

ProfilerContext gains a single shared purge-header parser, reading
whichever of xkey/surrogate-key is present and splitting on `/[,\s]+/`,
so the steps assert correctly under both the Varnish purger the test app
runs and the Souin purger production runs.
@codecov

codecov Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.73%. Comparing base (df98167) to head (d9ffc81).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff              @@
##               main     #235      +/-   ##
============================================
+ Coverage     87.70%   87.73%   +0.03%     
- Complexity     2653     2658       +5     
============================================
  Files           257      257              
  Lines          7653     7673      +20     
============================================
+ Hits           6712     6732      +20     
  Misses          941      941              
Flag Coverage Δ
behat 68.61% <56.52%> (-0.04%) ⬇️
phpunit 35.35% <100.00%> (+0.62%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@silverbackdan
silverbackdan merged commit 82b467b into main Sep 21, 2026
11 of 13 checks passed
@silverbackdan
silverbackdan deleted the feature/232-purge-rendered-html branch September 21, 2026 11:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Purge rendered HTML when a site-wide resource changes (front-end cache tag, no new endpoint)

1 participant