- PurgeSiteCacheAfterCommit queues the purge on a per-purger worker after commit: one request in flight, saves committed meanwhile collapse into one follow-up, so the admin request never waits for the site - new cooler.SiteCachePurger.Wait drains the queue; Handle.Close waits for it at shutdown (timeout plus one second) - Purge stays the synchronous send; exported names and signatures unchanged
175 lines
11 KiB
Markdown
175 lines
11 KiB
Markdown
---
|
|
title: Caching
|
|
description: Cache values with TTLs through cooler.Cache, in process or shared through Redis, under a per-application key prefix.
|
|
section: services
|
|
order: 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](../../modules/cooler/README.md): 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:
|
|
|
|
```go src=modules/cooler/example_test.go#ExampleFromApp
|
|
// 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](rate-limiting.md#sharing-limits-across-instances)), both use one connection pool.
|
|
|
|
```yaml
|
|
# config/cache.yaml
|
|
driver: redis
|
|
```
|
|
|
|
```yaml
|
|
# 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 queues a purge request to the site, 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 queues a purge; the request looks like this:
|
|
|
|
```http
|
|
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.
|
|
|
|
At most one request is in flight per process (per purger). Saves committed while it is sent collapse into one follow-up request carrying the last kind and slug, because each purge clears the site's whole route cache: a bulk delete of fifty rows sends at most two requests. The log line of such a follow-up adds `coalesced` with the number of saves it stands in for besides its own.
|
|
|
|
### Timing and failures
|
|
|
|
`cooler.PurgeSiteCacheAfterCommit` registers the purge on the save's transaction through `lagoon.AfterCommit`. It is queued 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 queued at once. Queuing never blocks: the admin request answers without waiting for the site, and a background worker sends the request.
|
|
|
|
A purge never fails a save. Each 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. Redirects are not followed: a 3xx answer is logged as a failed purge with its status, so the bearer token never goes to another URL and the POST never turns into a GET. Point `site_cache.purge_url` at the final URL.
|
|
|
|
Because the purge is fire and forget, a test or a console command that wants to see its effect calls `cooler.SiteCachePurger.Wait`, which returns once nothing is queued or in flight (or when its context ends). `serve` waits for a queued purge on shutdown, up to the timeout plus one second, through `cooler.Handle.Close`.
|
|
|
|
### 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`:
|
|
|
|
```go src=modules/cooler/example_test.go#articlesController.purge
|
|
// purge queues one site-cache purge for the saved article once the save
|
|
// transaction commits (at once outside one); the save never waits for the
|
|
// site. 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:
|
|
|
|
```go src=modules/cooler/example_test.go#articlesController.FormAfterUpdate
|
|
// 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. A tree or sortable list's reorder save runs `pact.ReorderAfterSave` the same way; see [Tree reorder](../backend/lists-and-filters.md#tree-reorder).
|
|
|
|
### 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.
|