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 <prefix>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 <prefix>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
This commit is contained in:
Jakub Zych
2026-10-15 17:40:23 +02:00
parent fa4d89d0b9
commit fd7f204979
11 changed files with 867 additions and 58 deletions

View File

@@ -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 `<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). Rate-limit keys are `<prefix>rl:<key>`.
- 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.
## 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 `<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` | 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.

View File

@@ -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
}

View File

@@ -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
}

154
modules/cooler/memory.go Normal file
View File

@@ -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 <prefix>cache:<key>.
// 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
}

View File

@@ -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
// <prefix>rl:<key> 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)
}

View File

@@ -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 <prefix>cache:<key>.
type redisCache struct {
client *redis.Client
prefix string
warn *warner
}
// NewRedisCache returns a Cache on client with keys <prefix>cache:<key>.
// 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
// <prefix>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()
}

View File

@@ -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)
}
}
}