Files
summercms/docs/services/caching.md
Jakub Zych 0bc390f39f feat(19.3-04): cooler site-cache purge service with site_cache.purge_url and purge_token
- cooler.SiteCachePurger moved from the Journal plugin: loopback-only http,
  32-character token, 2 s detached request, no token in logs or errors
- NewSiteCachePurgerFor names caller keys (for plugin alias keys)
- PurgeSiteCacheAfterCommit through lagoon.AfterCommit, one POST per save
- SiteCacheFromApp for hooks outside serve; Install publishes the purger
  unless a plugin already did, and fails boot on an invalid config
- caching docs section, cooler README, configuration row
2026-10-15 18:21:10 +02:00

10 KiB

title, description, section, order
title description section order
Caching Cache values with TTLs through cooler.Cache, in process or shared through Redis, under a per-application key prefix. services 45

Caching

WinterCMS and Laravel cache through the Cache facade: Cache::get, Cache::put, Cache::remember and friends, backed by the store config/cache.php selects. SummerCMS has the same core through cooler: one cooler.Cache per application, with an in-process driver by default and a Redis driver shared by every instance.

The Cache API

Laravel cooler
Cache::get($key) cooler.Cache.Get returns the bytes and whether the key was found.
Cache::put($key, $value, $ttl) cooler.Cache.Set stores bytes for a TTL.
Cache::forever($key, $value) cooler.Cache.Set with a TTL of 0.
Cache::has($key) cooler.Cache.Has.
Cache::forget($key) cooler.Cache.Forget.
Cache::remember($key, $ttl, $callback) cooler.Cache.Remember.
Cache::increment($key, $by) cooler.Cache.Increment.
Cache::flush() cooler.Cache.Flush, limited to this application's cache keys.

Values are bytes, so encode structured values yourself, for example with encoding/json. There are no tags and no locks.

A plugin looks the cache up on the application with app.Lookup[cooler.Cache](), or asks cooler.FromApp for it. Here the second cooler.Cache.Remember call is answered from the cache:

// serve publishes the Cache at start-up; a job or console command asks
// FromApp, which builds the configured driver (memory here) once.
app := backpack.New(nil)
cache, err := cooler.FromApp(app)
if err != nil {
	fmt.Println(err)
	return
}
ctx := context.Background()
loads := 0
popular := func(context.Context) ([]byte, error) {
	loads++
	return json.Marshal([]string{"Hello", "Summer"})
}
var titles []string
for range 2 {
	raw, err := cache.Remember(ctx, "blog:popular", 10*time.Minute, popular)
	if err != nil {
		fmt.Println(err)
		return
	}
	if err := json.Unmarshal(raw, &titles); err != nil {
		fmt.Println(err)
		return
	}
}
fmt.Println(titles, loads)
// Output: [Hello Summer] 1

Drivers

cache.driver selects the driver:

  • memory (the default) keeps values in the process, in an otter cache bounded by cache.memory.max_entries (default 10000; cooler.DefaultMaxEntries). Beyond that, the least valuable entries are evicted, so keys derived from request data cannot grow memory without limit. Each instance has its own memory cache.
  • redis keeps values in Redis at redis.url, shared by every instance. When the rate limiter also uses Redis (http.rate_limit.store: redis, see Rate limiting), both use one connection pool.
# config/cache.yaml
driver: redis
# config/redis.yaml (or SUMMER_REDIS__URL, which keeps a password out of YAML)
url: redis://10.0.0.7:6379/0

A missing or malformed redis.url, an unknown driver name or a cache.memory.max_entries below 1 fails the start-up with an error that names the key; an error never shows the Redis URL. Redis being down at start-up is a warning, not a failed start.

Keys and the prefix

Every key is stored as <prefix>cache:<key>. The prefix is cache.prefix when set; by default it is app.name lower-cased, with every run of characters other than letters and digits replaced by _, followed by :, so app.name: Acme Blog gives acme_blog: and the key blog:popular is stored as acme_blog:cache:blog:popular. With app.name empty the prefix is summer:. Rate-limit counters live under <prefix>rl:, so several applications, and the cache and the limiter of one application, can share a Redis without touching each other's keys.

TTLs

A TTL of 0 or less keeps a value until it is forgotten, flushed or, in the memory driver, evicted. A positive TTL expires the value after that long; Redis stores it with millisecond precision. cooler.Cache.Increment starts a missing key at 0 without a TTL and keeps the expiry of an existing key, as Redis INCRBY does.

Remember degrades instead of failing

cooler.Cache.Remember reads the key and, on a miss, calls the callback, stores its value for the TTL and returns it. When the cache itself fails, for example while Redis is down, it does not fail the page: a failed read still calls the callback and returns its value, a failed write is skipped, and both are logged as a warning at most once per minute. Only an error from the callback is returned. The other methods return cache errors to the caller.

Jobs and console commands

