| 1 | package edge |
| 2 | |
| 3 | import ( |
| 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. |
| 28 | const ( |
| 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). |
| 35 | type 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. |
| 44 | type 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. |
| 67 | type 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 | |
| 82 | type cacheURLKey struct { |
| 83 | Host, Method, Path, RawQuery string |
| 84 | } |
| 85 | |
| 86 | type 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. |
| 109 | type cacheFullKey struct { |
| 110 | cacheURLKey |
| 111 | Vary string // canonicalVary of the response's Vary field value; "" if it carried none |
| 112 | VaryValue string |
| 113 | } |
| 114 | |
| 115 | type 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. |
| 129 | func (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. |
| 140 | func (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. |
| 145 | func 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. |
| 161 | func (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. |
| 170 | func (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 | |
| 181 | func (k CacheKey) urlKey() cacheURLKey { |
| 182 | return cacheURLKey{Host: k.Host, Method: k.Method, Path: k.Path, RawQuery: k.RawQuery} |
| 183 | } |
| 184 | |
| 185 | func (k CacheKey) fullKey(vary string) cacheFullKey { |
| 186 | return cacheFullKey{cacheURLKey: k.urlKey(), Vary: vary, VaryValue: k.VaryValue} |
| 187 | } |
| 188 | |
| 189 | func (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. |
| 205 | func (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. |
| 229 | func (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. |
| 273 | func (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. |
| 321 | func 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. |
| 359 | func (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. |
| 410 | func (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. |
| 429 | func (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. |
| 458 | func (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. |
| 487 | func (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. |
| 513 | func (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). |
| 525 | func 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 | |