carlos / platform Public

edge: don't cache ledger entries and blobs — immutable Cache-Control instead

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5f99098858ece7683fd7934deede2cc5402f5134 parent 3b0b632
2 files changed, +155 −24
  • internal/edge/status.go +63 −17
  • internal/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