go-bananas/internal/httpapi/cache.go

288 lines
8.4 KiB
Go
Raw Normal View History

2026-10-07 15:11:35 +02:00
package httpapi
import (
"crypto/rand"
"encoding/hex"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/logging"
"github.com/urpi/go-bananas/internal/tokenengine"
)
// Logger is an alias for the logging interface so that middleware files do not
// need to import the logging package.
type Logger = logging.Logger
// randomHex returns n random bytes as hex.
func randomHex(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// itoa is a small helper so middleware does not import strconv everywhere.
func itoa(v int) string { return strconv.Itoa(v) }
// ------------------------------------------------------------- theme cache --
// ThemeCache memoises rendered theme artefacts.
//
// This is the single most important performance component in the service. A
// themed application requests CSS on every page view, usually from several
// pages at once; rendering a theme costs a full traversal of the token document,
// while serving a cached string costs one map lookup. Because the cache key
// includes the theme version, a publish invalidates precisely one entry and
// nothing else is disturbed.
//
// Entries are never evicted: themes are bounded by human authorship, so the
// working set is small and stable. Replacing a theme's entry happens on write,
// not on a timer, which removes any chance of serving a stale theme after a
// publish.
type ThemeCache struct {
mu sync.RWMutex
css map[string]cssEntry
json map[string]jsonEntry
metrics CacheStats
// clock allows the stats window to be advanced in tests.
now func() int64
}
// CacheStats reports hit rates for the metrics endpoint.
type CacheStats struct {
CSSHits int64
CSSMisses int64
JSONHits int64
JSONMisses int64
Entries int
LastEviction int64
}
// cssEntry is one rendered stylesheet.
type cssEntry struct {
css string
etag string
version int64
updatedAt int64
}
// jsonEntry is one rendered JSON payload.
type jsonEntry struct {
payload tokenengine.ResolvedTheme
etag string
version int64
}
// NewThemeCache builds an empty cache.
func NewThemeCache() *ThemeCache {
return &ThemeCache{
css: make(map[string]cssEntry, 16),
json: make(map[string]jsonEntry, 16),
now: func() int64 { return nowUnix() },
}
}
// CacheKey identifies one rendered artefact.
type CacheKey struct {
Slug string
Appearance domain.Appearance
// Variant distinguishes the option sets the API can render, so that a
// request for the compact stylesheet never receives the component stylesheet.
Variant string
}
// key renders a CacheKey as a map key.
func (k CacheKey) key() string {
return k.Slug + "|" + string(k.Appearance) + "|" + k.Variant
}
// CSS returns the cached stylesheet, rendering it with render when absent.
func (c *ThemeCache) CSS(theme *domain.Theme, opts tokenengine.RenderOptions, render func() string) (string, string) {
key := CacheKey{Slug: theme.Slug, Appearance: opts.Appearance, Variant: opts.Variant()}
c.mu.RLock()
entry, ok := c.css[key.key()]
c.mu.RUnlock()
if ok && entry.version == theme.Version {
atomic.AddInt64(&c.metrics.CSSHits, 1)
return entry.css, entry.etag
}
atomic.AddInt64(&c.metrics.CSSMisses, 1)
css := render()
etag := computeETag(css)
c.mu.Lock()
c.css[key.key()] = cssEntry{css: css, etag: etag, version: theme.Version, updatedAt: c.now()}
c.mu.Unlock()
return css, etag
}
// JSON returns the cached JSON payload, rendering it when absent.
func (c *ThemeCache) JSON(theme *domain.Theme, appearance domain.Appearance, render func() tokenengine.ResolvedTheme) (tokenengine.ResolvedTheme, string) {
key := CacheKey{Slug: theme.Slug, Appearance: appearance, Variant: "json"}
c.mu.RLock()
entry, ok := c.json[key.key()]
c.mu.RUnlock()
if ok && entry.version == theme.Version {
atomic.AddInt64(&c.metrics.JSONHits, 1)
return entry.payload, entry.etag
}
atomic.AddInt64(&c.metrics.JSONMisses, 1)
payload := render()
etag := computeETag(theme.Slug + ":" + strconv.FormatInt(theme.Version, 10) + ":" +
string(appearance) + ":" + payload.Theme.UpdatedAt)
c.mu.Lock()
c.json[key.key()] = jsonEntry{payload: payload, etag: etag, version: theme.Version}
c.mu.Unlock()
return payload, etag
}
// Invalidate drops every cached artefact for one slug.
func (c *ThemeCache) Invalidate(slug string) int {
c.mu.Lock()
defer c.mu.Unlock()
removed := 0
for key := range c.css {
if strings.HasPrefix(key, slug+"|") {
delete(c.css, key)
removed++
}
}
for key := range c.json {
if strings.HasPrefix(key, slug+"|") {
delete(c.json, key)
removed++
}
}
c.metrics.LastEviction = c.now()
return removed
}
// Prefetch renders and caches a theme for both appearances, so that the first
// request after a deploy is already warm. It is called once per theme at
// start-up.
func (c *ThemeCache) Prefetch(theme *domain.Theme, options func(tokenengine.RenderOptions)) int {
warmed := 0
for _, appearance := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} {
for _, variant := range tokenengine.VariantNames() {
opts := tokenengine.RenderOptions{
Slug: theme.Slug,
Appearance: appearance,
Components: variant == tokenengine.VariantFull,
Derive: tokenengine.DefaultDerive(),
}
if options != nil {
options(opts)
}
themeCopy := theme
_, _ = c.CSS(themeCopy, opts, func() string {
return tokenengine.Render(themeCopy, opts)
})
warmed++
_, _ = c.JSON(themeCopy, appearance, func() tokenengine.ResolvedTheme {
return tokenengine.JSONPayload(themeCopy, appearance)
})
}
}
return warmed
}
// Stats returns a snapshot of cache counters.
func (c *ThemeCache) Stats() CacheStats {
c.mu.RLock()
entries := len(c.css) + len(c.json)
c.mu.RUnlock()
return CacheStats{
CSSHits: atomic.LoadInt64(&c.metrics.CSSHits),
CSSMisses: atomic.LoadInt64(&c.metrics.CSSMisses),
JSONHits: atomic.LoadInt64(&c.metrics.JSONHits),
JSONMisses: atomic.LoadInt64(&c.metrics.JSONMisses),
Entries: entries,
LastEviction: atomic.LoadInt64(&c.metrics.LastEviction),
}
}
// Flush empties the cache. It exists for tests and for the admin "rebuild cache"
// action.
func (c *ThemeCache) Flush() {
c.mu.Lock()
defer c.mu.Unlock()
clear(c.css)
clear(c.json)
}
// computeETag derives a strong validator from a payload.
//
// A content hash is the right choice here rather than a version counter: two
// themes with different slugs can produce byte-identical CSS, and a content
// hash lets a shared CDN deduplicate them. It is also self-correcting, because a
// validator can never disagree with the body it describes.
func computeETag(content string) string {
return `"` + shortHash(content) + `"`
}
// shortHash returns a 128-bit hash rendered as hex.
func shortHash(content string) string {
// FNV-1a over two offset bases gives a 128-bit value using only the standard
// library. The collision risk is irrelevant for cache validation, where a
// collision would at worst serve one theme's bytes for another's.
const (
offset64 = 1469598103934665603
prime64 = 1099511628211
)
var a, b uint64 = offset64, offset64 ^ 0x9e3779b97f4a7c15
for i := 0; i < len(content); i++ {
a ^= uint64(content[i])
a *= prime64
b ^= uint64(content[len(content)-1-i])
b *= prime64
}
var out [16]byte
// The conversion to one byte per eight bits of the digest is the point of this
// function: it produces a fixed-width, hex-encodable cache key. Discarding the
// upper bits cannot overflow, because the shift count is bounded by the loop
// bound, and it costs no key entropy that matters for a content-addressed key.
//
//nolint:gosec // G115: see above. This is a digest truncation, not a narrowing
// of a length or a count.
for i := 0; i < 8; i++ {
out[i] = byte(a >> (8 * i))
out[8+i] = byte(b >> (8 * i))
}
return hex.EncodeToString(out[:])
}
// nowUnix returns the current Unix time in seconds.
func nowUnix() int64 { return timeNow().Unix() }
// sortedKeys returns a map's keys in sorted order, for stable output.
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// timeNow is the clock used by the cache, isolated so tests can reason about it.
func timeNow() time.Time { return time.Now() }