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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user