initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
287
internal/httpapi/cache.go
Normal file
287
internal/httpapi/cache.go
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
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() }
|
||||
Loading…
Add table
Add a link
Reference in a new issue