Caching

Caching and the webhook

What a publish drops

A read carries a second, narrower tag beside the site's own: the entry's when it read one entry, the type's when it read a list, a search, the site settings or the page tree. barakoCMS names the content type and sends the entry's public data with every delivery, so a publish drops that type's tag and that entry's tag, and leaves every other entry of the type cached. Correcting one typo on a school with a thousand news posts re-renders that post and the lists it appears in, not the other 999.

A delivery that names no content type, or one this site renders nothing of, drops the site's own tag, which is every read it has cached. That is what every delivery did before this, so a workflow posting a body of its own keeps working.

A response barakoCMS marks Cache-Control: no-store is not cached: the class is remembered against that path and every later read of it asks the CMS uncached, carrying no tag. That is how a type that has to be fresh to the minute lives beside pages cached for hours.

barakoCMS 4.8 also names the class in X-Barako-Cache-Class. no-store there is read the same way. short, long and swr are cached as every read is, under the configured backstop, and a path that was remembered as no-store is cached again from the read after the API stops saying so. Marking a path moves an epoch carried in the URL of its cached reads, so the copy that taught the class is never handed back and read as no-store again. The first no-store answer for a path is still written to Next's data cache once and never read back: Next takes the cache options before the response exists, and asking uncached until the class is known would make every route dynamic (#85).

Running more than one container

The kept answers, the host to tenant map, the webhook replay guard and the generation of each cache tag are in process by default, which is right for one container and wrong for two: a purge reaches one of them through the load balancer, and the others keep serving their own copies until the backstop runs out.

Pass a store on the config and they share it. It is three methods over anything the deployment already runs:

import type { PressStore } from "barakopress";

const store: PressStore = {
    async get(key) { return (await redis.get(key)) ?? null; },
    async set(key, value, ttlSeconds) { await (ttlSeconds ? redis.set(key, value, { EX: ttlSeconds }) : redis.set(key, value)); },
    async delete(key) { await redis.del(key); },
    // Only when the key is absent, atomically. This is what honours a replayed delivery once.
    async add(key, value, ttlSeconds) { return (await redis.set(key, value, { NX: true, ...(ttlSeconds ? { EX: ttlSeconds } : {}) })) !== null; },
};

A purge one container receives then reaches the rest: the honoured delivery writes a generation against each tag it dropped, and a read carries the newest generation of its own tags in the URL it asks the CMS for, so a container that was never told is asking for something it has never cached. Next's own data cache stays per container; what crosses is the knowledge that it is out of date.

What the engine does not ship

The PressStore seam and the example above are the whole contract. The engine ships no store backend and no Next cacheHandler, and a deployment that wants either supplies it.

  • No backend. The engine has one dependency, and a Redis client (or any other) is not worth the package carrying it for every site that runs one container. You write about ten lines over what you already run, as above. add has to be atomic write-if-absent, because that is what honours a replayed webhook delivery once.
  • No cacheHandler. Next's data cache stays per container. A shared one would let a second container serve a warm body instead of fetching once, which saves work but is not needed for a correct answer: the generation in the read URL already stops a container serving something that was purged. If you want that saving, set cacheHandler in your own next.config.

Running two servers behind one store is therefore tested as two module instances sharing one store (src/cache.test.ts), not as two started servers. That covers the logic. The wiring of your store client is yours to check: purge on one container, then confirm the other asks the CMS again.

Each read also carries a backstop, 300 seconds by default. That is not for correctness, it is for the deployment where nobody ever created the webhook: without it their blog would be empty forever and nothing would say why. With it, a missing webhook degrades publishing from instant to a few minutes.

The cache invalidation is configuration, not code. In barakoBrew: a workflow on each content type the site renders, trigger Published, one Webhook action with the URL and a shared secret. Nothing is deployed to change it.

The render cache

Reads being cached is half of it. The other half is not rendering the page again, and for a request-time site that used to be impossible: resolving the tenant meant reading the request host, reading a header makes a route dynamic, and Next never keeps a dynamic route. One container serving forty domains re-rendered every page for every visitor, with every read under it already cached.

