| 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 |
| +} |