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:
154
modules/cooler/memory.go
Normal file
154
modules/cooler/memory.go
Normal file
@@ -0,0 +1,154 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user