Next keys what it keeps by the path and by nothing else. There is no Vary and no second key to add. So whatever a render depends on has to be in the path, and three things are:

In the segment Why
the tenant two tenants must never share an entry. This is the whole reason the segment exists
the gate public, or shared for a request carrying a valid share session. A holding tenant answers differently for each, so each gets its own entry
the host the site's origin when the tenant's settings leave Url empty. Two hosts on one tenant are two renders, not one

The proxy writes them, as /_press/<tenant>~<gate>~<host>/<path>, and it is the only thing that does: a path that arrives under /_press from outside is a 404, because otherwise https://one.example/_press/other~public~other.example/ would render another tenant's site on this tenant's domain. A host the CMS does not know is a 404 before anything renders, so an invented host never makes an entry: only a host a tenant owns can put one there.

What is cached. The index, the pages tree, the collection indexes and the archives, per tenant and per path. revalidateTag drops a tenant's renders along with its reads, so the webhook that was already purging one purges both, and a publish on one tenant leaves every other tenant's renders in place. The first few views of a path answer STALE rather than HIT while the reads under them are still filling Next's data cache; after that it is HIT with no render and no read.

What is not cached, deliberately.

Not cached Why
a route that reads ?preview= a draft must never be in a shared cache, and reading the query is what keeps the route out of it. createBlogPostPreview is such a route; createBlogPost is not
a page binding {{query.X}} same reason. On a kept route the query is not handed to the blocks at all, so the binding is reported as an unbound scope and renders as nothing. A site that wants it writes createPage(config, blocks, { query: true }) and leaves generateStaticParams out of that file
createViewerPage per viewer by definition. It calls connection(), and a route that does cannot be kept
the feed, the sitemap and robots sitemap.ts and robots.ts have to sit at the root of the app tree, so neither can carry a tenant segment. They resolve their own tenant and stay dynamic
/_share and the redeem route per visitor

Which routes are kept is the consumer's call, in the consumer's file, the same as revalidate. A page route that exports createSiteStaticParams() is kept; one that does not is rendered per request. That is also the switch for the two rows above: a route that reads the query leaves it out.

Nothing downstream may keep a copy. Every rewritten answer carries Cache-Control: private, no-store, which is what a request-time site has always sent. Next's cache is the server's own and is not that header, so the render is still kept here. What stays true is that a browser or a proxy holding / is never holding one visitor's gate for the next visitor.

One secret

PRESS_SECRET keys everything this renderer signs: each tenant's webhook key, each tenant's binding report key and each share session cookie. A key derived from it puts its purpose first in what it signs (revalidate., bindings., press-share. or press-link.), so one derived for one purpose never verifies as another. Webhook keys are derived per tenant on a request-time site, binding report keys when the tenant is pinned or resolved per request. Elsewhere PRESS_SECRET itself is the key, so a build-time site with no pinned tenant verifies its webhook and its binding report with the same value. It is read per request and has one rule for every purpose: at least 32 characters, for example openssl rand -base64 48.

When PRESS_SECRET is Webhooks Share sessions Binding reports
32 characters or more verified with it signed with it verified with it
set but shorter refused with 503 none issued or accepted refused with 503
unset REVALIDATE_SECRET, as in 0.3.0 PRESS_PREVIEW_SECRET refused with 503, since there is no older name

The older names are read only when PRESS_SECRET is unset, so a site that set them keeps working, and the keys derive byte for byte as before, so a key already pasted into a tenant's workflow keeps verifying. A REVALIDATE_SECRET shorter than 32 characters still verifies and logs a warning once, until 1.0.0. A PRESS_PREVIEW_SECRET shorter than 32 characters opens no session, as before.

A delivery from barakoCMS 4.6 names its tenant in the signed body. After the signature verifies, a body naming another tenant than the one the host resolved to (or the one a build-time site pins) is answered 200 with revalidated: false and purges nothing. A body naming no tenant, from an older API, is read as before. A build-time site that pins no tenant has nothing to compare it with.

Moving to the new name is copying the value: PRESS_SECRET set to what REVALIDATE_SECRET held derives the same tenant keys, provided it is 32 characters or more. A shorter one has to be replaced, and each tenant given its new key.