// 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 cache:, // rate-limit keys rl:. 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 }