- caching.md: an https purge_url on another host goes through the site's proxy; set NUXT_SITE_CACHE_ALLOW_PROXIED=true there, the token is then the only gate - cooler README: the purge_url row points at it
228 lines
18 KiB
Markdown
228 lines
18 KiB
Markdown
# cooler
|
|
|
|
Cache drivers and shared counters for SummerCMS: memory and Redis caches with a key prefix and TTLs, a Redis-backed rate-limit store, and the site-cache purge after admin saves.
|
|
|
|
`import "git.golem15.com/golem15/summercms/modules/cooler"`
|
|
|
|
**Name:** The cool box you bring to the beach; it keeps things fresh and at hand for everyone in the group.
|
|
|
|
## Overview
|
|
|
|
cooler holds the state that several application instances share. `cooler.Cache` is a Laravel-core cache (get, set, has, forget, remember, increment, flush) with per-entry TTLs under a per-application key prefix, kept in process by an otter cache or shared through Redis. The second piece is a Redis-backed rate-limit store: with `http.rate_limit.store: redis`, the `serve` command of [surf](../surf/README.md) calls `cooler.Install`, which builds a Redis client from `redis.url`, and publishes the store so the `throttle` middleware counts in Redis instead of in the process. Two instances behind one load balancer then enforce one limit together. The third piece is the site-cache purge: with `site_cache.purge_url` and `site_cache.purge_token` set, an admin save in a plugin sends one bearer-token POST to the host site's purge route after the save commits, so a cached page shows the change on its next view. It is the counterpart of Laravel's `Cache` facade with its array and Redis stores, and of the Redis store behind `RateLimiter` and the `throttle` middleware.
|
|
|
|
## Role in the framework
|
|
|
|
A service module on `backpack` and `compass`, below `surf`: surf imports cooler, never the reverse. `cooler.RateLimitStore` has the method set of `surf.Store` (through `cooler.Attempter`), so surf takes no Redis dependency and cooler imports neither surf nor cabana. The framework wires cooler at boot; applications import nothing.
|
|
|
|
Use cases:
|
|
|
|
- Cache an expensive query or an upstream answer for a few minutes, in a plugin or a job.
|
|
- Keep `throttle` limits exact when an application runs as several instances behind a load balancer.
|
|
- Share one Redis connection setting (`redis.url`) between the framework services that need Redis.
|
|
- Drop a server-rendered site's page cache right after an editor saves a FAQ, a menu or a post in the admin, instead of waiting for the cache's TTL.
|
|
|
|
## Features
|
|
|
|
- `cooler.Cache` methods: `cooler.Cache.Get`, `cooler.Cache.Set`, `cooler.Cache.Has`, `cooler.Cache.Forget`, `cooler.Cache.Remember`, `cooler.Cache.Increment` and `cooler.Cache.Flush`, with a TTL per entry (0 or less: no expiry). No tags and no locks.
|
|
- Memory driver (`cache.driver: memory`, the default): `cooler.NewMemoryCache` on otter v2, bounded by `cache.memory.max_entries` (default `cooler.DefaultMaxEntries`, 10000) so request-derived keys cannot grow memory without limit. Values are copied in and out, and `cooler.Cache.Increment` is atomic per key.
|
|
- Redis driver (`cache.driver: redis`): `cooler.NewRedisCache` with keys `<prefix>cache:<key>`, `SET … PX` for TTLs, `EXISTS`, `UNLINK` and `INCRBY`. `cooler.Cache.Flush` walks `SCAN MATCH <prefix>cache:*` in batches of 500 and removes each batch with `UNLINK`; it never empties the whole database, so rate-limit counters and other applications' keys stay.
|
|
- `cooler.Cache.Increment` starts a missing key at 0 without a TTL and keeps an existing key's expiry, as Redis `INCRBY` does.
|
|
- `cooler.Cache.Remember` degrades instead of failing the page: a failed read still runs the callback and returns its value, a failed write is skipped, and both are logged at most once per minute. The other methods return their errors.
|
|
- `cooler.Install` reads `cache.driver` and `http.rate_limit.store` (each `memory`, the default, or `redis`), publishes the Cache on the app (reusing one `cooler.FromApp` published earlier) and, when either selects Redis, builds one client from `redis.url`, shared by both, and pings it within `cooler.PingTimeout` (3 s).
|
|
- `cooler.FromApp` gives commands that do not run `serve` (a dedicated job worker, console commands) the published Cache, or builds the configured one and publishes it, without a ping: go-redis dials on the first command.
|
|
- Start-up never fails because Redis is down: an unanswered ping is one warning that names the endpoint (host and port, never the URL), the limiter counts per instance and `cooler.Cache.Remember` degrades until Redis answers. A missing or malformed `redis.url`, an unknown driver name or a `cache.memory.max_entries` below 1 fails start-up with an error that names the key and never contains the URL or its password.
|
|
- `cooler.RateLimitStore` runs one atomic Lua script per attempt with the semantics of `surf.MemoryStore`: the first attempt opens the window, a denied attempt does not increment, the window never extends, and the retry time is what is left of the window. Redis keeps the clock, so instance clock skew does not matter.
|
|
- Each attempt is bounded by `cooler.AttemptTimeout` (500 ms). When Redis fails, the attempt is counted in the fallback store surf passes in (a per-instance `surf.MemoryStore`) and a warning is logged at most once per minute.
|
|
- Keys carry a per-application prefix: `cache.prefix`, or by default `app.name` lower-cased with every run of other characters than `a-z` and `0-9` replaced by `_`, then `:` (`summer:` when `app.name` is empty). Cache keys are `<prefix>cache:<key>`, rate-limit keys `<prefix>rl:<key>`.
|
|
- `cooler.OpenRedis` accepts `redis://`, `rediss://` (TLS) and `unix://` URLs, does not dial at construction, and skips the `CLIENT SETINFO` and maintenance-notification handshakes.
|
|
- Only `serve` installs cooler: `route:list` and `surf.Assemble` in tests keep the in-process store and never dial Redis.
|
|
- Site-cache purge: `cooler.SiteCachePurger` sends `POST {"kind":…,"slug":…}` with `Authorization: Bearer <token>` to `site_cache.purge_url`. `cooler.PurgeSiteCacheAfterCommit` queues that request on the save transaction through `lagoon.AfterCommit`, so it is queued only after the save commits and never for a save that rolled back. The save never waits for the site: a per-purger worker sends the request, with at most one in flight, and saves committed meanwhile collapse into one follow-up request (each purge clears the site's whole route cache), so a bulk delete sends at most two. The purger's `Wait` method drains the queue for tests and commands (see the API reference), and `cooler.Handle.Close` drains it at shutdown.
|
|
- A purge never fails a save: each request is bounded by `cooler.SiteCachePurgeTimeout` (2 s) and detached from the request's cancellation, and a failure, an error status or a timeout is one warning (`cooler: site cache purge failed`) with the kind, slug and status. A success is one info line (`cooler: site cache purged`) with the purged count the site answered. Log lines and errors never carry the token or the full URL.
|
|
- The purge URL must be http or https with a host and without user info or a fragment; plain http is accepted only for a loopback host (127.0.0.0/8, `::1`, `localhost`), so the token never crosses a network in clear. The token needs at least `cooler.SiteCachePurgeMinTokenLength` (32) characters, the same minimum the site's route applies. Setting only one of the two keys, or an invalid value, fails `serve` at boot with an error that names the key.
|
|
- `cooler.Install` publishes the purger at boot unless a plugin already published one (a plugin that still reads its own deprecated keys), and `cooler.SiteCacheFromApp` gives admin hooks the same purger lazily in commands and tests that do not run `serve`. Plugins take the save transaction from `cabana.TxFromContext` themselves; cooler imports neither surf nor cabana.
|
|
|
|
## Usage
|
|
|
|
A plugin or job reads and writes the cache the framework published:
|
|
|
|
```go
|
|
package blog
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
|
)
|
|
|
|
// Sidebar returns the rendered sidebar, cached for five minutes.
|
|
func Sidebar(ctx context.Context, app *backpack.App, render func(context.Context) ([]byte, error)) ([]byte, error) {
|
|
cache, err := cooler.FromApp(app)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cache.Remember(ctx, "blog:sidebar", 5*time.Minute, render)
|
|
}
|
|
```
|
|
|
|
`cooler.FromApp` returns the Cache `serve` published, so in a request handler `app.Lookup[cooler.Cache]()` works as well. In a separate `queue:work` process or a console command, `cooler.FromApp` builds the configured Cache the first time.
|
|
|
|
Select Redis for the cache, the rate limiter or both:
|
|
|
|
```yaml
|
|
# config/cache.yaml
|
|
driver: redis
|
|
```
|
|
|
|
Select the shared rate-limit store in the application's config and keep the URL, which may carry a password, in the environment (`SUMMER_REDIS__URL`):
|
|
|
|
```yaml
|
|
# config/http.yaml
|
|
rate_limit:
|
|
store: redis
|
|
```
|
|
|
|
```yaml
|
|
# config/redis.yaml (or SUMMER_REDIS__URL=redis://:secret@10.0.0.7:6379/0)
|
|
url: redis://10.0.0.7:6379/0
|
|
```
|
|
|
|
Nothing else changes: routes keep `throttle:60,1` or `throttle:<bucket>`, and `serve` publishes the Redis store before it builds the router.
|
|
|
|
An admin controller purges the host site's page cache after its saves. The hook runs inside the save transaction, and the purge waits for the commit:
|
|
|
|
```go
|
|
package blog
|
|
|
|
import (
|
|
"context"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
|
)
|
|
|
|
// Article is the blog's article model.
|
|
type Article struct {
|
|
Slug string
|
|
}
|
|
|
|
type articlesController struct {
|
|
app func() *backpack.App
|
|
}
|
|
|
|
// FormAfterUpdate purges the site cache once the save commits.
|
|
func (c articlesController) FormAfterUpdate(ctx context.Context, model any) error {
|
|
tx, ok := cabana.TxFromContext(ctx)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
purger := cooler.SiteCacheFromApp(c.app())
|
|
cooler.PurgeSiteCacheAfterCommit(ctx, tx, purger, "article", model.(*Article).Slug)
|
|
return nil
|
|
}
|
|
```
|
|
|
|
A tool outside `serve` can use the store directly:
|
|
|
|
```go
|
|
package blog
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
|
)
|
|
|
|
// AllowExport admits five exports a minute per user across all instances.
|
|
func AllowExport(ctx context.Context, app *backpack.App, fallback cooler.Attempter, userKey string) (bool, error) {
|
|
client, err := cooler.OpenRedis(ctx, app.Config.String(cooler.RedisURLKey))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer client.Close()
|
|
store := cooler.NewRateLimitStore(client, "acme:", fallback)
|
|
allowed, _, _ := store.Attempt("export|"+userKey, 5, time.Minute)
|
|
return allowed, nil
|
|
}
|
|
```
|
|
|
|
## API reference
|
|
|
|
| Identifier | Description |
|
|
|------------|-------------|
|
|
| `cooler.Cache` | The cache: `Get`, `Set`, `Has`, `Forget`, `Remember`, `Increment` and `Flush` with per-entry TTLs under the app's key prefix. |
|
|
| `cooler.Cache.Remember` | Returns the cached value or computes, stores and returns it; degrades on cache errors. |
|
|
| `cooler.Cache.Increment` | Adds to an integer value; a missing key starts at 0 without a TTL. |
|
|
| `cooler.Cache.Flush` | Removes this application's cache keys only. |
|
|
| `cooler.NewMemoryCache` | In-process driver on otter v2, bounded to a number of entries. |
|
|
| `cooler.NewRedisCache` | Redis driver on a go-redis client, keys `<prefix>cache:<key>`. |
|
|
| `cooler.FromApp` | Returns the published Cache, or builds the configured one without a ping and publishes it. |
|
|
| `cooler.Install` | Reads cooler's config, publishes the Cache, opens and pings Redis when a Redis driver is selected, and returns a `cooler.Handle`. Called by surf's `serve`. |
|
|
| `cooler.Handle` | What `cooler.Install` set up. |
|
|
| `cooler.Handle.Cache` | The Cache `cooler.Install` published or found. |
|
|
| `cooler.Handle.Close` | Waits for the site-cache purges queued on the purger `cooler.Install` published (up to `cooler.SiteCachePurgeTimeout` plus one second), then closes the Redis client `cooler.Install` opened, if any. |
|
|
| `cooler.Handle.RateLimitRedis` | Reports whether `http.rate_limit.store` selects the Redis store. |
|
|
| `cooler.Handle.RateLimitStore` | Returns the Redis rate-limit store with the given fallback, or the fallback itself when Redis is not selected. |
|
|
| `cooler.Attempter` | One atomic fixed-window admission; the method set of `surf.Store`. |
|
|
| `cooler.RateLimitStore` | Redis fixed-window store shared by all instances, with a fallback while Redis fails. |
|
|
| `cooler.NewRateLimitStore` | Builds a `cooler.RateLimitStore` from a go-redis client, a key prefix and a fallback. |
|
|
| `cooler.RateLimitStore.Attempt` | Admits and counts one request in one Lua script; falls back on a Redis error. |
|
|
| `cooler.OpenRedis` | Builds a go-redis client from a `redis://`, `rediss://` or `unix://` URL without dialing; errors never contain the URL. |
|
|
| `cooler.SiteCachePurger` | Sends the site-cache purge request; a nil purger does nothing. |
|
|
| `cooler.NewSiteCachePurger` | Builds the purger from the values of `site_cache.purge_url` and `site_cache.purge_token`; both empty returns nil; errors name those keys and never the token. |
|
|
| `cooler.NewSiteCachePurgerFor` | The same with the config keys its errors name passed in, for a plugin that reads deprecated keys of its own. |
|
|
| `cooler.SiteCachePurger.Purge` | Sends one purge request and blocks until it is done (2 s timeout, detached from cancellation, redirects not followed); the send the after-commit queue uses. Logs the outcome; never fails. |
|
|
| `cooler.SiteCachePurger.Wait` | Blocks until no queued purge is pending or in flight, or until the context ends (returning its error); nil at once for a nil or idle purger. |
|
|
| `cooler.SiteCachePurger.Endpoint` | The purge URL's host and path, for log lines. |
|
|
| `cooler.PurgeSiteCacheAfterCommit` | Queues one purge after the given transaction commits (at once outside a transaction), fire and forget: one request in flight per purger, later saves collapse into one follow-up. |
|
|
| `cooler.SiteCacheFromApp` | Returns the published purger, or builds it from the framework keys and publishes it; nil when unset or invalid. |
|
|
| `cooler.SiteCachePurgeURLKey` | `site_cache.purge_url`. |
|
|
| `cooler.SiteCachePurgeTokenKey` | `site_cache.purge_token`. |
|
|
| `cooler.SiteCachePurgeTimeout` | Bound of one purge request (2 s). |
|
|
| `cooler.SiteCachePurgeMinTokenLength` | Shortest accepted purge token (32). |
|
|
| `cooler.RedisURLKey` | `redis.url`. |
|
|
| `cooler.RateLimitStoreKey` | `http.rate_limit.store`. |
|
|
| `cooler.CacheDriverKey` | `cache.driver`. |
|
|
| `cooler.CachePrefixKey` | `cache.prefix`. |
|
|
| `cooler.CacheMaxEntriesKey` | `cache.memory.max_entries`. |
|
|
| `cooler.DefaultMaxEntries` | Default size bound of the memory driver (10000). |
|
|
| `cooler.PingTimeout` | Bound of the start-up ping (3 s). |
|
|
| `cooler.AttemptTimeout` | Bound of one rate-limit attempt on Redis (500 ms). |
|
|
|
|
## Configuration
|
|
|
|
`cooler.Install` reads these keys from the app's [compass](../compass/README.md) config:
|
|
|
|
| Key | Default | Controls |
|
|
|-----|---------|----------|
|
|
| `cache.driver` | `memory` | Cache driver: `memory` (per instance, otter) or `redis` (shared). Any other value fails at boot. Environment: `SUMMER_CACHE__DRIVER`. |
|
|
| `cache.memory.max_entries` | `10000` | Size bound of the memory driver; below 1 fails at boot. Set it in YAML (environment values are strings). |
|
|
| `http.rate_limit.store` | `memory` | Where `throttle` counters live: `memory` (per instance) or `redis` (shared). Any other value fails at boot. Environment: `SUMMER_HTTP__RATE_LIMIT__STORE`. |
|
|
| `redis.url` | none | Redis URL (`redis://`, `rediss://` or `unix://`). Required when a Redis cache or rate-limit store is selected; keep it in the environment when it carries a password. Environment: `SUMMER_REDIS__URL`. |
|
|
| `cache.prefix` | from `app.name` | Prefix of every cooler key. Environment: `SUMMER_CACHE__PREFIX`. |
|
|
| `site_cache.purge_url` | none | The host site's purge route, for example `http://127.0.0.1:3000/api/__site__/cache/purge`. http only to a loopback host, https otherwise; an https URL on another host goes through the site's proxy, so the site sets `NUXT_SITE_CACHE_ALLOW_PROXIED=true` (see [A purge URL on another host](../../docs/services/caching.md#a-purge-url-on-another-host)). Set together with the token, or neither (no purge). Environment: `SUMMER_SITE_CACHE__PURGE_URL`. |
|
|
| `site_cache.purge_token` | none | Bearer token of the purge request, at least 32 characters, the same value as the site's `NUXT_SITE_CACHE_PURGE_TOKEN`. Keep it in the environment. Environment: `SUMMER_SITE_CACHE__PURGE_TOKEN`. |
|
|
|
|
## Dependencies
|
|
|
|
- SummerCMS modules: [backpack](../backpack/README.md), [compass](../compass/README.md), [lagoon](../lagoon/README.md) (`lagoon.AfterCommit` for the after-commit purge).
|
|
- Third-party: `github.com/redis/go-redis/v9` (Redis client), `github.com/maypok86/otter/v2` (memory driver).
|
|
- Third-party (purge): `gorm.io/gorm` (the save transaction handle).
|
|
- Standard library: `bytes`, `context`, `encoding/json`, `errors`, `fmt`, `io`, `log/slog`, `net`, `net/http`, `net/url`, `strconv`, `strings`, `sync`, `time`.
|
|
- Tests additionally use `github.com/alicebob/miniredis/v2` (an in-process Redis for the `-short` suites; only `_test.go` files import it) and `github.com/testcontainers/testcontainers-go` (Redis and Postgres containers).
|
|
|
|
## Testing
|
|
|
|
```sh
|
|
go test -short ./modules/cooler/... # no Docker: memory driver, miniredis, httptest receivers
|
|
go test ./modules/cooler/... # also the Redis and Postgres containers (needs Docker)
|
|
```
|
|
|
|
The `-short` suites need no external services. `TestMemoryCache`, `TestMemoryCacheBounded` and `TestMemoryIncrementConcurrent` cover the memory driver; `TestRedisCache` and `TestRedisFlushOnlyPrefix` run the Redis driver on miniredis (TTLs through `FastForward`). `TestRateLimitParity` runs one table of attempts through `surf.NewMemoryStore` and the Redis store and expects the same answers; `TestRateLimitFallbackOnError` stops Redis and checks the fallback store answers with one warning. `TestInstallConfigErrors`, `TestInstallPingWarning` and `TestFromAppLazy` cover start-up; `TestSiteCachePurgeConfig` and `TestSiteCachePurgeRequest` the purge configuration and request against an `httptest` receiver.
|
|
|
|
The Docker suites skip under `-short`: `TestRateLimitParityDocker` and `TestRedisCacheDocker` run the same checks on a `redis:7-alpine` container, `TestSiteCacheAfterCommitOnly` and `TestSiteCacheFailureNeverFails` run the after-commit purge in Postgres transactions, and `TestRedisCacheSmoke` is the original Redis smoke test. The cross-instance rate-limit test lives in surf (`go test ./modules/surf -run TestRateLimitAcrossInstancesSmoke`).
|