The serve command installs cooler at start-up (cooler.Install) and publishes the cache on the application, so plugins and the in-process job worker find it with app.Lookup[cooler.Cache](). Other commands, such as a dedicated queue:work process or a console command, do not run serve; they call cooler.FromApp, which returns the published cache or builds the configured one and publishes it. It does not connect at construction: the Redis driver dials on the first call, so a command that never touches the cache never needs Redis.

Flush

cooler.Cache.Flush removes this application's cache keys and nothing else. The Redis driver walks SCAN MATCH <prefix>cache:* in batches of 500 and removes each batch with UNLINK; it never empties the whole Redis database, which would also drop rate-limit counters and other applications' keys. The memory driver empties the process cache.

Site cache purge

A server-rendered site in front of the API usually caches its pages for a few minutes (a Nuxt routeRules swr window, for example). Without help, an edit in the admin shows on the site only when that window ends. cooler closes the gap: after an admin save commits, it sends the site one purge request, and the site drops its route cache, so the next page view renders the change.

Configure the target once for the application:

Key Environment Meaning
site_cache.purge_url SUMMER_SITE_CACHE__PURGE_URL The site's purge route, for example http://127.0.0.1:3000/api/__site__/cache/purge.
site_cache.purge_token SUMMER_SITE_CACHE__PURGE_TOKEN The bearer token, at least 32 characters (cooler.SiteCachePurgeMinTokenLength).

Set both or neither; with neither, nothing is purged. The URL must be http or https with a host, without user info or a fragment, and plain http is accepted only for a loopback host (127.0.0.0/8, ::1, localhost): the site normally runs on the same machine, and the token must not cross a network in clear. Use https for any other host. A wrong or half-set configuration stops serve at start-up with an error that names the key; neither errors nor log lines ever contain the token.

What a purge sends

Each committed save sends one request, with no debounce:

POST /api/__site__/cache/purge
Authorization: Bearer <site_cache.purge_token>
Content-Type: application/json

{"kind":"faq","slug":"shipping"}

The kind names what was saved (a plugin picks it, for example faq, menu, menu_entry, post or category) and the slug which record; the site may log them. The site answers {"purged": <n>}, and cooler logs cooler: site cache purged with the kind, slug, status and count.

Timing and failures

cooler.PurgeSiteCacheAfterCommit registers the request on the save's transaction through lagoon.AfterCommit. It is sent only after the commit, so a page view between the purge and the commit cannot re-cache the old content, and a save that fails or is rolled back sends nothing. Outside a transaction it is sent at once.

A purge never fails a save. The request is bounded by cooler.SiteCachePurgeTimeout (2 s) and detached from the admin request's cancellation; an unreachable site, an error status or a timeout is logged as one cooler: site cache purge failed warning and the save answers as usual.

Purging from a plugin

The serve command publishes the purger at start-up (cooler.Install), and cooler.SiteCacheFromApp returns it, or builds it from the keys in commands and tests that do not run serve. An admin controller calls it from its after-save hooks, which run inside the save transaction, and takes that transaction from cabana.TxFromContext:

// purge registers one site-cache purge for the saved article, sent after the
// save transaction commits (at once outside one). Without a purge configured
// the purger is nil and nothing is sent.
func (c articlesController) purge(ctx context.Context, model any) error {
	article, ok := model.(*Article)
	if !ok {
		return nil
	}
	tx, _ := cabana.TxFromContext(ctx)
	cooler.PurgeSiteCacheAfterCommit(ctx, tx, cooler.SiteCacheFromApp(c.app()), "article", article.Slug)
	return nil
}

Each of pact.FormAfterCreate, pact.FormAfterUpdate and pact.FormAfterDelete calls it:

// FormAfterUpdate runs inside the update's transaction.
func (c articlesController) FormAfterUpdate(ctx context.Context, model any) error {
	return c.purge(ctx, model)
}

A nil purger (no purge configured) does nothing, so the hooks need no configuration check.

The site side

The Nuxt layers of the core plugins (the Journal, menu and FAQ layers) each ship the same route, so a site that extends any of them has it:

  • POST /api/__site__/cache/purge removes every entry of Nitro's route cache (pages, their _payload.json and feeds) and answers {"purged": <n>}.
  • Its token is runtimeConfig.siteCache.purgeToken, set through NUXT_SITE_CACHE_PURGE_TOKEN to the same value as site_cache.purge_token. With a token shorter than 32 characters the route answers 404.
  • It answers 404 to any request that came through a reverse proxy (X-Forwarded-For, X-Real-IP or Forwarded) and 401 to a missing or wrong bearer token, compared in constant time.
  • Each layer also ships the route rule '/api/__site__/**': { cache: false }. Without it, a site rule such as '/**': { swr: 300 } would wrap the purge route in Nitro's cached handler, which answers from the cache without running the route.

The three copies are byte-identical, so it does not matter which layer's copy Nitro picks.