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

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"github.com/redis/go-redis/v9"
@@ -34,9 +33,6 @@ count = redis.call('INCR', KEYS[1])
return {1, count, ttl}
`)
// warnEvery bounds how often a Redis failure is logged.
const warnEvery = time.Minute
// RateLimitStore is a fixed-window rate-limit store on Redis, shared by every
// application instance that points at the same Redis. Its Attempt has the
// exact signature of surf.Store, so surf uses it without cooler importing
@@ -46,17 +42,14 @@ type RateLimitStore struct {
client *redis.Client
prefix string
fallback Attempter
log *slog.Logger
mu sync.Mutex
lastWarn time.Time
warn *warner
}
// NewRateLimitStore returns a store that keeps its counters under
// <prefix>rl:<key> on client. fallback answers while Redis fails; surf passes
// a surf.MemoryStore. A nil fallback admits every request during an outage.
func NewRateLimitStore(client *redis.Client, prefix string, fallback Attempter) *RateLimitStore {
return &RateLimitStore{client: client, prefix: prefix, fallback: fallback, log: slog.Default()}
return &RateLimitStore{client: client, prefix: prefix, fallback: fallback, warn: newWarner(slog.Default())}
}
// Attempt admits and counts one request when key is below max, in one
@@ -72,7 +65,8 @@ func (s *RateLimitStore) Attempt(key string, max int, decay time.Duration) (allo
defer cancel()
res, err := s.run(ctx, s.prefix+"rl:"+key, max, ms)
if err != nil {
s.warn(err)
s.warn.warn("cooler: redis rate-limit store failed; counting per instance until it answers",
"endpoint", redisEndpoint(s.client), "error", err)
if s.fallback == nil {
return true, 0, 0
}
@@ -98,24 +92,3 @@ func (s *RateLimitStore) run(ctx context.Context, key string, max int, ms int64)
}
return res, nil
}
// warn logs a Redis failure at most once per warnEvery.
func (s *RateLimitStore) warn(err error) {
if s == nil {
return
}
s.mu.Lock()
now := time.Now()
if !s.lastWarn.IsZero() && now.Sub(s.lastWarn) < warnEvery {
s.mu.Unlock()
return
}
s.lastWarn = now
s.mu.Unlock()
log := s.log
if log == nil {
log = slog.Default()
}
log.Warn("cooler: redis rate-limit store failed; counting per instance until it answers",
"endpoint", redisEndpoint(s.client), "error", err)
}