carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
4 "container/list"
5 "net/http"
6 "sync"
7 "time"
8)
9
10// Admission caps that bound how much of the cache any single URL or path can
11// occupy, independent of the global LRU. The global LRU alone does not stop
12// one URL from being pumped full of noise entries that then evict OTHER
13// hosts' useful entries — a crawler sending Accept-Language: xx-1 … xx-4096
14// (distinct Vary variants) or ?x=1 … ?x=4096 (distinct query strings, part of
15// the key because correctness requires it: different query strings can mean
16// different content) would otherwise flush the whole cache on demand.
17//
18// Two caps, because the two floods land on different key components:
19// - maxVaryVariantsPerURL bounds the Vary variants of one exact URL
20// (host+method+path+query) — every variant of it, across every Vary scheme
21// its responses have used, so a URL that varies conditionally gets one
22// allowance rather than one per scheme. It is also what keeps the lookup
23// scan in selectLocked short: a handful of candidates, never a search.
24// - maxEntriesPerPath bounds everything under one path regardless of query
25// string or variant, which is what actually bounds a query-string flood.
26//
27// Both evict the oldest entry in their group (by StoredAt) when exceeded.
28const (
29 maxVaryVariantsPerURL = 8
30 maxEntriesPerPath = 32
31)
32
33// CacheKey is the full key for a cached response, including Host, Method, Path, RawQuery,
34// and VaryValue (concatenation of request-header values named by the response's Vary header).
35type CacheKey struct {
36 Host string // request Host header, exact match
37 Method string // "GET" or "HEAD"
38 Path string
39 RawQuery string
40 VaryValue string // concatenation of request-header values named by the response's own Vary header; "" if Vary absent
41}
42
43// CachedResponse holds a cached HTTP response along with TTL and storage time.
44type CachedResponse struct {
45 StatusCode int
46 Header http.Header
47 Body []byte
48 StoredAt time.Time
49 TTL time.Duration
50 // Route and Gen tag an entry with the RESOLVED serving route (the
51 // instance host, whatever name the request used: an alias, a wildcard,
52 // a path-routed front door) and the generation of it that produced the
53 // response (zero-downtime spec §7.2). Empty Route: untagged, which is
54 // every entry with generations off and every route the activator does
55 // not manage. A tagged entry is live only while Gen is still the
56 // route's newest generation (SetGenerations).
57 Route string
58 Gen uint64
59}
60
61// Cache is an in-memory, per-host response cache for cacheable GET/HEAD
62// responses. It exists to let the edge answer bot/crawler/repeat traffic
63// without invoking the hibernation activator (edge.go's ServeHTTP checks
64// it before ever reaching the activator) — see
65// docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md.
66// A nil *Cache disables caching entirely; callers must nil-check before use.
67type Cache struct {
68 mu sync.Mutex
69 maxEntries int
70 maxBodyBytes int
71 order *list.List // MRU at front, LRU at back
72 entries map[cacheFullKey]*list.Element
73 byURL map[cacheURLKey]map[cacheFullKey]*list.Element // urlKey -> its Vary variants
74 byPath map[cachePathKey]map[cacheFullKey]*list.Element // pathKey -> every entry under that path
75 byHost map[string]map[cacheFullKey]*list.Element // host -> every entry for that host
76 // genOf answers a tagged entry's route's newest generation
77 // (SetGenerations); nil with generations off. Called under mu, so it
78 // must take no lock its own side holds while calling into the cache.
79 genOf func(route string) (uint64, bool)
80}
81
82type cacheURLKey struct {
83 Host, Method, Path, RawQuery string
84}
85
86type cachePathKey struct {
87 Host, Method, Path string
88}
89
90// cacheFullKey identifies one stored variant. It carries BOTH halves of the
91// Vary contract — which headers the response was selected on and the values
92// those names resolved to for the request that elicited it — because a variant
93// is only reusable for a request that agrees on both. RFC 9111 §4.1.
94//
95// What the two components guarantee, precisely:
96//
97// - Vary holds the CANONICAL name list (canonicalVary: trimmed, deduped,
98// case-canonicalized, sorted), so two responses selecting on the same
99// headers share one slot however they spelled or ordered them, and two
100// responses selecting on DIFFERENT headers never share one.
101// - VaryValue is injective over request-header values FOR A GIVEN Vary: the
102// length prefix varyValue writes ahead of each value means a distinct tuple
103// of values always renders a distinct string, since no value's content can
104// be read as the separator structure around it (see varyValue).
105//
106// Injectivity is stated per-Vary because that is all the key needs: Vary is a
107// key component in its own right, so agreement on the rendered values is only
108// ever consulted between entries that already agree on the name list.
109type cacheFullKey struct {
110 cacheURLKey
111 Vary string // canonicalVary of the response's Vary field value; "" if it carried none
112 VaryValue string
113}
114
115type cacheEntry struct {
116 key cacheFullKey
117 resp *CachedResponse
118}
119
120// matches reports whether this stored variant is reusable for r: every header
121// named by the response's own Vary field resolves, for r, to the same values
122// the stored response was selected under.
123//
124// An entry stored from a response with no Vary matches every request for the
125// URL — that is precisely what "no Vary" means. The empty-Vary branch is
126// explicit rather than left to varyValue (which renders "" for an empty name
127// list, and so would agree) so that an entry claiming no Vary can never match
128// on a non-empty VaryValue it should not be holding.
129func (e *cacheEntry) matches(r *http.Request) bool {
130 if e.key.Vary == "" {
131 return e.key.VaryValue == ""
132 }
133 return varyValue(r, e.key.Vary) == e.key.VaryValue
134}
135
136// specificity counts the headers a variant was selected on: the canonical name
137// list, so duplicate, empty and differently-spelled tokens cannot inflate it
138// past the number of headers actually consulted. It orders candidates when
139// several match one request — see selectLocked.
140func (e *cacheEntry) specificity() int {
141 return len(canonicalVaryNames(e.key.Vary))
142}
143
144// NewCache creates a new cache with the given capacity (max entries) and max body size.
145func NewCache(maxEntries, maxBodyBytes int) *Cache {
146 return &Cache{
147 maxEntries: maxEntries,
148 maxBodyBytes: maxBodyBytes,
149 order: list.New(),
150 entries: make(map[cacheFullKey]*list.Element),
151 byURL: make(map[cacheURLKey]map[cacheFullKey]*list.Element),
152 byPath: make(map[cachePathKey]map[cacheFullKey]*list.Element),
153 byHost: make(map[string]map[cacheFullKey]*list.Element),
154 }
155}
156
157// SetGenerations arms the generation guard (spec §7.2): a tagged entry is
158// refused at Put, and treated as a miss at lookup, unless its Gen is
159// genOf(Route). The edge calls it once, from EnableCache, only with
160// generations on.
161func (c *Cache) SetGenerations(genOf func(route string) (uint64, bool)) {
162 c.mu.Lock()
163 defer c.mu.Unlock()
164 c.genOf = genOf
165}
166
167// genCurrentLocked reports whether resp may be served or stored: untagged,
168// or tagged with its route's newest generation. A tagged entry with no
169// guard armed is not current: it cannot be vouched for. Caller holds c.mu.
170func (c *Cache) genCurrentLocked(resp *CachedResponse) bool {
171 if resp.Route == "" {
172 return true
173 }
174 if c.genOf == nil {
175 return false
176 }
177 n, ok := c.genOf(resp.Route)
178 return ok && n == resp.Gen
179}
180
181func (k CacheKey) urlKey() cacheURLKey {
182 return cacheURLKey{Host: k.Host, Method: k.Method, Path: k.Path, RawQuery: k.RawQuery}
183}
184
185func (k CacheKey) fullKey(vary string) cacheFullKey {
186 return cacheFullKey{cacheURLKey: k.urlKey(), Vary: vary, VaryValue: k.VaryValue}
187}
188
189func (k cacheURLKey) pathKey() cachePathKey {
190 return cachePathKey{Host: k.Host, Method: k.Method, Path: k.Path}
191}
192
193// get retrieves a cached variant by an already-resolved VaryValue, if one
194// exists and has not expired. Returns (nil, false) for a miss or an expired
195// entry. Expired entries are evicted lazily on lookup, not swept on a timer.
196//
197// Deliberately unexported: every request-handling caller must go through
198// GetFor, which matches on the stored Vary names rather than a VaryValue the
199// caller had to guess — a request cannot know the response's Vary header until
200// the response exists, so an exported by-VaryValue lookup is an invitation to
201// guess "" and silently miss (or, worse, hit the wrong variant). get exists for
202// the package's own probes and tests, which already hold the exact variant
203// string; when several Vary schemes happen to render the same VaryValue it
204// picks between them by selectLocked's ordering.
205func (c *Cache) get(key CacheKey) (*CachedResponse, bool) {
206 c.mu.Lock()
207 defer c.mu.Unlock()
208 return c.selectLocked(key.urlKey(), func(e *cacheEntry) bool {
209 return e.key.VaryValue == key.VaryValue
210 })
211}
212
213// GetFor is the request-time lookup. baseKey carries everything known before
214// the response exists (host, method, path, query) with VaryValue left empty;
215// which variant r belongs to is decided by the Vary names each stored entry
216// recorded for itself.
217//
218// The Vary header names live on the RESPONSE, so they cannot be known from r
219// alone — which is why they travel with the entry (RFC 9111 §4.1). Every
220// candidate for this URL evaluates its OWN names against r, so responses that
221// vary by different headers, or that vary only sometimes, coexist under one
222// URL instead of overwriting each other's notion of how the URL varies.
223//
224// The scan is bounded by maxVaryVariantsPerURL, so it is a handful of string
225// comparisons, not a search. Both this and maybeStore render the variant
226// through varyValue, so store and lookup cannot drift apart — that drift
227// (storing under varyValue(request, vary) while looking up under "") is the
228// bug that once made every Vary-carrying response a permanent miss.
229func (c *Cache) GetFor(baseKey CacheKey, r *http.Request) (*CachedResponse, bool) {
230 c.mu.Lock()
231 defer c.mu.Unlock()
232 return c.selectLocked(baseKey.urlKey(), func(e *cacheEntry) bool {
233 return e.matches(r)
234 })
235}
236
237// selectLocked returns the best live entry for uk that satisfies want, evicting
238// any expired entry it walks past. Caller must hold c.mu.
239//
240// "Best", because several stored variants can satisfy one request: an entry
241// from a response with no Vary matches every request for the URL, so it
242// overlaps every varying entry beside it. RFC 9111 §4 leaves the choice among
243// suitable responses to freshness order; that rule assumes one Vary scheme per
244// URI, and under mixed schemes it would hand the non-varying entry to
245// everybody the moment it was the newest — a CORS response cached without its
246// Access-Control-Allow-Origin, served to the cross-origin requests that needed
247// it. So the ordering is most-specific-first: among matches, the entry
248// selected on the most headers wins, because it is the one whose stored
249// response actually depended on this request's headers. Equal specificity
250// falls back to the most recently stored, which is RFC 9111 §4's rule.
251//
252// The accepted cost of that ordering is stale shadowing: a more-specific
253// variant outranks a strictly NEWER general one that also matches, so an older
254// `Vary: Accept-Encoding` entry keeps being served to gzip-accepting requests
255// even after a fresher no-Vary response for the same URL has been stored beside
256// it. Pinned by TestCacheOlderSpecificVariantShadowsNewerGeneralUntilItExpires.
257//
258// This is knowingly kept, and no recency heuristic is layered on top:
259//
260// - It is bounded by the shadowing entry's own TTL. maybeStore clamps every
261// TTL to DefaultCacheMaxTTL, so the window is short and self-healing — the
262// stale entry is dropped by the expiry sweep in this very loop, after which
263// the general entry is the only match left. Bounded staleness is the whole
264// bargain of a cache, and PurgeURL/PurgeHost cut it short on deploy.
265// - The alternative is unbounded WRONGNESS. "Newest wins" hands the
266// non-varying entry to every request the moment it is newest, including the
267// cross-origin ones whose stored response carried an
268// Access-Control-Allow-Origin the new one does not — a correctness failure
269// no TTL bounds, because the newest entry keeps being the newest.
270//
271// Serving a slightly older response that was selected on this request's actual
272// headers is the safe side of that trade.
273func (c *Cache) selectLocked(uk cacheURLKey, want func(*cacheEntry) bool) (*CachedResponse, bool) {
274 var best *list.Element
275 var bestEntry *cacheEntry
276 var expired []*list.Element
277 for _, el := range c.byURL[uk] {
278 e := el.Value.(*cacheEntry)
279 if time.Since(e.resp.StoredAt) > e.resp.TTL {
280 expired = append(expired, el)
281 continue
282 }
283 if !want(e) {
284 continue
285 }
286 // A generation that is no longer its route's newest is a miss for
287 // every name the route answers to (spec §7.2). Left in place: the
288 // switch's purge, the TTL or the LRU reclaims it.
289 if !c.genCurrentLocked(e.resp) {
290 continue
291 }
292 if bestEntry == nil || better(e, bestEntry) {
293 best, bestEntry = el, e
294 }
295 }
296 // Removal is deferred out of the loop: removeLocked deletes from the very
297 // map being ranged over, and can drop c.byURL[uk] entirely once its last
298 // entry goes.
299 for _, el := range expired {
300 c.removeLocked(el)
301 }
302 if best == nil {
303 return nil, false
304 }
305 c.order.MoveToFront(best)
306 return bestEntry.resp, true
307}
308
309// better reports whether candidate outranks incumbent as the variant to serve:
310// more selecting headers first, then more recently stored, and finally by key
311// so the answer never depends on map iteration order.
312//
313// That last step is not cosmetic. selectLocked ranges over a Go map, whose
314// order is randomized per iteration, so two entries tied on both specificity
315// and StoredAt — same-millisecond stores, or timestamps a test or a restore
316// path set by hand — would otherwise serve a different body from one request to
317// the next for no visible reason. A cache that answers the same question two
318// ways is unreproducible in exactly the situation where it matters. Comparing
319// the key components (which are unique per entry: two entries agreeing on both
320// are the same map key) makes selection a pure function of what is stored.
321func better(candidate, incumbent *cacheEntry) bool {
322 cs, is := candidate.specificity(), incumbent.specificity()
323 if cs != is {
324 return cs > is
325 }
326 if !candidate.resp.StoredAt.Equal(incumbent.resp.StoredAt) {
327 return candidate.resp.StoredAt.After(incumbent.resp.StoredAt)
328 }
329 if candidate.key.VaryValue != incumbent.key.VaryValue {
330 return candidate.key.VaryValue < incumbent.key.VaryValue
331 }
332 return candidate.key.Vary < incumbent.key.Vary
333}
334
335// Put stores a response in the cache. Silently drops a response whose body
336// exceeds maxBodyBytes. Evicts the least-recently-used entry when at
337// maxEntries, and enforces the per-URL and per-path admission caps so no
338// single URL or path can flood the cache (see the cap constants above).
339//
340// vary is the response's Vary field value — every line of it, comma-joined —
341// and "" when the response carried no Vary header at all. It is a separate
342// parameter rather than something read back off resp.Header because the caller
343// must have computed key.VaryValue from this exact string (edge.go's maybeStore
344// does, via varyValue), and because key.VaryValue == "" cannot stand in for
345// "no Vary": a response may vary on a header the request never sent, which
346// computes an empty value from a very real Vary header.
347//
348// vary is stored WITH the entry, as part of its key, in the canonical form
349// canonicalVary produces — the same normalization varyValue applied when it
350// rendered key.VaryValue, so the two halves of the key agree by construction
351// and a scheme spelled "accept-encoding" cannot open a second slot beside the
352// one "Accept-Encoding" already holds. A URL whose responses vary
353// conditionally — CORS middleware emitting Vary: Origin only for
354// Origin-bearing requests, content negotiation emitting Vary: Accept-Language
355// only when it negotiates — therefore accumulates one variant per DISTINCT
356// scheme rather than having each flip invalidate the last, and each request is
357// matched against the scheme its own shape produced. The per-URL cap still
358// bounds the total, now across schemes rather than within one.
359func (c *Cache) Put(key CacheKey, vary string, resp *CachedResponse) {
360 if len(resp.Body) > c.maxBodyBytes {
361 return
362 }
363 c.mu.Lock()
364 defer c.mu.Unlock()
365 // A response whose generation stopped being current while it was in
366 // flight (a late answer from a draining process) never fills the cache
367 // after the switch (spec §7.2).
368 if !c.genCurrentLocked(resp) {
369 return
370 }
371 fk := key.fullKey(canonicalVary(vary))
372 uk := fk.cacheURLKey
373 if el, ok := c.entries[fk]; ok {
374 el.Value.(*cacheEntry).resp = resp
375 c.order.MoveToFront(el)
376 return
377 }
378 el := c.order.PushFront(&cacheEntry{key: fk, resp: resp})
379 c.entries[fk] = el
380 if c.byURL[uk] == nil {
381 c.byURL[uk] = make(map[cacheFullKey]*list.Element)
382 }
383 c.byURL[uk][fk] = el
384 pk := uk.pathKey()
385 if c.byPath[pk] == nil {
386 c.byPath[pk] = make(map[cacheFullKey]*list.Element)
387 }
388 c.byPath[pk][fk] = el
389 if c.byHost[uk.Host] == nil {
390 c.byHost[uk.Host] = make(map[cacheFullKey]*list.Element)
391 }
392 c.byHost[uk.Host][fk] = el
393
394 c.capGroupLocked(c.byURL[uk], maxVaryVariantsPerURL, el)
395 c.capGroupLocked(c.byPath[pk], maxEntriesPerPath, el)
396 for len(c.entries) > c.maxEntries {
397 victim := c.order.Back()
398 if victim == nil {
399 break
400 }
401 c.removeLocked(victim)
402 }
403}
404
405// capGroupLocked drops the oldest entries (by StoredAt) from one admission
406// group until it holds at most max, never dropping keep (the entry just
407// inserted — evicting it would make the flood self-defeating in the wrong
408// direction, turning every request into a permanent miss AND a permanent
409// insert). Caller must hold c.mu.
410func (c *Cache) capGroupLocked(group map[cacheFullKey]*list.Element, max int, keep *list.Element) {
411 for len(group) > max {
412 var oldest *list.Element
413 for _, el := range group {
414 if el == keep {
415 continue
416 }
417 if oldest == nil || el.Value.(*cacheEntry).resp.StoredAt.Before(oldest.Value.(*cacheEntry).resp.StoredAt) {
418 oldest = el
419 }
420 }
421 if oldest == nil {
422 return
423 }
424 c.removeLocked(oldest)
425 }
426}
427
428// removeLocked drops el from every index. Caller must hold c.mu.
429func (c *Cache) removeLocked(el *list.Element) {
430 e := el.Value.(*cacheEntry)
431 c.order.Remove(el)
432 delete(c.entries, e.key)
433 uk := e.key.cacheURLKey
434 if variants := c.byURL[uk]; variants != nil {
435 delete(variants, e.key)
436 if len(variants) == 0 {
437 delete(c.byURL, uk)
438 }
439 }
440 pk := uk.pathKey()
441 if group := c.byPath[pk]; group != nil {
442 delete(group, e.key)
443 if len(group) == 0 {
444 delete(c.byPath, pk)
445 }
446 }
447 if group := c.byHost[uk.Host]; group != nil {
448 delete(group, e.key)
449 if len(group) == 0 {
450 delete(c.byHost, uk.Host)
451 }
452 }
453}
454
455// PurgeURL drops every cached variant (every VaryValue) of (host, path, rawQuery)
456// for both GET and HEAD methods and returns how many entries it actually removed.
457// Returns 0 if none were cached — not an error.
458func (c *Cache) PurgeURL(host, path, rawQuery string) int {
459 c.mu.Lock()
460 defer c.mu.Unlock()
461 n := 0
462 // GET and HEAD are purged together — a write invalidates both.
463 for _, method := range [...]string{"GET", "HEAD"} {
464 uk := cacheURLKey{Host: host, Method: method, Path: path, RawQuery: rawQuery}
465 for _, el := range elementsOf(c.byURL[uk]) {
466 c.removeLocked(el)
467 n++
468 }
469 }
470 return n
471}
472
473// PurgePath drops every cached entry for (host, path) REGARDLESS of query
474// string — every query variant and every Vary variant of each, for both GET
475// and HEAD — and returns how many it removed.
476//
477// This is the primitive behind a bare-path Carlos-Purge target. PurgeURL
478// needs the exact RawQuery, so an app that wrote to /events/42 and purged
479// "/events/42" left /events/42?utm_source=newsletter serving the pre-write
480// body until its TTL ran out — the cached copies a campaign or a locale
481// switcher created are exactly the ones a reader is most likely to land on.
482//
483// It rides the byPath index, which already groups entries by
484// (Host, Method, Path) for the per-path admission cap, so this costs two map
485// lookups rather than a scan. Both method keys are purged for the same reason
486// PurgeURL does it: a write invalidates the GET and the HEAD alike.
487func (c *Cache) PurgePath(host, path string) int {
488 c.mu.Lock()
489 defer c.mu.Unlock()
490 n := 0
491 for _, method := range [...]string{"GET", "HEAD"} {
492 pk := cachePathKey{Host: host, Method: method, Path: path}
493 for _, el := range elementsOf(c.byPath[pk]) {
494 c.removeLocked(el)
495 n++
496 }
497 }
498 return n
499}
500
501// PurgeHost drops every cached entry for host — all paths, all query strings,
502// all Vary variants — and returns how many it removed. A deploy calls this: a
503// fresh process for a host means new code and potentially new content, so
504// everything cached from the old one is suspect.
505//
506// It is no longer only a deploy-time cost, though, and so no longer a
507// whole-cache scan: a Carlos-Purge header that names more targets than the
508// 64-target/8KiB caps allow degrades to PurgeHost (see purge_header.go),
509// which puts it on the request path. It rides the byHost index instead — the
510// same discipline byURL and byPath already keep in Put/removeLocked — so a
511// misbehaving or hostile app declaring an oversized purge cannot turn every
512// response into a scan of the entire cache.
513func (c *Cache) PurgeHost(host string) int {
514 c.mu.Lock()
515 defer c.mu.Unlock()
516 els := elementsOf(c.byHost[host])
517 for _, el := range els {
518 c.removeLocked(el)
519 }
520 return len(els)
521}
522
523// elementsOf snapshots a group's elements so the caller can remove them while
524// iterating (removeLocked mutates the very map being ranged over).
525func elementsOf(group map[cacheFullKey]*list.Element) []*list.Element {
526 els := make([]*list.Element, 0, len(group))
527 for _, el := range group {
528 els = append(els, el)
529 }
530 return els
531}
532