carlos / platform Public

edge: add LRU+TTL response cache, Vary- and purge-aware

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Paul Campbell 7bbc515fd068a1b6fc340e03d936e01f4e427497 parent 882219f
2 files changed, +255 −0
  • internal/edge/cache.go +160 −0
  • internal/edge/cache_test.go +95 −0
diff --git a/internal/edge/cache.go b/internal/edge/cache.go
new file mode 100644
index 000000000..fe873a387
--- /dev/null
+++ b/internal/edge/cache.go
@@ -0,0 +1,160 @@
+package edge
+
+import (
+ "container/list"
+ "net/http"
+ "sync"
+ "time"
+)
+
+// CacheKey is the full key for a cached response, including Host, Method, Path, RawQuery,
+// and VaryValue (concatenation of request-header values named by the response's Vary header).
+type CacheKey struct {
+ Host string // request Host header, exact match
+ Method string // "GET" or "HEAD"
+ Path string
+ RawQuery string
+ VaryValue string // concatenation of request-header values named by the response's own Vary header; "" if Vary absent
+}
+
+// CachedResponse holds a cached HTTP response along with TTL and storage time.
+type CachedResponse struct {
+ StatusCode int
+ Header http.Header
+ Body []byte
+ StoredAt time.Time
+ TTL time.Duration
+}
+
+// Cache is an in-memory, per-host response cache for cacheable GET/HEAD
+// responses. It exists to let the edge answer bot/crawler/repeat traffic
+// without invoking the hibernation activator (edge.go's ServeHTTP checks
+// it before ever reaching the activator) — see
+// docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md.
+// A nil *Cache disables caching entirely; callers must nil-check before use.
+type Cache struct {
+ mu sync.Mutex
+ maxEntries int
+ maxBodyBytes int
+ order *list.List // MRU at front, LRU at back
+ entries map[cacheFullKey]*list.Element
+ byURL map[cacheURLKey]map[string]struct{} // urlKey -> set of VaryValue
+}
+
+type cacheURLKey struct {
+ Host, Method, Path, RawQuery string
+}
+
+type cacheFullKey struct {
+ cacheURLKey
+ VaryValue string
+}
+
+type cacheEntry struct {
+ key cacheFullKey
+ resp *CachedResponse
+}
+
+// NewCache creates a new cache with the given capacity (max entries) and max body size.
+func NewCache(maxEntries, maxBodyBytes int) *Cache {
+ return &Cache{
+ maxEntries: maxEntries,
+ maxBodyBytes: maxBodyBytes,
+ order: list.New(),
+ entries: make(map[cacheFullKey]*list.Element),
+ byURL: make(map[cacheURLKey]map[string]struct{}),
+ }
+}
+
+func (k CacheKey) urlKey() cacheURLKey {
+ return cacheURLKey{Host: k.Host, Method: k.Method, Path: k.Path, RawQuery: k.RawQuery}
+}
+
+func (k CacheKey) fullKey() cacheFullKey {
+ return cacheFullKey{cacheURLKey: k.urlKey(), VaryValue: k.VaryValue}
+}
+
+// Get retrieves a cached response if it exists and has not expired.
+// Returns (nil, false) for a miss or an expired entry.
+// Expired entries are evicted lazily on lookup, not swept on a timer.
+func (c *Cache) Get(key CacheKey) (*CachedResponse, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ fk := key.fullKey()
+ el, ok := c.entries[fk]
+ if !ok {
+ return nil, false
+ }
+ e := el.Value.(*cacheEntry)
+ if time.Since(e.resp.StoredAt) > e.resp.TTL {
+ c.removeLocked(el)
+ return nil, false
+ }
+ c.order.MoveToFront(el)
+ return e.resp, true
+}
+
+// Put stores a response in the cache. Silently drops a response whose body
+// exceeds maxBodyBytes. Evicts the least-recently-used entry when at maxEntries.
+func (c *Cache) Put(key CacheKey, resp *CachedResponse) {
+ if len(resp.Body) > c.maxBodyBytes {
+ return
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ fk := key.fullKey()
+ if el, ok := c.entries[fk]; ok {
+ el.Value.(*cacheEntry).resp = resp
+ c.order.MoveToFront(el)
+ return
+ }
+ el := c.order.PushFront(&cacheEntry{key: fk, resp: resp})
+ c.entries[fk] = el
+ uk := fk.cacheURLKey
+ if c.byURL[uk] == nil {
+ c.byURL[uk] = make(map[string]struct{})
+ }
+ c.byURL[uk][fk.VaryValue] = struct{}{}
+ for len(c.entries) > c.maxEntries {
+ victim := c.order.Back()
+ if victim == nil {
+ break
+ }
+ c.removeLocked(victim)
+ }
+}
+
+// removeLocked drops el from every index. Caller must hold c.mu.
+func (c *Cache) removeLocked(el *list.Element) {
+ e := el.Value.(*cacheEntry)
+ c.order.Remove(el)
+ delete(c.entries, e.key)
+ if variants := c.byURL[e.key.cacheURLKey]; variants != nil {
+ delete(variants, e.key.VaryValue)
+ if len(variants) == 0 {
+ delete(c.byURL, e.key.cacheURLKey)
+ }
+ }
+}
+
+// PurgeURL drops every cached variant (every VaryValue) of (host, path, rawQuery)
+// for both GET and HEAD methods and returns how many entries it actually removed.
+// Returns 0 if none were cached — not an error.
+func (c *Cache) PurgeURL(host, path, rawQuery string) int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ n := 0
+ // GET and HEAD are purged together — a write invalidates both.
+ for _, method := range [...]string{"GET", "HEAD"} {
+ uk := cacheURLKey{Host: host, Method: method, Path: path, RawQuery: rawQuery}
+ variants := c.byURL[uk]
+ for vary := range variants {
+ fk := cacheFullKey{cacheURLKey: uk, VaryValue: vary}
+ if el, ok := c.entries[fk]; ok {
+ c.removeLocked(el)
+ n++
+ }
+ }
+ }
+ return n
+}
diff --git a/internal/edge/cache_test.go b/internal/edge/cache_test.go
new file mode 100644
index 000000000..aa06d467d
--- /dev/null
+++ b/internal/edge/cache_test.go
@@ -0,0 +1,95 @@
+package edge
+
+import (
+ "net/http"
+ "testing"
+ "time"
+)
+
+func TestCacheGetMissOnEmpty(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ if _, ok := c.Get(CacheKey{Host: "a", Method: "GET", Path: "/"}); ok {
+ t.Fatal("expected miss on empty cache")
+ }
+}
+
+func TestCachePutThenGetHits(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ key := CacheKey{Host: "a", Method: "GET", Path: "/event"}
+ resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute}
+ c.Put(key, resp)
+ got, ok := c.Get(key)
+ if !ok {
+ t.Fatal("expected hit after put")
+ }
+ if string(got.Body) != "hi" {
+ t.Fatalf("body = %q, want %q", got.Body, "hi")
+ }
+}
+
+func TestCacheEntryExpiresAfterTTL(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ key := CacheKey{Host: "a", Method: "GET", Path: "/event"}
+ resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now().Add(-2 * time.Minute), TTL: time.Minute}
+ c.Put(key, resp)
+ if _, ok := c.Get(key); ok {
+ t.Fatal("expected miss on expired entry")
+ }
+}
+
+func TestCachePutOverMaxBodyBytesIsDropped(t *testing.T) {
+ c := NewCache(10, 4) // 4-byte cap
+ key := CacheKey{Host: "a", Method: "GET", Path: "/event"}
+ resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("way too big"), StoredAt: time.Now(), TTL: time.Minute}
+ c.Put(key, resp)
+ if _, ok := c.Get(key); ok {
+ t.Fatal("expected oversized body to never be cached")
+ }
+}
+
+func TestCacheEvictsLeastRecentlyUsedAtCapacity(t *testing.T) {
+ c := NewCache(2, 1<<20)
+ mk := func(p string) CacheKey { return CacheKey{Host: "a", Method: "GET", Path: p} }
+ put := func(p string) {
+ c.Put(mk(p), &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(p), StoredAt: time.Now(), TTL: time.Minute})
+ }
+ put("/one")
+ put("/two")
+ c.Get(mk("/one")) // touch /one so /two becomes the LRU victim
+ put("/three")
+ if _, ok := c.Get(mk("/two")); ok {
+ t.Fatal("expected /two to have been evicted as least-recently-used")
+ }
+ if _, ok := c.Get(mk("/one")); !ok {
+ t.Fatal("expected /one to survive (recently touched)")
+ }
+ if _, ok := c.Get(mk("/three")); !ok {
+ t.Fatal("expected /three to have been inserted")
+ }
+}
+
+func TestCachePurgeURLRemovesAllVaryVariants(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ en := CacheKey{Host: "a", Method: "GET", Path: "/event", VaryValue: "en"}
+ fr := CacheKey{Host: "a", Method: "GET", Path: "/event", VaryValue: "fr"}
+ other := CacheKey{Host: "a", Method: "GET", Path: "/other"}
+ mkResp := func(b string) *CachedResponse {
+ return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute}
+ }
+ c.Put(en, mkResp("en"))
+ c.Put(fr, mkResp("fr"))
+ c.Put(other, mkResp("other"))
+ n := c.PurgeURL("a", "/event", "")
+ if n != 2 {
+ t.Fatalf("purged %d entries, want 2", n)
+ }
+ if _, ok := c.Get(en); ok {
+ t.Fatal("expected en variant purged")
+ }
+ if _, ok := c.Get(fr); ok {
+ t.Fatal("expected fr variant purged")
+ }
+ if _, ok := c.Get(other); !ok {
+ t.Fatal("expected unrelated URL to survive purge")
+ }
+}