carlos / platform Public

edge: PurgeHost rides a host index — a capped purge header made the scan request-reachable

A Carlos-Purge declaration exceeding the 64-target/8KiB caps degrades to
PurgeHost (see purge_header.go), putting its former whole-cache scan on the
request path rather than only once per deploy. byHost joins byURL/byPath
under the same maintain-in-Put/clean-in-removeLocked discipline, with no
per-host admission cap: the global maxEntries LRU already bounds it, and a
per-host cap would let one host's flood evict nothing while the keyed caps
exist for that.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 13b3b05a109fb672189bedf500d0dbc4a6de90f8 parent cb654c4
2 files changed, +55 −12
  • internal/edge/cache.go +26 −12
  • internal/edge/cache_test.go +29 −0
diff --git a/internal/edge/cache.go b/internal/edge/cache.go
index 630a183c8..d1c08cb16 100644
--- a/internal/edge/cache.go
+++ b/internal/edge/cache.go
@@ -63,6 +63,7 @@ type Cache struct {
entries map[cacheFullKey]*list.Element
byURL map[cacheURLKey]map[cacheFullKey]*list.Element // urlKey -> its Vary variants
byPath map[cachePathKey]map[cacheFullKey]*list.Element // pathKey -> every entry under that path
+ byHost map[string]map[cacheFullKey]*list.Element // host -> every entry for that host
}
type cacheURLKey struct {
@@ -136,6 +137,7 @@ func NewCache(maxEntries, maxBodyBytes int) *Cache {
entries: make(map[cacheFullKey]*list.Element),
byURL: make(map[cacheURLKey]map[cacheFullKey]*list.Element),
byPath: make(map[cachePathKey]map[cacheFullKey]*list.Element),
+ byHost: make(map[string]map[cacheFullKey]*list.Element),
}
}
@@ -335,6 +337,10 @@ func (c *Cache) Put(key CacheKey, vary string, resp *CachedResponse) {
c.byPath[pk] = make(map[cacheFullKey]*list.Element)
}
c.byPath[pk][fk] = el
+ if c.byHost[uk.Host] == nil {
+ c.byHost[uk.Host] = make(map[cacheFullKey]*list.Element)
+ }
+ c.byHost[uk.Host][fk] = el
c.capGroupLocked(c.byURL[uk], maxVaryVariantsPerURL, el)
c.capGroupLocked(c.byPath[pk], maxEntriesPerPath, el)
@@ -389,6 +395,12 @@ func (c *Cache) removeLocked(el *list.Element) {
delete(c.byPath, pk)
}
}
+ if group := c.byHost[uk.Host]; group != nil {
+ delete(group, e.key)
+ if len(group) == 0 {
+ delete(c.byHost, uk.Host)
+ }
+ }
}
// PurgeURL drops every cached variant (every VaryValue) of (host, path, rawQuery)
@@ -438,23 +450,25 @@ func (c *Cache) PurgePath(host, path string) int {
}
// PurgeHost drops every cached entry for host — all paths, all query strings,
-// all Vary variants — and returns how many it removed. This is what a deploy
-// calls: a fresh process for a host means new code and potentially new
-// content, so everything cached from the old one is suspect. A whole-cache
-// scan is fine here; it happens once per spawn, not per request.
+// all Vary variants — and returns how many it removed. A deploy calls this: a
+// fresh process for a host means new code and potentially new content, so
+// everything cached from the old one is suspect.
+//
+// It is no longer only a deploy-time cost, though, and so no longer a
+// whole-cache scan: a Carlos-Purge header that names more targets than the
+// 64-target/8KiB caps allow degrades to PurgeHost (see purge_header.go),
+// which puts it on the request path. It rides the byHost index instead — the
+// same discipline byURL and byPath already keep in Put/removeLocked — so a
+// misbehaving or hostile app declaring an oversized purge cannot turn every
+// response into a scan of the entire cache.
func (c *Cache) PurgeHost(host string) int {
c.mu.Lock()
defer c.mu.Unlock()
- var doomed []*list.Element
- for key, el := range c.entries {
- if key.Host == host {
- doomed = append(doomed, el)
- }
- }
- for _, el := range doomed {
+ els := elementsOf(c.byHost[host])
+ for _, el := range els {
c.removeLocked(el)
}
- return len(doomed)
+ return len(els)
}
// elementsOf snapshots a group's elements so the caller can remove them while
diff --git a/internal/edge/cache_test.go b/internal/edge/cache_test.go
index eb9eb087b..28b321531 100644
--- a/internal/edge/cache_test.go
+++ b/internal/edge/cache_test.go
@@ -468,6 +468,35 @@ func TestCachePurgeHostRemovesEveryEntryForThatHost(t *testing.T) {
}
}
+// PurgeHost must ride a per-host index rather than scan every entry: a
+// capped Carlos-Purge header degrades to PurgeHost on the request path (see
+// purge_header.go), so a whole-cache scan there is request-reachable, not
+// just a once-per-deploy cost.
+func TestPurgeHostUsesPerHostIndex(t *testing.T) {
+ c := NewCache(100, 1<<20)
+ put := func(host, path, q string) {
+ c.Put(CacheKey{Host: host, Method: "GET", Path: path, RawQuery: q}, "",
+ &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("x"), StoredAt: time.Now(), TTL: time.Minute})
+ }
+ put("a.example", "/1", "")
+ put("a.example", "/1", "v=2")
+ put("a.example", "/2", "")
+ put("b.example", "/1", "")
+ if n := c.PurgeHost("a.example"); n != 3 {
+ t.Fatalf("purged %d, want 3", n)
+ }
+ if _, ok := c.get(CacheKey{Host: "b.example", Method: "GET", Path: "/1"}); !ok {
+ t.Fatal("b.example must survive a.example's purge")
+ }
+ if len(c.byHost) != 1 {
+ t.Fatalf("byHost holds %d hosts after purge, want 1 (empty groups must be deleted)", len(c.byHost))
+ }
+ c.PurgeHost("b.example")
+ if len(c.byHost) != 0 || len(c.entries) != 0 {
+ t.Fatal("indexes must be empty after purging everything")
+ }
+}
+
// An attacker pumping distinct query strings at one path must not be able to
// evict the rest of the fleet's cache: the per-path cap bounds how many
// entries a single path can occupy, independent of the global LRU.