# Caching & Cloudflare Invalidation 7inOne serves anonymous traffic through a Cloudflare CDN in front of the Plone backend. To keep the CDN cache consistent with content changes, the `caching` feature tags every cacheable response and purges the matching cache entries on Cloudflare whenever the underlying content changes. A Redis job queue decouples the (potentially slow) Cloudflare API calls from the request that triggered them and handles time-based (scheduled) purges. ## Architecture Overview The feature combines three moving parts: 1. **Cache-Tag headers** -- every anonymous `GET` response is tagged with the context's UID and `portal_type`, so Cloudflare stores those tags alongside the cached object. 2. **CloudFlareClient** -- a Zope utility that talks to the Cloudflare *purge_cache* API. It can purge by tag, by URL prefix, or purge everything for a zone. 3. **Redis (RQ) job queue** -- content-change events enqueue a background job that POSTs to the `@invalidate` REST service, which collects the UIDs to purge and calls the client. Future-dated changes (effective / expiration / event end) are scheduled on a separate queue. ```text ┌───────────────┐ content change ┌──────────────┐ enqueue ┌──────────┐ │ Plone event │──────────────────▶│ invalidate │────────────▶│ Redis │ │ subscribers │ │ hook() │ │ (RQ) │ └───────────────┘ └──────────────┘ └────┬─────┘ │ POST @invalidate ▼ ┌──────────────┐ purge ┌──────────────┐ │ UID collector│────────────▶│ Cloudflare │ │ (per type) │ by tag │ purge API │ └──────────────┘ └──────────────┘ ``` ```{note} Only **anonymous** `GET` responses are cached and tagged. Authenticated backend traffic is never tagged and never cached at the CDN, so the whole invalidation machinery only ever has to worry about the anonymous view of the site. ``` ## Cache-Tag Header Model For every successful anonymous `GET` request to a content object or the site root, a publication subscriber sets two equivalent response headers: - `Cache-Tag` -- consumed by Cloudflare for tag-based purging. - `X-Cache-Tag` -- the same value, exposed for debugging/inspection. The default tag set for a content response is: - the context **UID** - the normalized **portal_type** (e.g. `contentpage`, `file`) Individual endpoints may add their own tags on top of these. For example the `@banner`, `@responsible` and `@responsible-backreferences` endpoints append generic tags (`banner`, `responsible`, `responsible-backreferences`) so that a single change can invalidate every cached response that embedded that data, regardless of which page it appeared on. When multiple tags apply to one response they are merged into a single comma-separated `Cache-Tag` header. ## Tag-Based vs. URL/Prefix Purging The `CloudFlareClient` supports three purge strategies. Which one is used depends on what changed. **Tag-based purge** (`purge_cache_by_tags` / `purge_cache_by_objs`) : The default mechanism. Given a set of UIDs (and generic tags), Cloudflare drops every cached object carrying one of those tags. This is how normal content edits are invalidated. Tags are purged in batches of at most 100 per Cloudflare request. **URL/prefix purge** (`purge_cache_by_prefixes`) : Used where there is no Cache-Tag to match -- most importantly for redirector (alternative URL) entries and the manual control-panel buttons. A prefix is the `host + path` portion of a URL. **Purge everything** (`purge_everything`) : A full zone flush, triggered only by the *Full Cache Purge* button in the control panel. ```{important} **Tag-based purging is zone-wide.** A `Cache-Tag` purge clears the matching objects across *all* subdomains of the zone in a single call -- you never have to enumerate subdomains for tag purges. **URL/prefix purging is not.** A prefix purge only clears the exact host you name, so every subdomain that may have served the content has to be listed explicitly. This is why redirector invalidation expands each path into one URL per configured subdomain (see *Subdomain Configuration*). **404 responses have no Cache-Tag.** Cloudflare cannot tag a not-found response, so a cached 404 can only be cleared via URL/prefix purging, never by tag. ``` ## Subdomain Configuration Cloudflare credentials and per-domain settings live in a single JSON registry record, `wcs.backend.caching.mapping`. Each top-level key is a registrable domain mapped to its Cloudflare zone and the subdomains served from it: ```json { "webcloud7.ch": { "zoneid": "5d6d31b39f784ef1e6920e9fbfeaa9ab", "apikey": "SHcw9lp0BjMv_R_-4IS26x2oNiqHZfwha6RtCAEm", "subdomains": ["www"] } } ``` - `zoneid` -- the Cloudflare zone ID used in the purge API URL. - `apikey` -- the Cloudflare API token (sent as a Bearer token). - `subdomains` -- the subdomains served from this zone. Defaults to `["www"]` when omitted. The `subdomains` list only matters for **URL/prefix** purges, where each path is expanded into `https://./` for every entry. For tag-based purges the list is irrelevant, because a tag purge already covers the whole zone. ## The Caching Control Panel A dedicated control panel, **7inOne Caching** (`@@caching-controlpanel`, restricted to *Manage portal*), exposes both the configuration and a set of manual purge actions. **Configuration section** | Field | Registry record | Purpose | |---|---|---| | Enable cache invalidation | `wcs.backend.caching.enabled` | Master switch. While off, all automatic invalidation is skipped. | | API base URL | `wcs.backend.caching.api_base_url` | The public API domain (used for redirector and *API Cache* purges). | | Cloudflare configuration | `wcs.backend.caching.mapping` | The domain → zone/apikey/subdomains JSON shown above. | The client only considers itself correctly configured when the domain extracted from the *API base URL* is present as a key in the mapping; otherwise automatic invalidation is treated as disabled and logged as a warning. **Manual invalidation section** - **API Cache** -- prefix-purges the configured API base URL. - **Backend Cache** -- prefix-purges the backend site URL. - **Full Cache Purge** -- purges *everything* for the configured preview (frontend) domain. The button is only shown when the preview domain is part of the mapping. These buttons are an operational escape hatch; day-to-day invalidation is fully automatic via the collector model below. ## The Collector Model When something has to be invalidated, the system does not simply purge the one object that changed. Each content type knows best which *other* cached responses depend on it, so an `IUIDPurgeCollector` adapter is looked up for the changed object and asked for the complete set of UIDs (and generic tags) to purge. The `@invalidate` service feeds that set straight into a tag-based Cloudflare purge. ### Base collector The default collector (`DxUIDContentCollector`, registered for all Dexterity content) always returns: - the object's own UID, plus - the UID of its **parent**, plus - the UIDs of all **back-references** to it (filtered by view permission and active/effective dates), plus - for Simplelayout pages, the UIDs of all **blocks** on the page. A block collector additionally always pulls in its parent page, and the site root collector additionally pulls in the site's default page. This recursive walk ensures that listing pages, referencing pages and embedding blocks are refreshed alongside the edited object. ### Type-specific collectors Several content types extend the base collector to cover the places their data is surfaced: | Collector | Triggered for | Adds to the purge set | |---|---|---| | `BannerUIDContentCollector` | Banner | The generic `banner` tag when the banner is published or scheduled, so every cached `@banner` response is refreshed. On retract/delete only the banner's own UID is purged. | | `NewsUIDContentCollector` | News items | The UIDs of every News-listing block whose query would include this news item. | | `MediaFolderUIDContentCollector` | Media folders (and the files/images inside) | The media folder's back-references and parent, so listing blocks and pages embedding the folder are refreshed when a file/image is added or changed. | | `ResponsibleUIDContentCollector` | Pages with the Responsible Unit behavior | The generic `responsible` and `responsible-backreferences` tags. | | `TopicFilterUIDContentCollector` | Content with flat-topic support | The UIDs of every (anonymous-visible) TopicFilter block whose first result page actually contains this content, verified against Elasticsearch. | ### What triggers a collection Content-lifecycle event subscribers decide *when* a collection/purge runs: - **Workflow transitions** on published content, and **working-copy apply** (staging) -- purge immediately. - **Modification** of content that has no workflow state -- only *schedules* a future purge if the content has a future effective/expiration date. - **Deletion / trashing** of published content -- purge immediately (trashing an already-trashed object is ignored). - **Media folder** file/image add or modify, and **image crop** changes -- purge immediately. - **Redirector** (alternative URL) additions -- purge *synchronously* by URL prefix, expanded across all configured subdomains. Time-based purges (future effective date, expiration date, or event end) are enqueued on the *important* Redis queue and fire at the scheduled moment, so a page becomes visible or disappears from the cache exactly when it should. ## The `@invalidate` REST Service `@invalidate` is the single entry point that performs a tag-based purge for a context. It runs the collector for the context, logs the resolved objects, and asks the Cloudflare client to purge the collected UIDs. The background Redis jobs described above call this service; you can also call it directly to force an invalidation of a specific object and everything that depends on it. The service is a `POST` to the object's URL and returns the UIDs that were purged together with the raw Cloudflare API responses. **JavaScript** ```javascript async function invalidate(objectUrl, auth) { const response = await fetch(`${objectUrl}/@invalidate`, { method: 'POST', headers: { 'Accept': 'application/json', 'Authorization': `Basic ${auth}` } }); return response.json(); } // Usage const result = await invalidate( 'https://backend.example.ch/Plone/topics/my-page', btoa('admin:secret') ); console.log(result.uids_to_purch); // ["abc123...", "def456...", "banner"] console.log(result.cloudflare_response); // raw Cloudflare purge responses ``` **Python** ```python import requests response = requests.post( 'https://backend.example.ch/Plone/topics/my-page/@invalidate', headers={'Accept': 'application/json'}, auth=('admin', 'secret'), ) data = response.json() print(data['uids_to_purch']) ``` Example response: ```json { "uids_to_purch": [ "8f1d2c3b4a5e6f7081920a1b2c3d4e5f", "1a2b3c4d5e6f70819203a4b5c6d7e8f9", "banner" ], "cloudflare_response": [ { "result": {"id": "5d6d31b39f784ef1e6920e9fbfeaa9ab"}, "success": true, "errors": [], "messages": [] } ] } ``` `uids_to_purch` is the full set the collector resolved for the context (own UID, parent, back-references, blocks, and any type-specific tags). `cloudflare_response` is a list with one entry per Cloudflare purge request -- more than one when the tag count exceeds the 100-tags-per-request limit and the purge is split into batches.