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>
85 lines
2.2 KiB
Go
85 lines
2.2 KiB
Go
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")
|
|
}
|
|
}
|