Files
summercms/modules/cooler/cooler.go
Jakub Zych fd7f204979 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
2026-10-15 17:40:23 +02:00

440 lines
14 KiB
Go

// Package cooler holds the shared-state services of a SummerCMS application:
// 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.
package cooler
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"github.com/redis/go-redis/v9"
)
// Config keys and defaults cooler reads.
const (
// RedisURLKey is the shared Redis URL (redis://, rediss:// or unix://),
// required when a Redis driver is selected. Environment: SUMMER_REDIS__URL.
RedisURLKey = "redis.url"
// CacheDriverKey selects the cache driver: memory (default) or redis.
CacheDriverKey = "cache.driver"
// CachePrefixKey is the per-application key prefix. The default derives
// from app.name.
CachePrefixKey = "cache.prefix"
// CacheMaxEntriesKey bounds the memory driver's entry count.
CacheMaxEntriesKey = "cache.memory.max_entries"
// RateLimitStoreKey selects where rate-limit counters live: memory
// (default, per instance) or redis (shared).
RateLimitStoreKey = "http.rate_limit.store"
// DefaultMaxEntries is the memory driver's size bound when
// cache.memory.max_entries is unset.
DefaultMaxEntries = 10000
// PingTimeout bounds the start-up Ping that Install sends to Redis.
PingTimeout = 3 * time.Second
// AttemptTimeout bounds one rate-limit attempt on Redis before the
// fallback store answers.
AttemptTimeout = 500 * time.Millisecond
)
// Driver names accepted by cache.driver and http.rate_limit.store.
const (
driverMemory = "memory"
driverRedis = "redis"
)
// defaultPrefix is the key prefix when neither cache.prefix nor app.name is set.
const defaultPrefix = "summer:"
// Attempter is one atomic fixed-window admission. It has exactly the method
// set of surf.Store, so any surf.Store is an Attempter and a RateLimitStore
// is a surf.Store.
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. 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
}
err := h.client.Close()
h.client = nil
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
}
// RateLimitStore returns the Redis rate-limit store, falling back to fallback
// while Redis does not answer. When the Redis store is not selected it
// returns fallback itself.
func (h *Handle) RateLimitStore(fallback Attempter) Attempter {
if !h.RateLimitRedis() {
return fallback
}
s := NewRateLimitStore(h.client, h.prefix, fallback)
s.warn.setLogger(h.log)
return s
}
// 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) {
if app == nil {
return nil, fmt.Errorf("cooler: app is nil")
}
if ctx == nil {
ctx = context.Background()
}
cfg := app.Config
h := &Handle{prefix: keyPrefix(cfg), log: loggerFromApp(app)}
store, err := driverName(cfg, RateLimitStoreKey)
if err != nil {
return nil, err
}
cacheDriver, err := driverName(cfg, CacheDriverKey)
if err != nil {
return nil, err
}
h.rateRedis = store == driverRedis
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
}
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) {
raw := ""
if cfg != nil {
raw = strings.TrimSpace(cfg.String(RedisURLKey))
}
if raw == "" {
return nil, fmt.Errorf("cooler: %s is required when %s is %s (set it in config/redis.yaml or SUMMER_REDIS__URL)", RedisURLKey, selectedBy, driverRedis)
}
return OpenRedis(ctx, raw)
}
// ping checks Redis once within PingTimeout and logs a warning on failure.
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 and the cache degrades until it does",
"endpoint", redisEndpoint(client), "error", err)
}
}
// driverName reads a memory|redis selector. Empty means memory.
func driverName(cfg *compass.Config, key string) (string, error) {
if cfg == nil {
return driverMemory, nil
}
v := strings.ToLower(strings.TrimSpace(cfg.String(key)))
switch v {
case "", driverMemory:
return driverMemory, nil
case driverRedis:
return driverRedis, nil
}
return "", fmt.Errorf("cooler: %s must be %s or %s, got %q", key, driverMemory, driverRedis, v)
}
// keyPrefix returns the key prefix every cooler key starts with: cache.prefix
// when set, otherwise app.name lower-cased with every run of characters
// outside [a-z0-9] replaced by "_" (trimmed at both ends) and a trailing ":",
// or "summer:" when app.name is empty too. Cache keys are <prefix>cache:<key>,
// rate-limit keys <prefix>rl:<key>.
func keyPrefix(cfg *compass.Config) string {
if cfg == nil {
return defaultPrefix
}
if p := strings.TrimSpace(cfg.String(CachePrefixKey)); p != "" {
return p
}
name := slug(cfg.String("app.name"))
if name == "" {
return defaultPrefix
}
return name + ":"
}
func slug(s string) string {
var b strings.Builder
under := false
for _, r := range strings.ToLower(s) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
under = false
continue
}
if !under {
b.WriteByte('_')
under = true
}
}
return strings.Trim(b.String(), "_")
}
func loggerFromApp(app *backpack.App) *slog.Logger {
if app != nil {
if log, ok := app.Lookup[*slog.Logger](); ok && log != nil {
return log
}
}
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
}