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

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