carlos / platform Public

edge: check the response cache before waking a hibernating instance

Wires Task 1's Cache into Edge.ServeHTTP: a cache hit for a cacheable
GET/HEAD is answered before the hibernating check ever runs, so bot/
crawler/repeat traffic never invokes the activator. A cache miss is
recorded via a passthrough ResponseWriter wrapper and stored only when
it's a 2xx with a positive Cache-Control max-age and no Vary: Cookie.

Also updates RunWithActivator's signature (third *Cache param) and its
other call site in internal/host/agent.go, which the task brief's step
3 didn't mention but which the compiler requires.
Paul Campbell 22c2f6245bd49fe7afc36e3066b3e9ebc2d69fb3 parent 7bbc515
3 files changed, +354 −5
  • internal/edge/edge.go +188 −4
  • internal/edge/edge_test.go +163 −0
  • internal/host/agent.go +3 −1
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 78be14db8..7d5479529 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -28,6 +28,8 @@ import (
"os"
"os/signal"
"path/filepath"
+ "strconv"
+ "strings"
"sync/atomic"
"syscall"
"time"
@@ -38,6 +40,16 @@ 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
+)
+
// 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 +87,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 +133,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 +254,174 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
e.unknownHost(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}
+ if resp, hit := e.cache.Get(key); 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)
}
+// 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 no
+// Vary: Cookie (varying by cookie would defeat correctness per-visitor).
+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
+}
+
+// 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)
+}
+
// 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 +555,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 +586,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..f82339d90 100644
--- a/internal/edge/edge_test.go
+++ b/internal/edge/edge_test.go
@@ -472,3 +472,166 @@ 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")
+ }
+}
+
+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")
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index abdc6a214..a47d0fc4a 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -140,7 +140,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, edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes))
// The edge has drained; take the children down too (they're this process's
// children — orphaning them would strand their leases until TTL expiry).