edge: don't cache ledger entries and blobs — immutable Cache-Control instead
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+155
−24
internal/edge/status.go+63 −17internal/edge/status_test.go+92 −7
diff --git a/internal/edge/status.go b/internal/edge/status.go| index ac6a83332..e951d5cd3 100644 |
| --- a/internal/edge/status.go |
| +++ b/internal/edge/status.go |
| @@ -136,10 +136,24 @@ func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| // left to serve" contract above belongs to the fleet page's own three |
| // objects, which are the page, not arbitrary reads. |
| // |
| -// The manifest read rides the same per-key cache as everything else, so |
| -// revoking public takes at most one TTL — and, symmetrically, a cached |
| -// public manifest keeps serving through a store outage exactly like any |
| -// other cached object (see get's stale-on-error half). |
| +// Ledger objects fall into two cache classes, because unlike the fleet |
| +// page's three fixed objects a public ledger has unboundedly many: |
| +// |
| +// - ledgers.json and HEAD.json are MUTABLE and few — one manifest per |
| +// public app, one HEAD per ledger — so they ride h.get's shared 60s |
| +// cache. Revoking public therefore takes at most one TTL, and a cached |
| +// public manifest keeps serving through a store outage exactly like any |
| +// other cached object (get's stale-on-error half). The cache map cannot |
| +// be grown by a stranger probing random sqids: get only writes an entry |
| +// AFTER a successful store read (see its tail), so a miss — the whole |
| +// of "no such app" — is never cached. Bounded by what actually exists. |
| +// - entries/*.json and blobs/* are IMMUTABLE and unbounded in number |
| +// (an entry per append, blobs up to ledger.MaxBlob each), so they are |
| +// NOT cached in this process at all: a direct store read plus a |
| +// year-long immutable Cache-Control, which puts the caching in browsers |
| +// and CDNs where the memory isn't ours. That is deliberate: a public |
| +// chain walker must not be able to pin arbitrary edge memory by simply |
| +// reading every entry of a public ledger. |
| func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p string) { |
| // path.Clean above already collapsed dot and empty segments; re-check |
| // them anyway, because this is the one place where a public request |
| @@ -159,7 +173,8 @@ func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p st |
| return |
| } |
| } |
| - if !ledgerServable(parts[3:]) { |
| + servable, immutable := ledgerShape(parts[3:]) |
| + if !servable { |
| http.NotFound(w, r) |
| return |
| } |
| @@ -181,6 +196,25 @@ func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p st |
| if !strings.HasSuffix(key, ".json") { |
| ctype = "application/octet-stream" // blobs are opaque bytes |
| } |
| + |
| + if immutable { |
| + // Uncached class: straight to the store, and let the caller cache |
| + // it forever — an entry key commits to its own bytes (the ts-sha |
| + // key) and a blob key IS its sha256, so neither can ever change |
| + // meaning. A store error here is the same plain 404 as a key that |
| + // was never written; nothing is logged, for the same reason the |
| + // rest of this handler logs nothing. |
| + body, _, err := h.store.Get(r.Context(), key) |
| + if err != nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.Header().Set("Content-Type", ctype) |
| + w.Header().Set("Cache-Control", ledgerImmutableCacheControl) |
| + w.Write(body) |
| + return |
| + } |
| + |
| body, ctype, err := h.get(r.Context(), key, ctype) |
| if err != nil { |
| http.NotFound(w, r) |
| @@ -190,24 +224,36 @@ func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p st |
| w.Write(body) |
| } |
| -// ledgerServable is the object-shape allowlist inside one app's ledger |
| -// subtree, given the path segments AFTER ledgers/<sqid>/<app>/. The ledger |
| -// primitive writes exactly four shapes (internal/ledger/ledger.go's key |
| -// builders) and the edge publishes exactly those: living under a public |
| -// app's prefix is not, by itself, a reason to hand an object to the |
| -// internet. Same allowlist spirit as statusRoutes, one level down. |
| -func ledgerServable(rest []string) bool { |
| +// ledgerImmutableCacheControl is what entries and blobs are served with: a |
| +// year, immutable. Content-addressed keys make that safe, and it is what |
| +// keeps the caching of an unbounded object set in browsers and CDNs instead |
| +// of this process's memory. ledgers.json and HEAD.json deliberately get NO |
| +// Cache-Control — they are the mutable head of the chain and their freshness |
| +// is the 60s server-side TTL's job, not a client's. |
| +const ledgerImmutableCacheControl = "public, max-age=31536000, immutable" |
| + |
| +// ledgerShape is the object-shape allowlist inside one app's ledger subtree, |
| +// given the path segments AFTER ledgers/<sqid>/<app>/. The ledger primitive |
| +// writes exactly four shapes (internal/ledger/ledger.go's key builders) and |
| +// the edge publishes exactly those: living under a public app's prefix is |
| +// not, by itself, a reason to hand an object to the internet. Same allowlist |
| +// spirit as statusRoutes, one level down. |
| +// |
| +// immutable splits the two cache classes documented on serveLedger — true |
| +// for content-addressed entries and blobs, false for the mutable |
| +// ledgers.json/HEAD.json pair. It is only meaningful when servable is true. |
| +func ledgerShape(rest []string) (servable, immutable bool) { |
| switch { |
| case len(rest) == 1: // ledgers/<sqid>/<app>/ledgers.json |
| - return rest[0] == "ledgers.json" |
| + return rest[0] == "ledgers.json", false |
| case len(rest) == 2: // .../<ledger>/HEAD.json |
| - return rest[1] == "HEAD.json" |
| + return rest[1] == "HEAD.json", false |
| case len(rest) == 3 && rest[1] == "entries": // .../<ledger>/entries/<ts>-<sha>.json |
| - return strings.HasSuffix(rest[2], ".json") |
| + return strings.HasSuffix(rest[2], ".json"), true |
| case len(rest) == 3 && rest[1] == "blobs": // .../<ledger>/blobs/<sha> |
| - return true |
| + return true, true |
| } |
| - return false |
| + return false, false |
| } |
| // get implements the cache contract documented on statusHandler: fresh cache |
diff --git a/internal/edge/status_test.go b/internal/edge/status_test.go| index 34d8b197a..beb152abe 100644 |
| --- a/internal/edge/status_test.go |
| +++ b/internal/edge/status_test.go |
| @@ -10,6 +10,7 @@ import ( |
| "net/http" |
| "net/http/httptest" |
| "os" |
| + "path/filepath" |
| "testing" |
| "time" |
| @@ -55,19 +56,26 @@ func TestLedgerServingPublic(t *testing.T) { |
| entryKey, blobSHA := seedLedger(t, st, true) |
| h := newStatusHandler(st) |
| - for path, wantCT := range map[string]string{ |
| - "/ledgers/bab/platform/ledgers.json": "application/json", |
| - "/ledgers/bab/platform/carbon/HEAD.json": "application/json", |
| - "/" + entryKey: "application/json", |
| - "/ledgers/bab/platform/carbon/blobs/" + blobSHA: "application/octet-stream", |
| + // [contentType, cacheControl]: the mutable pair carries no Cache-Control |
| + // (freshness is the handler's own 60s TTL); the content-addressed pair |
| + // carries the immutable year, because it is cached by clients and CDNs |
| + // instead of in this process. |
| + for path, want := range map[string][2]string{ |
| + "/ledgers/bab/platform/ledgers.json": {"application/json", ""}, |
| + "/ledgers/bab/platform/carbon/HEAD.json": {"application/json", ""}, |
| + "/" + entryKey: {"application/json", ledgerImmutableCacheControl}, |
| + "/ledgers/bab/platform/carbon/blobs/" + blobSHA: {"application/octet-stream", ledgerImmutableCacheControl}, |
| } { |
| rr := getStatus(t, h, path) |
| if rr.Code != http.StatusOK { |
| t.Errorf("GET %s = %d, want 200", path, rr.Code) |
| continue |
| } |
| - if ct := rr.Header().Get("Content-Type"); ct != wantCT { |
| - t.Errorf("GET %s Content-Type = %q, want %q", path, ct, wantCT) |
| + if ct := rr.Header().Get("Content-Type"); ct != want[0] { |
| + t.Errorf("GET %s Content-Type = %q, want %q", path, ct, want[0]) |
| + } |
| + if cc := rr.Header().Get("Cache-Control"); cc != want[1] { |
| + t.Errorf("GET %s Cache-Control = %q, want %q", path, cc, want[1]) |
| } |
| } |
| if body := getStatus(t, h, "/ledgers/bab/platform/carbon/blobs/"+blobSHA).Body.String(); body != "blobby" { |
| @@ -192,6 +200,83 @@ func TestLedgerServingMissingObjectIs404(t *testing.T) { |
| } |
| } |
| +// cacheKeys snapshots the handler's in-memory cache under its own lock. |
| +func cacheKeys(h *statusHandler) map[string]bool { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + keys := make(map[string]bool, len(h.cache)) |
| + for k := range h.cache { |
| + keys[k] = true |
| + } |
| + return keys |
| +} |
| + |
| +// TestLedgerCacheClasses is the memory-safety pin: a public ledger has |
| +// unboundedly many entries and blobs (blobs up to 4 MiB), so serving them |
| +// must NOT put anything in the handler's never-evicting cache — otherwise |
| +// anyone on the internet could pin arbitrary edge memory just by walking a |
| +// public chain. Only the mutable, bounded set (one ledgers.json per public |
| +// app, one HEAD.json per ledger) is cached. |
| +func TestLedgerCacheClasses(t *testing.T) { |
| + dir := t.TempDir() |
| + st := release.NewDirStore(dir) |
| + entryKey, blobSHA := seedLedger(t, st, true) |
| + h := newStatusHandler(st) |
| + |
| + entryPath, blobPath := "/"+entryKey, "/ledgers/bab/platform/carbon/blobs/"+blobSHA |
| + if rr := getStatus(t, h, entryPath); rr.Code != http.StatusOK { |
| + t.Fatalf("GET %s = %d, want 200", entryPath, rr.Code) |
| + } |
| + if rr := getStatus(t, h, blobPath); rr.Code != http.StatusOK { |
| + t.Fatalf("GET %s = %d, want 200", blobPath, rr.Code) |
| + } |
| + keys := cacheKeys(h) |
| + if keys[entryKey] || keys["ledgers/bab/platform/carbon/blobs/"+blobSHA] { |
| + t.Errorf("immutable objects were cached in-process: %v", keys) |
| + } |
| + // The gate itself is cached — that read is what bounds the map. |
| + if want := ledger.ManifestKey("bab", "platform"); !keys[want] { |
| + t.Errorf("manifest %s not cached: %v", want, keys) |
| + } |
| + if len(keys) != 1 { |
| + t.Errorf("cache holds %d keys, want only the manifest: %v", len(keys), keys) |
| + } |
| + |
| + // Serving HEAD.json adds exactly one more key (the mutable class). |
| + if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusOK { |
| + t.Fatalf("GET HEAD.json = %d, want 200", rr.Code) |
| + } |
| + if keys = cacheKeys(h); len(keys) != 2 || !keys[ledger.HeadKey("bab", "platform", "carbon")] { |
| + t.Errorf("after HEAD.json the cache holds %v, want manifest + HEAD", keys) |
| + } |
| + |
| + // A miss never grows the map: probing sqids that do not exist must not |
| + // be a way to allocate memory in the edge (get only records an entry |
| + // after a SUCCESSFUL store read). |
| + for i, p := range []string{ |
| + "/ledgers/nope/nothing/ledgers.json", |
| + "/ledgers/nope/nothing/carbon/HEAD.json", |
| + "/ledgers/bab/platform/carbon/entries/20260101T000000Z-000000000000.json", |
| + } { |
| + if rr := getStatus(t, h, p); rr.Code != http.StatusNotFound { |
| + t.Fatalf("probe %d (%s) = %d, want 404", i, p, rr.Code) |
| + } |
| + } |
| + if after := cacheKeys(h); len(after) != 2 { |
| + t.Errorf("probing grew the cache to %v", after) |
| + } |
| + |
| + // And because entries are not cached at all, removing the backing |
| + // object is visible immediately — no stale copy is held anywhere in |
| + // this process, even though the manifest gate is still cached. |
| + if err := os.Remove(filepath.Join(dir, filepath.FromSlash(entryKey))); err != nil { |
| + t.Fatalf("remove entry: %v", err) |
| + } |
| + if rr := getStatus(t, h, entryPath); rr.Code != http.StatusNotFound { |
| + t.Errorf("removed entry = %d, want 404 (nothing cached in-process)", rr.Code) |
| + } |
| +} |
| + |
| // TestLedgerServingStaleManifestHonored pins the interaction with get()'s |
| // stale-on-error half: the manifest is just another cached object, so once a |
| // public manifest has been read, a store outage keeps the already-cached |