feat(api): Redis read cache for product details and search
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>
This commit is contained in:
Vendored
+126
@@ -0,0 +1,126 @@
|
||||
// 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()
|
||||
}
|
||||
Vendored
+84
@@ -0,0 +1,84 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestDisabledFailsOpen verifies a Cache without a Redis backend never panics,
|
||||
// always misses, and silently drops writes.
|
||||
func TestDisabledFailsOpen(t *testing.T) {
|
||||
c := New("not-a-valid-url") // parse error => disabled
|
||||
if c.Enabled() {
|
||||
t.Fatal("expected cache to be disabled for invalid url")
|
||||
}
|
||||
c.SetJSON(context.Background(), "k", map[string]int{"a": 1}, time.Minute)
|
||||
var dst map[string]int
|
||||
if c.GetJSON(context.Background(), "k", &dst) {
|
||||
t.Fatalf("disabled cache must always miss, got %+v", dst)
|
||||
}
|
||||
}
|
||||
|
||||
func testCache(t *testing.T) *Cache {
|
||||
t.Helper()
|
||||
url := os.Getenv("OPENGOODS_REDIS_URL")
|
||||
if url == "" {
|
||||
url = "redis://localhost:6379/0"
|
||||
}
|
||||
c := New(url)
|
||||
if !c.Enabled() {
|
||||
t.Skip("redis not configured")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := c.rdb.Ping(ctx).Err(); err != nil {
|
||||
t.Skipf("redis not reachable: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// TestRoundTrip stores then reads a value back.
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
c := testCache(t)
|
||||
ctx := context.Background()
|
||||
suffix := fmt.Sprintf("test:rt:%d", time.Now().UnixNano())
|
||||
|
||||
c.SetJSON(ctx, suffix, map[string]any{"name": "foo", "n": float64(3)}, time.Minute)
|
||||
got := map[string]any{}
|
||||
if !c.GetJSON(ctx, suffix, &got) {
|
||||
t.Fatal("expected cache hit after set")
|
||||
}
|
||||
if got["name"] != "foo" || got["n"] != float64(3) {
|
||||
t.Fatalf("unexpected payload: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEpochInvalidation verifies that bumping the epoch counter logically drops
|
||||
// every previously cached entry.
|
||||
func TestEpochInvalidation(t *testing.T) {
|
||||
c := testCache(t)
|
||||
ctx := context.Background()
|
||||
suffix := fmt.Sprintf("test:epoch:%d", time.Now().UnixNano())
|
||||
|
||||
c.SetJSON(ctx, suffix, map[string]int{"v": 1}, time.Minute)
|
||||
var dst map[string]int
|
||||
if !c.GetJSON(ctx, suffix, &dst) {
|
||||
t.Fatal("expected hit before epoch bump")
|
||||
}
|
||||
|
||||
// Simulate an ingestion write bumping the global epoch.
|
||||
if err := c.rdb.Incr(ctx, epochKey).Err(); err != nil {
|
||||
t.Fatalf("incr epoch: %v", err)
|
||||
}
|
||||
// Force the process to re-read the epoch rather than use its cached value.
|
||||
c.mu.Lock()
|
||||
c.epochOK = false
|
||||
c.mu.Unlock()
|
||||
|
||||
if c.GetJSON(ctx, suffix, &dst) {
|
||||
t.Fatal("entry should be invisible after epoch bump")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user