- 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
155 lines
4.2 KiB
Go
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
|
|
}
|