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:
@@ -4,21 +4,35 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/baicai2026-baicai/goods/api/internal/cache"
|
||||
)
|
||||
|
||||
// Cache TTLs for the public read cache. Product details are far less volatile
|
||||
// than search result sets, so they live longer; both are also invalidated
|
||||
// wholesale whenever ingestion bumps the cache epoch.
|
||||
const (
|
||||
productCacheTTL = 24 * time.Hour
|
||||
searchCacheTTL = time.Hour
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a requested row does not exist.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// Store wraps a PostgreSQL connection pool.
|
||||
// Store wraps a PostgreSQL connection pool and an optional read cache.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
pool *pgxpool.Pool
|
||||
cache *cache.Cache
|
||||
}
|
||||
|
||||
// New constructs a Store from an existing pgx pool.
|
||||
@@ -26,6 +40,30 @@ func New(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// WithCache attaches a Redis-backed read cache. A nil or disabled cache leaves
|
||||
// the Store reading straight from PostgreSQL.
|
||||
func (s *Store) WithCache(c *cache.Cache) *Store {
|
||||
s.cache = c
|
||||
return s
|
||||
}
|
||||
|
||||
// cacheGet reads a cached JSON value into dest, reporting a hit. It is a no-op
|
||||
// miss when no cache is attached.
|
||||
func (s *Store) cacheGet(ctx context.Context, suffix string, dest any) bool {
|
||||
if s.cache == nil {
|
||||
return false
|
||||
}
|
||||
return s.cache.GetJSON(ctx, suffix, dest)
|
||||
}
|
||||
|
||||
// cacheSet stores a JSON value when a cache is attached.
|
||||
func (s *Store) cacheSet(ctx context.Context, suffix string, val any, ttl time.Duration) {
|
||||
if s.cache == nil {
|
||||
return
|
||||
}
|
||||
s.cache.SetJSON(ctx, suffix, val, ttl)
|
||||
}
|
||||
|
||||
// Ping verifies database connectivity.
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
return s.pool.Ping(ctx)
|
||||
@@ -222,6 +260,10 @@ func stringifyAttr(v any) string {
|
||||
|
||||
// ProductByGTIN looks up an active product by any of its barcodes.
|
||||
func (s *Store) ProductByGTIN(ctx context.Context, gtin string) (*Product, error) {
|
||||
const suffix = "prod:gtin:"
|
||||
if cached := new(Product); s.cacheGet(ctx, suffix+gtin, cached) {
|
||||
return cached, nil
|
||||
}
|
||||
row := s.pool.QueryRow(ctx, productSelect+`
|
||||
WHERE p.status = 'active'
|
||||
AND (p.gtin = $1 OR EXISTS (
|
||||
@@ -238,11 +280,16 @@ func (s *Store) ProductByGTIN(ctx context.Context, gtin string) (*Product, error
|
||||
if p.Specs, err = s.buildSpecs(ctx, p.ArchiveKind, attrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.cacheSet(ctx, suffix+gtin, p, productCacheTTL)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ProductByID looks up a product by its UUID.
|
||||
func (s *Store) ProductByID(ctx context.Context, id string) (*Product, error) {
|
||||
const suffix = "prod:id:"
|
||||
if cached := new(Product); s.cacheGet(ctx, suffix+id, cached) {
|
||||
return cached, nil
|
||||
}
|
||||
row := s.pool.QueryRow(ctx, productSelect+" WHERE p.id = $1", id)
|
||||
p, attrs, err := scanProduct(row)
|
||||
if err != nil {
|
||||
@@ -254,6 +301,7 @@ func (s *Store) ProductByID(ctx context.Context, id string) (*Product, error) {
|
||||
if p.Specs, err = s.buildSpecs(ctx, p.ArchiveKind, attrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.cacheSet(ctx, suffix+id, p, productCacheTTL)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@@ -268,6 +316,11 @@ const fuzzyThreshold = "0.42"
|
||||
// similarity blended with quality_score so the best, most-complete records
|
||||
// surface first. Without a query, results are ordered by quality_score.
|
||||
func (s *Store) SearchProducts(ctx context.Context, f SearchFilters, limit, offset int) ([]ProductSummary, int, error) {
|
||||
suffix := searchCacheSuffix(f, limit, offset)
|
||||
if entry := new(searchCacheEntry); s.cacheGet(ctx, suffix, entry) {
|
||||
return entry.Items, entry.Total, nil
|
||||
}
|
||||
|
||||
args := []any{}
|
||||
where := "WHERE p.status = 'active'"
|
||||
|
||||
@@ -334,7 +387,28 @@ LEFT JOIN category c ON c.id = p.category_id `
|
||||
}
|
||||
out = append(out, ps)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
s.cacheSet(ctx, suffix, searchCacheEntry{Items: out, Total: total}, searchCacheTTL)
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// searchCacheEntry is the cached payload for a SearchProducts call.
|
||||
type searchCacheEntry struct {
|
||||
Items []ProductSummary `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// searchCacheSuffix derives a stable cache key from the full filter set and
|
||||
// paging window so distinct queries never collide.
|
||||
func searchCacheSuffix(f SearchFilters, limit, offset int) string {
|
||||
raw := strings.Join([]string{
|
||||
f.Query, f.Category, f.Brand, f.Country,
|
||||
strconv.Itoa(limit), strconv.Itoa(offset),
|
||||
}, "\x1f")
|
||||
sum := sha1.Sum([]byte(raw))
|
||||
return "search:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Nutriments returns just the nutrition payload for a product.
|
||||
|
||||
Reference in New Issue
Block a user