Skip to content

CDN purge adapter: invalidate Bunny.net cache on content edition #639

@jbourdin

Description

@jbourdin

Problem

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
     */
    public function purgeUrls(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).
     */
    public function purgeTag(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) /archetypes/<slug> (per locale), /archetypes (catalog)
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
  • src/Message/PurgeCdnMessage.php (new)
  • src/MessageHandler/PurgeCdnHandler.php (new)
  • src/EventListener/CdnInvalidationListener.php (new) — Doctrine postFlush dispatcher
  • config/packages/messenger.yaml — register cdn_purge transport
  • config/services.yaml — service config + env-var-based factory for CdnPurgerInterface
  • src/Controller/AdminTechnicalController.php — "Purge cache" tile + endpoint
  • templates/admin/technical/dashboard.html.twig — UI for the tile
  • docs/technicalities/cdn_purge.md (new)

Dependencies / context

  • Depends on Cache-Control headers + server-side HTTP cache for anonymous read-only responses #638 — without Cache-Control headers, the CDN doesn't cache and there's nothing to purge.
  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status

    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions