plan: edge cache-before-wake, platform side
Five-task TDD plan implementing the spec's platform-repo half: the LRU+TTL response cache, the Edge.ServeHTTP insertion point with Vary-aware keys, the local per-instance purge listener, and wiring it into ExecBackend's spawn path via a new --control-socket flag derived from rt.Socket (no registry schema change needed). Grounded against the real edge.go/backend_exec.go/agent.go code, including a construction-order fix (RunAgent now builds the Cache first and shares it with both ExecBackend and edge.RunWithActivator) discovered while tracing how edge and activator actually compose in carlos agent.
1 file changed,
+1363
−0
docs/superpowers/plans/2026-08-02-edge-cache-before-wake-platform.md+1363 −0
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. |