- cooler.SiteCachePurger moved from the Journal plugin: loopback-only http, 32-character token, 2 s detached request, no token in logs or errors - NewSiteCachePurgerFor names caller keys (for plugin alias keys) - PurgeSiteCacheAfterCommit through lagoon.AfterCommit, one POST per save - SiteCacheFromApp for hooks outside serve; Install publishes the purger unless a plugin already did, and fails boot on an invalid config - caching docs section, cooler README, configuration row
449 lines
14 KiB
Go
449 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, a Redis-backed fixed-window rate-limit
|
|
// store that keeps limits across application instances, and the site-cache
|
|
// purge that asks the host site to drop its page caches after admin saves.
|
|
// One redis.url key configures Redis for the cache and the store. The
|
|
// framework installs cooler at boot (surf's serve command) and FromApp and
|
|
// SiteCacheFromApp serve 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.
|
|
//
|
|
// Install also publishes the site-cache purger (SiteCachePurger) built from
|
|
// site_cache.purge_url and site_cache.purge_token, unless a plugin already
|
|
// published one; an invalid site_cache configuration is a boot error naming
|
|
// the key, and an enabled purger is logged with its Endpoint.
|
|
//
|
|
// 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)}
|
|
if err := installSiteCache(app, h.log); err != nil {
|
|
return nil, err
|
|
}
|
|
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
|
|
}
|