Files
summercms/modules/cooler/memory.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

155 lines
4.2 KiB
Go

package cooler
import (
"context"
"fmt"
"log/slog"
"strconv"
"time"
"github.com/maypok86/otter/v2"
)
// forever is how long an entry without a TTL lives in the memory driver:
// otter's documented way to say "no expiration" is an excessively long period.
const forever = 50 * 365 * 24 * time.Hour
// memEntry is a stored value with its absolute expiry; a zero expiresAt
// never expires.
type memEntry struct {
val []byte
expiresAt time.Time
}
func (e memEntry) expired(now time.Time) bool {
return !e.expiresAt.IsZero() && !now.Before(e.expiresAt)
}
// memoryCache is the in-process Cache on otter v2: size-bounded, with a
// per-entry expiry computed from the absolute expiresAt stored with the value.
type memoryCache struct {
c *otter.Cache[string, memEntry]
prefix string
warn *warner
}
// NewMemoryCache returns an in-process Cache holding at most maxEntries
// entries (cache.memory.max_entries, default DefaultMaxEntries); the least
// valuable entries are evicted beyond that. Keys are <prefix>cache:<key>.
// A maxEntries below 1 is an error naming cache.memory.max_entries.
func NewMemoryCache(prefix string, maxEntries int) (Cache, error) {
if maxEntries < 1 {
return nil, fmt.Errorf("cooler: %s must be at least 1, got %d", CacheMaxEntriesKey, maxEntries)
}
c, err := otter.New(&otter.Options[string, memEntry]{
MaximumSize: maxEntries,
ExpiryCalculator: otter.ExpiryWritingFunc(func(e otter.Entry[string, memEntry]) time.Duration {
if e.Value.expiresAt.IsZero() {
return forever
}
if d := time.Until(e.Value.expiresAt); d > 0 {
return d
}
return time.Nanosecond
}),
})
if err != nil {
return nil, fmt.Errorf("cooler: memory cache: %w", err)
}
return &memoryCache{c: c, prefix: prefix, warn: newWarner(slog.Default())}, nil
}
func (m *memoryCache) key(k string) string { return m.prefix + "cache:" + k }
func (m *memoryCache) setLogger(log *slog.Logger) { m.warn.setLogger(log) }
// lookup returns the live entry for a full key, dropping an expired one.
func (m *memoryCache) lookup(full string) (memEntry, bool) {
e, ok := m.c.GetIfPresent(full)
if !ok {
return memEntry{}, false
}
if e.expired(time.Now()) {
m.c.Invalidate(full)
return memEntry{}, false
}
return e, true
}
func (m *memoryCache) Get(_ context.Context, key string) ([]byte, bool, error) {
e, ok := m.lookup(m.key(key))
if !ok {
return nil, false, nil
}
return clone(e.val), true, nil
}
func (m *memoryCache) Set(_ context.Context, key string, value []byte, ttl time.Duration) error {
e := memEntry{val: clone(value)}
if ttl > 0 {
e.expiresAt = time.Now().Add(ttl)
}
m.c.Set(m.key(key), e)
return nil
}
func (m *memoryCache) Has(_ context.Context, key string) (bool, error) {
_, ok := m.lookup(m.key(key))
return ok, nil
}
func (m *memoryCache) Forget(_ context.Context, key string) error {
m.c.Invalidate(m.key(key))
return nil
}
func (m *memoryCache) Remember(ctx context.Context, key string, ttl time.Duration, fn func(context.Context) ([]byte, error)) ([]byte, error) {
return remember(ctx, m, m.warn, key, ttl, fn)
}
// Increment adds by to the integer at key atomically (the update runs under
// otter's per-key lock). A missing or expired key starts at 0 and gets no
// TTL; an existing key keeps its expiry, as Redis INCRBY does.
func (m *memoryCache) Increment(_ context.Context, key string, by int64) (int64, error) {
var (
n int64
valErr error
)
now := time.Now()
m.c.Compute(m.key(key), func(old memEntry, found bool) (memEntry, otter.ComputeOp) {
cur := int64(0)
next := memEntry{}
if found && !old.expired(now) {
v, err := strconv.ParseInt(string(old.val), 10, 64)
if err != nil {
valErr = fmt.Errorf("cooler: cache value at %q is not an integer", key)
return old, otter.CancelOp
}
cur = v
next.expiresAt = old.expiresAt
}
n = cur + by
next.val = []byte(strconv.FormatInt(n, 10))
return next, otter.WriteOp
})
if valErr != nil {
return 0, valErr
}
return n, nil
}
// Flush removes every entry of this cache.
func (m *memoryCache) Flush(context.Context) error {
m.c.InvalidateAll()
return nil
}
func clone(b []byte) []byte {
if b == nil {
return nil
}
out := make([]byte, len(b))
copy(out, b)
return out
}