From b57a041b6af6ff67912f12047e211b5d906ee6d1 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Mon, 23 Mar 2026 14:38:32 +0000 Subject: [PATCH] perf: use Redis for favicon cache with 24h TTL Favicons are now cached in Valkey/Redis instead of an in-memory map: - TTL: 24 hours (up from 1 hour in-memory) - ETag derived from body SHA256 (no extra storage needed) - Falls back to in-memory on cache miss when Valkey is unavailable - GetBytes/SetBytes added to cache package for raw byte storage In-memory faviconCache map, sync.RWMutex, and time-based expiry logic removed from handlers.go. --- cmd/samsa/main.go | 2 +- internal/cache/cache.go | 32 ++++++++++++ internal/httpapi/handlers.go | 83 ++++++++++---------------------- internal/httpapi/httpapi_test.go | 2 +- 4 files changed, 59 insertions(+), 60 deletions(-) diff --git a/cmd/samsa/main.go b/cmd/samsa/main.go index 080e3a2..199033b 100644 --- a/cmd/samsa/main.go +++ b/cmd/samsa/main.go @@ -77,7 +77,7 @@ func main() { acSvc := autocomplete.NewService(cfg.Upstream.URL, cfg.HTTPTimeout()) - h := httpapi.NewHandler(svc, acSvc.Suggestions, cfg.Server.SourceURL) + h := httpapi.NewHandler(svc, acSvc.Suggestions, cfg.Server.SourceURL, searchCache) mux := http.NewServeMux() diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 2d4ea73..4d25b16 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -144,6 +144,38 @@ func (c *Cache) Invalidate(ctx context.Context, key string) { c.client.Del(ctx, fullKey) } +// GetBytes retrieves a raw byte slice from the cache. Returns (data, true) on hit, +// (nil, false) on miss or error. +func (c *Cache) GetBytes(ctx context.Context, key string) ([]byte, bool) { + if !c.Enabled() { + return nil, false + } + fullKey := "samsa:" + key + data, err := c.client.Get(ctx, fullKey).Bytes() + if err != nil { + if err != redis.Nil { + c.logger.Debug("cache bytes miss (error)", "key", fullKey, "error", err) + } + return nil, false + } + return data, true +} + +// SetBytes stores a raw byte slice with a custom TTL. +// If ttl <= 0, the cache's default TTL is used. +func (c *Cache) SetBytes(ctx context.Context, key string, data []byte, ttl time.Duration) { + if !c.Enabled() { + return + } + if ttl <= 0 { + ttl = c.ttl + } + fullKey := "samsa:" + key + if err := c.client.Set(ctx, fullKey, data, ttl).Err(); err != nil { + c.logger.Warn("cache set bytes failed", "key", fullKey, "error", err) + } +} + // Close closes the Valkey connection. func (c *Cache) Close() error { if c.client == nil { diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index a7d787e..f83731d 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -24,9 +24,9 @@ import ( "io" "net/http" "strings" - "sync" "time" + "github.com/metamorphosis-dev/samsa/internal/cache" "github.com/metamorphosis-dev/samsa/internal/contracts" "github.com/metamorphosis-dev/samsa/internal/httpclient" "github.com/metamorphosis-dev/samsa/internal/search" @@ -34,16 +34,18 @@ import ( ) type Handler struct { - searchSvc *search.Service - autocompleteSvc func(ctx context.Context, query string) ([]string, error) - sourceURL string + searchSvc *search.Service + autocompleteSvc func(ctx context.Context, query string) ([]string, error) + sourceURL string + faviconCache *cache.Cache } -func NewHandler(searchSvc *search.Service, autocompleteSuggestions func(ctx context.Context, query string) ([]string, error), sourceURL string) *Handler { +func NewHandler(searchSvc *search.Service, autocompleteSuggestions func(ctx context.Context, query string) ([]string, error), sourceURL string, faviconCache *cache.Cache) *Handler { return &Handler{ searchSvc: searchSvc, autocompleteSvc: autocompleteSuggestions, sourceURL: sourceURL, + faviconCache: faviconCache, } } @@ -166,24 +168,10 @@ func (h *Handler) Preferences(w http.ResponseWriter, r *http.Request) { } } -// faviconCacheEntry holds a cached favicon body and its ETag. -type faviconCacheEntry struct { - body []byte - etag string - cachedAt time.Time -} - -// faviconCache is a simple in-memory cache for fetched favicons. -// Entries expire after 1 hour. -var faviconCache = struct { - m map[string]faviconCacheEntry - sync.RWMutex -}{m: make(map[string]faviconCacheEntry)} - -const faviconCacheTTL = 1 * time.Hour +const faviconCacheTTL = 24 * time.Hour // Favicon serves a fetched favicon for the given domain, with ETag support -// and a 1-hour in-memory cache. This lets Kafka act as a privacy-preserving +// and a 24-hour Redis cache. This lets Kafka act as a privacy-preserving // favicon proxy: the user's browser talks to Kafka, not Google or DuckDuckGo. func (h *Handler) Favicon(w http.ResponseWriter, r *http.Request) { domain := strings.TrimPrefix(r.URL.Path, "/favicon/") @@ -195,23 +183,11 @@ func (h *Handler) Favicon(w http.ResponseWriter, r *http.Request) { return } - // Check cache. - faviconCache.RLock() - entry, ok := faviconCache.m[domain] - faviconCache.RUnlock() + cacheKey := "favicon:" + domain - now := time.Now() - if ok && now.Sub(entry.cachedAt) < faviconCacheTTL { - // ETag-based cache validation. - if etag := r.Header.Get("If-None-Match"); etag != "" && etag == entry.etag { - w.WriteHeader(http.StatusNotModified) - return - } - w.Header().Set("Content-Type", "image/x-icon") - w.Header().Set("ETag", entry.etag) - w.Header().Set("Cache-Control", "private, max-age=3600") - w.WriteHeader(http.StatusOK) - w.Write(entry.body) + // Check Redis cache. + if cached, ok := h.faviconCache.GetBytes(r.Context(), cacheKey); ok { + h.serveFavicon(w, r, cached) return } @@ -233,7 +209,6 @@ func (h *Handler) Favicon(w http.ResponseWriter, r *http.Request) { } defer resp.Body.Close() - // Upstream favicon server issues a redirect or error. if resp.StatusCode != http.StatusOK { http.Error(w, "favicon not found", http.StatusNotFound) return @@ -245,34 +220,26 @@ func (h *Handler) Favicon(w http.ResponseWriter, r *http.Request) { return } - etag := resp.Header.Get("ETag") - if etag == "" { - // Fallback ETag: hash of body. - h := sha256.Sum256(body) - etag = `"` + hex.EncodeToString(h[:8]) + `"` - } + // Store in Redis with 24h TTL. + h.faviconCache.SetBytes(r.Context(), cacheKey, body, faviconCacheTTL) - // Store in cache. - faviconCache.Lock() - faviconCache.m[domain] = faviconCacheEntry{ - body: body, - etag: etag, - cachedAt: now, - } - faviconCache.Unlock() + h.serveFavicon(w, r, body) +} + +// serveFavicon writes a cached or freshly-fetched body with appropriate +// caching headers. ETag is derived from the body hash (no storage needed). +func (h *Handler) serveFavicon(w http.ResponseWriter, r *http.Request, body []byte) { + h2 := sha256.Sum256(body) + etag := `"` + hex.EncodeToString(h2[:8]) + `"` if etagMatch := r.Header.Get("If-None-Match"); etagMatch != "" && etagMatch == etag { w.WriteHeader(http.StatusNotModified) return } - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - contentType = "image/x-icon" - } - w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Type", "image/x-icon") w.Header().Set("ETag", etag) - w.Header().Set("Cache-Control", "private, max-age=3600") + w.Header().Set("Cache-Control", "private, max-age=86400") w.WriteHeader(http.StatusOK) w.Write(body) } diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 1d692d3..71b1095 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -67,7 +67,7 @@ func newTestServer(t *testing.T) (*httptest.Server, *httpapi.Handler) { EnginesConfig: nil, }) - h := httpapi.NewHandler(svc, nil, "https://src.example.com") + h := httpapi.NewHandler(svc, nil, "https://src.example.com", nil) mux := http.NewServeMux() mux.HandleFunc("/healthz", h.Healthz)