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 cache:. // 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 }