// 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() }