merge: edge cache-before-wake — bots shouldn't wake hibernated instances (#10)
edge cache-before-wake: bots shouldn't wake hibernated instances
11 files changed,
+3800
−9
docs/superpowers/plans/2026-08-02-edge-cache-before-wake-platform.md+1363 −0docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md+270 −0internal/activator/activator_test.go+190 −0internal/activator/backend_exec.go+56 −4internal/edge/cache.go+339 −0internal/edge/cache_test.go+202 −0internal/edge/edge.go+346 −4internal/edge/edge_test.go+830 −0internal/edge/purge.go+85 −0internal/edge/purge_test.go+109 −0internal/host/agent.go+10 −1
diff --git a/docs/superpowers/plans/2026-08-02-edge-cache-before-wake-platform.md b/docs/superpowers/plans/2026-08-02-edge-cache-before-wake-platform.md| new file mode 100644 |
| index 000000000..be4598fc2 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-02-edge-cache-before-wake-platform.md |
| @@ -0,0 +1,1363 @@ |
| +# Edge Cache-Before-Wake (Platform) Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Give the CARLOS edge a response cache that serves cacheable GET/HEAD |
| +requests without ever invoking the hibernation activator, kept fresh by a |
| +purge channel each hibernating instance can call right after a write. |
| + |
| +**Architecture:** A new `internal/edge/cache.go` holds an in-memory, |
| +LRU+TTL, Vary-aware response cache. `Edge.ServeHTTP` (`internal/edge/edge.go`) |
| +checks it before calling the activator; a miss falls through to today's |
| +proxy/wake behavior and populates the cache from the real response on the |
| +way back. A new `internal/edge/purge.go` exposes a per-instance local-only |
| +unix-socket listener so an instance can invalidate its own cache entries. |
| +`internal/activator/backend_exec.go` spawns that listener alongside each |
| +instance process and passes it the socket path via a new `--control-socket` |
| +flag, mirroring the existing `--socket`/`--db` flags. |
| + |
| +**Tech Stack:** Go (matches the rest of `carlosframework/platform`), standard |
| +library only (`net/http`, `net`, `container/list`) — no new dependencies. |
| + |
| +## Global Constraints |
| + |
| +- Go, same module as the rest of `carlosframework/platform`; `go build ./...` |
| + and `go vet ./...` must stay clean after every task. |
| +- Existing test conventions: table-driven where natural, `_test.go` |
| + colocated with the code it tests, `go test ./...` for the full suite. |
| +- Several new tests create real unix sockets (`net.Listen("unix", ...)`). |
| + If your shell sandboxes filesystem/socket access, run those tests with |
| + sandboxing disabled — this is a known, pre-existing constraint in this |
| + repo's test suite, not new to this work. |
| +- No new third-party dependencies for this feature — the cache, LRU, and |
| + purge protocol are all small enough for the standard library. |
| +- Nil-means-disabled is this package's existing idiom (`Activator` is nil |
| + for the pure-proxy `carlos edge` role). The new `*Cache` field on |
| + `ExecBackend` follows the same rule: nil disables purge-listener startup |
| + without an error. |
| + |
| +--- |
| + |
| +### Task 1: Cache store |
| + |
| +**Files:** |
| +- Create: `internal/edge/cache.go` |
| +- Test: `internal/edge/cache_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: |
| + ```go |
| + 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 |
| + } |
| + |
| + type CachedResponse struct { |
| + StatusCode int |
| + Header http.Header |
| + Body []byte |
| + StoredAt time.Time |
| + TTL time.Duration |
| + } |
| + |
| + func NewCache(maxEntries, maxBodyBytes int) *Cache |
| + func (c *Cache) Get(key CacheKey) (*CachedResponse, bool) |
| + func (c *Cache) Put(key CacheKey, resp *CachedResponse) |
| + func (c *Cache) PurgeURL(host, path, rawQuery string) int // returns count purged |
| + ``` |
| + `Get` returns `(nil, false)` for a miss or an expired (`StoredAt+TTL < |
| + now`) entry — expired entries are evicted lazily on lookup, not swept on a |
| + timer. `Put` silently drops a response whose body exceeds `maxBodyBytes` |
| + (never caches it, no error — a cache is allowed to say no) and evicts the |
| + least-recently-used entry when at `maxEntries`. `PurgeURL` drops every |
| + cached variant (every `VaryValue`) of `(host, path, rawQuery)` regardless |
| + of which locale/Vary-bucket it was stored under, and returns how many |
| + entries it actually removed (0 if none were cached — not an error). |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| + ```go |
| + 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") |
| + } |
| + } |
| + ``` |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail** |
| + |
| + Run: `go test ./internal/edge/ -run TestCache -v` |
| + Expected: FAIL — `Cache`, `CacheKey`, `CachedResponse`, `NewCache` undefined. |
| + |
| +- [ ] **Step 3: Write the implementation** |
| + |
| + ```go |
| + package edge |
| + |
| + import ( |
| + "container/list" |
| + "sync" |
| + "time" |
| + ) |
| + |
| + // 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 |
| + } |
| + |
| + 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} |
| + } |
| + |
| + 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 |
| + } |
| + |
| + 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) |
| + } |
| + } |
| + } |
| + |
| + 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 |
| + } |
| + ``` |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| + Run: `go test ./internal/edge/ -run TestCache -v` |
| + Expected: PASS (all six). |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| + ```bash |
| + git add internal/edge/cache.go internal/edge/cache_test.go |
| + git commit -m "edge: add LRU+TTL response cache, Vary- and purge-aware" |
| + ``` |
| + |
| +--- |
| + |
| +### Task 2: Cache-key computation and the ServeHTTP insertion point |
| + |
| +**Files:** |
| +- Modify: `internal/edge/edge.go` |
| +- Test: `internal/edge/edge_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Cache`, `CacheKey`, `CachedResponse`, `NewCache` (Task 1). |
| +- Produces: |
| + ```go |
| + const ( |
| + DefaultCacheMaxEntries = 4096 |
| + DefaultCacheMaxBodyBytes = 1 << 20 // 1 MiB |
| + ) |
| + |
| + func (e *Edge) EnableCache(cache *Cache) // sets e.cache; nil is a valid no-op |
| + ``` |
| + and the new field `Edge.cache *Cache` (unexported — only `ServeHTTP` |
| + reads it). `RunWithActivator` gains a third parameter: |
| + `func RunWithActivator(args []string, act Activator, cache *Cache)`. |
| + `Run(args []string)` keeps its existing single-argument signature and |
| + builds its own private cache internally, so `carlos edge`'s pure-proxy |
| + role gets caching too even though it has no activator. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| + Add to `internal/edge/edge_test.go`. This reuses the file's existing |
| + fixtures verbatim: `openReg(t)` (registry against a temp DB), |
| + `reg.Upsert(registry.Route{...})`, and `New(reg, act)` — the exact |
| + pattern `TestHibernatingRouteGoesThroughActivator` (already in this |
| + file) uses for a hibernating route, and the exact pattern |
| + `testEdgeWithStore`'s doc comment recommends (`httptest.NewRecorder` + |
| + `httptest.NewRequest`, no real listener) for everything else. No new |
| + shared helpers are introduced by this task; `refusingActivator` below is |
| + a second, deliberately different fake from the file's existing |
| + `fakeActivator` because it needs to fail the test on any call, not just |
| + count calls. |
| + |
| + ```go |
| + // refusingActivator fails the test immediately if ever invoked — used to |
| + // prove a cache hit never reaches the activator at all. |
| + type refusingActivator struct{ t *testing.T } |
| + |
| + func (r refusingActivator) ServeHTTP(w http.ResponseWriter, req *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + r.t.Fatal("activator invoked on what should have been a cache hit — this defeats the whole point of caching") |
| + return true |
| + } |
| + |
| + func TestServeHTTPCacheHitNeverInvokesActivator(t *testing.T) { |
| + reg := openReg(t) |
| + // Addr is deliberately a bogus, never-dialed address: if the cache |
| + // check is ever bypassed, proxying to it fails loudly instead of |
| + // silently succeeding, which would mask the bug. |
| + if err := reg.Upsert(registry.Route{Host: "cached.example.com", Kind: "instance", Addr: "127.0.0.1:1", Status: "ready", Hibernate: true}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, refusingActivator{t: t}) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + key := CacheKey{Host: "cached.example.com", Method: "GET", Path: "/event"} |
| + e.cache.Put(key, &CachedResponse{ |
| + StatusCode: 200, |
| + Header: http.Header{"Content-Type": {"text/html"}}, |
| + Body: []byte("cached page"), |
| + StoredAt: time.Now(), |
| + TTL: time.Minute, |
| + }) |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("GET", "http://cached.example.com/event", nil)) |
| + |
| + if rec.Code != 200 { |
| + t.Fatalf("status = %d, want 200", rec.Code) |
| + } |
| + if rec.Body.String() != "cached page" { |
| + t.Fatalf("body = %q, want %q", rec.Body.String(), "cached page") |
| + } |
| + } |
| + |
| + func TestServeHTTPCacheMissPopulatesCacheFromResponse(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + w.WriteHeader(200) |
| + w.Write([]byte("fresh page")) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "miss.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + |
| + rec1 := httptest.NewRecorder() |
| + e.ServeHTTP(rec1, httptest.NewRequest("GET", "http://miss.example.com/event", nil)) |
| + if rec1.Body.String() != "fresh page" { |
| + t.Fatalf("first response body = %q, want %q", rec1.Body.String(), "fresh page") |
| + } |
| + |
| + // Close the backend so a second real request would fail to dial — |
| + // proves the second request is answered from cache, not the backend. |
| + backend.Close() |
| + rec2 := httptest.NewRecorder() |
| + e.ServeHTTP(rec2, httptest.NewRequest("GET", "http://miss.example.com/event", nil)) |
| + if rec2.Body.String() != "fresh page" { |
| + t.Fatalf("second response body = %q, want %q (should be served from cache)", rec2.Body.String(), "fresh page") |
| + } |
| + } |
| + |
| + func TestServeHTTPNeverCachesNon2xx(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + w.WriteHeader(500) |
| + w.Write([]byte("boom")) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "err.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://err.example.com/event", nil)) |
| + |
| + if _, ok := e.cache.Get(CacheKey{Host: "err.example.com", Method: "GET", Path: "/event"}); ok { |
| + t.Fatal("a 500 response must never be cached") |
| + } |
| + } |
| + |
| + func TestServeHTTPVaryAcceptLanguageProducesSeparateEntries(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + w.Header().Set("Vary", "Accept-Language") |
| + w.WriteHeader(200) |
| + w.Write([]byte("lang=" + r.Header.Get("Accept-Language"))) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "i18n.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + |
| + reqEn := httptest.NewRequest("GET", "http://i18n.example.com/event", nil) |
| + reqEn.Header.Set("Accept-Language", "en") |
| + recEn := httptest.NewRecorder() |
| + e.ServeHTTP(recEn, reqEn) |
| + |
| + reqFr := httptest.NewRequest("GET", "http://i18n.example.com/event", nil) |
| + reqFr.Header.Set("Accept-Language", "fr") |
| + recFr := httptest.NewRecorder() |
| + e.ServeHTTP(recFr, reqFr) |
| + |
| + if recEn.Body.String() == recFr.Body.String() { |
| + t.Fatalf("expected distinct cached responses per Accept-Language, got %q for both", recEn.Body.String()) |
| + } |
| + } |
| + |
| + func TestServeHTTPRefusesVaryCookie(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + w.Header().Set("Vary", "Cookie") |
| + w.WriteHeader(200) |
| + w.Write([]byte("should not be cached")) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "cookie.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://cookie.example.com/event", nil)) |
| + |
| + if _, ok := e.cache.Get(CacheKey{Host: "cookie.example.com", Method: "GET", Path: "/event"}); ok { |
| + t.Fatal("a response Vary-ing on Cookie must never be cached — it would defeat per-visitor correctness") |
| + } |
| + } |
| + ``` |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail** |
| + |
| + Run: `go test ./internal/edge/ -run TestServeHTTP -v` |
| + Expected: FAIL — `e.cache`/`EnableCache` undefined, or (for the hit test) |
| + the activator gets invoked because there's no cache check yet. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| + Add to `edge.go` near the other package-level consts: |
| + |
| + ```go |
| + const ( |
| + DefaultCacheMaxEntries = 4096 |
| + DefaultCacheMaxBodyBytes = 1 << 20 // 1 MiB |
| + ) |
| + ``` |
| + |
| + Add the field to the `Edge` struct (alongside the existing unexported |
| + fields — find that struct definition in `edge.go`): |
| + |
| + ```go |
| + cache *Cache // nil disables caching entirely |
| + ``` |
| + |
| + Add the enabler, next to `EnableStatic`: |
| + |
| + ```go |
| + // EnableCache wires a response cache into the edge. Called once at |
| + // startup (RunWithActivator or Run); nil is a valid no-op, matching the |
| + // package's existing nil-means-disabled convention for Activator. |
| + func (e *Edge) EnableCache(cache *Cache) { |
| + e.cache = cache |
| + } |
| + ``` |
| + |
| + Modify `ServeHTTP` — locate the existing body (from the earlier read, |
| + it looks up `up` from `e.current()`, then does the hibernating check |
| + before proxying). Insert the cache check right before the existing |
| + `if up.hibernating { ... e.act.ServeHTTP(...) }` block, and wrap the |
| + fallthrough proxy call so a cacheable response gets captured: |
| + |
| + ```go |
| + func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + host := hostOnly(r.Host) |
| + m := e.current() |
| + up, ok := m[host] |
| + if !ok { |
| + e.unknownHost(w, r) |
| + return |
| + } |
| + |
| + cacheable := e.cache != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) |
| + var key CacheKey |
| + if cacheable { |
| + key = CacheKey{Host: host, Method: r.Method, Path: r.URL.Path, RawQuery: r.URL.RawQuery} |
| + if resp, hit := e.cache.Get(key); hit { |
| + writeCached(w, resp) |
| + return |
| + } |
| + } |
| + |
| + if up.hibernating { |
| + rec := &recordingResponseWriter{ResponseWriter: w, cacheable: cacheable, maxBody: e.cacheMaxBody()} |
| + if e.act.ServeHTTP(rec, r, up.route, up.proxy) { |
| + e.maybeStore(key, rec) |
| + return |
| + } |
| + up.proxy.ServeHTTP(rec, r) |
| + e.maybeStore(key, rec) |
| + return |
| + } |
| + |
| + if cacheable { |
| + rec := &recordingResponseWriter{ResponseWriter: w, cacheable: true, maxBody: e.cacheMaxBody()} |
| + up.proxy.ServeHTTP(rec, r) |
| + e.maybeStore(key, rec) |
| + return |
| + } |
| + up.proxy.ServeHTTP(w, r) |
| + } |
| + |
| + func (e *Edge) cacheMaxBody() int { |
| + if e.cache == nil { |
| + return 0 |
| + } |
| + return e.cache.maxBodyBytes |
| + } |
| + |
| + // maybeStore inspects a recorded response and caches it if it qualifies: |
| + // 2xx status, a Cache-Control that names a positive max-age, and no |
| + // Vary: Cookie (varying by cookie would defeat correctness per-visitor — |
| + // see docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md §5). |
| + func (e *Edge) maybeStore(key CacheKey, rec *recordingResponseWriter) { |
| + if e.cache == nil || !rec.cacheable || rec.truncated { |
| + return |
| + } |
| + if rec.status < 200 || rec.status >= 300 { |
| + return |
| + } |
| + ttl, ok := parseMaxAge(rec.Header().Get("Cache-Control")) |
| + if !ok || ttl <= 0 { |
| + return |
| + } |
| + vary := rec.Header().Get("Vary") |
| + if vary != "" { |
| + for _, h := range strings.Split(vary, ",") { |
| + if strings.EqualFold(strings.TrimSpace(h), "Cookie") { |
| + log.Printf("edge: refusing to cache %s%s — Vary: Cookie would break per-visitor correctness", key.Host, key.Path) |
| + return |
| + } |
| + } |
| + key.VaryValue = varyValue(rec.request, vary) |
| + } |
| + e.cache.Put(key, &CachedResponse{ |
| + StatusCode: rec.status, |
| + Header: rec.Header().Clone(), |
| + Body: rec.body, |
| + StoredAt: time.Now(), |
| + TTL: ttl, |
| + }) |
| + } |
| + |
| + // varyValue concatenates r's values for each header named in the |
| + // response's Vary list, in the order Vary names them — the same raw |
| + // string always produces the same bucket, which is all correctness |
| + // requires (standard HTTP Vary semantics; RFC 7234 §4.1). |
| + func varyValue(r *http.Request, vary string) string { |
| + var b strings.Builder |
| + for _, h := range strings.Split(vary, ",") { |
| + h = strings.TrimSpace(h) |
| + b.WriteString(h) |
| + b.WriteByte('=') |
| + b.WriteString(r.Header.Get(h)) |
| + b.WriteByte(';') |
| + } |
| + return b.String() |
| + } |
| + |
| + // parseMaxAge extracts max-age from a Cache-Control header. Returns |
| + // ok=false for "no-store", "no-cache", or a missing/malformed max-age — |
| + // any of which mean "don't cache this." |
| + func parseMaxAge(cc string) (time.Duration, bool) { |
| + for _, part := range strings.Split(cc, ",") { |
| + part = strings.TrimSpace(part) |
| + if strings.EqualFold(part, "no-store") || strings.EqualFold(part, "no-cache") { |
| + return 0, false |
| + } |
| + if v, ok := strings.CutPrefix(strings.ToLower(part), "max-age="); ok { |
| + secs, err := strconv.Atoi(v) |
| + if err != nil { |
| + return 0, false |
| + } |
| + return time.Duration(secs) * time.Second, true |
| + } |
| + } |
| + return 0, false |
| + } |
| + |
| + func writeCached(w http.ResponseWriter, resp *CachedResponse) { |
| + h := w.Header() |
| + for k, v := range resp.Header { |
| + h[k] = v |
| + } |
| + w.WriteHeader(resp.StatusCode) |
| + w.Write(resp.Body) |
| + } |
| + |
| + // recordingResponseWriter captures a response as it's written so it can |
| + // be cached afterward, while still passing bytes through to the real |
| + // client immediately (no buffering delay for the requester). Recording |
| + // stops (silently, not an error) once the body exceeds maxBody — that |
| + // response is simply never cached, per maybeStore's rec.truncated check. |
| + type recordingResponseWriter struct { |
| + http.ResponseWriter |
| + request *http.Request |
| + cacheable bool |
| + maxBody int |
| + status int |
| + body []byte |
| + truncated bool |
| + wroteHdr bool |
| + } |
| + |
| + func (r *recordingResponseWriter) WriteHeader(status int) { |
| + if !r.wroteHdr { |
| + r.status = status |
| + r.wroteHdr = true |
| + } |
| + r.ResponseWriter.WriteHeader(status) |
| + } |
| + |
| + func (r *recordingResponseWriter) Write(p []byte) (int, error) { |
| + if !r.wroteHdr { |
| + r.WriteHeader(http.StatusOK) |
| + } |
| + if r.cacheable && !r.truncated { |
| + if len(r.body)+len(p) > r.maxBody { |
| + r.truncated = true |
| + r.body = nil |
| + } else { |
| + r.body = append(r.body, p...) |
| + } |
| + } |
| + return r.ResponseWriter.Write(p) |
| + } |
| + ``` |
| + |
| + Add imports as needed (`strconv`, `strings`, `log` if not already |
| + present in `edge.go`). |
| + |
| + Update `Run` and `RunWithActivator`: |
| + |
| + ```go |
| + func Run(args []string) { |
| + RunWithActivator(args, nil, NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + } |
| + |
| + func RunWithActivator(args []string, act Activator, cache *Cache) { |
| + // ... unchanged up through: |
| + e, err := New(reg, act) |
| + if err != nil { |
| + log.Fatalf("edge: load routes: %v", err) |
| + } |
| + e.fallbackURL = *fallbackURL |
| + e.EnableCache(cache) |
| + // ... rest unchanged |
| + } |
| + ``` |
| + |
| + Update the one other call site, `main.go:27` (`edge.Run(args)`), does |
| + **not** need to change — `Run`'s signature is unchanged. |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| + Run: `go test ./internal/edge/... -v` |
| + Expected: PASS, including all pre-existing `edge_test.go` tests |
| + (unchanged behavior for non-cacheable/non-hibernating routes). |
| + |
| +- [ ] **Step 5: Run go vet and the full build** |
| + |
| + Run: `go vet ./... && go build ./...` |
| + Expected: clean. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| + ```bash |
| + git add internal/edge/edge.go internal/edge/edge_test.go |
| + git commit -m "edge: check the response cache before waking a hibernating instance" |
| + ``` |
| + |
| +--- |
| + |
| +### Task 3: Local purge listener |
| + |
| +**Files:** |
| +- Create: `internal/edge/purge.go` |
| +- Test: `internal/edge/purge_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `*Cache` (Task 1), `Cache.PurgeURL`. |
| +- Produces: |
| + ```go |
| + type PurgeListener struct{ ... } |
| + func ListenPurge(socketPath, host string, cache *Cache) (*PurgeListener, error) |
| + func (p *PurgeListener) Close() error |
| + ``` |
| + The listener binds a unix socket at `socketPath` (removing any stale |
| + file left from a previous run first), mode `0600`, and serves exactly |
| + one route: `POST /purge` with a JSON body `{"path":"/event","query":""}`. |
| + `host` is fixed at listener-construction time — the request body never |
| + names a host, so an instance can only ever purge its own route's cache |
| + entries, by construction (not just by socket-file permission). |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| + ```go |
| + package edge |
| + |
| + import ( |
| + "bytes" |
| + "encoding/json" |
| + "net" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + ) |
| + |
| + func dialPurgeSocket(t *testing.T, socketPath string) *http.Client { |
| + t.Helper() |
| + return &http.Client{ |
| + Transport: &http.Transport{ |
| + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return net.Dial("unix", socketPath) |
| + }, |
| + }, |
| + } |
| + } |
| + |
| + func TestListenPurgeRemovesCachedEntry(t *testing.T) { |
| + dir := t.TempDir() |
| + sock := filepath.Join(dir, "instance.sock") |
| + cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes) |
| + cache.Put(CacheKey{Host: "a.example", Method: "GET", Path: "/event"}, &CachedResponse{ |
| + StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute, |
| + }) |
| + |
| + pl, err := ListenPurge(sock, "a.example", cache) |
| + if err != nil { |
| + t.Fatalf("ListenPurge: %v", err) |
| + } |
| + defer pl.Close() |
| + |
| + body, _ := json.Marshal(map[string]string{"path": "/event", "query": ""}) |
| + client := dialPurgeSocket(t, sock) |
| + resp, err := client.Post("http://unix/purge", "application/json", bytes.NewReader(body)) |
| + if err != nil { |
| + t.Fatalf("purge POST: %v", err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("purge status = %d, want 200", resp.StatusCode) |
| + } |
| + |
| + if _, ok := cache.Get(CacheKey{Host: "a.example", Method: "GET", Path: "/event"}); ok { |
| + t.Fatal("expected entry to be purged") |
| + } |
| + } |
| + |
| + func TestListenPurgeCannotAffectAnotherHost(t *testing.T) { |
| + dir := t.TempDir() |
| + sock := filepath.Join(dir, "instance.sock") |
| + cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes) |
| + cache.Put(CacheKey{Host: "other.example", Method: "GET", Path: "/event"}, &CachedResponse{ |
| + StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute, |
| + }) |
| + |
| + pl, err := ListenPurge(sock, "a.example", cache) // bound to a.example, not other.example |
| + if err != nil { |
| + t.Fatalf("ListenPurge: %v", err) |
| + } |
| + defer pl.Close() |
| + |
| + body, _ := json.Marshal(map[string]string{"path": "/event", "query": ""}) |
| + client := dialPurgeSocket(t, sock) |
| + resp, _ := client.Post("http://unix/purge", "application/json", bytes.NewReader(body)) |
| + resp.Body.Close() |
| + |
| + if _, ok := cache.Get(CacheKey{Host: "other.example", Method: "GET", Path: "/event"}); !ok { |
| + t.Fatal("a purge listener bound to a.example must never affect other.example's cache entries") |
| + } |
| + } |
| + |
| + func TestListenPurgeSocketFileIsOwnerOnly(t *testing.T) { |
| + dir := t.TempDir() |
| + sock := filepath.Join(dir, "instance.sock") |
| + pl, err := ListenPurge(sock, "a.example", NewCache(10, 1<<20)) |
| + if err != nil { |
| + t.Fatalf("ListenPurge: %v", err) |
| + } |
| + defer pl.Close() |
| + |
| + fi, err := os.Stat(sock) |
| + if err != nil { |
| + t.Fatalf("stat socket: %v", err) |
| + } |
| + if mode := fi.Mode().Perm(); mode != 0600 { |
| + t.Fatalf("socket mode = %o, want 0600", mode) |
| + } |
| + } |
| + |
| + func TestListenPurgeRemovesStaleSocketFile(t *testing.T) { |
| + dir := t.TempDir() |
| + sock := filepath.Join(dir, "instance.sock") |
| + if err := os.WriteFile(sock, []byte("stale"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + pl, err := ListenPurge(sock, "a.example", NewCache(10, 1<<20)) |
| + if err != nil { |
| + t.Fatalf("ListenPurge should clean up a stale socket file, got: %v", err) |
| + } |
| + pl.Close() |
| + } |
| + ``` |
| + |
| + Add `"context"` to the imports used by `dialPurgeSocket`. |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail** |
| + |
| + Run: `go test ./internal/edge/ -run TestListenPurge -v` |
| + Expected: FAIL — `ListenPurge` undefined. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| + ```go |
| + package edge |
| + |
| + import ( |
| + "encoding/json" |
| + "errors" |
| + "net" |
| + "net/http" |
| + "os" |
| + ) |
| + |
| + // PurgeListener is a per-instance, local-only channel for invalidating |
| + // that instance's cache entries right after a write — see |
| + // docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md §3-4. |
| + // It is deliberately NOT reachable over the network: an externally |
| + // reachable purge endpoint would let anyone manufacture wake storms by |
| + // force-purging popular pages, exactly what this feature exists to |
| + // prevent. |
| + type PurgeListener struct { |
| + ln net.Listener |
| + srv *http.Server |
| + } |
| + |
| + type purgeRequest struct { |
| + Path string `json:"path"` |
| + Query string `json:"query"` |
| + } |
| + |
| + // ListenPurge binds socketPath (removing any stale file left behind by a |
| + // crashed previous instance) and serves purge requests scoped to host — |
| + // host is fixed here, never taken from the request, so a compromised or |
| + // buggy instance can only ever purge its own cache entries. |
| + func ListenPurge(socketPath, host string, cache *Cache) (*PurgeListener, error) { |
| + if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return nil, err |
| + } |
| + ln, err := net.Listen("unix", socketPath) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if err := os.Chmod(socketPath, 0600); err != nil { |
| + ln.Close() |
| + return nil, err |
| + } |
| + |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("POST /purge", func(w http.ResponseWriter, r *http.Request) { |
| + var req purgeRequest |
| + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| + http.Error(w, "bad request", http.StatusBadRequest) |
| + return |
| + } |
| + cache.PurgeURL(host, req.Path, req.Query) |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + |
| + srv := &http.Server{Handler: mux} |
| + p := &PurgeListener{ln: ln, srv: srv} |
| + go srv.Serve(ln) //nolint:errcheck // Close() below triggers the expected ErrServerClosed |
| + return p, nil |
| + } |
| + |
| + func (p *PurgeListener) Close() error { |
| + return p.srv.Close() |
| + } |
| + ``` |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| + Run: `go test ./internal/edge/ -run TestListenPurge -v` |
| + Expected: PASS (all four). |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| + ```bash |
| + git add internal/edge/purge.go internal/edge/purge_test.go |
| + git commit -m "edge: add local-only per-instance purge listener" |
| + ``` |
| + |
| +--- |
| + |
| +### Task 4: Wire the purge listener into instance spawn |
| + |
| +**Files:** |
| +- Modify: `internal/activator/backend_exec.go` |
| +- Modify: `internal/host/agent.go` |
| +- Test: `internal/activator/backend_exec_test.go` (or wherever |
| + `ExecBackend`'s existing tests live — check `backend_unit_test.go`'s |
| + neighbor file for the exec-specific test file name first) |
| + |
| +**Interfaces:** |
| +- Consumes: `edge.Cache`, `edge.ListenPurge`, `edge.NewCache`, |
| + `edge.DefaultCacheMaxEntries`, `edge.DefaultCacheMaxBodyBytes` (Tasks 1 |
| + and 3). |
| +- Produces: `ExecBackend.Cache *edge.Cache` field (nil disables |
| + purge-listener startup, per this plan's Global Constraints); the |
| + spawned instance process now also receives a `--control-socket <path>` |
| + flag. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| + `ExecBackend`'s existing tests live in `internal/activator/activator_test.go` |
| + (search for `func testExecBackend(t *testing.T) *ExecBackend` and |
| + `func stageLive(t *testing.T, b *ExecBackend, host, ver string)` — both |
| + already exist there and are reused below verbatim). Note there is no |
| + existing test that runs `ExecBackend.Start` all the way through a |
| + successful health-check — `stageLive`'s fake binary is just |
| + `#!/bin/sh\n`, which never actually binds `rt.Socket`, so `Start` is |
| + expected to return a health-wait error in this test too. That's fine: |
| + the purge listener starts (per Step 3's placement, right after |
| + `cmd.Start()` succeeds) *before* the health-wait runs, so the control |
| + socket file exists by the time `Start` returns, regardless of whether |
| + it returns an error. The test asserts on the control socket file |
| + directly and explicitly ignores `Start`'s returned error. |
| + |
| + ```go |
| + func TestExecBackendStartPassesControlSocketFlagAndStartsPurgeListener(t *testing.T) { |
| + b := testExecBackend(t) |
| + b.Cache = edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes) |
| + stageLive(t, b, "control-socket.example.com", "v1") |
| + |
| + rt := registry.Route{ |
| + Host: "control-socket.example.com", |
| + Kind: "instance", |
| + Socket: filepath.Join(t.TempDir(), "instance.sock"), |
| + } |
| + wantControlSocket := rt.Socket + ".control" |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + proc, _, _ := b.Start(ctx, rt) // health-wait error from the fake binary is expected and irrelevant here |
| + if proc != nil { |
| + defer proc.Stop(context.Background()) |
| + } |
| + |
| + if _, err := os.Stat(wantControlSocket); err != nil { |
| + t.Fatalf("expected control socket %s to exist after Start, got: %v", wantControlSocket, err) |
| + } |
| + } |
| + |
| + func TestExecBackendStartWithNilCacheSkipsPurgeListener(t *testing.T) { |
| + b := testExecBackend(t) // b.Cache left nil |
| + stageLive(t, b, "no-cache.example.com", "v1") |
| + |
| + rt := registry.Route{ |
| + Host: "no-cache.example.com", |
| + Kind: "instance", |
| + Socket: filepath.Join(t.TempDir(), "instance.sock"), |
| + } |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + proc, _, _ := b.Start(ctx, rt) |
| + if proc != nil { |
| + defer proc.Stop(context.Background()) |
| + } |
| + |
| + if _, err := os.Stat(rt.Socket + ".control"); err == nil { |
| + t.Fatal("expected no control socket when ExecBackend.Cache is nil") |
| + } |
| + } |
| + ``` |
| + |
| + Add `"github.com/carlosframework/platform/internal/edge"` to this test |
| + file's imports if not already present. |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail** |
| + |
| + Run: `go test ./internal/activator/ -run TestExecBackendStartPassesControlSocketFlag -v` |
| + Run: `go test ./internal/activator/ -run TestExecBackendStartWithNilCacheSkipsPurgeListener -v` |
| + Expected: FAIL — `ExecBackend.Cache` field doesn't exist yet (compile |
| + error). |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| + In `internal/activator/backend_exec.go`, add the field to `ExecBackend` |
| + (find its struct definition near the top of the file, alongside `Cfg`, |
| + `OptDir`, `DataDir`): |
| + |
| + ```go |
| + // Cache, when non-nil, gets a purge listener started for each spawned |
| + // instance so it can invalidate its own cached responses right after a |
| + // write. Nil disables this — no purge listener, no error. |
| + Cache *edge.Cache |
| + ``` |
| + |
| + Add the import: `"github.com/carlosframework/platform/internal/edge"` |
| + (match this repo's actual module path — check `go.mod`'s `module` line |
| + if this isn't it verbatim). |
| + |
| + Modify `Start` — locate the existing |
| + `cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath)` line |
| + and the `childProc{cmd: cmd, ...}` construction just after `cmd.Start()` |
| + succeeds: |
| + |
| + ```go |
| + controlSocket := rt.Socket + ".control" |
| + cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath, "--control-socket", controlSocket) |
| + cmd.Env = childEnv(fileEnv) |
| + cmd.Stdout = os.Stdout |
| + cmd.Stderr = os.Stderr |
| + spawnVer, _ := b.LiveVersion(host) |
| + if err := cmd.Start(); err != nil { |
| + return nil, "", err |
| + } |
| + |
| + p := &childProc{cmd: cmd, done: make(chan struct{})} |
| + if b.Cache != nil { |
| + pl, err := edge.ListenPurge(controlSocket, rt.Host, b.Cache) |
| + if err != nil { |
| + // Not fatal to the wake — the instance still serves requests, |
| + // it just can't purge its own cache until the next restart. |
| + // A stuck-stale cache self-heals via the safety-net TTL anyway. |
| + log.Printf("activator: purge listener for %s: %v (continuing without it)", rt.Host, err) |
| + } else { |
| + p.purge = pl |
| + } |
| + } |
| + go func() { |
| + p.err = cmd.Wait() |
| + close(p.done) |
| + }() |
| + ``` |
| + |
| + Add the field to `childProc` (find its struct definition — near |
| + `func (p *childProc) Stop` seen earlier): |
| + |
| + ```go |
| + purge *edge.PurgeListener // nil if b.Cache was nil or ListenPurge failed |
| + ``` |
| + |
| + Modify `childProc.Stop` to close it: |
| + |
| + ```go |
| + func (p *childProc) Stop(ctx context.Context) error { |
| + if p.purge != nil { |
| + p.purge.Close() |
| + } |
| + // ... existing body unchanged |
| + } |
| + ``` |
| + |
| + In `internal/host/agent.go`'s `RunAgent`, construct the cache before |
| + the activator so both `ExecBackend` and `edge.RunWithActivator` share |
| + the same instance: |
| + |
| + ```go |
| + cache := edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes) |
| + |
| + cfg, err := activator.ConfigFromEnv() |
| + if err != nil { |
| + log.Fatalf("agent: %v", err) |
| + } |
| + var act edge.Activator |
| + var svc *activator.Service |
| + if cfg != nil { |
| + svc = activator.New(*cfg, &activator.SplitBackend{ |
| + Exec: &activator.ExecBackend{ |
| + Cfg: *cfg, |
| + OptDir: release.OptDir(), |
| + DataDir: dd, |
| + Cache: cache, |
| + }, |
| + Unit: &activator.UnitBackend{OptDir: release.OptDir()}, |
| + }, reg) |
| + svc.Start() |
| + act = svc |
| + log.Printf("agent: hibernation activator armed (bucket %s, idle %s)", cfg.Bucket, cfg.Idle) |
| + } |
| + |
| + // ... adoptLoop/canaryAdoptLoop/statusLoop/domainsLoop goroutines unchanged ... |
| + |
| + edge.RunWithActivator(args, act, cache) |
| + ``` |
| + |
| + (Only the two lines constructing `cache` and passing it into both the |
| + `ExecBackend` literal and the final `edge.RunWithActivator` call |
| + change; everything else in `RunAgent` stays as-is.) |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| + Run: `go test ./internal/activator/... ./internal/host/... -v` |
| + Expected: PASS, including all pre-existing tests in both packages |
| + (`UnitBackend`'s tests are untouched by this task — only `ExecBackend` |
| + changed). |
| + |
| +- [ ] **Step 5: Run the full build and vet** |
| + |
| + Run: `go build ./... && go vet ./...` |
| + Expected: clean. This is the step that catches a missed call site if |
| + `RunWithActivator`'s new third parameter wasn't threaded everywhere. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| + ```bash |
| + git add internal/activator/backend_exec.go internal/host/agent.go internal/activator/backend_exec_test.go |
| + git commit -m "activator: start a purge listener alongside each spawned instance" |
| + ``` |
| + |
| +--- |
| + |
| +### Task 5: End-to-end cache-before-wake test |
| + |
| +**Files:** |
| +- Modify: `internal/edge/edge_test.go` (or a new |
| + `internal/edge/cache_before_wake_test.go` if the existing file is |
| + already large — match this repo's existing file-size norms, checked |
| + during Task 2) |
| + |
| +**Interfaces:** |
| +- Consumes: everything from Tasks 1-4. No new production code — this |
| + task is purely a test that exercises the whole path together, standing |
| + in for the spec's §8 "one test that has to exist even if everything |
| + else is cut for time." |
| + |
| +- [ ] **Step 1: Write the test** |
| + |
| + Uses the same `openReg`/`reg.Upsert`/`New(reg, act)` fixtures as |
| + Task 2, plus a new `activatorFunc` adapter (a plain-function `Activator` |
| + for tests that want custom per-call behavior — distinct from the file's |
| + existing `fakeActivator`, which always proxies straight through and |
| + can't count wakes the way this test needs). |
| + |
| + ```go |
| + // activatorFunc adapts a plain function to the Activator interface, for |
| + // tests that want custom wake-counting behavior without a full fake type. |
| + type activatorFunc func(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool |
| + |
| + func (f activatorFunc) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + return f(w, r, rt, proxy) |
| + } |
| + |
| + // TestCacheBeforeWakeFullCycle exercises the sequence the whole feature |
| + // exists for: a cold miss wakes the instance and populates the cache; a |
| + // repeat request hits the cache and does NOT wake the instance again; a |
| + // write's purge call makes the next request miss and see fresh content. |
| + func TestCacheBeforeWakeFullCycle(t *testing.T) { |
| + body := "version 1" |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + w.WriteHeader(200) |
| + w.Write([]byte(body)) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + wakes := 0 |
| + act := activatorFunc(func(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + wakes++ |
| + proxy.ServeHTTP(w, r) |
| + return true |
| + }) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "cycle.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready", Hibernate: true}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, act) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes) |
| + e.EnableCache(cache) |
| + |
| + get := func() string { |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("GET", "http://cycle.example.com/event", nil)) |
| + return rec.Body.String() |
| + } |
| + |
| + if got := get(); got != "version 1" { |
| + t.Fatalf("first request body = %q, want %q", got, "version 1") |
| + } |
| + if wakes != 1 { |
| + t.Fatalf("expected exactly 1 wake for the cold request, got %d", wakes) |
| + } |
| + |
| + if got := get(); got != "version 1" { |
| + t.Fatalf("second request body = %q, want %q", got, "version 1") |
| + } |
| + if wakes != 1 { |
| + t.Fatalf("expected the second request to hit cache without waking again, wakes = %d", wakes) |
| + } |
| + |
| + // Simulate the write + purge an instance does after a real change. |
| + body = "version 2" |
| + cache.PurgeURL("cycle.example.com", "/event", "") |
| + |
| + if got := get(); got != "version 2" { |
| + t.Fatalf("post-purge request body = %q, want %q", got, "version 2") |
| + } |
| + if wakes != 2 { |
| + t.Fatalf("expected the post-purge request to miss and wake once more, wakes = %d", wakes) |
| + } |
| + } |
| + ``` |
| + |
| +- [ ] **Step 2: Run the test to verify it fails (before this task, it doesn't exist; run it right after writing to confirm the harness compiles and the assertions are meaningful)** |
| + |
| + Run: `go test ./internal/edge/ -run TestCacheBeforeWakeFullCycle -v` |
| + Expected: PASS immediately if Tasks 1-4 are correctly implemented — this |
| + task adds coverage, it doesn't drive new production code. If it fails, |
| + that's a real regression in one of Tasks 1-4 to fix before proceeding, |
| + not a signal to weaken this test. |
| + |
| +- [ ] **Step 3: Run the full edge package test suite** |
| + |
| + Run: `go test ./internal/edge/... -v` |
| + Expected: PASS, all tests from every task in this plan plus every |
| + pre-existing test in the package. |
| + |
| +- [ ] **Step 4: Commit** |
| + |
| + ```bash |
| + git add internal/edge/edge_test.go |
| + git commit -m "edge: add end-to-end cache-before-wake test" |
| + ``` |
| + |
| +--- |
| + |
| +## After this plan |
| + |
| +The platform side is done: any `routed` app that sets `Cache-Control` on |
| +its public GET responses gets cache-before-wake for free, and any |
| +hibernating instance can purge its own entries via |
| +`<its-socket-path>.control`. Nothing here is titogo-specific — titogo's |
| +own consumer-side changes (cache headers, the availability-fragment split, |
| +purge calls on writes, and the `SalesEndAt` default) are planned |
| +separately in `paulca/titogo`'s own |
| +`docs/superpowers/plans/2026-08-02-edge-cache-before-wake-titogo.md`, |
| +which depends on this plan's `--control-socket` flag and purge wire |
| +protocol (`POST /purge` with `{"path":"...","query":"..."}`) existing |
| +first. |
diff --git a/docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md b/docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md| new file mode 100644 |
| index 000000000..412722727 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md |
| @@ -0,0 +1,270 @@ |
| +# 🤖 Edge Cache-Before-Wake — Keeping Bots From Needlessly Waking Hibernated Instances |
| + |
| +> 🤖 This document was written by an LLM (Claude), on the ideas, instruction, |
| +> and editing of humans, per [factor X](https://11factor.org/#x). |
| + |
| +**Status:** Concept spec, approved in brainstorming 2026-08-02. |
| + |
| +**Relationship to the platform spec:** the |
| +[CARLOS platform design](2026-08-01-carlos-platform-design.md) defines |
| +hibernation, wake, and the edge/activator split in the abstract (§4, §5). |
| +This document adds one capability to that picture — caching GET responses |
| +at the edge so a wake is never triggered for traffic a cache can already |
| +answer — without changing any of the platform's hibernation semantics. |
| +Where the two could diverge, the platform spec wins. |
| + |
| +## 1. Purpose |
| + |
| +Once an app hibernates (spec §4), any HTTP request to a parked instance |
| +wakes it — that's the whole point of the activator. But not all GET |
| +traffic represents a real visitor: search-engine crawlers, LLM/agent |
| +scrapers, and general bot traffic hit public pages too, and none of them |
| +care whether the response is five seconds or five minutes old. Left |
| +unmitigated, that traffic defeats hibernation's entire cost story for any |
| +app with public, crawlable pages — an instance that would otherwise park |
| +for hours gets woken every few minutes by traffic that was never going to |
| +buy anything. |
| + |
| +Motivating case: **Tito Go** (`paulca/titogo`, self-hosted on the Tito |
| +CARLOS deployment per |
| +[2026-08-01-tito-production-infra-design.md](2026-08-01-tito-production-infra-design.md)). |
| +Titogo hasn't yet adopted the platform's hibernating activator — that |
| +migration ("Stage 2 adoption") is still open — but every event page it |
| +serves is exactly this kind of public, crawlable, bot-attractive surface, |
| +so this is a real blocker worth solving before that migration lands, not |
| +after. |
| + |
| +## 2. Decisions (settled in brainstorming, 2026-08-02) |
| + |
| +1. **This is a platform capability, not an app-level workaround.** It |
| + lives at the edge, general to any `routed` app, the same way static |
| + serving (spec §5) is a platform capability rather than something each |
| + static app reimplements. Titogo is the motivating and first consumer, |
| + not the only one. |
| +2. **Built now, independent of titogo's Stage 2 hibernation migration.** |
| + The capability is valuable even for always-on apps today (less load, |
| + faster bot responses, lower bandwidth), and whichever app adopts |
| + hibernation later gets the wake-prevention benefit automatically — |
| + there's no reason to gate this work on Stage 2's undated completion. |
| +3. **Correctness over cleverness: cache freshness is enforced by |
| + purge-on-write, not by tolerating staleness.** A real write (event |
| + edit, ticket sale, sales open/close) always already requires the |
| + instance to be awake — so having it also signal a purge afterward |
| + costs nothing in extra wakes. This is what makes "cache everything |
| + public" safe rather than a staleness trade-off: bots and real visitors |
| + are served the identical, always-fresh cache, so no bot-detection is |
| + needed anywhere in this design. |
| +4. **A safety-net TTL bounds the worst case.** Purge is best-effort, |
| + fire-and-forget from the instance's side — it doesn't block or retry |
| + the write it followed. A short fixed ceiling (exact value: an |
| + implementation-plan tuning choice, not fixed here) means a lost or |
| + buggy purge call self-heals within that window rather than serving |
| + stale data indefinitely. |
| + |
| +## 3. Architecture |
| + |
| +`internal/edge/edge.go`'s `Edge.ServeHTTP` already has the right seam: for |
| +a hibernating route it calls `e.act.ServeHTTP(w, r, up.route, up.proxy)` — |
| +the `Activator` interface that triggers or joins a wake — and only falls |
| +through to the raw `proxy` once the instance is confirmed up. The cache |
| +check slots in **immediately before that activator call**: for GET/HEAD |
| +requests, look up a cached response by cache key (§5); on a hit, write it |
| +directly and return. The activator, and therefore any wake, is never |
| +reached. On a miss, today's behavior is unchanged, except that a |
| +cacheable response is captured on the way back through. |
| + |
| +New components, all inside the `carlos agent` process (edge and activator |
| +already share one process per box, per `edge.RunWithActivator`): |
| + |
| +- **`internal/edge/cache.go`** (new) — an in-memory, per-host response |
| + cache, keyed as in §5. Populated only from responses the app marked |
| + cacheable via the standard `Cache-Control: max-age=N` response header |
| + (titogo already emits `Cache-Control: no-store` on its checkout render |
| + today — this reuses the same mechanism in the positive direction). No |
| + new manifest schema. |
| +- **A local purge channel** (new) — instances currently have no way to |
| + call back into their own box's `carlos agent` process; this needs a |
| + small new one (a unix socket or loopback endpoint the co-located edge |
| + exposes), reachable **only locally, from the same box** — never over |
| + the public network. An instance calls it right after a write to |
| + invalidate the cache key(s) that write affected. |
| +- **A hard cache size cap with LRU eviction.** This project exists to |
| + save RAM; an unbounded cache would be self-defeating. Exact |
| + count/byte-size limits: an implementation-plan tuning choice. |
| + |
| +## 4. Data flow |
| + |
| +**Cache hit** (the common case for bot/crawler traffic once warm): the |
| +edge finds a valid entry for the request's cache key and writes it |
| +directly. The instance is never touched. |
| + |
| +**Cache miss** (cold cache, or first request after purge/TTL expiry): the |
| +edge proceeds exactly as today — the activator wakes the instance (or |
| +joins an in-progress wake; this existing coalescing behavior, |
| +`TestUnitTenantWakeServesHeldRequest`, is unchanged and the cache layer |
| +does not reimplement it) and proxies the request through. The edge |
| +inspects the real response on the way back; if it's a 2xx carrying a |
| +cacheable `Cache-Control`, the edge stores it before returning it to the |
| +client. Non-2xx responses are never cached, at any TTL — a transient |
| +error must not get stuck serving itself for the whole window. |
| + |
| +**Purge**: the instance is already awake (it just wrote something) and |
| +calls the local purge channel with the affected cache key(s) — e.g. an |
| +event edit purges the page shell key; a ticket sale purges the |
| +availability-fragment key (§6). The edge drops those entries immediately; |
| +the next request for them is a miss and repopulates on the next real |
| +response. |
| + |
| +A request in flight when a write commits could theoretically finish and |
| +populate the cache with pre-write data *after* the purge fires, briefly |
| +re-poisoning it. This is accepted, bounded by the safety-net TTL, not |
| +solved with stronger consistency machinery — consistent with decision 4. |
| + |
| +## 5. Cache key and locale |
| + |
| +Cache key = request URL (path + query string) **plus a normalized locale |
| +bucket**, negotiated the same way titogo's `publicLocale` (§6) does today |
| +from `Accept-Language`, clamped to the event's enabled locale set. This |
| +means one cache entry per event per *enabled* locale — a small, bounded |
| +fan-out, not one per visitor. |
| + |
| +The `titogo_lang` cookie (an explicit switcher preference, per |
| +`internal/instance/locale.go`) is **deliberately not part of the cache |
| +key**. Varying the cache by cookie was considered and rejected: it tends |
| +to make every visitor a cache miss (cookies differ per-visitor for |
| +unrelated reasons — analytics, sessions), which would defeat the point. |
| +The accepted consequence: a returning visitor whose cookie preference |
| +differs from what their browser's `Accept-Language` negotiates to sees |
| +the negotiated version on a cache **hit**, not their saved cookie choice. |
| +An explicit `?lang=xx` URL — already how the switcher persists a choice |
| +today — is its own distinct, independently cacheable and purgeable key, |
| +so a visitor who actually clicks the switcher still gets exactly what |
| +they asked for; only the passive cookie-remembered case is affected, and |
| +only on a page they haven't explicitly re-chosen the language on this |
| +visit. |
| + |
| +## 6. Titogo-side changes (`paulca/titogo`) |
| + |
| +None of these touch the platform's caching mechanism — they're titogo |
| +being a well-behaved consumer of it. |
| + |
| +1. **Cache headers on public GET routes.** `handleHome`, `handleEvent` / |
| + `handleEventFlow`, and published custom pages (`pages.go`) set |
| + `Cache-Control: max-age=N` (N: a plan-level tuning choice — the purge |
| + path is what keeps things fresh, so this ceiling only bounds the |
| + worst case of a missed purge). Every token-gated route (orders, |
| + tickets, portals, checkout, quotes) stays uncached, exactly as today — |
| + already effectively private by unguessable token, and caching it would |
| + leak state across requests. |
| +2. **A cacheable ticket-availability fragment.** Today `handleEventFlow` |
| + computes `onSaleOnly`/`ticketSalesOpen` (`shop.go`) and bakes |
| + availability straight into the full server-rendered page — exactly the |
| + part that changes on every sale. This splits: the event page shell |
| + (title, description, dates, images, price display) becomes cacheable; |
| + live availability becomes a small separate endpoint the page fetches |
| + client-side after load. That fragment is cacheable and purgeable via |
| + the same mechanism as the shell — a second cache key per event, not a |
| + special case. |
| +3. **Purge calls on writes.** Any admin write that changes a cached |
| + shell (event save) or fragment (ticket sale, inventory change, sales |
| + open/close) calls the local purge channel for the affected key(s) |
| + right after committing. |
| + |
| +**Also, a data-hygiene fix surfaced during this brainstorm, bundled here |
| +because it shrinks the set of pages that can mutate indefinitely (which |
| +is part of what makes long-lived caching safe):** default `SalesEndAt` to |
| +the event's own end date when the organizer hasn't configured one |
| +explicitly, instead of leaving sales open forever |
| +(`ticketSalesOpen := ev.SalesEndAt == 0 || now() < ev.SalesEndAt` in |
| +`shop.go` currently treats unset as "always open"). **Explicit |
| +configuration always overrides the derived default** — this is a change |
| +to the default only, not to what's configurable, so a deliberate future |
| +opt-in for keeping sales open past an event (e.g. collecting payment at |
| +the door from walk-ins who didn't pre-pay — `door.go`'s existing on-site |
| +payment flow is exactly this case) stays a simple config exposure, not a |
| +fight against this change. That opt-in is explicitly **out of scope for |
| +this spec** — noted so it isn't lost, not designed here. |
| + |
| +## 7. Error handling and edge cases |
| + |
| +- **Purge channel is local-only,** reachable exclusively by the |
| + instance's own process on the same box. This is a deliberate security |
| + boundary, not an oversight: an externally-reachable purge endpoint |
| + would be a new DoS vector — force-purging popular pages to manufacture |
| + wake storms is exactly the failure mode this whole design exists to |
| + prevent. |
| +- **Never cache non-2xx responses,** at any TTL. |
| +- **Memory bounds:** hard cap + LRU eviction (§3). |
| +- **Wake-coalescing** is unchanged, existing activator behavior; the |
| + cache sits strictly in front of it. |
| +- **Purge/populate race:** accepted, bounded by the safety-net TTL (§4). |
| +- **Host relocation/failover:** the cache is per-host and in-memory; a |
| + new or recovered host starts cold. The first real request after a move |
| + is one ordinary wake — consistent with how this platform already |
| + treats hosts as disposable (platform spec §4). |
| + |
| +## 8. Testing |
| + |
| +- **Edge cache unit tests** (`internal/edge`): hit/miss/TTL-expiry/ |
| + eviction. The test that actually proves the mitigation: a cache hit |
| + must never invoke the `Activator` — asserted with a fake activator that |
| + fails the test if called at all. |
| +- **Purge channel tests:** a purge invalidates exactly the targeted |
| + key(s) and nothing else; the channel is unreachable except locally (an |
| + isolation test, not just a happy path, given §7's DoS concern). |
| +- **Locale bucketing tests:** two `Accept-Language` values negotiating to |
| + the same enabled locale share one cache entry; different negotiated |
| + locales get separate entries; the `titogo_lang` cookie never affects |
| + the cache key. |
| +- **titogo-side tests:** public routes emit the expected `Cache-Control`; |
| + every token-gated route never does; writes call purge for the correct |
| + key(s); the default-`SalesEndAt`-from-event-date logic (extending the |
| + existing `expiry_test.go` pattern), with explicit configuration still |
| + winning over the derived default. |
| +- **End-to-end**, alongside the platform's existing `e2e` suite: cold |
| + miss wakes the instance and populates the cache → repeat request hits |
| + and does **not** wake it → a write plus its purge call makes the next |
| + request miss again and reflect the change. This three-step sequence is |
| + the whole point of the design and should survive even if everything |
| + else here is cut for time. |
| + |
| +## 9. Considered and rejected |
| + |
| +- **A CDN (CloudFront) fronting `routed` apps,** extending the existing |
| + static-only CDN binding (platform spec §5). Would offload bot traffic |
| + even further — it would never reach the box at all — but it's |
| + AWS-specific infrastructure, conflicts with this platform's explicit |
| + "no load balancers, self-hostable without a cloud provider" stance, and |
| + CloudFront invalidation isn't instant (seconds, not immediate), a worse |
| + fit given decision 3's freshness requirement. Left as a possible future |
| + accelerator layered on top of this design for deployments that want it |
| + — never the default mechanism. |
| +- **Bot-detection / user-agent gating** instead of caching: serving |
| + crawlers a degraded response while real users always wake the instance |
| + reintroduces exactly the brittleness (UA sniffing, false positives |
| + hurting real SEO) caching avoids, and is unnecessary once purge-on-write |
| + makes the cache always-fresh for everyone regardless of who's asking. |
| + |
| +## 10. Open questions |
| + |
| +1. **Exact purge-channel transport.** Unix socket vs. an extension of an |
| + existing local channel — no existing instance→host-agent control path |
| + was found in `internal/activator` or `internal/host`, so this is new |
| + plumbing either way. A plan-level decision. |
| +2. **TTL and cache-size tuning** (§3, §4, §6): concrete numbers, not |
| + fixed here. |
| +3. **Exact shell/fragment split boundary** for titogo's event page: this |
| + spec establishes that availability must be a separate fetch from the |
| + cacheable shell, not the precise template/markup boundary — a |
| + titogo-repo implementation detail. |
| + |
| +## 11. Provenance |
| + |
| +Brainstormed 2026-08-02, prompted directly by Paul's cost analysis of |
| +Tito Go's AWS footprint (see the platform-repo session's cost-scope |
| +discussion) surfacing that titogo hasn't yet migrated to the platform's |
| +hibernating activator, and that migration's cost benefit would be |
| +undermined by unmitigated bot/crawler traffic if solved after the fact |
| +rather than before. Three ideas from that discussion became this design's |
| +core: caching public pages, lazy-loading dynamic ticket availability, and |
| +defaulting ticket sales closed for events with no explicit configured end. |
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go| index 5aee62fd7..8b48b26c0 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -20,12 +20,14 @@ import ( |
| "net/http/httputil" |
| "os" |
| "path/filepath" |
| + "slices" |
| "strings" |
| "sync" |
| "sync/atomic" |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -1909,3 +1911,191 @@ func TestExecBackendHasSidecar(t *testing.T) { |
| t.Fatal("HasSidecar false with the sidecar env file staged") |
| } |
| } |
| + |
| +// stageRecordingLive stages host's live binary as a shell script that dumps |
| +// its argv and its CARLOS_CONTROL_SOCKET to two files and exits — the only way |
| +// to see what the child was ACTUALLY handed, argv and environment both. |
| +// Returns the two paths. |
| +func stageRecordingLive(t *testing.T, b *ExecBackend, host, ver string) (argvPath, envPath string) { |
| + t.Helper() |
| + dir := t.TempDir() |
| + argvPath = filepath.Join(dir, "argv") |
| + envPath = filepath.Join(dir, "env") |
| + script := "#!/bin/sh\n" + |
| + "printf '%s\\n' \"$@\" > '" + argvPath + "'\n" + |
| + "printf '%s\\n' \"${CARLOS_CONTROL_SOCKET-<unset>}\" > '" + envPath + "'\n" |
| + |
| + vdir := filepath.Join(b.OptDir, "versions", ver) |
| + if err := os.MkdirAll(vdir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + bin := filepath.Join(vdir, "app") |
| + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(b.OptDir, "live"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + os.Remove(b.liveLink(host)) |
| + if err := os.Symlink(bin, b.liveLink(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + return argvPath, envPath |
| +} |
| + |
| +// waitForFile polls until path exists (the child writes it concurrently with |
| +// Start's health wait) and returns its contents. |
| +func waitForFile(t *testing.T, path string, within time.Duration) string { |
| + t.Helper() |
| + deadline := time.Now().Add(within) |
| + for time.Now().Before(deadline) { |
| + if b, err := os.ReadFile(path); err == nil { |
| + return string(b) |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| + t.Fatalf("%s never appeared within %s", path, within) |
| + return "" |
| +} |
| + |
| +// Start hands the spawned instance its control socket through the ENVIRONMENT |
| +// (CARLOS_CONTROL_SOCKET), never as a CLI flag, and starts a purge listener |
| +// bound to it whenever ExecBackend.Cache is set. |
| +// |
| +// The argv assertion is the regression guard: a --control-socket flag makes |
| +// every app binary that predates this feature exit 2 at startup (Go's flag |
| +// package with ExitOnError, which is what app binaries use), i.e. a |
| +// fleet-wide outage the moment the agent upgrades. An unknown environment |
| +// variable is simply ignored by such a binary. |
| +// |
| +// Deviation from the task brief's literal test: the brief asserted the |
| +// control socket file still exists AFTER Start returns, even on the |
| +// health-wait error the fake binary (which never binds rt.Socket) is expected |
| +// to produce here. That's not actually true of the implementation the same |
| +// brief specifies — Start's pre-existing health-wait-failure path calls |
| +// p.Stop() before returning, and Step 3's own childProc.Stop change closes |
| +// the purge listener there, which (per Go's net.Listener.Close on a unix |
| +// socket the listener itself created) unlinks the socket file. So by the time |
| +// Start returns with an error, the file is already gone — confirmed |
| +// empirically, not a guess. This test instead polls for the socket WHILE |
| +// Start's health-wait is still running (i.e. before its internal Stop cleanup |
| +// fires), which is the invariant that's actually guaranteed and is what |
| +// "Start wires up the purge listener" means in practice. |
| +func TestExecBackendStartPassesControlSocketInEnvAndStartsPurgeListener(t *testing.T) { |
| + b := testExecBackend(t) |
| + b.Cache = edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes) |
| + argvPath, envPath := stageRecordingLive(t, b, "control-socket.example.com", "v1") |
| + |
| + rt := registry.Route{ |
| + Host: "control-socket.example.com", |
| + Kind: "instance", |
| + Socket: filepath.Join(t.TempDir(), "instance.sock"), |
| + } |
| + wantControlSocket := rt.Socket + ".control" |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + |
| + done := make(chan struct{}) |
| + var proc Proc |
| + go func() { |
| + proc, _, _ = b.Start(ctx, rt) // health-wait error from the fake binary is expected and irrelevant here |
| + close(done) |
| + }() |
| + |
| + found := false |
| + deadline := time.Now().Add(1500 * time.Millisecond) |
| + for time.Now().Before(deadline) { |
| + if _, err := os.Stat(wantControlSocket); err == nil { |
| + found = true |
| + break |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| + |
| + gotArgv := strings.Fields(waitForFile(t, argvPath, time.Second)) |
| + gotEnv := strings.TrimSpace(waitForFile(t, envPath, time.Second)) |
| + |
| + <-done |
| + if proc != nil { |
| + defer proc.Stop(context.Background()) |
| + } |
| + |
| + wantArgv := []string{"--socket", rt.Socket, "--db", filepath.Join(b.DataDir, rt.Host+".db")} |
| + if !slices.Equal(gotArgv, wantArgv) { |
| + t.Fatalf("child argv = %v, want %v — a flag the app binary doesn't parse makes it exit 2 at startup", gotArgv, wantArgv) |
| + } |
| + if gotEnv != wantControlSocket { |
| + t.Fatalf("child CARLOS_CONTROL_SOCKET = %q, want %q", gotEnv, wantControlSocket) |
| + } |
| + if !found { |
| + t.Fatalf("expected control socket %s to exist while Start's health-wait ran", wantControlSocket) |
| + } |
| +} |
| + |
| +// A nil ExecBackend.Cache disables the purge listener entirely — no control |
| +// socket, no CARLOS_CONTROL_SOCKET in the child's environment (there is |
| +// nothing for it to talk to), no error. |
| +func TestExecBackendStartWithNilCacheSkipsPurgeListener(t *testing.T) { |
| + b := testExecBackend(t) // b.Cache left nil |
| + _, envPath := stageRecordingLive(t, b, "no-cache.example.com", "v1") |
| + |
| + rt := registry.Route{ |
| + Host: "no-cache.example.com", |
| + Kind: "instance", |
| + Socket: filepath.Join(t.TempDir(), "instance.sock"), |
| + } |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + proc, _, _ := b.Start(ctx, rt) |
| + if proc != nil { |
| + defer proc.Stop(context.Background()) |
| + } |
| + |
| + if _, err := os.Stat(rt.Socket + ".control"); err == nil { |
| + t.Fatal("expected no control socket when ExecBackend.Cache is nil") |
| + } |
| + if got := strings.TrimSpace(waitForFile(t, envPath, time.Second)); got != "<unset>" { |
| + t.Fatalf("child CARLOS_CONTROL_SOCKET = %q, want it unset when there is no cache", got) |
| + } |
| +} |
| + |
| +// Spawning a fresh process for a host is a deploy boundary: the code behind |
| +// the host just changed, so every response cached from the old process is |
| +// stale. Start drops them all before the new process serves anything — |
| +// without this, a redeploy keeps serving old HTML until the TTL expires. |
| +func TestExecBackendStartPurgesHostCacheOnSpawn(t *testing.T) { |
| + b := testExecBackend(t) |
| + cache := edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes) |
| + b.Cache = cache |
| + stageLive(t, b, "deploy.example.com", "v1") |
| + |
| + stale := edge.CacheKey{Host: "deploy.example.com", Method: "GET", Path: "/event"} |
| + otherHost := edge.CacheKey{Host: "bystander.example.com", Method: "GET", Path: "/event"} |
| + for _, k := range []edge.CacheKey{stale, otherHost} { |
| + cache.Put(k, "", &edge.CachedResponse{ |
| + StatusCode: 200, Header: http.Header{}, Body: []byte("old html"), |
| + StoredAt: time.Now(), TTL: time.Minute, |
| + }) |
| + } |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + rt := registry.Route{ |
| + Host: "deploy.example.com", |
| + Kind: "instance", |
| + Socket: filepath.Join(t.TempDir(), "instance.sock"), |
| + } |
| + proc, _, _ := b.Start(ctx, rt) // health-wait failure is expected; the spawn still happened |
| + if proc != nil { |
| + defer proc.Stop(context.Background()) |
| + } |
| + |
| + if _, ok := cache.Get(stale); ok { |
| + t.Fatal("spawning a fresh process for a host must drop that host's cached responses") |
| + } |
| + if _, ok := cache.Get(otherHost); !ok { |
| + t.Fatal("another host's cached responses must survive a spawn") |
| + } |
| +} |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index ffc0b128b..f04447ee9 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -19,6 +19,7 @@ import ( |
| "syscall" |
| "time" |
| + "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -50,6 +51,10 @@ type ExecBackend struct { |
| // but a carlos app is a separate binary from the release store — there is |
| // nothing sensible to invent here, and the adopt pass owns the symlinks. |
| Materialize func(ctx context.Context, host string) error |
| + // Cache, when non-nil, gets a purge listener started for each spawned |
| + // instance so it can invalidate its own cached responses right after a |
| + // write. Nil disables this — no purge listener, no error. |
| + Cache *edge.Cache |
| } |
| var _ Backend = (*ExecBackend)(nil) |
| @@ -231,8 +236,21 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| return nil, "", fmt.Errorf("read env file: %w", err) |
| } |
| + // The control socket reaches the child through its ENVIRONMENT, never as a |
| + // CLI flag: every app binary built before this feature existed parses its |
| + // flags with flag.ExitOnError, which exits 2 on an unrecognized flag — so |
| + // passing --control-socket would refuse to start every app that hasn't |
| + // also shipped support for it, a fleet-wide outage on agent upgrade. An |
| + // environment variable a binary doesn't know about is simply ignored. |
| + // Only set when there's actually a purge listener to talk to, so the |
| + // child's environment stays honest about what exists. |
| + controlSocket := rt.Socket + ".control" |
| + childVars := fileEnv |
| + if b.Cache != nil { |
| + childVars = withVar(fileEnv, "CARLOS_CONTROL_SOCKET", controlSocket) |
| + } |
| cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath) |
| - cmd.Env = childEnv(fileEnv) |
| + cmd.Env = childEnv(childVars) |
| // The child logs through the agent's own stderr/stdout (journald under |
| // systemd); there's no per-instance unit to own a log stream anymore. |
| cmd.Stdout = os.Stdout |
| @@ -252,6 +270,25 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| } |
| p := &childProc{cmd: cmd, done: make(chan struct{})} |
| + if b.Cache != nil { |
| + // A fresh process for this host is exactly the moment its cached |
| + // content becomes suspect: a deploy/redeploy means new code and |
| + // potentially new pages, and nothing else would invalidate them until |
| + // their TTL ran out. Drop the whole host's entries before the new |
| + // process serves its first request. |
| + if n := b.Cache.PurgeHost(rt.Host); n > 0 { |
| + log.Printf("activator: purged %d cached response(s) for %s on spawn", n, rt.Host) |
| + } |
| + pl, err := edge.ListenPurge(controlSocket, rt.Host, b.Cache) |
| + if err != nil { |
| + // Not fatal to the wake — the instance still serves requests, it |
| + // just can't purge its own cache until the next restart. A |
| + // stuck-stale cache self-heals via the safety-net TTL anyway. |
| + log.Printf("activator: purge listener for %s: %v (continuing without it)", rt.Host, err) |
| + } else { |
| + p.purge = pl |
| + } |
| + } |
| go func() { |
| p.err = cmd.Wait() |
| close(p.done) |
| @@ -285,6 +322,17 @@ func childEnv(fileEnv map[string]string) []string { |
| return env |
| } |
| +// withVar returns a copy of env with key=value added — a copy, so the |
| +// caller's map (an EnvironmentFile's parsed contents) is never mutated. |
| +func withVar(env map[string]string, key, value string) map[string]string { |
| + out := make(map[string]string, len(env)+1) |
| + for k, v := range env { |
| + out[k] = v |
| + } |
| + out[key] = value |
| + return out |
| +} |
| + |
| // sidecarEnvPath: the host's sidecar EnvironmentFile, |
| // <DataDir>/<host>-sidecar.env (Eleven's <host>-bot.env, renamed). |
| func (b *ExecBackend) sidecarEnvPath(host string) string { |
| @@ -402,9 +450,10 @@ func readEnvFile(path string) (map[string]string, error) { |
| // childProc is a spawned child: SIGTERM starts the instance's own graceful |
| // drain; SIGKILL only if it overstays. |
| type childProc struct { |
| - cmd *exec.Cmd |
| - done chan struct{} |
| - err error |
| + cmd *exec.Cmd |
| + done chan struct{} |
| + err error |
| + purge *edge.PurgeListener // nil if b.Cache was nil or ListenPurge failed |
| } |
| func (p *childProc) Exited() bool { |
| @@ -417,6 +466,9 @@ func (p *childProc) Exited() bool { |
| } |
| func (p *childProc) Stop(ctx context.Context) error { |
| + if p.purge != nil { |
| + p.purge.Close() |
| + } |
| if p.Exited() { |
| return nil |
| } |
diff --git a/internal/edge/cache.go b/internal/edge/cache.go| new file mode 100644 |
| index 000000000..ed4f07c47 |
| --- /dev/null |
| +++ b/internal/edge/cache.go |
| @@ -0,0 +1,339 @@ |
| +package edge |
| + |
| +import ( |
| + "container/list" |
| + "net/http" |
| + "sync" |
| + "time" |
| +) |
| + |
| +// Admission caps that bound how much of the cache any single URL or path can |
| +// occupy, independent of the global LRU. The global LRU alone does not stop |
| +// one URL from being pumped full of noise entries that then evict OTHER |
| +// hosts' useful entries — a crawler sending Accept-Language: xx-1 … xx-4096 |
| +// (distinct Vary variants) or ?x=1 … ?x=4096 (distinct query strings, part of |
| +// the key because correctness requires it: different query strings can mean |
| +// different content) would otherwise flush the whole cache on demand. |
| +// |
| +// Two caps, because the two floods land on different key components: |
| +// - maxVaryVariantsPerURL bounds the Vary variants of one exact URL |
| +// (host+method+path+query). |
| +// - maxEntriesPerPath bounds everything under one path regardless of query |
| +// string or variant, which is what actually bounds a query-string flood. |
| +// |
| +// Both evict the oldest entry in their group (by StoredAt) when exceeded. |
| +const ( |
| + maxVaryVariantsPerURL = 8 |
| + maxEntriesPerPath = 32 |
| +) |
| + |
| +// 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[cacheFullKey]*list.Element // urlKey -> its Vary variants |
| + byPath map[cachePathKey]map[cacheFullKey]*list.Element // pathKey -> every entry under that path |
| + // varyNames records, per URL, the raw Vary header value of the most recent |
| + // response stored for that URL. It is what makes the two-phase lookup |
| + // possible: at request time the response's Vary header isn't known yet, so |
| + // GetFor replays the last one seen to compute the same VaryValue Put |
| + // stored the entry under. Dropped when a URL's last entry goes away, so it |
| + // can't grow without bound. |
| + // |
| + // "Most recent", emphatically, not "first ever". A URL whose responses |
| + // vary conditionally (CORS middleware emitting Vary: Origin only for |
| + // Origin-bearing requests) would otherwise be poisoned by a single |
| + // request: the remembered names outlive the response that set them, later |
| + // non-varying responses are stored under the empty variant, and every |
| + // lookup keeps resolving against names nothing is stored under — a |
| + // permanent miss on that URL, which on a hibernating route is a permanent |
| + // wake per request. Put rewrites this record on every store, so a URL that |
| + // stops varying stops being resolved against stale names. |
| + varyNames map[cacheURLKey]string |
| +} |
| + |
| +type cacheURLKey struct { |
| + Host, Method, Path, RawQuery string |
| +} |
| + |
| +type cachePathKey struct { |
| + Host, Method, Path 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[cacheFullKey]*list.Element), |
| + byPath: make(map[cachePathKey]map[cacheFullKey]*list.Element), |
| + varyNames: make(map[cacheURLKey]string), |
| + } |
| +} |
| + |
| +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} |
| +} |
| + |
| +func (k cacheURLKey) pathKey() cachePathKey { |
| + return cachePathKey{Host: k.Host, Method: k.Method, Path: k.Path} |
| +} |
| + |
| +// 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. |
| +// |
| +// Get takes the VaryValue already resolved by the caller. Request handling |
| +// goes through GetFor instead, which resolves it first — a request can't know |
| +// the response's Vary header until the response exists. |
| +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 |
| +} |
| + |
| +// GetFor is the request-time lookup: phase one resolves which Vary variant r |
| +// belongs to, phase two does the ordinary Get. baseKey carries everything |
| +// known before the response exists (host, method, path, query) with VaryValue |
| +// left empty. |
| +// |
| +// The Vary header names live on the RESPONSE, so at lookup time the only |
| +// honest source for them is what the last response stored for this URL said |
| +// (c.varyNames). With none on record — no response yet, or the last one |
| +// carried no Vary — the lookup uses an empty VaryValue, |
| +// which is exactly the no-Vary case and keeps that path byte-for-byte as it |
| +// was. Both this and maybeStore compute the variant through varyValue, so |
| +// store and lookup can never drift apart — the bug this method exists to fix |
| +// was precisely that drift: storing under varyValue(request, vary) while |
| +// looking up under "", so a Vary-carrying response never hit. |
| +func (c *Cache) GetFor(baseKey CacheKey, r *http.Request) (*CachedResponse, bool) { |
| + c.mu.Lock() |
| + names := c.varyNames[baseKey.urlKey()] |
| + c.mu.Unlock() |
| + if names != "" { |
| + baseKey.VaryValue = varyValue(r, names) |
| + } |
| + return c.Get(baseKey) |
| +} |
| + |
| +// Put stores a response in the cache. Silently drops a response whose body |
| +// exceeds maxBodyBytes. Evicts the least-recently-used entry when at |
| +// maxEntries, and enforces the per-URL and per-path admission caps so no |
| +// single URL or path can flood the cache (see the cap constants above). |
| +// |
| +// vary is the response's Vary field value — every line of it, comma-joined — |
| +// and "" when the response carried no Vary header at all. It is a separate |
| +// parameter rather than something read back off resp.Header because the caller |
| +// must have computed key.VaryValue from this exact string (edge.go's maybeStore |
| +// does, via varyValue), and because key.VaryValue == "" cannot stand in for |
| +// "no Vary": a response may vary on a header the request never sent, which |
| +// computes an empty value from a very real Vary header. |
| +// |
| +// Put records vary as this URL's Vary scheme so GetFor can resolve the same |
| +// variant on the next request, and — this is the load-bearing half — clears |
| +// or replaces that record when vary changes, including changing to "". |
| +func (c *Cache) Put(key CacheKey, vary string, resp *CachedResponse) { |
| + if len(resp.Body) > c.maxBodyBytes { |
| + return |
| + } |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + fk := key.fullKey() |
| + uk := fk.cacheURLKey |
| + if vary != c.varyNames[uk] { |
| + // This URL's Vary scheme changed: it stopped varying, started, or |
| + // started varying by different headers. Every entry already stored for |
| + // it was keyed under the OLD scheme, while GetFor will now resolve |
| + // against the new one — none of them can ever be reached again, so |
| + // they are pure garbage holding LRU and per-URL-cap space. Dropping |
| + // them is what keeps a conditionally-varying URL from slowly filling |
| + // the cache with unreachable variants. |
| + // |
| + // The sweep runs before the record is rewritten because removeLocked |
| + // deletes varyNames[uk] once the URL's last entry goes. |
| + for _, el := range elementsOf(c.byURL[uk]) { |
| + c.removeLocked(el) |
| + } |
| + if vary == "" { |
| + delete(c.varyNames, uk) |
| + } else { |
| + c.varyNames[uk] = vary |
| + } |
| + } |
| + 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 |
| + if c.byURL[uk] == nil { |
| + c.byURL[uk] = make(map[cacheFullKey]*list.Element) |
| + } |
| + c.byURL[uk][fk] = el |
| + pk := uk.pathKey() |
| + if c.byPath[pk] == nil { |
| + c.byPath[pk] = make(map[cacheFullKey]*list.Element) |
| + } |
| + c.byPath[pk][fk] = el |
| + |
| + c.capGroupLocked(c.byURL[uk], maxVaryVariantsPerURL, el) |
| + c.capGroupLocked(c.byPath[pk], maxEntriesPerPath, el) |
| + for len(c.entries) > c.maxEntries { |
| + victim := c.order.Back() |
| + if victim == nil { |
| + break |
| + } |
| + c.removeLocked(victim) |
| + } |
| +} |
| + |
| +// capGroupLocked drops the oldest entries (by StoredAt) from one admission |
| +// group until it holds at most max, never dropping keep (the entry just |
| +// inserted — evicting it would make the flood self-defeating in the wrong |
| +// direction, turning every request into a permanent miss AND a permanent |
| +// insert). Caller must hold c.mu. |
| +func (c *Cache) capGroupLocked(group map[cacheFullKey]*list.Element, max int, keep *list.Element) { |
| + for len(group) > max { |
| + var oldest *list.Element |
| + for _, el := range group { |
| + if el == keep { |
| + continue |
| + } |
| + if oldest == nil || el.Value.(*cacheEntry).resp.StoredAt.Before(oldest.Value.(*cacheEntry).resp.StoredAt) { |
| + oldest = el |
| + } |
| + } |
| + if oldest == nil { |
| + return |
| + } |
| + c.removeLocked(oldest) |
| + } |
| +} |
| + |
| +// 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) |
| + uk := e.key.cacheURLKey |
| + if variants := c.byURL[uk]; variants != nil { |
| + delete(variants, e.key) |
| + if len(variants) == 0 { |
| + delete(c.byURL, uk) |
| + // No entries left for this URL, so the remembered Vary names have |
| + // nothing to resolve against — dropping them keeps varyNames |
| + // bounded by the number of live URLs. |
| + delete(c.varyNames, uk) |
| + } |
| + } |
| + pk := uk.pathKey() |
| + if group := c.byPath[pk]; group != nil { |
| + delete(group, e.key) |
| + if len(group) == 0 { |
| + delete(c.byPath, pk) |
| + } |
| + } |
| +} |
| + |
| +// 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} |
| + for _, el := range elementsOf(c.byURL[uk]) { |
| + c.removeLocked(el) |
| + n++ |
| + } |
| + } |
| + return n |
| +} |
| + |
| +// 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. |
| +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 { |
| + c.removeLocked(el) |
| + } |
| + return len(doomed) |
| +} |
| + |
| +// elementsOf snapshots a group's elements so the caller can remove them while |
| +// iterating (removeLocked mutates the very map being ranged over). |
| +func elementsOf(group map[cacheFullKey]*list.Element) []*list.Element { |
| + els := make([]*list.Element, 0, len(group)) |
| + for _, el := range group { |
| + els = append(els, el) |
| + } |
| + return els |
| +} |
diff --git a/internal/edge/cache_test.go b/internal/edge/cache_test.go| new file mode 100644 |
| index 000000000..a3b52d633 |
| --- /dev/null |
| +++ b/internal/edge/cache_test.go |
| @@ -0,0 +1,202 @@ |
| +package edge |
| + |
| +import ( |
| + "fmt" |
| + "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") |
| + } |
| +} |
| + |
| +// varyOf reports the Vary field value the response behind k must have carried: |
| +// a key only has a VaryValue because its response varied by something, and Put |
| +// needs to be told which something. |
| +func varyOf(k CacheKey) string { |
| + if k.VaryValue == "" { |
| + return "" |
| + } |
| + return "Accept-Language" |
| +} |
| + |
| +// Put records the Vary scheme of the LATEST response for a URL, not the first |
| +// one ever seen. When a URL stops varying, the old per-variant entries are |
| +// unreachable — GetFor resolves against the new (empty) scheme — so they must |
| +// go rather than sit in the LRU forever, and the remembered names must go with |
| +// them or every later lookup resolves against names nothing is stored under. |
| +func TestCachePutWithoutVaryClearsRememberedNamesAndStaleVariants(t *testing.T) { |
| + c := NewCache(10, 1<<20) |
| + uk := CacheKey{Host: "a", Method: "GET", Path: "/page"}.urlKey() |
| + mkResp := func(b string) *CachedResponse { |
| + return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute} |
| + } |
| + |
| + varied := CacheKey{Host: "a", Method: "GET", Path: "/page", VaryValue: "Origin=https://x;"} |
| + c.Put(varied, "Origin", mkResp("cors")) |
| + c.mu.Lock() |
| + names := c.varyNames[uk] |
| + c.mu.Unlock() |
| + if names != "Origin" { |
| + t.Fatalf("varyNames = %q after a Vary-carrying store, want %q", names, "Origin") |
| + } |
| + |
| + plain := CacheKey{Host: "a", Method: "GET", Path: "/page"} |
| + c.Put(plain, "", mkResp("plain")) |
| + c.mu.Lock() |
| + names, remembered := c.varyNames[uk] |
| + variants := len(c.byURL[uk]) |
| + c.mu.Unlock() |
| + if remembered { |
| + t.Fatalf("varyNames still holds %q after a response with no Vary — every later lookup would resolve against names nothing is stored under", names) |
| + } |
| + if variants != 1 { |
| + t.Fatalf("URL holds %d entries, want 1 — the old Origin variant is unreachable once the URL stops varying and must not linger in the LRU", variants) |
| + } |
| + if got, ok := c.Get(plain); !ok || string(got.Body) != "plain" { |
| + t.Fatalf("Get(no-Vary key) = %v, %v; want the freshly stored body", got, ok) |
| + } |
| +} |
| + |
| +// A deploy replaces the code behind a host, so every cached page for it is |
| +// suspect — PurgeHost drops all of them, across paths and Vary variants, and |
| +// nothing belonging to any other host. |
| +func TestCachePurgeHostRemovesEveryEntryForThatHost(t *testing.T) { |
| + c := NewCache(100, 1<<20) |
| + mkResp := func(b string) *CachedResponse { |
| + return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute} |
| + } |
| + keys := []CacheKey{ |
| + {Host: "a", Method: "GET", Path: "/one"}, |
| + {Host: "a", Method: "GET", Path: "/two", VaryValue: "en"}, |
| + {Host: "a", Method: "GET", Path: "/two", VaryValue: "fr"}, |
| + {Host: "a", Method: "HEAD", Path: "/one"}, |
| + {Host: "a", Method: "GET", Path: "/one", RawQuery: "page=2"}, |
| + } |
| + for _, k := range keys { |
| + c.Put(k, varyOf(k), mkResp(k.Path)) |
| + } |
| + other := CacheKey{Host: "b", Method: "GET", Path: "/one"} |
| + c.Put(other, "", mkResp("b/one")) |
| + |
| + if n := c.PurgeHost("a"); n != len(keys) { |
| + t.Fatalf("PurgeHost removed %d entries, want %d", n, len(keys)) |
| + } |
| + for _, k := range keys { |
| + if _, ok := c.Get(k); ok { |
| + t.Fatalf("entry %+v survived PurgeHost", k) |
| + } |
| + } |
| + if _, ok := c.Get(other); !ok { |
| + t.Fatal("PurgeHost(\"a\") must leave host b's entries untouched") |
| + } |
| + if n := c.PurgeHost("nobody"); n != 0 { |
| + t.Fatalf("PurgeHost on an uncached host removed %d, want 0", n) |
| + } |
| +} |
| + |
| +// 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. |
| +func TestCacheQueryStringFloodIsBoundedPerPath(t *testing.T) { |
| + c := NewCache(1000, 1<<20) |
| + for i := 0; i < 10*maxEntriesPerPath; i++ { |
| + k := CacheKey{Host: "a", Method: "GET", Path: "/event", RawQuery: fmt.Sprintf("x=%d", i)} |
| + c.Put(k, "", &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("x"), StoredAt: time.Now(), TTL: time.Minute}) |
| + } |
| + c.mu.Lock() |
| + n := len(c.byPath[cachePathKey{Host: "a", Method: "GET", Path: "/event"}]) |
| + total := len(c.entries) |
| + c.mu.Unlock() |
| + if n > maxEntriesPerPath { |
| + t.Fatalf("one path holds %d cached entries, want at most %d", n, maxEntriesPerPath) |
| + } |
| + if total > maxEntriesPerPath { |
| + t.Fatalf("cache holds %d entries after a query-string flood, want at most %d", total, maxEntriesPerPath) |
| + } |
| +} |
| + |
| +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, "Accept-Language", mkResp("en")) |
| + c.Put(fr, "Accept-Language", 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") |
| + } |
| +} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 78be14db8..ed9ec5a03 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -16,6 +16,7 @@ |
| package edge |
| import ( |
| + "bufio" |
| "context" |
| "crypto/ed25519" |
| "errors" |
| @@ -28,6 +29,8 @@ import ( |
| "os" |
| "os/signal" |
| "path/filepath" |
| + "strconv" |
| + "strings" |
| "sync/atomic" |
| "syscall" |
| "time" |
| @@ -38,6 +41,24 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| +// Default cache sizing for EnableCache: 4096 entries is comfortably above a |
| +// single box's route × Vary-variant count, and 1 MiB caps a single cached |
| +// body well above a typical HTML page while still bounding worst-case memory |
| +// (4096 * 1MiB = 4GiB ceiling, never actually reached in practice since most |
| +// bodies are far smaller). |
| +const ( |
| + DefaultCacheMaxEntries = 4096 |
| + DefaultCacheMaxBodyBytes = 1 << 20 // 1 MiB |
| +) |
| + |
| +// DefaultCacheMaxTTL is the ceiling every cached entry's TTL is clamped to, |
| +// however long the app's own max-age asks for. It is what makes spec decision |
| +// 4 true — "a lost purge self-heals within a bounded window": without a |
| +// ceiling, an app setting max-age=31536000 turns one dropped purge call into a |
| +// permanently wrong page. Five minutes is short enough that a missed purge is |
| +// a blip and long enough that a crawler sweep still rides the cache. |
| +const DefaultCacheMaxTTL = 5 * time.Minute |
| + |
| // Activator is the hibernation seam (implemented by the activator package): |
| // given a hibernate-flagged instance route, it wakes (or joins an in-progress |
| // wake of) the backing process before letting the request through, and |
| @@ -75,6 +96,10 @@ type Edge struct { |
| staticPub ed25519.PublicKey |
| staticCacheDir string |
| staticRecheck time.Duration |
| + // cache, when non-nil, answers cacheable GET/HEAD requests without ever |
| + // invoking the hibernation activator or the backend — the mitigation |
| + // this whole feature exists for. nil disables caching entirely. |
| + cache *Cache |
| } |
| // upstream is one served host: the handler for its backend, plus — when the |
| @@ -117,6 +142,13 @@ func (e *Edge) EnableStatic(store release.Store, pub ed25519.PublicKey, cacheDir |
| e.staticRecheck = staticRecheck |
| } |
| +// EnableCache wires a response cache into the edge. Called once at startup |
| +// (RunWithActivator or Run); nil is a valid no-op, matching the package's |
| +// existing nil-means-disabled convention for Activator. |
| +func (e *Edge) EnableCache(cache *Cache) { |
| + e.cache = cache |
| +} |
| + |
| // SetStaticRecheck tunes how long a static route trusts a resolved channel |
| // pointer before re-reading it (default 60s; 0 = every request). Call after |
| // EnableStatic and before Reload; handlers built on the next reload use it. |
| @@ -231,17 +263,323 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| e.unknownHost(w, r) |
| return |
| } |
| + |
| + // A Connection: Upgrade request (a WebSocket handshake, typically GET) |
| + // must reach the activator/proxy with the real ResponseWriter, completely |
| + // untouched by caching: the stdlib reverse proxy's upgrade path type- |
| + // asserts the ResponseWriter to http.Hijacker to take over the raw |
| + // connection, and once upgraded, the open WebSocket is also what keeps a |
| + // hibernating instance's idle clock from firing (the activator's inflight |
| + // counting — see internal/activator/activator.go's actEntry.inflight doc |
| + // comment). So this path skips caching entirely and stays byte-for-byte |
| + // identical to pre-cache behavior, rather than relying on |
| + // recordingResponseWriter's Hijack forwarding below. |
| + if isUpgradeRequest(r) { |
| + if up.hibernating { |
| + if e.act.ServeHTTP(w, r, up.route, up.proxy) { |
| + return |
| + } |
| + } |
| + up.proxy.ServeHTTP(w, r) |
| + return |
| + } |
| + |
| + // Cache check happens before the hibernation branch below — a hit must |
| + // never reach the activator, since avoiding pointless wakes for |
| + // bot/crawler/repeat traffic is the entire point of this cache. |
| + cacheable := e.cache != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) |
| + var key CacheKey |
| + if cacheable { |
| + key = CacheKey{Host: host, Method: r.Method, Path: r.URL.Path, RawQuery: r.URL.RawQuery} |
| + // GetFor, not Get: the variant this request belongs to depends on the |
| + // response's Vary header, which doesn't exist yet — GetFor resolves it |
| + // from the Vary names the last response for this URL recorded, so the |
| + // lookup key matches the key maybeStore stored under. |
| + if resp, hit := e.cache.GetFor(key, r); hit { |
| + writeCached(w, resp) |
| + return |
| + } |
| + } |
| + |
| if up.hibernating { |
| // Wake (or join an in-progress wake) before proxying; the activator |
| // also stamps this request's activity for the idle clock. Note a bare |
| // TLS handshake never wakes anything — only a proxied request does. |
| - if e.act.ServeHTTP(w, r, up.route, up.proxy) { |
| + // The response is always recorded (even when not cacheable, in which |
| + // case maybeStore is a no-op on rec.cacheable) so this branch stays a |
| + // single shape regardless of method. |
| + rec := &recordingResponseWriter{ResponseWriter: w, request: r, cacheable: cacheable, maxBody: e.cacheMaxBody()} |
| + if e.act.ServeHTTP(rec, r, up.route, up.proxy) { |
| + e.maybeStore(key, rec) |
| return |
| } |
| + up.proxy.ServeHTTP(rec, r) |
| + e.maybeStore(key, rec) |
| + return |
| + } |
| + |
| + if cacheable { |
| + rec := &recordingResponseWriter{ResponseWriter: w, request: r, cacheable: true, maxBody: e.cacheMaxBody()} |
| + up.proxy.ServeHTTP(rec, r) |
| + e.maybeStore(key, rec) |
| + return |
| } |
| up.proxy.ServeHTTP(w, r) |
| } |
| +// isUpgradeRequest reports whether r is a protocol-upgrade request (e.g. a |
| +// WebSocket handshake) per the Connection header's token list (RFC 7230 |
| +// §6.7: comma-separated, case-insensitive — "Upgrade", "keep-alive, Upgrade", |
| +// etc. all qualify). |
| +func isUpgradeRequest(r *http.Request) bool { |
| + for _, token := range strings.Split(r.Header.Get("Connection"), ",") { |
| + if strings.EqualFold(strings.TrimSpace(token), "Upgrade") { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// cacheMaxBody reports the body-size ceiling recordingResponseWriter should |
| +// enforce, or 0 when caching is disabled (recordingResponseWriter is never |
| +// constructed with cacheable=true in that case, so the value is unused). |
| +func (e *Edge) cacheMaxBody() int { |
| + if e.cache == nil { |
| + return 0 |
| + } |
| + return e.cache.maxBodyBytes |
| +} |
| + |
| +// maybeStore inspects a recorded response and caches it if it qualifies: |
| +// 2xx status, a Cache-Control that names a positive max-age, and none of the |
| +// per-visitor markers below. This cache is shared across every visitor of a |
| +// host and caching is unconditional per route (not opt-in per app), so each |
| +// refusal here is the only thing standing between one visitor's response and |
| +// everyone else's: |
| +// |
| +// - Cache-Control: private — the app itself saying "one visitor only". |
| +// - Set-Cookie — the response carries per-visitor state (a session id, a |
| +// CSRF token); replaying it would hand that cookie to later visitors. |
| +// - an Authorization header on the REQUEST — the response is presumed |
| +// private per RFC 9111 §3.5, unless Cache-Control explicitly says public, |
| +// which is the app asserting the response is safe to share. |
| +// - Vary: Cookie — varying by cookie would defeat correctness per-visitor. |
| +// - Vary: * — RFC 9111 §4.1 / RFC 9110 §12.5.5: the response depends on |
| +// something request headers can't capture, so it is never reusable from |
| +// a shared cache. |
| +// |
| +// The stored TTL is clamped to DefaultCacheMaxTTL so a lost purge always |
| +// self-heals within a bounded window. |
| +func (e *Edge) maybeStore(key CacheKey, rec *recordingResponseWriter) { |
| + if e.cache == nil || !rec.cacheable || rec.truncated { |
| + return |
| + } |
| + if rec.status < 200 || rec.status >= 300 { |
| + return |
| + } |
| + cc := joinHeader(rec.Header(), "Cache-Control") |
| + ttl, ok := parseMaxAge(cc) |
| + if !ok || ttl <= 0 { |
| + return |
| + } |
| + if len(rec.Header().Values("Set-Cookie")) > 0 { |
| + return |
| + } |
| + if len(rec.request.Header.Values("Authorization")) > 0 && !hasCCDirective(cc, "public") { |
| + return |
| + } |
| + if ttl > DefaultCacheMaxTTL { |
| + ttl = DefaultCacheMaxTTL |
| + } |
| + vary := joinHeader(rec.Header(), "Vary") |
| + if vary != "" { |
| + for _, h := range strings.Split(vary, ",") { |
| + h = strings.TrimSpace(h) |
| + if h == "*" || strings.EqualFold(h, "Cookie") { |
| + return |
| + } |
| + } |
| + // The same computation GetFor replays at lookup time — both call |
| + // varyValue so the store key and the lookup key can never drift. |
| + key.VaryValue = varyValue(rec.request, vary) |
| + } |
| + // vary is handed to Put as well as consumed here: it is what Put records |
| + // (or, when empty, clears) as this URL's Vary scheme, so the next request's |
| + // GetFor resolves the very variant this store used. |
| + e.cache.Put(key, vary, &CachedResponse{ |
| + StatusCode: rec.status, |
| + Header: rec.Header().Clone(), |
| + Body: rec.body, |
| + StoredAt: time.Now(), |
| + TTL: ttl, |
| + }) |
| +} |
| + |
| +// joinHeader returns every line of one header field as a single comma-joined |
| +// value. RFC 9110 §5.3: a field may legally appear on multiple lines and that |
| +// is identical in meaning to one comma-joined line — which is exactly what |
| +// http.Header.Add produces, and exactly how layered middleware emits |
| +// list-valued fields (compression adds Vary: Accept-Encoding, the session |
| +// layer separately adds Vary: Cookie; one handler sets Cache-Control: |
| +// max-age=600 and an auth wrapper adds Cache-Control: private). |
| +// |
| +// Every Cache-Control and Vary question this file asks goes through here |
| +// because http.Header.Get returns ONLY the first line: reading either field |
| +// with Get made every later line invisible, so a second line saying "private" |
| +// or "Cookie" silently failed to refuse the response. Joining with ", " keeps |
| +// the result a well-formed field value for the comma-splitting parsers below. |
| +func joinHeader(h http.Header, name string) string { |
| + return strings.Join(h.Values(name), ", ") |
| +} |
| + |
| +// varyValue concatenates r's values for each header named in the response's |
| +// Vary list, in the order Vary names them — the same raw string always |
| +// produces the same bucket, which is all correctness requires (standard HTTP |
| +// Vary semantics; RFC 7234 §4.1). |
| +// |
| +// vary itself may be a comma-joined multi-line field (see joinHeader); the |
| +// split-and-trim below handles that form identically to a single line. The |
| +// request side reads all of a header's lines too, so two requests that differ |
| +// only in how they split a varied-on header still land in different buckets. |
| +func varyValue(r *http.Request, vary string) string { |
| + var b strings.Builder |
| + for _, h := range strings.Split(vary, ",") { |
| + h = strings.TrimSpace(h) |
| + b.WriteString(h) |
| + b.WriteByte('=') |
| + b.WriteString(joinHeader(r.Header, h)) |
| + b.WriteByte(';') |
| + } |
| + return b.String() |
| +} |
| + |
| +// parseMaxAge extracts max-age from a Cache-Control header. Returns |
| +// ok=false for "no-store", "no-cache", "private", or a missing/malformed |
| +// max-age — any of which mean "don't cache this in a shared cache." |
| +// |
| +// The whole header is scanned before the max-age is honored, so a |
| +// "max-age=600, private" (directives in either order, and "private" in its |
| +// field-name form private="Set-Cookie") is refused rather than cached because |
| +// max-age happened to come first. |
| +func parseMaxAge(cc string) (time.Duration, bool) { |
| + var ttl time.Duration |
| + found := false |
| + for name, arg := range ccDirectives(cc) { |
| + switch name { |
| + case "no-store", "no-cache", "private": |
| + return 0, false |
| + case "max-age": |
| + secs, err := strconv.Atoi(strings.TrimSpace(arg)) |
| + if err != nil { |
| + return 0, false |
| + } |
| + ttl, found = time.Duration(secs)*time.Second, true |
| + } |
| + } |
| + if !found { |
| + return 0, false |
| + } |
| + return ttl, true |
| +} |
| + |
| +// hasCCDirective reports whether cc carries the named directive. |
| +func hasCCDirective(cc, want string) bool { |
| + for name := range ccDirectives(cc) { |
| + if name == want { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// ccDirectives iterates a Cache-Control header as (lowercased name, argument) |
| +// pairs; the argument is "" for a bare directive. One parse shape for every |
| +// directive question this file asks. |
| +func ccDirectives(cc string) func(func(string, string) bool) { |
| + return func(yield func(string, string) bool) { |
| + for _, part := range strings.Split(cc, ",") { |
| + name, arg, _ := strings.Cut(strings.TrimSpace(part), "=") |
| + if !yield(strings.ToLower(strings.TrimSpace(name)), arg) { |
| + return |
| + } |
| + } |
| + } |
| +} |
| + |
| +// writeCached replays a cached response verbatim onto w. |
| +func writeCached(w http.ResponseWriter, resp *CachedResponse) { |
| + h := w.Header() |
| + for k, v := range resp.Header { |
| + h[k] = v |
| + } |
| + w.WriteHeader(resp.StatusCode) |
| + w.Write(resp.Body) |
| +} |
| + |
| +// recordingResponseWriter captures a response as it's written so it can be |
| +// cached afterward, while still passing bytes through to the real client |
| +// immediately (no buffering delay for the requester). Recording stops |
| +// (silently, not an error) once the body exceeds maxBody — that response is |
| +// simply never cached, per maybeStore's rec.truncated check. |
| +type recordingResponseWriter struct { |
| + http.ResponseWriter |
| + request *http.Request |
| + cacheable bool |
| + maxBody int |
| + status int |
| + body []byte |
| + truncated bool |
| + wroteHdr bool |
| +} |
| + |
| +func (r *recordingResponseWriter) WriteHeader(status int) { |
| + if !r.wroteHdr { |
| + r.status = status |
| + r.wroteHdr = true |
| + } |
| + r.ResponseWriter.WriteHeader(status) |
| +} |
| + |
| +func (r *recordingResponseWriter) Write(p []byte) (int, error) { |
| + if !r.wroteHdr { |
| + r.WriteHeader(http.StatusOK) |
| + } |
| + if r.cacheable && !r.truncated { |
| + if len(r.body)+len(p) > r.maxBody { |
| + r.truncated = true |
| + r.body = nil |
| + } else { |
| + r.body = append(r.body, p...) |
| + } |
| + } |
| + return r.ResponseWriter.Write(p) |
| +} |
| + |
| +// Hijack forwards to the underlying ResponseWriter's http.Hijacker, when it |
| +// implements one — the same optional-interface pattern the stdlib itself |
| +// uses. ServeHTTP special-cases upgrade requests before ever constructing a |
| +// recordingResponseWriter (see isUpgradeRequest), so this method is not |
| +// expected to be called on the hot path today; it exists so the wrapper type |
| +// is never itself the reason a hijack fails, matching http.ResponseWriter |
| +// wrapper conventions generally. |
| +func (r *recordingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { |
| + hj, ok := r.ResponseWriter.(http.Hijacker) |
| + if !ok { |
| + return nil, nil, fmt.Errorf("edge: underlying ResponseWriter does not support http.Hijacker") |
| + } |
| + return hj.Hijack() |
| +} |
| + |
| +// Flush forwards to the underlying ResponseWriter's http.Flusher, when it |
| +// implements one — needed so a recorded (non-upgrade) response can still |
| +// stream incrementally to the real client, e.g. Server-Sent Events, instead |
| +// of buffering until the handler returns. |
| +func (r *recordingResponseWriter) Flush() { |
| + if f, ok := r.ResponseWriter.(http.Flusher); ok { |
| + f.Flush() |
| + } |
| +} |
| + |
| // unknownHost answers a request for a host we don't serve: a redirect to the |
| // fallback (302 — a typo'd host must not be cached as permanently moved), or |
| // the classic 404 when no fallback is configured. |
| @@ -375,12 +713,15 @@ func isRetryableDialErr(err error) bool { |
| // Run is the `carlos edge` verb: the shared front door for every instance, |
| // sibling service, and static site on the box. |
| -func Run(args []string) { RunWithActivator(args, nil) } |
| +func Run(args []string) { |
| + RunWithActivator(args, nil, NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| +} |
| // RunWithActivator is Run with an injected hibernation activator — the seam |
| // `carlos agent` (internal/host) uses to compose edge + activator in one |
| -// process. act may be nil: pure proxy, exactly Run's behavior. |
| -func RunWithActivator(args []string, act Activator) { |
| +// process. act may be nil: pure proxy, exactly Run's behavior. cache may |
| +// also be nil, disabling response caching entirely. |
| +func RunWithActivator(args []string, act Activator, cache *Cache) { |
| dataDir := envOr("CARLOS_DATA_DIR", "/data") |
| fs := flag.NewFlagSet("edge", flag.ExitOnError) |
| registryPath := fs.String("registry", filepath.Join(dataDir, "registry.db"), "registry SQLite path (default $CARLOS_DATA_DIR/registry.db)") |
| @@ -403,6 +744,7 @@ func RunWithActivator(args []string, act Activator) { |
| log.Fatalf("edge: load routes: %v", err) |
| } |
| e.fallbackURL = *fallbackURL |
| + e.EnableCache(cache) |
| // Static serving lights up when the environment carries a deployment |
| // store and the release pubkey; otherwise static routes 404 until it does. |
diff --git a/internal/edge/edge_test.go b/internal/edge/edge_test.go| index 88477efd1..492479c7e 100644 |
| --- a/internal/edge/edge_test.go |
| +++ b/internal/edge/edge_test.go |
| @@ -1,8 +1,12 @@ |
| package edge |
| import ( |
| + "bufio" |
| + "bytes" |
| "context" |
| + "encoding/json" |
| "errors" |
| + "fmt" |
| "io" |
| "net" |
| "net/http" |
| @@ -10,6 +14,7 @@ import ( |
| "os" |
| "path/filepath" |
| "strings" |
| + "sync/atomic" |
| "syscall" |
| "testing" |
| "time" |
| @@ -472,3 +477,828 @@ func TestHibernatingRouteGoesThroughActivator(t *testing.T) { |
| t.Fatalf("RetireExcept keep-set %v missing hib.example.com", last) |
| } |
| } |
| + |
| +// refusingActivator fails the test immediately if ever invoked — used to |
| +// prove a cache hit never reaches the activator at all. |
| +type refusingActivator struct{ t *testing.T } |
| + |
| +func (r refusingActivator) ServeHTTP(w http.ResponseWriter, req *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + r.t.Fatal("activator invoked on what should have been a cache hit — this defeats the whole point of caching") |
| + return true |
| +} |
| + |
| +func (r refusingActivator) RetireExcept(keep map[string]bool) {} |
| + |
| +func TestServeHTTPCacheHitNeverInvokesActivator(t *testing.T) { |
| + reg := openReg(t) |
| + // Addr is deliberately a bogus, never-dialed address: if the cache |
| + // check is ever bypassed, proxying to it fails loudly instead of |
| + // silently succeeding, which would mask the bug. |
| + if err := reg.Upsert(registry.Route{Host: "cached.example.com", Kind: "instance", Addr: "127.0.0.1:1", Status: "ready", Hibernate: true}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, refusingActivator{t: t}) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + key := CacheKey{Host: "cached.example.com", Method: "GET", Path: "/event"} |
| + e.cache.Put(key, "", &CachedResponse{ |
| + StatusCode: 200, |
| + Header: http.Header{"Content-Type": {"text/html"}}, |
| + Body: []byte("cached page"), |
| + StoredAt: time.Now(), |
| + TTL: time.Minute, |
| + }) |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("GET", "http://cached.example.com/event", nil)) |
| + |
| + if rec.Code != 200 { |
| + t.Fatalf("status = %d, want 200", rec.Code) |
| + } |
| + if rec.Body.String() != "cached page" { |
| + t.Fatalf("body = %q, want %q", rec.Body.String(), "cached page") |
| + } |
| +} |
| + |
| +func TestServeHTTPCacheMissPopulatesCacheFromResponse(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + w.WriteHeader(200) |
| + w.Write([]byte("fresh page")) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "miss.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + |
| + rec1 := httptest.NewRecorder() |
| + e.ServeHTTP(rec1, httptest.NewRequest("GET", "http://miss.example.com/event", nil)) |
| + if rec1.Body.String() != "fresh page" { |
| + t.Fatalf("first response body = %q, want %q", rec1.Body.String(), "fresh page") |
| + } |
| + |
| + // Close the backend so a second real request would fail to dial — |
| + // proves the second request is answered from cache, not the backend. |
| + backend.Close() |
| + rec2 := httptest.NewRecorder() |
| + e.ServeHTTP(rec2, httptest.NewRequest("GET", "http://miss.example.com/event", nil)) |
| + if rec2.Body.String() != "fresh page" { |
| + t.Fatalf("second response body = %q, want %q (should be served from cache)", rec2.Body.String(), "fresh page") |
| + } |
| +} |
| + |
| +func TestServeHTTPNeverCachesNon2xx(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + w.WriteHeader(500) |
| + w.Write([]byte("boom")) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "err.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://err.example.com/event", nil)) |
| + |
| + if _, ok := e.cache.Get(CacheKey{Host: "err.example.com", Method: "GET", Path: "/event"}); ok { |
| + t.Fatal("a 500 response must never be cached") |
| + } |
| +} |
| + |
| +// varyBackendEdge wires an Edge in front of a backend that echoes the request's |
| +// Accept-Language and counts its own invocations — the counter is what proves a |
| +// second request was answered from cache rather than re-proxied, which asserting |
| +// on response bodies alone cannot do (a miss echoes the same body a hit would). |
| +func varyBackendEdge(t *testing.T, host string, hits *atomic.Int64) *Edge { |
| + t.Helper() |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + hits.Add(1) |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + w.Header().Set("Vary", "Accept-Language") |
| + w.WriteHeader(200) |
| + w.Write([]byte("lang=" + r.Header.Get("Accept-Language"))) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + return e |
| +} |
| + |
| +// getLang issues a GET carrying Accept-Language: lang (omitted entirely when |
| +// lang is "") and returns the body. |
| +func getLang(t *testing.T, e *Edge, url, lang string) string { |
| + t.Helper() |
| + req := httptest.NewRequest("GET", url, nil) |
| + if lang != "" { |
| + req.Header.Set("Accept-Language", lang) |
| + } |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, req) |
| + return rec.Body.String() |
| +} |
| + |
| +// A Vary-carrying response must be both separated per variant AND actually |
| +// re-served from cache on a repeat request. The backend-invocation count is |
| +// the load-bearing assertion: pre-fix, ServeHTTP looked entries up with an |
| +// empty VaryValue while maybeStore stored them under varyValue(request, vary), |
| +// so every request was a miss — 4 backend hits, not 2 — while the bodies still |
| +// differed per language, which is why the old body-comparison assertion passed |
| +// with Vary support entirely broken. |
| +func TestServeHTTPVaryAcceptLanguageProducesSeparateEntries(t *testing.T) { |
| + var hits atomic.Int64 |
| + e := varyBackendEdge(t, "i18n.example.com", &hits) |
| + const url = "http://i18n.example.com/event" |
| + |
| + for _, want := range []struct{ lang, body string }{ |
| + {"en", "lang=en"}, {"en", "lang=en"}, {"fr", "lang=fr"}, {"fr", "lang=fr"}, |
| + } { |
| + if got := getLang(t, e, url, want.lang); got != want.body { |
| + t.Fatalf("Accept-Language %q: body = %q, want %q", want.lang, got, want.body) |
| + } |
| + } |
| + |
| + if n := hits.Load(); n != 2 { |
| + t.Fatalf("backend invoked %d times across 4 requests (en, en, fr, fr), want 2 — the repeat of each language must be served from cache", n) |
| + } |
| +} |
| + |
| +// The core regression: a repeat of an identical request against a |
| +// Vary-carrying response must hit. Here no Accept-Language is sent at all, so |
| +// both requests resolve to the same (empty-valued) variant. |
| +func TestServeHTTPVaryRepeatIdenticalRequestHitsCache(t *testing.T) { |
| + var hits atomic.Int64 |
| + e := varyBackendEdge(t, "repeat.example.com", &hits) |
| + const url = "http://repeat.example.com/event" |
| + |
| + if got := getLang(t, e, url, ""); got != "lang=" { |
| + t.Fatalf("first body = %q, want %q", got, "lang=") |
| + } |
| + if got := getLang(t, e, url, ""); got != "lang=" { |
| + t.Fatalf("second body = %q, want %q", got, "lang=") |
| + } |
| + if n := hits.Load(); n != 1 { |
| + t.Fatalf("backend invoked %d times across 2 identical requests, want 1 — the second must be a cache hit", n) |
| + } |
| +} |
| + |
| +// A crawler pumping distinct Vary values at one URL must not be able to fill |
| +// the cache with unreachable variants: the per-URL cap bounds how many a |
| +// single URL can occupy, evicting the oldest as new ones arrive. |
| +func TestServeHTTPVaryVariantCapBoundsEntriesPerURL(t *testing.T) { |
| + var hits atomic.Int64 |
| + e := varyBackendEdge(t, "flood.example.com", &hits) |
| + const url = "http://flood.example.com/event" |
| + |
| + langs := make([]string, 4*maxVaryVariantsPerURL) |
| + for i := range langs { |
| + langs[i] = fmt.Sprintf("xx-%d", i) |
| + getLang(t, e, url, langs[i]) |
| + } |
| + |
| + uk := CacheKey{Host: "flood.example.com", Method: "GET", Path: "/event"}.urlKey() |
| + e.cache.mu.Lock() |
| + n := len(e.cache.byURL[uk]) |
| + e.cache.mu.Unlock() |
| + if n > maxVaryVariantsPerURL { |
| + t.Fatalf("one URL holds %d cached variants, want at most %d", n, maxVaryVariantsPerURL) |
| + } |
| + |
| + // The newest variant survives; the oldest was evicted to make room. |
| + before := hits.Load() |
| + getLang(t, e, url, langs[len(langs)-1]) |
| + if hits.Load() != before { |
| + t.Fatal("the most recent Vary variant should still be cached") |
| + } |
| + getLang(t, e, url, langs[0]) |
| + if hits.Load() != before+1 { |
| + t.Fatal("the oldest Vary variant should have been evicted by the per-URL cap") |
| + } |
| +} |
| + |
| +// cacheProbeEdge wires an Edge whose backend answers with headers chosen per |
| +// path by hdr — the shape every "must not be cached" rule test needs. |
| +func cacheProbeEdge(t *testing.T, host string, hdr func(path string, h http.Header)) *Edge { |
| + t.Helper() |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + hdr(r.URL.Path, w.Header()) |
| + w.WriteHeader(200) |
| + w.Write([]byte("body for " + r.URL.Path)) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + return e |
| +} |
| + |
| +func cachedAt(e *Edge, host, path string) bool { |
| + _, ok := e.cache.Get(CacheKey{Host: host, Method: "GET", Path: path}) |
| + return ok |
| +} |
| + |
| +// Cache-Control: private means "for one visitor only" — caching it at the |
| +// shared edge and replaying it to everyone else is a cross-visitor leak. |
| +func TestServeHTTPRefusesCacheControlPrivate(t *testing.T) { |
| + e := cacheProbeEdge(t, "priv.example.com", func(path string, h http.Header) { |
| + if path == "/private" { |
| + h.Set("Cache-Control", "private, max-age=600") |
| + return |
| + } |
| + h.Set("Cache-Control", "max-age=600") |
| + }) |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://priv.example.com/private", nil)) |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://priv.example.com/shared", nil)) |
| + |
| + if cachedAt(e, "priv.example.com", "/private") { |
| + t.Fatal("a Cache-Control: private response must never be cached at the shared edge") |
| + } |
| + if !cachedAt(e, "priv.example.com", "/shared") { |
| + t.Fatal("an ordinary max-age response must still be cached") |
| + } |
| +} |
| + |
| +// A Set-Cookie response carries per-visitor state (a session, a CSRF token); |
| +// caching it would hand that cookie to every later visitor. |
| +func TestServeHTTPRefusesResponseWithSetCookie(t *testing.T) { |
| + e := cacheProbeEdge(t, "cookie2.example.com", func(path string, h http.Header) { |
| + h.Set("Cache-Control", "max-age=600") |
| + if path == "/session" { |
| + h.Set("Set-Cookie", "sid=abc123; Path=/; HttpOnly") |
| + } |
| + }) |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://cookie2.example.com/session", nil)) |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://cookie2.example.com/plain", nil)) |
| + |
| + if cachedAt(e, "cookie2.example.com", "/session") { |
| + t.Fatal("a response carrying Set-Cookie must never be cached — the cookie would be replayed to other visitors") |
| + } |
| + if !cachedAt(e, "cookie2.example.com", "/plain") { |
| + t.Fatal("a response without Set-Cookie must still be cached") |
| + } |
| +} |
| + |
| +// A response to an Authorization-bearing request is presumed private (RFC 9111 |
| +// §3.5) unless the app explicitly marks it public. |
| +func TestServeHTTPRefusesAuthorizedRequestUnlessPublic(t *testing.T) { |
| + e := cacheProbeEdge(t, "auth.example.com", func(path string, h http.Header) { |
| + if path == "/public" { |
| + h.Set("Cache-Control", "public, max-age=600") |
| + return |
| + } |
| + h.Set("Cache-Control", "max-age=600") |
| + }) |
| + authGet := func(path string) { |
| + req := httptest.NewRequest("GET", "http://auth.example.com"+path, nil) |
| + req.Header.Set("Authorization", "Bearer secret-token") |
| + e.ServeHTTP(httptest.NewRecorder(), req) |
| + } |
| + authGet("/account") |
| + authGet("/public") |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://auth.example.com/anon", nil)) |
| + |
| + if cachedAt(e, "auth.example.com", "/account") { |
| + t.Fatal("a response to an Authorization-bearing request must not be cached without an explicit Cache-Control: public") |
| + } |
| + if !cachedAt(e, "auth.example.com", "/public") { |
| + t.Fatal("Cache-Control: public overrides the Authorization concern (RFC 9111 §3.5) — this response should be cached") |
| + } |
| + if !cachedAt(e, "auth.example.com", "/anon") { |
| + t.Fatal("an unauthenticated request's ordinary max-age response must still be cached") |
| + } |
| +} |
| + |
| +// An app asking for a year of freshness would make a single lost purge |
| +// effectively permanent; the edge clamps every TTL to its own ceiling so a |
| +// missed purge always self-heals within a bounded window (spec decision 4). |
| +func TestServeHTTPClampsMaxAgeToCeiling(t *testing.T) { |
| + e := cacheProbeEdge(t, "ttl.example.com", func(path string, h http.Header) { |
| + h.Set("Cache-Control", "max-age=31536000") |
| + }) |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://ttl.example.com/event", nil)) |
| + |
| + resp, ok := e.cache.Get(CacheKey{Host: "ttl.example.com", Method: "GET", Path: "/event"}) |
| + if !ok { |
| + t.Fatal("expected the response to be cached") |
| + } |
| + if resp.TTL != DefaultCacheMaxTTL { |
| + t.Fatalf("cached TTL = %s, want it clamped to %s", resp.TTL, DefaultCacheMaxTTL) |
| + } |
| +} |
| + |
| +func TestServeHTTPRefusesVaryCookie(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + w.Header().Set("Vary", "Cookie") |
| + w.WriteHeader(200) |
| + w.Write([]byte("should not be cached")) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "cookie.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://cookie.example.com/event", nil)) |
| + |
| + if _, ok := e.cache.Get(CacheKey{Host: "cookie.example.com", Method: "GET", Path: "/event"}); ok { |
| + t.Fatal("a response Vary-ing on Cookie must never be cached — it would defeat per-visitor correctness") |
| + } |
| +} |
| + |
| +// cachedVariants counts every cached variant of one URL, whatever VaryValue |
| +// each was stored under. A "must not be cached" assertion has to ask this |
| +// question rather than Get with an empty VaryValue: a response that varies is |
| +// stored under a computed variant key, so the empty-key Get misses and the |
| +// assertion passes whether or not the refusal actually fired. |
| +func cachedVariants(e *Edge, host, path string) int { |
| + uk := CacheKey{Host: host, Method: "GET", Path: path}.urlKey() |
| + e.cache.mu.Lock() |
| + defer e.cache.mu.Unlock() |
| + return len(e.cache.byURL[uk]) |
| +} |
| + |
| +// getWith issues a GET carrying one extra header and returns the recorder. |
| +func getWith(t *testing.T, e *Edge, url, name, value string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + req := httptest.NewRequest("GET", url, nil) |
| + if name != "" { |
| + req.Header.Set(name, value) |
| + } |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, req) |
| + return rec |
| +} |
| + |
| +// RFC 9110 §5.3: one header field may arrive as several lines, and that is |
| +// semantically identical to a single comma-joined line — which is exactly what |
| +// http.Header.Add produces and what layered middleware emits in practice |
| +// (compression adds Vary: Accept-Encoding, the session layer separately adds |
| +// Vary: Cookie). http.Header.Get returns only the FIRST line, so reading Vary |
| +// that way made everything after it invisible: the Vary: Cookie refusal never |
| +// fired and the entry was keyed on Accept-Encoding alone, so one visitor's |
| +// cookie-scoped response was replayed to the next visitor. |
| +func TestServeHTTPRefusesVaryCookieSplitAcrossHeaderLines(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + // Two separate Add calls, the standard shape for a list-valued header. |
| + w.Header().Add("Vary", "Accept-Encoding") |
| + w.Header().Add("Vary", "Cookie") |
| + w.WriteHeader(200) |
| + w.Write([]byte("dashboard for " + r.Header.Get("Cookie"))) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "split.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + const url = "http://split.example.com/dashboard" |
| + |
| + alice := getWith(t, e, url, "Cookie", "sid=alice").Body.String() |
| + if alice != "dashboard for sid=alice" { |
| + t.Fatalf("alice body = %q, want %q", alice, "dashboard for sid=alice") |
| + } |
| + if n := cachedVariants(e, "split.example.com", "/dashboard"); n != 0 { |
| + // Errorf, not Fatalf: the replay assertion below is the live |
| + // consequence and is worth reporting in the same run. |
| + t.Errorf("%d variants cached for a response that varies on Cookie, want 0 — a multi-line Vary is still a Vary", n) |
| + } |
| + |
| + bob := getWith(t, e, url, "Cookie", "sid=bob").Body.String() |
| + if bob != "dashboard for sid=bob" { |
| + t.Fatalf("bob was served %q — one visitor's cookie-scoped response was replayed to another", bob) |
| + } |
| +} |
| + |
| +// The same root cause on Cache-Control: with max-age=600 on one line and |
| +// private on the next, Header.Get saw only max-age and the private response |
| +// was cached at the shared edge — the cross-visitor leak the private rule |
| +// exists to prevent. |
| +func TestServeHTTPRefusesCacheControlPrivateSplitAcrossHeaderLines(t *testing.T) { |
| + e := cacheProbeEdge(t, "splitcc.example.com", func(path string, h http.Header) { |
| + if path == "/private" { |
| + h.Add("Cache-Control", "max-age=600") |
| + h.Add("Cache-Control", "private") |
| + return |
| + } |
| + h.Set("Cache-Control", "max-age=600") |
| + }) |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://splitcc.example.com/private", nil)) |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://splitcc.example.com/shared", nil)) |
| + |
| + if n := cachedVariants(e, "splitcc.example.com", "/private"); n != 0 { |
| + t.Fatalf("%d entries cached for a response whose second Cache-Control line says private, want 0", n) |
| + } |
| + if !cachedAt(e, "splitcc.example.com", "/shared") { |
| + t.Fatal("an ordinary max-age response must still be cached") |
| + } |
| +} |
| + |
| +// The same root cause once more, on the Authorization rule: a response to an |
| +// Authorization-bearing request is private per RFC 9111 §3.5 unless |
| +// Cache-Control says public — and "public" on a second header line counts. |
| +func TestServeHTTPAuthorizedPublicSplitAcrossHeaderLines(t *testing.T) { |
| + e := cacheProbeEdge(t, "splitauth.example.com", func(path string, h http.Header) { |
| + h.Add("Cache-Control", "max-age=600") |
| + h.Add("Cache-Control", "public") |
| + }) |
| + req := httptest.NewRequest("GET", "http://splitauth.example.com/public", nil) |
| + req.Header.Set("Authorization", "Bearer secret-token") |
| + e.ServeHTTP(httptest.NewRecorder(), req) |
| + |
| + if !cachedAt(e, "splitauth.example.com", "/public") { |
| + t.Fatal("Cache-Control: public on a second header line still overrides the Authorization concern") |
| + } |
| +} |
| + |
| +// An app that emits Vary only for some requests — CORS middleware adding |
| +// Vary: Origin only when the request carries Origin, content negotiation |
| +// adding Vary: Accept only when it must — used to poison its own URL |
| +// permanently: the first Vary-carrying response recorded the Vary names for |
| +// that URL and nothing ever cleared them, so later non-Vary responses were |
| +// stored under the empty variant while every lookup kept resolving against the |
| +// remembered names. Store and lookup drifted apart, every ordinary request |
| +// missed, and on a hibernating route every miss is a wake — a remote, |
| +// unauthenticated wake-storm from one request. |
| +func TestServeHTTPConditionalVaryDoesNotPoisonURL(t *testing.T) { |
| + var hits atomic.Int64 |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + hits.Add(1) |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + if origin := r.Header.Get("Origin"); origin != "" { |
| + w.Header().Set("Vary", "Origin") |
| + w.Header().Set("Access-Control-Allow-Origin", origin) |
| + } |
| + w.WriteHeader(200) |
| + w.Write([]byte("page")) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "cors.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + const url = "http://cors.example.com/page" |
| + |
| + // One unauthenticated request on a cold URL, eliciting the Vary variant. |
| + getWith(t, e, url, "Origin", "https://crawler.example") |
| + |
| + // Then ordinary traffic: the first is a legitimate miss, every one after |
| + // it must be served from cache. |
| + var last *httptest.ResponseRecorder |
| + for i := 0; i < 10; i++ { |
| + last = getWith(t, e, url, "", "") |
| + if got := last.Body.String(); got != "page" { |
| + t.Fatalf("ordinary request %d body = %q, want %q", i, got, "page") |
| + } |
| + } |
| + if n := hits.Load(); n != 2 { |
| + t.Fatalf("backend invoked %d times for 1 Origin request + 10 ordinary ones, want 2 — after the first ordinary miss every ordinary request must hit the cache", n) |
| + } |
| + if acao := last.Header().Get("Access-Control-Allow-Origin"); acao != "" { |
| + t.Fatalf("ordinary request served the Origin variant (Access-Control-Allow-Origin: %s)", acao) |
| + } |
| +} |
| + |
| +// The same root cause on the Authorization rule, but in the read of the |
| +// REQUEST'S Authorization header rather than the response's Cache-Control: |
| +// http.Header.Get returns only the first line, so a request that splits |
| +// Authorization across two lines (an empty first line, the real credential on |
| +// the second — Go's HTTP server accepts exactly this from a client) made Get |
| +// see "" and the request looked unauthenticated. The RFC 9111 §3.5 refusal |
| +// never fired and a credentialed response got cached at the shared edge for |
| +// everyone. |
| +func TestServeHTTPRefusesAuthorizationSplitAcrossHeaderLines(t *testing.T) { |
| + e := cacheProbeEdge(t, "splitauthreq.example.com", func(path string, h http.Header) { |
| + if path == "/public" { |
| + h.Set("Cache-Control", "max-age=60, public") |
| + return |
| + } |
| + h.Set("Cache-Control", "max-age=60") |
| + }) |
| + |
| + req := httptest.NewRequest("GET", "http://splitauthreq.example.com/private", nil) |
| + req.Header.Add("Authorization", "") |
| + req.Header.Add("Authorization", "Bearer secret-token") |
| + e.ServeHTTP(httptest.NewRecorder(), req) |
| + |
| + if n := cachedVariants(e, "splitauthreq.example.com", "/private"); n != 0 { |
| + t.Fatalf("%d variants cached for a request whose Authorization arrived on a second header line, want 0 — a multi-line Authorization is still an Authorization", n) |
| + } |
| + |
| + // Positive control: the same split-Authorization request, but the |
| + // response says public — proving the refusal isn't just breaking caching |
| + // wholesale. |
| + pubReq := httptest.NewRequest("GET", "http://splitauthreq.example.com/public", nil) |
| + pubReq.Header.Add("Authorization", "") |
| + pubReq.Header.Add("Authorization", "Bearer secret-token") |
| + e.ServeHTTP(httptest.NewRecorder(), pubReq) |
| + |
| + if !cachedAt(e, "splitauthreq.example.com", "/public") { |
| + t.Fatal("Cache-Control: public must still be cached even with a split-line Authorization present") |
| + } |
| +} |
| + |
| +// RFC 9111 §4.1 / RFC 9110 §12.5.5: Vary: * means the response is NEVER |
| +// reusable from a shared cache — it signals that the response depends on |
| +// something not captured by request headers at all. The token-scanning loop |
| +// treated "*" as if it were an ordinary header name: varyValue computed the |
| +// constant string "*=;" for every request regardless of who they were, so one |
| +// variant was stored and replayed to everyone. |
| +func TestServeHTTPRefusesVaryStar(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + w.Header().Set("Vary", "*") |
| + w.WriteHeader(200) |
| + w.Write([]byte("dashboard for " + r.Header.Get("Cookie"))) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "varystar.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + const url = "http://varystar.example.com/dashboard" |
| + |
| + alice := getWith(t, e, url, "Cookie", "sid=alice").Body.String() |
| + if alice != "dashboard for sid=alice" { |
| + t.Fatalf("alice body = %q, want %q", alice, "dashboard for sid=alice") |
| + } |
| + if n := cachedVariants(e, "varystar.example.com", "/dashboard"); n != 0 { |
| + // Errorf, not Fatalf: the replay assertion below is the live |
| + // consequence and is worth reporting in the same run. |
| + t.Errorf("%d variants cached for a response carrying Vary: *, want 0 — Vary: * means never reusable from a shared cache", n) |
| + } |
| + |
| + bob := getWith(t, e, url, "Cookie", "sid=bob").Body.String() |
| + if bob != "dashboard for sid=bob" { |
| + t.Fatalf("bob was served %q — one visitor's response was replayed to another despite Vary: *", bob) |
| + } |
| +} |
| + |
| +// hijackableRecorder embeds httptest.ResponseRecorder (which deliberately |
| +// does NOT implement http.Hijacker) and adds a no-op Hijack, standing in for |
| +// a real connection's ResponseWriter — the minimum shape needed to prove |
| +// ServeHTTP passes an Upgrade request's ResponseWriter through untouched |
| +// instead of wrapping it in recordingResponseWriter (which pre-fix had no |
| +// Hijack, breaking the stdlib reverse proxy's WebSocket-upgrade path). |
| +type hijackableRecorder struct { |
| + *httptest.ResponseRecorder |
| +} |
| + |
| +func (h *hijackableRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { |
| + return nil, nil, nil |
| +} |
| + |
| +// hijackCheckingActivator fails the test if the ResponseWriter it's handed |
| +// doesn't satisfy http.Hijacker — proving ServeHTTP special-cased the |
| +// Upgrade request instead of wrapping it in recordingResponseWriter. |
| +type hijackCheckingActivator struct{ t *testing.T } |
| + |
| +func (h hijackCheckingActivator) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + if _, ok := w.(http.Hijacker); !ok { |
| + h.t.Fatal("ResponseWriter passed to activator for an Upgrade request does not implement http.Hijacker — recordingResponseWriter must not wrap upgrade requests") |
| + } |
| + return true |
| +} |
| + |
| +func (h hijackCheckingActivator) RetireExcept(keep map[string]bool) {} |
| + |
| +// TestServeHTTPUpgradeRequestBypassesRecording proves a Connection: Upgrade |
| +// request (a WebSocket handshake) to a hibernating route reaches the |
| +// activator with a still-Hijacker-capable ResponseWriter — i.e. ServeHTTP |
| +// never wraps it in recordingResponseWriter, which would break the stdlib |
| +// reverse proxy's hijack-based upgrade path and, with it, the open WebSocket |
| +// the activator relies on to keep the instance's idle clock from firing. |
| +func TestServeHTTPUpgradeRequestBypassesRecording(t *testing.T) { |
| + reg := openReg(t) |
| + // Addr is a bogus, never-dialed address — this test only needs to reach |
| + // the activator, never the real backend. |
| + if err := reg.Upsert(registry.Route{Host: "ws.example.com", Kind: "instance", Addr: "127.0.0.1:1", Status: "ready", Hibernate: true}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, hijackCheckingActivator{t: t}) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + |
| + req := httptest.NewRequest("GET", "http://ws.example.com/socket", nil) |
| + req.Header.Set("Connection", "Upgrade") |
| + req.Header.Set("Upgrade", "websocket") |
| + rec := &hijackableRecorder{ResponseRecorder: httptest.NewRecorder()} |
| + |
| + e.ServeHTTP(rec, req) |
| +} |
| + |
| +// activatorFunc adapts a plain function to the Activator interface, for |
| +// tests that want custom wake-counting behavior without a full fake type. |
| +type activatorFunc func(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool |
| + |
| +func (f activatorFunc) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + return f(w, r, rt, proxy) |
| +} |
| + |
| +func (f activatorFunc) RetireExcept(keep map[string]bool) { |
| + // No-op for tests that don't need retirement tracking. |
| +} |
| + |
| +// TestCacheBeforeWakeFullCycleOverPurgeSocket is the full cycle with the REAL |
| +// purge channel in it: the same cold-miss → cached-repeat → purge → fresh |
| +// sequence as TestCacheBeforeWakeFullCycle, except the purge arrives the way a |
| +// spawned instance actually sends it — a JSON POST /purge over the unix |
| +// control socket ListenPurge binds (the socket whose path the activator hands |
| +// the child as CARLOS_CONTROL_SOCKET). The sibling test below drives the |
| +// in-process cache API, which cannot catch a broken socket, encoding, or |
| +// host-scoping; this one proves the whole channel from "instance calls purge" |
| +// to "edge cache entry is gone." |
| +func TestCacheBeforeWakeFullCycleOverPurgeSocket(t *testing.T) { |
| + body := "version 1" |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + w.WriteHeader(200) |
| + w.Write([]byte(body)) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + wakes := 0 |
| + act := activatorFunc(func(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + wakes++ |
| + proxy.ServeHTTP(w, r) |
| + return true |
| + }) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "sock-cycle.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready", Hibernate: true}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, act) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes) |
| + e.EnableCache(cache) |
| + |
| + // The control socket the activator would create for this instance and |
| + // hand it as CARLOS_CONTROL_SOCKET. |
| + controlSocket := filepath.Join(t.TempDir(), "instance.sock.control") |
| + pl, err := ListenPurge(controlSocket, "sock-cycle.example.com", cache) |
| + if err != nil { |
| + if errors.Is(err, syscall.EPERM) || strings.Contains(err.Error(), "operation not permitted") { |
| + t.Skipf("unix-socket listeners not permitted in this environment: %v", err) |
| + } |
| + t.Fatalf("ListenPurge: %v", err) |
| + } |
| + defer pl.Close() |
| + |
| + get := func() string { |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("GET", "http://sock-cycle.example.com/event", nil)) |
| + return rec.Body.String() |
| + } |
| + |
| + if got := get(); got != "version 1" { |
| + t.Fatalf("first request body = %q, want %q", got, "version 1") |
| + } |
| + if wakes != 1 { |
| + t.Fatalf("expected exactly 1 wake for the cold request, got %d", wakes) |
| + } |
| + if got := get(); got != "version 1" || wakes != 1 { |
| + t.Fatalf("second request body = %q wakes = %d, want %q and no second wake", got, wakes, "version 1") |
| + } |
| + |
| + // The write happens, and the instance purges its own page over the control |
| + // socket — exactly the request a real instance makes. |
| + body = "version 2" |
| + payload, _ := json.Marshal(purgeRequest{Path: "/event", Query: ""}) |
| + resp, err := dialPurgeSocket(t, controlSocket).Post("http://unix/purge", "application/json", bytes.NewReader(payload)) |
| + if err != nil { |
| + t.Fatalf("purge POST over control socket: %v", err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("purge status = %d, want 200", resp.StatusCode) |
| + } |
| + |
| + if got := get(); got != "version 2" { |
| + t.Fatalf("post-purge request body = %q, want %q — the purge never reached the cache through the socket", got, "version 2") |
| + } |
| + if wakes != 2 { |
| + t.Fatalf("expected the post-purge request to miss and wake once more, wakes = %d", wakes) |
| + } |
| +} |
| + |
| +// TestCacheBeforeWakeFullCycle exercises the sequence the whole feature |
| +// exists for: a cold miss wakes the instance and populates the cache; a |
| +// repeat request hits the cache and does NOT wake the instance again; a |
| +// write's purge call makes the next request miss and see fresh content. |
| +func TestCacheBeforeWakeFullCycle(t *testing.T) { |
| + body := "version 1" |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + w.WriteHeader(200) |
| + w.Write([]byte(body)) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + wakes := 0 |
| + act := activatorFunc(func(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + wakes++ |
| + proxy.ServeHTTP(w, r) |
| + return true |
| + }) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "cycle.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready", Hibernate: true}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, act) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes) |
| + e.EnableCache(cache) |
| + |
| + get := func() string { |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("GET", "http://cycle.example.com/event", nil)) |
| + return rec.Body.String() |
| + } |
| + |
| + if got := get(); got != "version 1" { |
| + t.Fatalf("first request body = %q, want %q", got, "version 1") |
| + } |
| + if wakes != 1 { |
| + t.Fatalf("expected exactly 1 wake for the cold request, got %d", wakes) |
| + } |
| + |
| + if got := get(); got != "version 1" { |
| + t.Fatalf("second request body = %q, want %q", got, "version 1") |
| + } |
| + if wakes != 1 { |
| + t.Fatalf("expected the second request to hit cache without waking again, wakes = %d", wakes) |
| + } |
| + |
| + // Simulate the write + purge an instance does after a real change. |
| + body = "version 2" |
| + cache.PurgeURL("cycle.example.com", "/event", "") |
| + |
| + if got := get(); got != "version 2" { |
| + t.Fatalf("post-purge request body = %q, want %q", got, "version 2") |
| + } |
| + if wakes != 2 { |
| + t.Fatalf("expected the post-purge request to miss and wake once more, wakes = %d", wakes) |
| + } |
| +} |
diff --git a/internal/edge/purge.go b/internal/edge/purge.go| new file mode 100644 |
| index 000000000..2f931ef79 |
| --- /dev/null |
| +++ b/internal/edge/purge.go |
| @@ -0,0 +1,85 @@ |
| +package edge |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "net" |
| + "net/http" |
| + "os" |
| + "sync" |
| + "syscall" |
| +) |
| + |
| +// PurgeListener is a per-instance, local-only channel for invalidating |
| +// that instance's cache entries right after a write — see |
| +// docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md §3-4. |
| +// It is deliberately NOT reachable over the network: an externally |
| +// reachable purge endpoint would let anyone manufacture wake storms by |
| +// force-purging popular pages, exactly what this feature exists to |
| +// prevent. |
| +type PurgeListener struct { |
| + ln net.Listener |
| + srv *http.Server |
| +} |
| + |
| +type purgeRequest struct { |
| + Path string `json:"path"` |
| + Query string `json:"query"` |
| +} |
| + |
| +// listenMu guards the umask-wrapped net.Listen call in ListenPurge. Since |
| +// syscall.Umask is process-wide and not per-goroutine, concurrent ListenPurge |
| +// calls (e.g., in carlos agent spawning multiple instances) must serialize |
| +// around the umask change to avoid race windows where the socket is created |
| +// world-connectable before being chmod'd to 0600. |
| +var listenMu sync.Mutex |
| + |
| +// ListenPurge binds socketPath (removing any stale file left behind by a |
| +// crashed previous instance) and serves purge requests scoped to host — |
| +// host is fixed here, never taken from the request, so a compromised or |
| +// buggy instance can only ever purge its own cache entries. |
| +func ListenPurge(socketPath, host string, cache *Cache) (*PurgeListener, error) { |
| + if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return nil, err |
| + } |
| + |
| + // Create the socket with umask 0177 to prevent a world-connectable race window. |
| + // syscall.Umask is process-wide, so we serialize around it with listenMu |
| + // to protect against concurrent ListenPurge calls (e.g., in carlos agent |
| + // spawning multiple instances). |
| + listenMu.Lock() |
| + prevUmask := syscall.Umask(0177) |
| + ln, err := net.Listen("unix", socketPath) |
| + syscall.Umask(prevUmask) |
| + listenMu.Unlock() |
| + |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + // Defensive chmod as backstop (no longer load-bearing, but kept for defense-in-depth). |
| + if err := os.Chmod(socketPath, 0600); err != nil { |
| + ln.Close() |
| + return nil, err |
| + } |
| + |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("POST /purge", func(w http.ResponseWriter, r *http.Request) { |
| + var req purgeRequest |
| + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| + http.Error(w, "bad request", http.StatusBadRequest) |
| + return |
| + } |
| + cache.PurgeURL(host, req.Path, req.Query) |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + |
| + srv := &http.Server{Handler: mux} |
| + p := &PurgeListener{ln: ln, srv: srv} |
| + go srv.Serve(ln) //nolint:errcheck // Close() below triggers the expected ErrServerClosed |
| + return p, nil |
| +} |
| + |
| +func (p *PurgeListener) Close() error { |
| + return p.srv.Close() |
| +} |
diff --git a/internal/edge/purge_test.go b/internal/edge/purge_test.go| new file mode 100644 |
| index 000000000..6d126fa2f |
| --- /dev/null |
| +++ b/internal/edge/purge_test.go |
| @@ -0,0 +1,109 @@ |
| +package edge |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "net" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func dialPurgeSocket(t *testing.T, socketPath string) *http.Client { |
| + t.Helper() |
| + return &http.Client{ |
| + Transport: &http.Transport{ |
| + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return net.Dial("unix", socketPath) |
| + }, |
| + }, |
| + } |
| +} |
| + |
| +func TestListenPurgeRemovesCachedEntry(t *testing.T) { |
| + dir := t.TempDir() |
| + sock := filepath.Join(dir, "instance.sock") |
| + cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes) |
| + cache.Put(CacheKey{Host: "a.example", Method: "GET", Path: "/event"}, "", &CachedResponse{ |
| + StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute, |
| + }) |
| + |
| + pl, err := ListenPurge(sock, "a.example", cache) |
| + if err != nil { |
| + t.Fatalf("ListenPurge: %v", err) |
| + } |
| + defer pl.Close() |
| + |
| + body, _ := json.Marshal(map[string]string{"path": "/event", "query": ""}) |
| + client := dialPurgeSocket(t, sock) |
| + resp, err := client.Post("http://unix/purge", "application/json", bytes.NewReader(body)) |
| + if err != nil { |
| + t.Fatalf("purge POST: %v", err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("purge status = %d, want 200", resp.StatusCode) |
| + } |
| + |
| + if _, ok := cache.Get(CacheKey{Host: "a.example", Method: "GET", Path: "/event"}); ok { |
| + t.Fatal("expected entry to be purged") |
| + } |
| +} |
| + |
| +func TestListenPurgeCannotAffectAnotherHost(t *testing.T) { |
| + dir := t.TempDir() |
| + sock := filepath.Join(dir, "instance.sock") |
| + cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes) |
| + cache.Put(CacheKey{Host: "other.example", Method: "GET", Path: "/event"}, "", &CachedResponse{ |
| + StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute, |
| + }) |
| + |
| + pl, err := ListenPurge(sock, "a.example", cache) // bound to a.example, not other.example |
| + if err != nil { |
| + t.Fatalf("ListenPurge: %v", err) |
| + } |
| + defer pl.Close() |
| + |
| + body, _ := json.Marshal(map[string]string{"path": "/event", "query": ""}) |
| + client := dialPurgeSocket(t, sock) |
| + resp, _ := client.Post("http://unix/purge", "application/json", bytes.NewReader(body)) |
| + resp.Body.Close() |
| + |
| + if _, ok := cache.Get(CacheKey{Host: "other.example", Method: "GET", Path: "/event"}); !ok { |
| + t.Fatal("a purge listener bound to a.example must never affect other.example's cache entries") |
| + } |
| +} |
| + |
| +func TestListenPurgeSocketFileIsOwnerOnly(t *testing.T) { |
| + dir := t.TempDir() |
| + sock := filepath.Join(dir, "instance.sock") |
| + pl, err := ListenPurge(sock, "a.example", NewCache(10, 1<<20)) |
| + if err != nil { |
| + t.Fatalf("ListenPurge: %v", err) |
| + } |
| + defer pl.Close() |
| + |
| + fi, err := os.Stat(sock) |
| + if err != nil { |
| + t.Fatalf("stat socket: %v", err) |
| + } |
| + if mode := fi.Mode().Perm(); mode != 0600 { |
| + t.Fatalf("socket mode = %o, want 0600", mode) |
| + } |
| +} |
| + |
| +func TestListenPurgeRemovesStaleSocketFile(t *testing.T) { |
| + dir := t.TempDir() |
| + sock := filepath.Join(dir, "instance.sock") |
| + if err := os.WriteFile(sock, []byte("stale"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + pl, err := ListenPurge(sock, "a.example", NewCache(10, 1<<20)) |
| + if err != nil { |
| + t.Fatalf("ListenPurge should clean up a stale socket file, got: %v", err) |
| + } |
| + pl.Close() |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 0290fa70d..07a1d6f55 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -111,6 +111,12 @@ func RunAgent(args []string) { |
| log.Fatalf("agent: open registry %s: %v", regPath, err) |
| } |
| + // Constructed once here so the activator's ExecBackend (purges) and the |
| + // edge (reads + serves cached hits) share the very same store — see the |
| + // package doc comment above about caching applying before the |
| + // hibernation branch. |
| + cache := edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes) |
| + |
| cfg, err := activator.ConfigFromEnv() |
| if err != nil { |
| log.Fatalf("agent: %v", err) |
| @@ -127,6 +133,7 @@ func RunAgent(args []string) { |
| Cfg: *cfg, |
| OptDir: release.OptDir(), |
| DataDir: dd, |
| + Cache: cache, |
| }, |
| Unit: &activator.UnitBackend{OptDir: release.OptDir()}, |
| }, reg) // the registry satisfies activator.DueStore directly |
| @@ -141,7 +148,9 @@ func RunAgent(args []string) { |
| go domainsLoop(reg, peekAcmeCache(args)) |
| // The same serving loop as `carlos edge`, with the activator injected. |
| - edge.RunWithActivator(args, act) |
| + // Caching applies here too: cached hits still avoid the activator, which |
| + // is the entire point of checking the cache before the hibernation branch. |
| + edge.RunWithActivator(args, act, cache) |
| // The edge has drained; take the children down too (they're this process's |
| // children — orphaning them would strand their leases until TTL expiry). |