carlos / platform Public

edge: compress responses at the client-write boundary

One wrap site in ServeHTTP after the upgrade branch and before the
cache check, so cache hits, proxied misses, static trees and the status
host all pass through it. The cache keeps storing identity bodies and
the usage meter counts the compressed byte count.

CARLOS_EDGE_GZIP=off disables it without a binary roll.

Two catches the gate found. Trailers: a header set after WriteHeader
lands on the private map, which syncHeader has already been past, so
the wrapper ate them — TestPurgeHeaderInTrailerIsNotHonoured went red.
finish() now syncs late additions down, skipping the fields the
compression edits own. And canonical for ETag is "Etag", so the
exclusion list's "ETag" literal matched nothing and copied the
identity validator back over the weakened one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoqzkUVahW5ykubsBQitxc
Paul Campbell 15c47ac588cc3b6dfbb46e1b1429ecbfb1a2d07c parent 73ba627
3 files changed, +264 −1
  • internal/edge/compress.go +50 −0
  • internal/edge/compress_test.go +191 −0
  • internal/edge/edge.go +23 −1
diff --git a/internal/edge/compress.go b/internal/edge/compress.go
index eb11eaaa5..e460c0401 100644
--- a/internal/edge/compress.go
+++ b/internal/edge/compress.go
@@ -16,6 +16,8 @@ import (
"io"
"net"
"net/http"
+ "net/textproto"
+ "os"
"strconv"
"strings"
"sync"
@@ -266,6 +268,7 @@ func (g *gzipWriter) finish() {
g.syncHeader()
return
}
+ g.syncTrailers()
if g.gz == nil {
return
}
@@ -275,6 +278,41 @@ func (g *gzipWriter) finish() {
g.gz = nil
}
+// syncTrailers copies LATE additions to the private map down to the real
+// writer. net/http moves a header set after WriteHeader into the trailer block
+// when it was announced in Trailer (or carries http.TrailerPrefix) — and every
+// such Set landed on the private map, where syncHeader has already been and
+// gone. Without this the wrapper silently eats trailers, which
+// purge_header.go's contract names as a channel apps may legitimately use.
+//
+// The fields the compression edits own are skipped while compressing: the
+// private map holds the IDENTITY representation's versions of them, and
+// copying those down would undo the Content-Encoding, restore a Content-Length
+// that describes the wrong bytes, drop our Vary, and re-strengthen the ETag.
+func (g *gzipWriter) syncTrailers() {
+ down := g.ResponseWriter.Header()
+ for k, v := range g.hdr {
+ if g.gz != nil && compressionOwned(k) {
+ continue
+ }
+ down[k] = v
+ }
+}
+
+// compressionOwned reports whether a header name is one the compression edits
+// control on the real writer's map. It is called with RAW map keys, which are
+// canonical — and canonical for ETag is "Etag". Spelling it "ETag" here (the
+// form the RFC and Header.Get use, since Get canonicalizes its argument)
+// matched nothing, and the identity ETag was copied back over the weakened
+// one. TestGzipWriterForwardsTrailers is what holds this.
+func compressionOwned(name string) bool {
+ switch textproto.CanonicalMIMEHeaderKey(name) {
+ case "Content-Encoding", "Content-Length", "Vary", "Etag":
+ return true
+ }
+ return false
+}
+
// Flush pushes the compressor's buffered bytes out and then flushes the writer
// below — needed so a streamed response reaches the client incrementally
// instead of buffering until the handler returns.
@@ -299,3 +337,15 @@ func (g *gzipWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
}
return hj.Hijack()
}
+
+// gzipEnabled reads the CARLOS_EDGE_GZIP kill switch. Compression is ON unless
+// the variable names a false value — the rollback path is an env change and a
+// restart, not a platform binary roll. Matches the CARLOS_* env idiom in
+// certcache.go and dns.go.
+func gzipEnabled() bool {
+ switch strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_EDGE_GZIP"))) {
+ case "off", "false", "0", "no":
+ return false
+ }
+ return true
+}
diff --git a/internal/edge/compress_test.go b/internal/edge/compress_test.go
index 2e37a3050..08ab2eb52 100644
--- a/internal/edge/compress_test.go
+++ b/internal/edge/compress_test.go
@@ -9,6 +9,8 @@ import (
"net/http/httptest"
"strings"
"testing"
+
+ "github.com/carlosframework/platform/internal/registry"
)
// TestAcceptsGzip pins the q-value rules. The two that matter in production
@@ -356,3 +358,192 @@ func TestGzipWriterForwardsHijack(t *testing.T) {
t.Fatal("Hijack did not reach the writer below")
}
}
+
+// edgeWithBackend builds an Edge with one proxied route, "app.test", pointed
+// at h. Driven with httptest.NewRecorder + httptest.NewRequest rather than a
+// real client: http.Transport adds its own Accept-Encoding and transparently
+// decompresses what comes back, which would hide exactly what these tests are
+// checking.
+func edgeWithBackend(t *testing.T, h http.Handler) *Edge {
+ t.Helper()
+ backend := httptest.NewServer(h)
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "app.test", Kind: "service", Status: "ready",
+ Addr: backend.Listener.Addr().String(),
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ return e
+}
+
+// cssBackend answers every request with a cacheable stylesheet of n bytes.
+func cssBackend(n int) http.Handler {
+ body := strings.Repeat("a{color:red}\n", n/13+1)[:n]
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/css")
+ w.Header().Set("Cache-Control", "max-age=600")
+ io.WriteString(w, body)
+ })
+}
+
+// TestEdgeCompressesProxiedAssets is the payoff: an app ships identity bytes
+// and the edge puts them on the wire compressed.
+func TestEdgeCompressesProxiedAssets(t *testing.T) {
+ e := edgeWithBackend(t, cssBackend(4096))
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css"))
+
+ if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
+ t.Fatalf("Content-Encoding = %q, want gzip", got)
+ }
+ if got := rec.Header().Get("Vary"); !strings.Contains(got, "Accept-Encoding") {
+ t.Errorf("Vary = %q, want it to name Accept-Encoding", got)
+ }
+ zr, err := gzip.NewReader(rec.Body)
+ if err != nil {
+ t.Fatalf("body is not gzip: %v", err)
+ }
+ got, err := io.ReadAll(zr)
+ if err != nil {
+ t.Fatalf("read gzip body: %v", err)
+ }
+ if len(got) != 4096 {
+ t.Fatalf("decompressed %d bytes, want 4096", len(got))
+ }
+}
+
+// TestEdgeCacheStoresIdentityBodies is the invariant the whole writer ordering
+// exists to hold. The recorder sits ABOVE the compressor, so what lands in the
+// cache is the identity body with the app's own headers — one variant, not one
+// per encoding — and a client that cannot decode gzip is still served correctly
+// from that same entry.
+func TestEdgeCacheStoresIdentityBodies(t *testing.T) {
+ e := edgeWithBackend(t, cssBackend(4096))
+ e.EnableCache(NewCache(10, 1<<20))
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) // miss
+ if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
+ t.Fatalf("miss: Content-Encoding = %q, want gzip", got)
+ }
+
+ stored, ok := e.cache.get(CacheKey{Host: "app.test", Method: "GET", Path: "/tokens.css"})
+ if !ok {
+ t.Fatal("the response was not cached — this test cannot say anything about what was stored")
+ }
+ if ce := stored.Header.Get("Content-Encoding"); ce != "" {
+ t.Errorf("cached entry carries Content-Encoding %q: an identity body labelled gzip, served to every later visitor as an undecodable response", ce)
+ }
+ if v := stored.Header.Get("Vary"); strings.Contains(v, "Accept-Encoding") {
+ t.Errorf("cached entry carries Vary %q: the edge's own Vary belongs below the recorder, not in the cache key", v)
+ }
+ if len(stored.Body) != 4096 {
+ t.Errorf("cached body is %d bytes, want the 4096-byte identity body", len(stored.Body))
+ }
+
+ hit := httptest.NewRecorder()
+ e.ServeHTTP(hit, gzipReq("GET", "http://app.test/tokens.css"))
+ if got := hit.Header().Get("Content-Encoding"); got != "gzip" {
+ t.Fatalf("cache hit: Content-Encoding = %q, want gzip — a hit is a delivery like any other", got)
+ }
+ zr, err := gzip.NewReader(hit.Body)
+ if err != nil {
+ t.Fatalf("cache hit body is not gzip: %v", err)
+ }
+ if b, err := io.ReadAll(zr); err != nil || len(b) != 4096 {
+ t.Fatalf("cache hit decompressed to %d bytes (err %v), want 4096", len(b), err)
+ }
+
+ plain := httptest.NewRecorder()
+ e.ServeHTTP(plain, httptest.NewRequest("GET", "http://app.test/tokens.css", nil))
+ if got := plain.Header().Get("Content-Encoding"); got != "" {
+ t.Errorf("identity client got Content-Encoding %q from the shared entry", got)
+ }
+ if plain.Body.Len() != 4096 {
+ t.Errorf("identity client got %d bytes, want the 4096-byte identity body", plain.Body.Len())
+ }
+}
+
+// TestEdgeMetersCompressedBytes: a tenant is billed for what crossed the wire.
+// The counter sits BELOW the compressor and finish() runs before Record — the
+// equality below is what pins that defer order, since gzip buffers until Close
+// and a reversed order would meter a fraction of the bytes.
+func TestEdgeMetersCompressedBytes(t *testing.T) {
+ e := edgeWithBackend(t, cssBackend(8192))
+ m := NewUsageMeter()
+ e.EnableUsage(m)
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css"))
+
+ row, ok := m.Drain()["app.test"]
+ if !ok {
+ t.Fatal("no usage row for app.test")
+ }
+ if row.BytesOut != int64(rec.Body.Len()) {
+ t.Fatalf("metered %d bytes, client received %d — finish() must run before Record (defer order) or the trailer is missed", row.BytesOut, rec.Body.Len())
+ }
+ if row.BytesOut >= 8192 {
+ t.Fatalf("metered %d bytes for an 8192-byte identity body — the meter is counting bytes the edge did not send", row.BytesOut)
+ }
+}
+
+// TestEdgeGzipKillSwitch: CARLOS_EDGE_GZIP=off is the rollback that needs no
+// redeploy, so it must produce today's bytes exactly.
+func TestEdgeGzipKillSwitch(t *testing.T) {
+ t.Setenv("CARLOS_EDGE_GZIP", "off")
+ e := edgeWithBackend(t, cssBackend(4096))
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css"))
+
+ if got := rec.Header().Get("Content-Encoding"); got != "" {
+ t.Errorf("Content-Encoding = %q with the kill switch off, want empty", got)
+ }
+ if got := rec.Header().Get("Vary"); strings.Contains(got, "Accept-Encoding") {
+ t.Errorf("Vary = %q with the kill switch off — nothing may hint at a variant that is never produced", got)
+ }
+ if rec.Body.Len() != 4096 {
+ t.Errorf("body = %d bytes, want the 4096-byte identity body", rec.Body.Len())
+ }
+}
+
+// TestGzipWriterForwardsTrailers: net/http moves a header set AFTER
+// WriteHeader into the trailer block when it was announced in Trailer. Every
+// such Set lands on the private map, where syncHeader has already been and
+// gone — without a late sync the wrapper silently eats trailers, and
+// purge_header.go's contract names them as a channel apps may use.
+func TestGzipWriterForwardsTrailers(t *testing.T) {
+ rec := httptest.NewRecorder()
+ g := newGzipWriter(rec, gzipReq("GET", "http://app.test/"))
+ g.Header().Set("Trailer", "Carlos-Purge")
+ g.Header().Set("Content-Type", "text/html")
+ g.Header().Set("ETag", `"strong"`)
+ g.WriteHeader(http.StatusOK)
+ io.WriteString(g, strings.Repeat("<p>hi</p>", 200))
+ g.Header().Set("Carlos-Purge", "/trailed") // after the body: a trailer
+ g.finish()
+
+ if got := rec.Header().Get("Carlos-Purge"); got != "/trailed" {
+ t.Fatalf("trailer Carlos-Purge = %q, want /trailed — the private map swallowed it", got)
+ }
+ // The late sync must not undo the compression edits by copying the
+ // identity representation's fields back down.
+ if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
+ t.Errorf("Content-Encoding = %q after the trailer sync, want gzip", got)
+ }
+ if got := rec.Header().Get("ETag"); got != `W/"strong"` {
+ t.Errorf("ETag = %q after the trailer sync, want the weakened form", got)
+ }
+ if got := rec.Header().Get("Content-Length"); got != "" {
+ t.Errorf("Content-Length = %q after the trailer sync, want it still dropped", got)
+ }
+ if got := rec.Header().Get("Vary"); got != "Accept-Encoding" {
+ t.Errorf("Vary = %q after the trailer sync, want Accept-Encoding", got)
+ }
+}
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 151be4f35..12c148917 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -157,6 +157,10 @@ type Edge struct {
// quietly 503s. Never stopped in production (the edge lives as long as the
// process); tests Stop it so probe goroutines don't outlive them.
probers *proberSet
+ // gzipOn is the CARLOS_EDGE_GZIP kill switch, read once in New. When
+ // false, ServeHTTP never constructs a gzipWriter and the writer stack is
+ // byte-for-byte what it was before compression existed (compress.go).
+ gzipOn bool
}
// upstream is one served host: the handler for its backend, plus — when the
@@ -179,7 +183,7 @@ type upstream struct {
// New builds an Edge over reg and performs the startup reload. act may be nil
// (no hibernation).
func New(reg *registry.Registry, act Activator) (*Edge, error) {
- e := &Edge{reg: reg, act: act, probers: newProberSet()}
+ e := &Edge{reg: reg, act: act, probers: newProberSet(), gzipOn: gzipEnabled()}
if err := e.Reload(); err != nil {
return nil, err
}
@@ -801,6 +805,24 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
+ // Response compression (compress.go). Placed AFTER the upgrade branch, so
+ // an upgrading request keeps the exact writer stack it has always had —
+ // purge_header.go's contract records what wrapping the raw ResponseWriter
+ // on that path has already cost this package — and BEFORE the cache check,
+ // so a cache hit compresses on replay like any other delivery.
+ //
+ // The recorders constructed below wrap THIS writer, so they record
+ // identity bytes and the cache keeps storing identity bodies; the usage
+ // meter's countingWriter is below it, so the metered count is the
+ // compressed one. This defer is registered after the meter's, so LIFO
+ // closes the gzip stream — trailer included — before Record reads it.
+ if e.gzipOn {
+ if gw := newGzipWriter(w, r); gw != nil {
+ w = gw
+ defer gw.finish()
+ }
+ }
+
// 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.