a75318b811
Stage-0 caching from docs/scalability.md. The Go API now caches hot product-detail and search-result reads in Redis with a fail-open, epoch-versioned scheme; Python ingestion bumps the epoch after a write run to invalidate the cache globally in O(1). - api/internal/cache: fail-open Cache (GetJSON/SetJSON) namespaced by an epoch counter (og:cache:epoch); disabled when Redis is unconfigured. - store: ProductByID/ProductByGTIN (24h TTL) and SearchProducts (1h TTL) read-through the cache via WithCache. - ingestion: bump_cache_epoch() called after update_off/seed_off/ import_bypos/dedup commits when rows changed; best-effort, never fails a run. Adds redis dependency. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
127 lines
3.8 KiB
Go
127 lines
3.8 KiB
Go
// Package cache is a Redis-backed, fail-open read cache for the public API.
|
|
//
|
|
// It caches hot product details and search results so repeated reads avoid
|
|
// PostgreSQL. Like the ratelimit package, every operation fails open: if Redis
|
|
// is unavailable or misconfigured the caller simply falls back to the database,
|
|
// so the cache can never take the API down or serve stale data after Redis loss.
|
|
//
|
|
// Invalidation is global and O(1): keys are namespaced by an epoch counter
|
|
// stored in Redis (og:cache:epoch). The Python ingestion bumps that counter
|
|
// after a write run, which logically invalidates every cached entry at once
|
|
// while old keys age out via their TTL. The epoch is read at most once per
|
|
// refresh interval per process, so it adds no per-request round trip.
|
|
package cache
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// epochKey is the Redis key holding the global cache generation counter.
|
|
const epochKey = "og:cache:epoch"
|
|
|
|
// epochRefresh bounds how often a process re-reads the epoch from Redis.
|
|
const epochRefresh = 10 * time.Second
|
|
|
|
// opTimeout caps any single Redis operation so a slow backend never blocks a
|
|
// request beyond this; on timeout the cache fails open.
|
|
const opTimeout = 150 * time.Millisecond
|
|
|
|
// Cache wraps a Redis client. A nil-backed Cache (Redis unconfigured) disables
|
|
// caching: every Get misses and every Set is a no-op.
|
|
type Cache struct {
|
|
rdb *redis.Client
|
|
|
|
mu sync.RWMutex
|
|
epoch int64
|
|
epochSetAt time.Time
|
|
epochOK bool
|
|
}
|
|
|
|
// New builds a Cache from a redis:// URL. On a parse error it logs and returns a
|
|
// disabled (fail-open) cache so the server still boots.
|
|
func New(redisURL string) *Cache {
|
|
opt, err := redis.ParseURL(redisURL)
|
|
if err != nil {
|
|
log.Printf("cache: invalid redis url %q: %v (caching disabled)", redisURL, err)
|
|
return &Cache{}
|
|
}
|
|
return &Cache{rdb: redis.NewClient(opt)}
|
|
}
|
|
|
|
// Enabled reports whether a Redis backend is configured.
|
|
func (c *Cache) Enabled() bool { return c != nil && c.rdb != nil }
|
|
|
|
// epochNow returns the current cache generation, reading it from Redis at most
|
|
// once per epochRefresh. On any Redis error it keeps the last known value and
|
|
// throttles re-reads so a down backend cannot slow the hot path.
|
|
func (c *Cache) epochNow(ctx context.Context) int64 {
|
|
c.mu.RLock()
|
|
if c.epochOK && time.Since(c.epochSetAt) < epochRefresh {
|
|
e := c.epoch
|
|
c.mu.RUnlock()
|
|
return e
|
|
}
|
|
c.mu.RUnlock()
|
|
|
|
cctx, cancel := context.WithTimeout(ctx, opTimeout)
|
|
defer cancel()
|
|
n, err := c.rdb.Get(cctx, epochKey).Int64()
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
switch {
|
|
case err == nil:
|
|
c.epoch = n
|
|
case errors.Is(err, redis.Nil):
|
|
c.epoch = 0
|
|
}
|
|
c.epochSetAt = time.Now()
|
|
c.epochOK = true
|
|
return c.epoch
|
|
}
|
|
|
|
// key namespaces a logical suffix under the current epoch.
|
|
func (c *Cache) key(ctx context.Context, suffix string) string {
|
|
return "og:v" + strconv.FormatInt(c.epochNow(ctx), 10) + ":" + suffix
|
|
}
|
|
|
|
// GetJSON unmarshals the cached value for suffix into dest and reports a hit.
|
|
// Any miss, decode error, or Redis error returns false (fail-open).
|
|
func (c *Cache) GetJSON(ctx context.Context, suffix string, dest any) bool {
|
|
if !c.Enabled() {
|
|
return false
|
|
}
|
|
k := c.key(ctx, suffix)
|
|
cctx, cancel := context.WithTimeout(ctx, opTimeout)
|
|
defer cancel()
|
|
b, err := c.rdb.Get(cctx, k).Bytes()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return json.Unmarshal(b, dest) == nil
|
|
}
|
|
|
|
// SetJSON stores val (JSON-encoded) for suffix with the given TTL. Best effort:
|
|
// marshal or Redis errors are ignored.
|
|
func (c *Cache) SetJSON(ctx context.Context, suffix string, val any, ttl time.Duration) {
|
|
if !c.Enabled() {
|
|
return
|
|
}
|
|
b, err := json.Marshal(val)
|
|
if err != nil {
|
|
return
|
|
}
|
|
k := c.key(ctx, suffix)
|
|
cctx, cancel := context.WithTimeout(ctx, opTimeout)
|
|
defer cancel()
|
|
_ = c.rdb.Set(cctx, k, b, ttl).Err()
|
|
}
|