You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Once #638 ships long(-ish) Cache-Control: public, s-maxage=… TTLs on anonymous responses, the CDN (Bunny.net, configured in sibling expandedDecksInfra) will hold cached copies of public pages for several minutes. That's fine for most edits — content trickles in slowly — but it's wrong for editor-driven changes that need to surface immediately:
A CMS editor publishes a homepage news post → cached / and /pages/<slug> still show the old version for up to TTL minutes
An archetype editor saves a new variant or rewrites the description → cached /archetypes/<slug> and /archetypes lag
A user publishes a deck → cached /deck and /deck/<shortTag> lag
Without an explicit invalidation hook, editors will start writing content twice ("Why isn't my update showing? Did I save it?") and lose trust in the publish flow.
Proposed approach
Track 1 — CdnPurgerInterface adapter
A driver-style interface in src/Service/Cdn/:
interface CdnPurgerInterface
{
/** * Purge a list of absolute URLs from the CDN. Bunny.net supports up to * ~100 URLs per call; implementations are expected to batch internally. * * @param list<string> $urls */publicfunctionpurgeUrls(array$urls): void;
/** * Purge by tag — Bunny.net supports this via cache-tag header semantics. * Useful when one entity affects many URLs (e.g. an archetype change * invalidates the catalog list + its own detail page + every channel / * locale variant). */publicfunctionpurgeTag(string$tag): void;
}
Implementations:
BunnyCdnPurger — uses https://api.bunny.net/purge?url=<URL>&async=true per URL (auth via AccessKey header from BUNNY_API_KEY env var) and https://api.bunny.net/pullzone/{id}/purgeCache?CacheTag=<tag> for tag purges (env var BUNNY_PULL_ZONE_ID).
NullCdnPurger — dev / test no-op; activated whenever BUNNY_API_KEY is empty.
Service-config picks the implementation by env-var presence (similar pattern to F14.7 Sentry's SENTRY_DSN-empty-means-off).
Track 2 — Async dispatch via Symfony Messenger
Synchronous purge would block the editor's save by a network round-trip to Bunny's API. Wrap each purge in a PurgeCdnMessage dispatched to a new cdn_purge Messenger transport (mirroring the per-domain transport pattern from F14.1). Handler picks up the message, batches by tag/URL set, and calls CdnPurgerInterface::purgeUrls() / purgeTag(). Retry policy: 3 retries × 2× multiplier (matches the other transports). On final failure: log + Sentry capture (the response was wrong for a few minutes, that's a noticeable but non-fatal degradation).
Track 3 — Doctrine listeners that map entity changes to purge calls
A CdnInvalidationListener (mirroring the ArchetypeFreshnessListener pattern from F2.27 / 1.12.22) that buffers affected URL/tag sets during a flush and dispatches one PurgeCdnMessage in postFlush:
Entity changed
Tags / URLs to purge
Page (postPersist/postUpdate when isPublished == true, or postUpdate when transitioning false→true)
/pages/<slug> (per locale prefix), the news-category index if the page is in one, / if the page is in the homepage news category
Archetype (postPersist/postUpdate when isPublished == true)
Deck variant (owner IS NULL) postPersist/postUpdate (already tracked by ArchetypeFreshnessListener)
Same as the parent archetype + /sitemap.xml
Deck (owner-decks, public == true) postPersist/postUpdate
/deck/<shortTag>, /deck, /sitemap.xml
Event postPersist/postUpdate (public)
/event/<id>, /event, /event.ics, the event-tag pages it belongs to
Multi-channel surface: every URL needs purging on every channel that exposes it (the catalog channel expandedtalks.wip vs. the app channel expandeddecks.wip). The listener resolves channel-aware URLs via the existing feature_url() / canonical_url() helpers.
Track 4 — Admin "Purge cache" tile on /admin/technical
A button that dispatches a global purgeTag('all') (which Bunny.net supports). Useful for emergency drift recovery without redeploying. Behind ROLE_TECHNICAL_ADMIN.
Acceptance criteria
A CMS editor publishes a news page → the cached homepage and /pages/<slug> reflect the change within ~10 s (one CDN round-trip after the async handler runs).
An archetype editor saves a description change → cached /archetypes/<slug> reflects within ~10 s.
A deck owner makes their deck public → cached /deck/<shortTag> reflects within ~10 s.
With BUNNY_API_KEY unset, all of the above runs the NullCdnPurger and nothing breaks; the cdn_purge Messenger transport still drains cleanly.
make test passes; new unit tests cover the BunnyCdnPurger URL batching, the listener's entity → URL mapping, and the Messenger handler's retry behaviour.
Documentation: a new docs/technicalities/cdn_purge.md covers the env-var matrix, the entity → URL map, and the "Purge cache" admin tile.
Files likely touched
src/Service/Cdn/CdnPurgerInterface.php (new)
src/Service/Cdn/BunnyCdnPurger.php (new)
src/Service/Cdn/NullCdnPurger.php (new)
src/Service/Cdn/PurgeUrlResolver.php (new) — entity → URL mapping logic shared by listener and admin tile
Pairs with the sibling expandedDecksInfra Bunny.net edge-rule config: the bot-block rule (297c019) is the prevention layer; this issue is the invalidation layer.
The Doctrine-postFlush dispatch pattern matches the ArchetypeFreshnessListener from F2.27 — deferred so we don't re-enter the in-progress UnitOfWork, batched so a multi-entity edit produces one purge call.
Problem
Once #638 ships long(-ish)
Cache-Control: public, s-maxage=…TTLs on anonymous responses, the CDN (Bunny.net, configured in siblingexpandedDecksInfra) will hold cached copies of public pages for several minutes. That's fine for most edits — content trickles in slowly — but it's wrong for editor-driven changes that need to surface immediately:/and/pages/<slug>still show the old version for up to TTL minutes/archetypes/<slug>and/archetypeslag/deckand/deck/<shortTag>lagWithout an explicit invalidation hook, editors will start writing content twice ("Why isn't my update showing? Did I save it?") and lose trust in the publish flow.
Proposed approach
Track 1 —
CdnPurgerInterfaceadapterA driver-style interface in
src/Service/Cdn/:Implementations:
BunnyCdnPurger— useshttps://api.bunny.net/purge?url=<URL>&async=trueper URL (auth viaAccessKeyheader fromBUNNY_API_KEYenv var) andhttps://api.bunny.net/pullzone/{id}/purgeCache?CacheTag=<tag>for tag purges (env varBUNNY_PULL_ZONE_ID).NullCdnPurger— dev / test no-op; activated wheneverBUNNY_API_KEYis empty.SENTRY_DSN-empty-means-off).Track 2 — Async dispatch via Symfony Messenger
Synchronous purge would block the editor's save by a network round-trip to Bunny's API. Wrap each purge in a
PurgeCdnMessagedispatched to a newcdn_purgeMessenger transport (mirroring the per-domain transport pattern from F14.1). Handler picks up the message, batches by tag/URL set, and callsCdnPurgerInterface::purgeUrls()/purgeTag(). Retry policy: 3 retries × 2× multiplier (matches the other transports). On final failure: log + Sentry capture (the response was wrong for a few minutes, that's a noticeable but non-fatal degradation).Track 3 — Doctrine listeners that map entity changes to purge calls
A
CdnInvalidationListener(mirroring theArchetypeFreshnessListenerpattern from F2.27 / 1.12.22) that buffers affected URL/tag sets during a flush and dispatches onePurgeCdnMessageinpostFlush:Page(postPersist/postUpdate whenisPublished == true, or postUpdate when transitioning false→true)/pages/<slug>(per locale prefix), the news-category index if the page is in one,/if the page is in the homepage news categoryArchetype(postPersist/postUpdate whenisPublished == true)/archetypes/<slug>(per locale),/archetypes(catalog)Deckvariant (owner IS NULL) postPersist/postUpdate (already tracked byArchetypeFreshnessListener)/sitemap.xmlDeck(owner-decks,public == true) postPersist/postUpdate/deck/<shortTag>,/deck,/sitemap.xmlEventpostPersist/postUpdate (public)/event/<id>,/event,/event.ics, the event-tag pages it belongs toMulti-channel surface: every URL needs purging on every channel that exposes it (the catalog channel
expandedtalks.wipvs. the app channelexpandeddecks.wip). The listener resolves channel-aware URLs via the existingfeature_url()/canonical_url()helpers.Track 4 — Admin "Purge cache" tile on
/admin/technicalA button that dispatches a global
purgeTag('all')(which Bunny.net supports). Useful for emergency drift recovery without redeploying. BehindROLE_TECHNICAL_ADMIN.Acceptance criteria
/pages/<slug>reflect the change within ~10 s (one CDN round-trip after the async handler runs)./archetypes/<slug>reflects within ~10 s./deck/<shortTag>reflects within ~10 s.BUNNY_API_KEYunset, all of the above runs theNullCdnPurgerand nothing breaks; thecdn_purgeMessenger transport still drains cleanly.make testpasses; new unit tests cover theBunnyCdnPurgerURL batching, the listener's entity → URL mapping, and theMessengerhandler's retry behaviour.docs/technicalities/cdn_purge.mdcovers the env-var matrix, the entity → URL map, and the "Purge cache" admin tile.Files likely touched
src/Service/Cdn/CdnPurgerInterface.php(new)src/Service/Cdn/BunnyCdnPurger.php(new)src/Service/Cdn/NullCdnPurger.php(new)src/Service/Cdn/PurgeUrlResolver.php(new) — entity → URL mapping logic shared by listener and admin tilesrc/Message/PurgeCdnMessage.php(new)src/MessageHandler/PurgeCdnHandler.php(new)src/EventListener/CdnInvalidationListener.php(new) — Doctrine postFlush dispatcherconfig/packages/messenger.yaml— registercdn_purgetransportconfig/services.yaml— service config + env-var-based factory forCdnPurgerInterfacesrc/Controller/AdminTechnicalController.php— "Purge cache" tile + endpointtemplates/admin/technical/dashboard.html.twig— UI for the tiledocs/technicalities/cdn_purge.md(new)Dependencies / context
Cache-Controlheaders, the CDN doesn't cache and there's nothing to purge.expandedDecksInfraBunny.net edge-rule config: the bot-block rule (297c019) is the prevention layer; this issue is the invalidation layer.ArchetypeFreshnessListenerfrom F2.27 — deferred so we don't re-enter the in-progress UnitOfWork, batched so a multi-entity edit produces one purge call.