| 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. |