From fd7f204979f7ee73decf1e8ceb29bc99310c34ec Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 15 Oct 2026 17:40:23 +0200 Subject: [PATCH] feat(19.3-01): add cooler.Cache with otter memory and Redis drivers - Cache: Get, Set, Has, Forget, Remember (degrades on cache errors), Increment, Flush with per-entry TTLs under cache: - memory driver on otter v2.3.0 bounded by cache.memory.max_entries; Redis driver with SET PX, INCRBY and a Flush limited to SCAN MATCH cache:* plus UNLINK - Install publishes the Cache (sharing one Redis client with the rate limiter); FromApp serves jobs and commands without a dial - new docs/services/caching.md, configuration rows, cooler README --- docs/services/caching.md | 96 +++++++++++++ docs/setup/configuration.md | 3 +- go.mod | 1 + go.sum | 2 + modules/cooler/README.md | 73 ++++++++-- modules/cooler/cooler.go | 255 ++++++++++++++++++++++++++++++--- modules/cooler/example_test.go | 42 ++++++ modules/cooler/memory.go | 154 ++++++++++++++++++++ modules/cooler/ratelimit.go | 35 +---- modules/cooler/rediscache.go | 140 ++++++++++++++++++ modules/cooler/smoke_test.go | 124 ++++++++++++++++ 11 files changed, 867 insertions(+), 58 deletions(-) create mode 100644 docs/services/caching.md create mode 100644 modules/cooler/example_test.go create mode 100644 modules/cooler/memory.go create mode 100644 modules/cooler/rediscache.go diff --git a/docs/services/caching.md b/docs/services/caching.md new file mode 100644 index 0000000..b0f8258 --- /dev/null +++ b/docs/services/caching.md @@ -0,0 +1,96 @@ +--- +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 `cache:`. 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 `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 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. diff --git a/docs/setup/configuration.md b/docs/setup/configuration.md index fee622c..caed203 100644 --- a/docs/setup/configuration.md +++ b/docs/setup/configuration.md @@ -54,7 +54,8 @@ These are the keys most applications set. Each module's reference page lists all | `http.body_limits.default_bytes`, `http.body_limits.upload_bytes` | Request body limits. Required by `serve` and `route:list`. | [surf](../../modules/surf/README.md) | | `http.cors.*`, `http.trusted_proxies` | CORS and the proxies whose forwarded client IP is trusted. | [surf](../../modules/surf/README.md) | | `http.rate_limit.store` | Where `throttle` counters live: `memory` (per instance, the default) or `redis` (shared by every instance). | [cooler](../../modules/cooler/README.md) | -| `redis.url` | Redis URL (`redis://`, `rediss://` or `unix://`), required when a Redis store is selected. Keep it in `SUMMER_REDIS__URL` when it carries a password. | [cooler](../../modules/cooler/README.md) | +| `cache.driver`, `cache.prefix`, `cache.memory.max_entries` | The cache driver (`memory`, the default, or `redis`), the key prefix (default from `app.name`) and the memory driver's size bound (default 10000). | [cooler](../../modules/cooler/README.md) | +| `redis.url` | Redis URL (`redis://`, `rediss://` or `unix://`), required when a Redis cache or rate-limit store is selected. Keep it in `SUMMER_REDIS__URL` when it carries a password. | [cooler](../../modules/cooler/README.md) | | `http.access_log` | Logs one record per request with method, matched route pattern, status and duration. Defaults to `false`. | [surf](../../modules/surf/README.md) | | `storage.uploads.bucket_url` | Uploads bucket, `file://` or `mem://`. Required by `serve`. | [lagoon](../../modules/lagoon/README.md) | | `storage.uploads.serve` | Mounts `attach.StaticHandlerPublic` under `storage.uploads.public_path_prefix` with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `Content-Disposition: attachment` for anything but a raster image. Defaults to `false`. | [surf](../../modules/surf/README.md) | diff --git a/go.mod b/go.mod index 92b56b2..c8e754f 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/knadh/koanf/providers/env/v2 v2.0.1 github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.3.6 + github.com/maypok86/otter/v2 v2.3.0 github.com/microcosm-cc/bluemonday v1.0.27 github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/redis/go-redis/v9 v9.23.0 diff --git a/go.sum b/go.sum index 057701b..23e5432 100644 --- a/go.sum +++ b/go.sum @@ -209,6 +209,8 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w= +github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= diff --git a/modules/cooler/README.md b/modules/cooler/README.md index 08fdead..c6b928e 100644 --- a/modules/cooler/README.md +++ b/modules/cooler/README.md @@ -8,7 +8,7 @@ Cache drivers and shared counters for SummerCMS: memory and Redis caches with a ## Overview -cooler holds the state that several application instances share. Its first 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. It is the counterpart of Laravel's Redis cache store behind `RateLimiter` and the `throttle` middleware. +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. 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 @@ -16,22 +16,61 @@ A service module on `backpack` and `compass`, below `surf`: surf imports cooler, 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. ## Features -- `cooler.Install` reads `http.rate_limit.store` (`memory`, the default, or `redis`). With `redis` it builds the client from `redis.url` and pings it within `cooler.PingTimeout` (3 s). -- Start-up never fails because Redis is down: an unanswered ping is one warning that names the endpoint (host and port, never the URL), and the limiter counts per instance until Redis answers. A missing or malformed `redis.url`, or an unknown store name, fails start-up with an error that names the key and never contains the URL or its password. +- `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 `cache:`, `SET … PX` for TTLs, `EXISTS`, `UNLINK` and `INCRBY`. `cooler.Cache.Flush` walks `SCAN MATCH 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). Rate-limit keys are `rl:`. +- 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 `cache:`, rate-limit keys `rl:`. - `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. ## Usage -Select the shared store in the application's config and keep the URL, which may carry a password, in the environment (`SUMMER_REDIS__URL`): +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 @@ -76,8 +115,16 @@ func AllowExport(ctx context.Context, app *backpack.App, fallback cooler.Attempt | Identifier | Description | |------------|-------------| -| `cooler.Install` | Reads cooler's config, opens and pings Redis when a Redis store is selected, and returns a `cooler.Handle`. Called by surf's `serve`. | +| `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 `cache:`. | +| `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` | 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. | @@ -88,7 +135,10 @@ func AllowExport(ctx context.Context, app *backpack.App, fallback cooler.Attempt | `cooler.OpenRedis` | Builds a go-redis client from a `redis://`, `rediss://` or `unix://` URL without dialing; errors never contain the URL. | | `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). | @@ -98,15 +148,18 @@ func AllowExport(ctx context.Context, app *backpack.App, fallback cooler.Attempt | 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 store is selected; keep it in the environment when it carries a password. Environment: `SUMMER_REDIS__URL`. | +| `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`. | ## Dependencies - SummerCMS modules: [backpack](../backpack/README.md), [compass](../compass/README.md). -- Third-party: `github.com/redis/go-redis/v9` (Redis client). -- Standard library: `context`, `errors`, `fmt`, `log/slog`, `strings`, `sync`, `time`. +- Third-party: `github.com/redis/go-redis/v9` (Redis client), `github.com/maypok86/otter/v2` (memory driver). +- Standard library: `context`, `errors`, `fmt`, `log/slog`, `strconv`, `strings`, `sync`, `time`. +- Tests additionally use `github.com/testcontainers/testcontainers-go` (Redis container). ## Testing @@ -114,4 +167,4 @@ func AllowExport(ctx context.Context, app *backpack.App, fallback cooler.Attempt go test ./modules/cooler/... ``` -The smoke tests need no external services. The cross-instance rate-limit test lives in surf (`go test ./modules/surf -run TestRateLimitAcrossInstancesSmoke`), starts a Redis container through testcontainers-go and needs Docker; `-short` skips it. +`TestRedisCacheSmoke` starts a Redis container through testcontainers-go and needs Docker; `go test -short ./modules/cooler/...` skips it, and the other tests need no external services. The cross-instance rate-limit test lives in surf (`go test ./modules/surf -run TestRateLimitAcrossInstancesSmoke`), starts a Redis container through testcontainers-go and needs Docker; `-short` skips it. diff --git a/modules/cooler/cooler.go b/modules/cooler/cooler.go index d0c5c27..9f74fcc 100644 --- a/modules/cooler/cooler.go +++ b/modules/cooler/cooler.go @@ -1,8 +1,10 @@ // Package cooler holds the shared-state services of a SummerCMS application: -// a Redis-backed fixed-window rate-limit store that keeps limits across -// application instances, with the Redis client built from the one redis.url -// key. The framework installs it at boot (surf's serve command), so -// applications import nothing. +// a Laravel-core Cache with a memory (otter) and a Redis driver under a +// per-application key prefix, and a Redis-backed fixed-window rate-limit +// store that keeps limits across application instances. One redis.url key +// configures Redis for both. The framework installs cooler at boot (surf's +// serve command) and FromApp serves other commands, so applications import +// nothing. // // cooler imports neither surf nor cabana: surf imports cooler, and the // rate-limit store satisfies surf.Store structurally through Attempter. @@ -10,9 +12,11 @@ package cooler import ( "context" + "errors" "fmt" "log/slog" "strings" + "sync" "time" "git.golem15.com/golem15/summercms/modules/backpack" @@ -62,17 +66,46 @@ type Attempter interface { Attempt(key string, max int, decay time.Duration) (allowed bool, attempts int, retryAfter time.Duration) } +// Cache is a Laravel-core cache: byte values under string keys, each with +// its own TTL, stored under the application's key prefix. A ttl of 0 or less +// keeps the value until it is forgotten, flushed or evicted. There are no tags +// and no locks. +type Cache interface { + // Get returns the value at key and whether it was found. + Get(ctx context.Context, key string) ([]byte, bool, error) + // Set stores value at key for ttl (0 or less: no expiry). + Set(ctx context.Context, key string, value []byte, ttl time.Duration) error + // Has reports whether key holds a live value. + Has(ctx context.Context, key string) (bool, error) + // Forget removes key. + Forget(ctx context.Context, key string) error + // Remember returns the value at key, or calls fn, stores its value for + // ttl and returns it. It degrades instead of failing: when the read fails + // it still calls fn and returns its value, a failing write is skipped, + // and both are logged as a warning at most once per minute. Only an error + // from fn is returned. + Remember(ctx context.Context, key string, ttl time.Duration, fn func(context.Context) ([]byte, error)) ([]byte, error) + // Increment adds by to the integer at key and returns the new value. A + // missing key starts at 0 and gets no TTL; an existing key keeps its + // expiry, as Redis INCRBY does. + Increment(ctx context.Context, key string, by int64) (int64, error) + // Flush removes every key of this application's cache, and nothing else. + Flush(ctx context.Context) error +} + // Handle is what Install set up. Close releases the Redis client Install // opened, if any. type Handle struct { client *redis.Client prefix string rateRedis bool + cache Cache log *slog.Logger } // Close closes the Redis client Install opened. It is safe on a nil Handle -// and when no Redis driver was selected. +// and when no Redis driver was selected. A Redis-backed Cache that Install +// built on that client stops working; serve closes the handle on shutdown. func (h *Handle) Close() error { if h == nil || h.client == nil { return nil @@ -82,6 +115,15 @@ func (h *Handle) Close() error { return err } +// Cache returns the Cache Install published (or the one FromApp had +// published before it). +func (h *Handle) Cache() Cache { + if h == nil { + return nil + } + return h.cache +} + // RateLimitRedis reports whether http.rate_limit.store selects the Redis store. func (h *Handle) RateLimitRedis() bool { return h != nil && h.rateRedis && h.client != nil @@ -95,16 +137,21 @@ func (h *Handle) RateLimitStore(fallback Attempter) Attempter { return fallback } s := NewRateLimitStore(h.client, h.prefix, fallback) - s.log = h.log + s.warn.setLogger(h.log) return s } -// Install reads cooler's configuration and opens what it selects. With -// http.rate_limit.store set to redis it builds a client from redis.url and -// pings it within PingTimeout. An unreachable Redis is logged as a warning -// (the endpoint only, never the URL) and start-up continues: the rate limiter -// falls back to per-instance memory until Redis answers. A missing or invalid -// redis.url, or an unknown store name, is a boot error naming the key. +// Install reads cooler's configuration, opens what it selects and publishes +// the Cache on app (reusing one FromApp already published). +// +// cache.driver selects the cache (memory, the default, or redis) and +// http.rate_limit.store the rate-limit store (memory or redis). When either +// selects Redis, Install builds one client from redis.url, shared by both, +// and pings it within PingTimeout. An unreachable Redis is logged as a +// warning (the endpoint only, never the URL) and start-up continues: the rate +// limiter falls back to per-instance memory and Remember degrades until Redis +// answers. A missing or invalid redis.url, an unknown driver name or a +// non-positive cache.memory.max_entries is a boot error naming the key. // // Only the serve command calls Install; route:list and tests never dial Redis. func Install(ctx context.Context, app *backpack.App) (*Handle, error) { @@ -120,18 +167,119 @@ func Install(ctx context.Context, app *backpack.App) (*Handle, error) { if err != nil { return nil, err } + cacheDriver, err := driverName(cfg, CacheDriverKey) + if err != nil { + return nil, err + } h.rateRedis = store == driverRedis - if h.rateRedis { - client, err := openFromConfig(ctx, cfg, RateLimitStoreKey) + existing, published := app.Lookup[Cache]() + published = published && existing != nil + needRedis := h.rateRedis || (!published && cacheDriver == driverRedis) + if needRedis { + selectedBy := RateLimitStoreKey + if !h.rateRedis { + selectedBy = CacheDriverKey + } + client, err := openFromConfig(ctx, cfg, selectedBy) if err != nil { return nil, err } h.client = client - ping(ctx, client, h.log) + } + if published { + h.cache = existing + } else { + c, err := buildCache(cfg, cacheDriver, h.prefix, h.client) + if err != nil { + _ = h.Close() + return nil, err + } + setLogger(c, h.log) + if err := app.Publish[Cache](c); err != nil { + _ = h.Close() + return nil, fmt.Errorf("cooler: %w", err) + } + h.cache = c + } + if h.client != nil { + ping(ctx, h.client, h.log) } return h, nil } +// FromApp returns the Cache published on app, or builds the configured one +// and publishes it. It never pings: go-redis dials on the first command, so a +// job worker, a console command or a plugin's Boot can call it without a +// running Redis. When another caller published first, FromApp returns that +// Cache. A Redis client FromApp opens lives as long as the process. +func FromApp(app *backpack.App) (Cache, error) { + if app == nil { + return nil, fmt.Errorf("cooler: app is nil") + } + if c, ok := app.Lookup[Cache](); ok && c != nil { + return c, nil + } + cfg := app.Config + driver, err := driverName(cfg, CacheDriverKey) + if err != nil { + return nil, err + } + prefix := keyPrefix(cfg) + var client *redis.Client + if driver == driverRedis { + client, err = openFromConfig(context.Background(), cfg, CacheDriverKey) + if err != nil { + return nil, err + } + } + c, err := buildCache(cfg, driver, prefix, client) + if err != nil { + if client != nil { + _ = client.Close() + } + return nil, err + } + setLogger(c, loggerFromApp(app)) + if err := app.Publish[Cache](c); err != nil { + if client != nil { + _ = client.Close() + } + if winner, ok := app.Lookup[Cache](); ok && winner != nil { + return winner, nil + } + return nil, fmt.Errorf("cooler: %w", err) + } + return c, nil +} + +// buildCache builds the selected driver. client is used by the Redis driver. +func buildCache(cfg *compass.Config, driver, prefix string, client *redis.Client) (Cache, error) { + if driver == driverRedis { + if client == nil { + return nil, errNilClient + } + return NewRedisCache(client, prefix), nil + } + n, err := maxEntries(cfg) + if err != nil { + return nil, err + } + return NewMemoryCache(prefix, n) +} + +// maxEntries reads cache.memory.max_entries: unset means DefaultMaxEntries, +// anything below 1 (or not a number) is an error naming the key. +func maxEntries(cfg *compass.Config) (int, error) { + if cfg == nil || !cfg.Has(CacheMaxEntriesKey) { + return DefaultMaxEntries, nil + } + n := cfg.Int(CacheMaxEntriesKey) + if n < 1 { + return 0, fmt.Errorf("cooler: %s must be a whole number of at least 1", CacheMaxEntriesKey) + } + return n, nil +} + // openFromConfig builds the Redis client from redis.url, naming the key that // selected Redis when the URL is missing. func openFromConfig(ctx context.Context, cfg *compass.Config, selectedBy string) (*redis.Client, error) { @@ -150,7 +298,7 @@ func ping(ctx context.Context, client *redis.Client, log *slog.Logger) { pctx, cancel := context.WithTimeout(ctx, PingTimeout) defer cancel() if err := client.Ping(pctx).Err(); err != nil { - log.Warn("cooler: redis did not answer at start-up; rate limits count per instance until it does", + log.Warn("cooler: redis did not answer at start-up; rate limits count per instance and the cache degrades until it does", "endpoint", redisEndpoint(client), "error", err) } } @@ -214,3 +362,78 @@ func loggerFromApp(app *backpack.App) *slog.Logger { } return slog.Default() } + +// errNilClient is returned when a Redis driver is built without a client. +var errNilClient = errors.New("cooler: redis client is nil") + +// warnEvery bounds how often a degraded call is logged. +const warnEvery = time.Minute + +// warner logs a message at most once per warnEvery. +type warner struct { + mu sync.Mutex + last time.Time + log *slog.Logger +} + +func newWarner(log *slog.Logger) *warner { return &warner{log: log} } + +func (w *warner) setLogger(log *slog.Logger) { + if w == nil || log == nil { + return + } + w.mu.Lock() + w.log = log + w.mu.Unlock() +} + +func (w *warner) warn(msg string, args ...any) { + if w == nil { + return + } + w.mu.Lock() + now := time.Now() + if !w.last.IsZero() && now.Sub(w.last) < warnEvery { + w.mu.Unlock() + return + } + w.last = now + log := w.log + w.mu.Unlock() + if log == nil { + log = slog.Default() + } + log.Warn(msg, args...) +} + +// setLogger hands the app's logger to a driver built by this package. +func setLogger(c Cache, log *slog.Logger) { + if s, ok := c.(interface{ setLogger(*slog.Logger) }); ok { + s.setLogger(log) + } +} + +// remember is Cache.Remember for every driver: read, else compute and store, +// degrading on cache errors instead of failing the caller. +func remember(ctx context.Context, c Cache, w *warner, key string, ttl time.Duration, fn func(context.Context) ([]byte, error)) ([]byte, error) { + if fn == nil { + return nil, fmt.Errorf("cooler: Remember %q: callback is nil", key) + } + v, ok, err := c.Get(ctx, key) + if err == nil && ok { + return v, nil + } + if err != nil { + w.warn("cooler: cache read failed; computing the value without the cache", "error", err) + } + v, ferr := fn(ctx) + if ferr != nil { + return nil, ferr + } + if err == nil { + if serr := c.Set(ctx, key, v, ttl); serr != nil { + w.warn("cooler: cache write failed; the value was not stored", "error", serr) + } + } + return v, nil +} diff --git a/modules/cooler/example_test.go b/modules/cooler/example_test.go new file mode 100644 index 0000000..d3949f1 --- /dev/null +++ b/modules/cooler/example_test.go @@ -0,0 +1,42 @@ +package cooler_test + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/cooler" +) + +func 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 +} diff --git a/modules/cooler/memory.go b/modules/cooler/memory.go new file mode 100644 index 0000000..d3b954f --- /dev/null +++ b/modules/cooler/memory.go @@ -0,0 +1,154 @@ +package cooler + +import ( + "context" + "fmt" + "log/slog" + "strconv" + "time" + + "github.com/maypok86/otter/v2" +) + +// forever is how long an entry without a TTL lives in the memory driver: +// otter's documented way to say "no expiration" is an excessively long period. +const forever = 50 * 365 * 24 * time.Hour + +// memEntry is a stored value with its absolute expiry; a zero expiresAt +// never expires. +type memEntry struct { + val []byte + expiresAt time.Time +} + +func (e memEntry) expired(now time.Time) bool { + return !e.expiresAt.IsZero() && !now.Before(e.expiresAt) +} + +// memoryCache is the in-process Cache on otter v2: size-bounded, with a +// per-entry expiry computed from the absolute expiresAt stored with the value. +type memoryCache struct { + c *otter.Cache[string, memEntry] + prefix string + warn *warner +} + +// NewMemoryCache returns an in-process Cache holding at most maxEntries +// entries (cache.memory.max_entries, default DefaultMaxEntries); the least +// valuable entries are evicted beyond that. Keys are cache:. +// A maxEntries below 1 is an error naming cache.memory.max_entries. +func NewMemoryCache(prefix string, maxEntries int) (Cache, error) { + if maxEntries < 1 { + return nil, fmt.Errorf("cooler: %s must be at least 1, got %d", CacheMaxEntriesKey, maxEntries) + } + c, err := otter.New(&otter.Options[string, memEntry]{ + MaximumSize: maxEntries, + ExpiryCalculator: otter.ExpiryWritingFunc(func(e otter.Entry[string, memEntry]) time.Duration { + if e.Value.expiresAt.IsZero() { + return forever + } + if d := time.Until(e.Value.expiresAt); d > 0 { + return d + } + return time.Nanosecond + }), + }) + if err != nil { + return nil, fmt.Errorf("cooler: memory cache: %w", err) + } + return &memoryCache{c: c, prefix: prefix, warn: newWarner(slog.Default())}, nil +} + +func (m *memoryCache) key(k string) string { return m.prefix + "cache:" + k } + +func (m *memoryCache) setLogger(log *slog.Logger) { m.warn.setLogger(log) } + +// lookup returns the live entry for a full key, dropping an expired one. +func (m *memoryCache) lookup(full string) (memEntry, bool) { + e, ok := m.c.GetIfPresent(full) + if !ok { + return memEntry{}, false + } + if e.expired(time.Now()) { + m.c.Invalidate(full) + return memEntry{}, false + } + return e, true +} + +func (m *memoryCache) Get(_ context.Context, key string) ([]byte, bool, error) { + e, ok := m.lookup(m.key(key)) + if !ok { + return nil, false, nil + } + return clone(e.val), true, nil +} + +func (m *memoryCache) Set(_ context.Context, key string, value []byte, ttl time.Duration) error { + e := memEntry{val: clone(value)} + if ttl > 0 { + e.expiresAt = time.Now().Add(ttl) + } + m.c.Set(m.key(key), e) + return nil +} + +func (m *memoryCache) Has(_ context.Context, key string) (bool, error) { + _, ok := m.lookup(m.key(key)) + return ok, nil +} + +func (m *memoryCache) Forget(_ context.Context, key string) error { + m.c.Invalidate(m.key(key)) + return nil +} + +func (m *memoryCache) Remember(ctx context.Context, key string, ttl time.Duration, fn func(context.Context) ([]byte, error)) ([]byte, error) { + return remember(ctx, m, m.warn, key, ttl, fn) +} + +// Increment adds by to the integer at key atomically (the update runs under +// otter's per-key lock). A missing or expired key starts at 0 and gets no +// TTL; an existing key keeps its expiry, as Redis INCRBY does. +func (m *memoryCache) Increment(_ context.Context, key string, by int64) (int64, error) { + var ( + n int64 + valErr error + ) + now := time.Now() + m.c.Compute(m.key(key), func(old memEntry, found bool) (memEntry, otter.ComputeOp) { + cur := int64(0) + next := memEntry{} + if found && !old.expired(now) { + v, err := strconv.ParseInt(string(old.val), 10, 64) + if err != nil { + valErr = fmt.Errorf("cooler: cache value at %q is not an integer", key) + return old, otter.CancelOp + } + cur = v + next.expiresAt = old.expiresAt + } + n = cur + by + next.val = []byte(strconv.FormatInt(n, 10)) + return next, otter.WriteOp + }) + if valErr != nil { + return 0, valErr + } + return n, nil +} + +// Flush removes every entry of this cache. +func (m *memoryCache) Flush(context.Context) error { + m.c.InvalidateAll() + return nil +} + +func clone(b []byte) []byte { + if b == nil { + return nil + } + out := make([]byte, len(b)) + copy(out, b) + return out +} diff --git a/modules/cooler/ratelimit.go b/modules/cooler/ratelimit.go index f40933f..d9234a9 100644 --- a/modules/cooler/ratelimit.go +++ b/modules/cooler/ratelimit.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log/slog" - "sync" "time" "github.com/redis/go-redis/v9" @@ -34,9 +33,6 @@ count = redis.call('INCR', KEYS[1]) return {1, count, ttl} `) -// warnEvery bounds how often a Redis failure is logged. -const warnEvery = time.Minute - // RateLimitStore is a fixed-window rate-limit store on Redis, shared by every // application instance that points at the same Redis. Its Attempt has the // exact signature of surf.Store, so surf uses it without cooler importing @@ -46,17 +42,14 @@ type RateLimitStore struct { client *redis.Client prefix string fallback Attempter - log *slog.Logger - - mu sync.Mutex - lastWarn time.Time + warn *warner } // NewRateLimitStore returns a store that keeps its counters under // rl: on client. fallback answers while Redis fails; surf passes // a surf.MemoryStore. A nil fallback admits every request during an outage. func NewRateLimitStore(client *redis.Client, prefix string, fallback Attempter) *RateLimitStore { - return &RateLimitStore{client: client, prefix: prefix, fallback: fallback, log: slog.Default()} + return &RateLimitStore{client: client, prefix: prefix, fallback: fallback, warn: newWarner(slog.Default())} } // Attempt admits and counts one request when key is below max, in one @@ -72,7 +65,8 @@ func (s *RateLimitStore) Attempt(key string, max int, decay time.Duration) (allo defer cancel() res, err := s.run(ctx, s.prefix+"rl:"+key, max, ms) if err != nil { - s.warn(err) + s.warn.warn("cooler: redis rate-limit store failed; counting per instance until it answers", + "endpoint", redisEndpoint(s.client), "error", err) if s.fallback == nil { return true, 0, 0 } @@ -98,24 +92,3 @@ func (s *RateLimitStore) run(ctx context.Context, key string, max int, ms int64) } return res, nil } - -// warn logs a Redis failure at most once per warnEvery. -func (s *RateLimitStore) warn(err error) { - if s == nil { - return - } - s.mu.Lock() - now := time.Now() - if !s.lastWarn.IsZero() && now.Sub(s.lastWarn) < warnEvery { - s.mu.Unlock() - return - } - s.lastWarn = now - s.mu.Unlock() - log := s.log - if log == nil { - log = slog.Default() - } - log.Warn("cooler: redis rate-limit store failed; counting per instance until it answers", - "endpoint", redisEndpoint(s.client), "error", err) -} diff --git a/modules/cooler/rediscache.go b/modules/cooler/rediscache.go new file mode 100644 index 0000000..4d4add9 --- /dev/null +++ b/modules/cooler/rediscache.go @@ -0,0 +1,140 @@ +package cooler + +import ( + "context" + "errors" + "log/slog" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +// flushBatch is how many keys one SCAN step asks for during Flush. +const flushBatch = 500 + +// redisCache is the Cache on Redis, shared by every instance using the same +// Redis and prefix. Keys are cache:. +type redisCache struct { + client *redis.Client + prefix string + warn *warner +} + +// NewRedisCache returns a Cache on client with keys cache:. +// It does not dial; the first call does. +func NewRedisCache(client *redis.Client, prefix string) Cache { + return &redisCache{client: client, prefix: prefix, warn: newWarner(slog.Default())} +} + +func (r *redisCache) key(k string) string { return r.prefix + "cache:" + k } + +func (r *redisCache) setLogger(log *slog.Logger) { r.warn.setLogger(log) } + +func (r *redisCache) ready() error { + if r == nil || r.client == nil { + return errNilClient + } + return nil +} + +func (r *redisCache) Get(ctx context.Context, key string) ([]byte, bool, error) { + if err := r.ready(); err != nil { + return nil, false, err + } + b, err := r.client.Get(ctx, r.key(key)).Bytes() + if errors.Is(err, redis.Nil) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return b, true, nil +} + +// Set stores value with SET … PX; a ttl of 0 or less stores it without expiry. +func (r *redisCache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error { + if err := r.ready(); err != nil { + return err + } + if value == nil { + value = []byte{} + } + if ttl <= 0 { + return r.client.Set(ctx, r.key(key), value, 0).Err() + } + ms := ttl.Milliseconds() + if ms < 1 { + ms = 1 + } + return r.client.Do(ctx, "SET", r.key(key), value, "PX", ms).Err() +} + +func (r *redisCache) Has(ctx context.Context, key string) (bool, error) { + if err := r.ready(); err != nil { + return false, err + } + n, err := r.client.Exists(ctx, r.key(key)).Result() + return n > 0, err +} + +func (r *redisCache) Forget(ctx context.Context, key string) error { + if err := r.ready(); err != nil { + return err + } + return r.client.Unlink(ctx, r.key(key)).Err() +} + +func (r *redisCache) Remember(ctx context.Context, key string, ttl time.Duration, fn func(context.Context) ([]byte, error)) ([]byte, error) { + return remember(ctx, r, r.warn, key, ttl, fn) +} + +// Increment runs INCRBY: a missing key starts at 0 without a TTL, an existing +// key keeps its expiry. +func (r *redisCache) Increment(ctx context.Context, key string, by int64) (int64, error) { + if err := r.ready(); err != nil { + return 0, err + } + return r.client.IncrBy(ctx, r.key(key), by).Result() +} + +// Flush removes only this application's cache keys: it walks SCAN MATCH +// cache:* in batches and removes each batch with UNLINK. It never +// empties the database, which would also drop rate-limit counters and other +// applications' keys. +func (r *redisCache) Flush(ctx context.Context) error { + if err := r.ready(); err != nil { + return err + } + match := escapeGlob(r.prefix+"cache:") + "*" + var cursor uint64 + for { + keys, next, err := r.client.Scan(ctx, cursor, match, flushBatch).Result() + if err != nil { + return err + } + if len(keys) > 0 { + if err := r.client.Unlink(ctx, keys...).Err(); err != nil { + return err + } + } + if next == 0 { + return nil + } + cursor = next + } +} + +// escapeGlob escapes the characters SCAN MATCH treats as a pattern, so a +// prefix is matched literally. +func escapeGlob(s string) string { + var b strings.Builder + for _, r := range s { + switch r { + case '*', '?', '[', ']', '\\': + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() +} diff --git a/modules/cooler/smoke_test.go b/modules/cooler/smoke_test.go index c2bea01..8838acf 100644 --- a/modules/cooler/smoke_test.go +++ b/modules/cooler/smoke_test.go @@ -1,8 +1,14 @@ package cooler import ( + "context" + "fmt" "strings" "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" ) // Smoke tests only; plan 05 of phase 19.3 owns full coverage. @@ -34,3 +40,121 @@ func TestOpenRedisRedactsSmoke(t *testing.T) { t.Fatal("OpenRedis must disable the CLIENT SETINFO handshake") } } + +// cacheRoundTrip runs the shared smoke sequence against one driver. +func cacheRoundTrip(t *testing.T, c Cache) { + t.Helper() + ctx := t.Context() + if err := c.Set(ctx, "greeting", []byte("hello"), 0); err != nil { + t.Fatal(err) + } + if v, ok, err := c.Get(ctx, "greeting"); err != nil || !ok || string(v) != "hello" { + t.Fatalf("Get = %q %v %v", v, ok, err) + } + if ok, err := c.Has(ctx, "greeting"); err != nil || !ok { + t.Fatalf("Has = %v %v", ok, err) + } + if n, err := c.Increment(ctx, "hits", 2); err != nil || n != 2 { + t.Fatalf("Increment from missing = %d %v", n, err) + } + if n, err := c.Increment(ctx, "hits", 3); err != nil || n != 5 { + t.Fatalf("Increment existing = %d %v", n, err) + } + calls := 0 + fn := func(context.Context) ([]byte, error) { calls++; return []byte("computed"), nil } + for range 2 { + v, err := c.Remember(ctx, "remembered", time.Minute, fn) + if err != nil || string(v) != "computed" { + t.Fatalf("Remember = %q %v", v, err) + } + } + if calls != 1 { + t.Fatalf("Remember called the callback %d times, want 1", calls) + } + if err := c.Forget(ctx, "greeting"); err != nil { + t.Fatal(err) + } + if ok, _ := c.Has(ctx, "greeting"); ok { + t.Fatal("Forget left the key") + } + if err := c.Set(ctx, "short", []byte("x"), time.Millisecond); err != nil { + t.Fatal(err) + } + time.Sleep(20 * time.Millisecond) + if _, ok, _ := c.Get(ctx, "short"); ok { + t.Fatal("a 1 ms TTL did not expire") + } + if err := c.Flush(ctx); err != nil { + t.Fatal(err) + } + for _, k := range []string{"hits", "remembered"} { + if ok, _ := c.Has(ctx, k); ok { + t.Fatalf("Flush left %q", k) + } + } +} + +func TestMemoryCacheSmoke(t *testing.T) { + c, err := NewMemoryCache("acme:", DefaultMaxEntries) + if err != nil { + t.Fatal(err) + } + cacheRoundTrip(t, c) + + // Stored bytes are copies. + ctx := t.Context() + in := []byte("abc") + _ = c.Set(ctx, "copy", in, 0) + in[0] = 'X' + out, _, _ := c.Get(ctx, "copy") + out[1] = 'Y' + if again, _, _ := c.Get(ctx, "copy"); string(again) != "abc" { + t.Fatalf("stored value mutated: %q", again) + } + if _, err := NewMemoryCache("acme:", 0); err == nil || !strings.Contains(err.Error(), CacheMaxEntriesKey) { + t.Fatalf("max entries 0: %v", err) + } +} + +func TestRedisCacheSmoke(t *testing.T) { + if testing.Short() { + t.Skip("requires testcontainers redis") + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + defer cancel() + ctr, err := testcontainers.Run(ctx, "redis:7-alpine", + testcontainers.WithExposedPorts("6379/tcp"), + testcontainers.WithWaitStrategy(wait.ForListeningPort("6379/tcp")), + ) + if err != nil { + t.Fatalf("redis: testcontainers: %v", err) + } + t.Cleanup(func() { _ = testcontainers.TerminateContainer(ctr) }) + host, err := ctr.Host(ctx) + if err != nil { + t.Fatal(err) + } + port, err := ctr.MappedPort(ctx, "6379/tcp") + if err != nil { + t.Fatal(err) + } + client, err := OpenRedis(ctx, fmt.Sprintf("redis://%s:%s/0", host, port.Port())) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + + // A rate-limit counter of this app and another app's cache key survive Flush. + if err := client.Set(ctx, "acme:rl:login|192.0.2.1", 3, 0).Err(); err != nil { + t.Fatal(err) + } + if err := client.Set(ctx, "other:cache:greeting", "theirs", 0).Err(); err != nil { + t.Fatal(err) + } + cacheRoundTrip(t, NewRedisCache(client, "acme:")) + for _, k := range []string{"acme:rl:login|192.0.2.1", "other:cache:greeting"} { + if n, err := client.Exists(ctx, k).Result(); err != nil || n != 1 { + t.Fatalf("Flush removed %q (exists=%d, err=%v)", k, n, err) + } + } +}