ledgers: cap the published set at 64, negative-cache only real not-found
Publishing became additive read-modify-write, which removed the implicit bound the 8 KiB body cap gave under replace semantics: names accumulate, and ledgers.json is member-supplied content served publicly at the edge, held in its in-memory cache and scanned linearly on every ledger request. Cap the RESULTING set at 64 inside the apply — only the stored manifest knows the current count, and UpdateManifest re-reads on a lost race, so a check outside the closure could be raced past. apply can now REFUSE by returning an error, which aborts before the Put, so a refused publish writes nothing and answers 400 rather than a laundered 500. At the edge, markMiss only on os.ErrNotExist: the probe defense is nonexistent manifests, and pinning a 404 for a full TTL because the bucket blipped while the cache was cold takes a live public app dark. The blipped request still 404s; the next one retries. And correct markMiss's TTL claim — a positive-cached public:false manifest can stamp a negative entry that outlives it, so publishing takes effect within two ticks worst case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+240
−14
internal/console/apiledger.go+40 −2internal/console/apiledger_test.go+67 −0internal/edge/status.go+22 −5internal/edge/status_test.go+60 −0internal/ledger/manifest.go+15 −3internal/ledger/manifest_test.go+36 −4
diff --git a/internal/console/apiledger.go b/internal/console/apiledger.go| index a4bd2aa78..d6f32a48e 100644 |
| --- a/internal/console/apiledger.go |
| +++ b/internal/console/apiledger.go |
| @@ -244,6 +244,26 @@ func (s *Server) handleAPILedgerList(w http.ResponseWriter, r *http.Request) { |
| // holds for every reader that will ever fetch the object. |
| const maxLedgerTitle = 120 |
| +// maxLedgerCount bounds Manifest.Ledgers. Publishing is additive |
| +// read-modify-write, which removed the implicit bound the 8 KiB request-body |
| +// cap used to give when a publish stored the request's manifest wholesale: |
| +// names now ACCUMULATE across calls, and ledgers.json is member-supplied |
| +// content that is served publicly at the edge, held in the edge's in-memory |
| +// cache, and scanned linearly on every single ledger request (the allowlist |
| +// gate). 64 published chains per app is far past any real app and keeps that |
| +// per-request scan trivially short. Like the title's cap, it lives at the |
| +// write door, because that is the only place a bound holds for every reader |
| +// that will ever fetch the object. |
| +const maxLedgerCount = 64 |
| + |
| +// errTooManyLedgers is how the publish apply below REFUSES from inside |
| +// ledger.UpdateManifest's CAS loop: the cap can only be judged against the |
| +// stored set, which the loop may re-read, so the check has to live where the |
| +// decision is made and travel back out as an error the handler recognises. |
| +// Its text is the client-facing message — it names the cap and nothing else, |
| +// so there is no path by which store internals reach a caller through it. |
| +var errTooManyLedgers = fmt.Errorf("too many public ledgers (max %d)", maxLedgerCount) |
| + |
| // handleAPILedgerPublish is READ-MODIFY-WRITE, per ledger. The manifest is a |
| // SET of published ledgers accrued across separate publish calls, and the |
| // edge reads that set as an ALLOWLIST (internal/edge/status.go) — so a |
| @@ -306,7 +326,7 @@ func (s *Server) handleAPILedgerPublish(w http.ResponseWriter, r *http.Request) |
| } |
| } |
| - m, err := ledger.UpdateManifest(ctx, s.cfg.Releases, acct.Sqid, app, func(m *ledger.Manifest) { |
| + m, err := ledger.UpdateManifest(ctx, s.cfg.Releases, acct.Sqid, app, func(m *ledger.Manifest) error { |
| if in.Public { |
| m.Public = true |
| for _, n := range in.Ledgers { |
| @@ -314,10 +334,19 @@ func (s *Server) handleAPILedgerPublish(w http.ResponseWriter, r *http.Request) |
| m.Ledgers = append(m.Ledgers, n) |
| } |
| } |
| + // The cap is checked HERE, on the manifest this attempt read, and |
| + // on the RESULT — not on the request. Only the stored set knows |
| + // how many names are already there, and UpdateManifest re-reads |
| + // and re-applies on a lost race, so a check made once outside |
| + // this closure could be stepped over by two publishers racing. |
| + // Refusing from inside aborts before the Put: nothing is written. |
| + if len(m.Ledgers) > maxLedgerCount { |
| + return errTooManyLedgers |
| + } |
| if title != "" { |
| m.Title = title |
| } |
| - return |
| + return nil |
| } |
| m.Ledgers = slices.DeleteFunc(m.Ledgers, func(n string) bool { |
| return slices.Contains(in.Ledgers, n) |
| @@ -325,7 +354,16 @@ func (s *Server) handleAPILedgerPublish(w http.ResponseWriter, r *http.Request) |
| if len(m.Ledgers) == 0 { |
| m.Public = false |
| } |
| + return nil |
| }) |
| + // A refusal is the CALLER's fault and must not be laundered through |
| + // ledgerStoreError, which would log it and answer 500: this is the one |
| + // error out of UpdateManifest that a client can act on, and the only one |
| + // whose text is ours to show. |
| + if errors.Is(err, errTooManyLedgers) { |
| + jsonError(w, http.StatusBadRequest, errTooManyLedgers.Error()) |
| + return |
| + } |
| if err != nil { |
| s.ledgerStoreError(w, fmt.Sprintf("ledger publish %s/%s", acct.Sqid, app), err) |
| return |
diff --git a/internal/console/apiledger_test.go b/internal/console/apiledger_test.go| index b49ff0746..bdd218192 100644 |
| --- a/internal/console/apiledger_test.go |
| +++ b/internal/console/apiledger_test.go |
| @@ -13,8 +13,10 @@ import ( |
| "context" |
| "encoding/json" |
| "errors" |
| + "fmt" |
| "io" |
| "net/http" |
| + "slices" |
| "strings" |
| "testing" |
| "time" |
| @@ -462,6 +464,71 @@ func TestAPILedgerPublishTitleCap(t *testing.T) { |
| } |
| } |
| +// TestAPILedgerPublishCountCap: the manifest became read-modify-write, which |
| +// removed the implicit bound the 8 KiB request cap used to give a whole-value |
| +// replace — names now ACCUMULATE. ledgers.json is member-supplied content |
| +// served publicly at the edge, held in the edge's in-memory cache and |
| +// linearly scanned on every ledger request, so the set is bounded at the |
| +// write door, on the RESULTING manifest, like every other public-facing input |
| +// here. |
| +func TestAPILedgerPublishCountCap(t *testing.T) { |
| + fx, token := ledgerFixture(t, "code-ledger-count") |
| + |
| + names := make([]string, 0, maxLedgerCount) |
| + for i := range maxLedgerCount { |
| + names = append(names, fmt.Sprintf("l%d", i)) |
| + } |
| + full, err := json.Marshal(map[string]any{"public": true, "ledgers": names, "title": "T"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + status, m := publishLedgers(t, fx, token, string(full)) |
| + if status != http.StatusOK { |
| + t.Fatalf("publish of %d ledgers = %d, want 200 (the cap is a ceiling, not a fence below it)", maxLedgerCount, status) |
| + } |
| + if len(m.Ledgers) != maxLedgerCount { |
| + t.Fatalf("published %d ledgers, want %d", len(m.Ledgers), maxLedgerCount) |
| + } |
| + |
| + // The one that would step over is refused as the caller's fault, and |
| + // nothing new is written: not the name, not the title that rode with it. |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, |
| + `{"public":true,"ledgers":["overflow"],"title":"nope"}`) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("publish over the cap = %d %s, want 400", status, out) |
| + } |
| + if !strings.Contains(string(out), "too many public ledgers") { |
| + t.Fatalf("refusal = %s, want it to name the cap", out) |
| + } |
| + stored, err := ledger.ReadManifest(context.Background(), fx.rel, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(stored.Ledgers) != maxLedgerCount || slices.Contains(stored.Ledgers, "overflow") { |
| + t.Fatalf("stored manifest = %+v, want the refused publish to have written nothing", stored) |
| + } |
| + if stored.Title != "T" { |
| + t.Fatalf("title = %q, want the refused publish to have left it alone", stored.Title) |
| + } |
| + |
| + // Re-publishing one that is ALREADY on the list at the cap is fine — the |
| + // bound is on the resulting set, and that set does not grow. |
| + if status, m := publishLedgers(t, fx, token, `{"public":true,"ledgers":["l0"]}`); status != http.StatusOK || |
| + len(m.Ledgers) != maxLedgerCount { |
| + t.Fatalf("idempotent re-publish at the cap = %d %+v, want 200 and no growth", status, m.Ledgers) |
| + } |
| + |
| + // And the way back down is never blocked: an app that hit the cap must |
| + // still be able to unpublish. |
| + status, m = publishLedgers(t, fx, token, `{"public":false,"ledgers":["l0"]}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("unpublish at the cap = %d, want 200", status) |
| + } |
| + if len(m.Ledgers) != maxLedgerCount-1 || slices.Contains(m.Ledgers, "l0") { |
| + t.Fatalf("unpublish at the cap left %v", m.Ledgers) |
| + } |
| +} |
| + |
| // TestAPILedgerHeadMissing: a ledger that was never written is the same |
| // stdlib 404 as an app you can't see — there is nothing to distinguish. |
| func TestAPILedgerHeadMissing(t *testing.T) { |
diff --git a/internal/edge/status.go b/internal/edge/status.go| index 90439336c..f7ab59944 100644 |
| --- a/internal/edge/status.go |
| +++ b/internal/edge/status.go |
| @@ -15,8 +15,10 @@ package edge |
| import ( |
| "context" |
| "encoding/json" |
| + "errors" |
| "log" |
| "net/http" |
| + "os" |
| "path" |
| "slices" |
| "strings" |
| @@ -128,10 +130,15 @@ func (h *statusHandler) missFresh(key string) bool { |
| return ok && h.now().Sub(at) < h.ttl |
| } |
| -// markMiss records that an app is not servable — no manifest, an unreadable |
| -// or unparseable one, or public:false — for the same TTL the positive cache |
| -// trusts a fetched object. Publishing an app therefore takes effect within |
| -// one tick, the same bound revoking already had. |
| +// markMiss records that an app is not servable — no manifest, an unparseable |
| +// one, or public:false — for the same TTL the positive cache trusts a fetched |
| +// object. Publishing an app therefore takes effect within TWO ticks worst |
| +// case (~2×TTL), one typically: the two caches are stamped independently, so |
| +// a public:false manifest read at t=0 can still be serving from the positive |
| +// cache at t=59s and stamp a NEGATIVE entry then, which outlives it. Revoking |
| +// has the same shape from the other side. Both are bounded and small; the |
| +// alternative — invalidating across the two caches — would buy a second off a |
| +// 5-minute-cadence page. |
| // |
| // The bound is the point: see statusMissCacheMax. |
| func (h *statusHandler) markMiss(key string) { |
| @@ -246,7 +253,17 @@ func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p st |
| } |
| mb, _, err := h.get(r.Context(), mkey, "application/json") |
| if err != nil { |
| - h.markMiss(mkey) |
| + // Only a genuine not-found is REMEMBERED. That is what the negative |
| + // cache is for: a prober names manifests that do not exist, and the |
| + // store answers os.ErrNotExist (release.Store's contract). Any other |
| + // error is a blip — a throttle, an expired credential — and stamping |
| + // it would take a live public app dark for the full TTL because the |
| + // bucket hiccuped once while this key's positive cache happened to be |
| + // cold. This request still 404s (fail-closed: nothing readable said |
| + // public); the next one retries. |
| + if errors.Is(err, os.ErrNotExist) { |
| + h.markMiss(mkey) |
| + } |
| http.NotFound(w, r) // fail-closed: no readable manifest = not public |
| return |
| } |
diff --git a/internal/edge/status_test.go b/internal/edge/status_test.go| index bb698ae71..76cd05c80 100644 |
| --- a/internal/edge/status_test.go |
| +++ b/internal/edge/status_test.go |
| @@ -7,6 +7,7 @@ package edge |
| import ( |
| "context" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "net/http" |
| "net/http/httptest" |
| @@ -459,6 +460,65 @@ func TestLedgerManifestMissesAreNegativeCached(t *testing.T) { |
| } |
| } |
| +// flakyStore fails every read while armed, with an error that is NOT |
| +// not-found — a bucket blip, a throttle, an expired credential — the class of |
| +// failure that resolves on its own. |
| +type flakyStore struct { |
| + release.Store |
| + mu sync.Mutex |
| + broken bool |
| +} |
| + |
| +func (f *flakyStore) breakIt(b bool) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + f.broken = b |
| +} |
| + |
| +func (f *flakyStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + f.mu.Lock() |
| + broken := f.broken |
| + f.mu.Unlock() |
| + if broken { |
| + return nil, "", errors.New("Throttling: slow down") |
| + } |
| + return f.Store.Get(ctx, key) |
| +} |
| + |
| +// TestLedgerTransientStoreErrorIsNotNegativeCached: the negative cache exists |
| +// to make PROBING cheap to refuse, and a probe's manifest is not-found. A |
| +// transient store error is a different thing entirely: caching it would pin a |
| +// public app dark for the full TTL because the bucket hiccuped once while the |
| +// cache happened to be cold — the exact dishonesty the positive cache's |
| +// stale-on-error half exists to avoid, reintroduced from the other side. The |
| +// blipped request still 404s (fail-closed: nothing readable said public), but |
| +// the decision is not remembered, so the next request retries. |
| +func TestLedgerTransientStoreErrorIsNotNegativeCached(t *testing.T) { |
| + base := release.NewDirStore(t.TempDir()) |
| + st := &flakyStore{Store: base} |
| + seedLedger(t, base, true) |
| + h := newStatusHandler(st) |
| + |
| + // Cold cache + broken store: 404, because nothing readable says public. |
| + st.breakIt(true) |
| + if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusNotFound { |
| + t.Fatalf("during the blip = %d, want 404", rr.Code) |
| + } |
| + h.mu.Lock() |
| + n := len(h.misses) |
| + h.mu.Unlock() |
| + if n != 0 { |
| + t.Fatalf("a transient store error stamped %d negative-cache entries, want 0", n) |
| + } |
| + |
| + // The store recovers well inside the 60s TTL: the very next request must |
| + // serve, not sit behind a cached refusal. |
| + st.breakIt(false) |
| + if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusOK { |
| + t.Fatalf("after the store recovered = %d, want 200 (the blip must not be negatively cached)", rr.Code) |
| + } |
| +} |
| + |
| // TestLedgerMissCacheIsBounded: the attacker picks the account and app path |
| // segments, so the set of distinct manifest keys a prober can name is |
| // unbounded — a negative cache that grew with it would be the memory |
diff --git a/internal/ledger/manifest.go b/internal/ledger/manifest.go| index 67407997e..60c54a8c9 100644 |
| --- a/internal/ledger/manifest.go |
| +++ b/internal/ledger/manifest.go |
| @@ -29,7 +29,10 @@ func ReadManifest(ctx context.Context, st release.Store, sqid, app string) (Mani |
| // UpdateManifest instead: replacing the whole value there unpublishes |
| // everything the caller didn't happen to name. |
| func WriteManifest(ctx context.Context, st release.Store, sqid, app string, m Manifest) error { |
| - _, err := UpdateManifest(ctx, st, sqid, app, func(cur *Manifest) { *cur = m }) |
| + _, err := UpdateManifest(ctx, st, sqid, app, func(cur *Manifest) error { |
| + *cur = m |
| + return nil |
| + }) |
| return err |
| } |
| @@ -48,8 +51,15 @@ const manifestAttempts = 4 |
| // |
| // Contention that outlasts the retries surfaces as release.ErrCASConflict |
| // (the console maps it to a retry-worthy 503) — never as a silent no-op. |
| +// |
| +// apply may REFUSE: an error from it aborts the attempt BEFORE the Put and is |
| +// returned untouched, so nothing is written. That is what lets a caller |
| +// enforce a rule the request alone cannot decide — a cap on the RESULTING set |
| +// — on whatever manifest each attempt actually read, including the re-read |
| +// after a lost race, rather than on a snapshot that a concurrent publisher |
| +// has already moved past. |
| func UpdateManifest(ctx context.Context, st release.Store, sqid, app string, |
| - apply func(*Manifest)) (Manifest, error) { |
| + apply func(*Manifest) error) (Manifest, error) { |
| key := ManifestKey(sqid, app) |
| var lastErr error |
| for range manifestAttempts { |
| @@ -67,7 +77,9 @@ func UpdateManifest(ctx context.Context, st release.Store, sqid, app string, |
| } |
| } |
| - apply(&m) |
| + if err := apply(&m); err != nil { |
| + return Manifest{}, err |
| + } |
| m.Schema = Schema |
| body, err := json.Marshal(m) |
| if err != nil { |
diff --git a/internal/ledger/manifest_test.go b/internal/ledger/manifest_test.go| index 48ff0beab..d7313defa 100644 |
| --- a/internal/ledger/manifest_test.go |
| +++ b/internal/ledger/manifest_test.go |
| @@ -46,10 +46,11 @@ func TestUpdateManifestReadModifyWrite(t *testing.T) { |
| // Missing manifest is the zero value, not an error: the first publish |
| // of an app creates it. |
| - m, err := UpdateManifest(ctx, st, "bab", "platform", func(m *Manifest) { |
| + m, err := UpdateManifest(ctx, st, "bab", "platform", func(m *Manifest) error { |
| m.Public = true |
| m.Ledgers = append(m.Ledgers, "carbon") |
| m.Title = "CARLOS" |
| + return nil |
| }) |
| if err != nil { |
| t.Fatalf("first update: %v", err) |
| @@ -59,8 +60,9 @@ func TestUpdateManifestReadModifyWrite(t *testing.T) { |
| } |
| // A second update sees the first one's state. |
| - m, err = UpdateManifest(ctx, st, "bab", "platform", func(m *Manifest) { |
| + m, err = UpdateManifest(ctx, st, "bab", "platform", func(m *Manifest) error { |
| m.Ledgers = append(m.Ledgers, "prompts") |
| + return nil |
| }) |
| if err != nil { |
| t.Fatalf("second update: %v", err) |
| @@ -98,11 +100,12 @@ func (c *conflictOnce) Put(ctx context.Context, key string, body []byte, ifMatch |
| // two ledgers at once is a lost update, and the loser never learns. |
| func TestUpdateManifestCASReapplies(t *testing.T) { |
| st := &conflictOnce{Store: release.NewDirStore(t.TempDir())} |
| - m, err := UpdateManifest(context.Background(), st, "bab", "platform", func(m *Manifest) { |
| + m, err := UpdateManifest(context.Background(), st, "bab", "platform", func(m *Manifest) error { |
| m.Public = true |
| if !slices.Contains(m.Ledgers, "carbon") { |
| m.Ledgers = append(m.Ledgers, "carbon") |
| } |
| + return nil |
| }) |
| if err != nil { |
| t.Fatalf("update: %v", err) |
| @@ -122,13 +125,42 @@ func TestUpdateManifestCASReapplies(t *testing.T) { |
| // is reported, never swallowed — the console maps it to a retry-worthy 503. |
| func TestUpdateManifestSurfacesLostRaces(t *testing.T) { |
| st := alwaysConflict{release.NewDirStore(t.TempDir())} |
| - if _, err := UpdateManifest(context.Background(), st, "bab", "platform", func(m *Manifest) { |
| + if _, err := UpdateManifest(context.Background(), st, "bab", "platform", func(m *Manifest) error { |
| m.Public = true |
| + return nil |
| }); !errors.Is(err, release.ErrCASConflict) { |
| t.Fatalf("update under total contention = %v, want a CAS conflict", err) |
| } |
| } |
| +// TestUpdateManifestApplyRefusalWritesNothing: apply is where a caller |
| +// enforces a rule only the STORED manifest can decide (the console's cap on |
| +// how many ledgers an app may publish). A refusal has to abort before the Put |
| +// — a loop that wrote first and reported the refusal afterwards would store |
| +// exactly the state it just called illegal. |
| +func TestUpdateManifestApplyRefusalWritesNothing(t *testing.T) { |
| + ctx := context.Background() |
| + st := release.NewDirStore(t.TempDir()) |
| + if err := WriteManifest(ctx, st, "bab", "platform", |
| + Manifest{Public: true, Ledgers: []string{"carbon"}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + refused := errors.New("no") |
| + if _, err := UpdateManifest(ctx, st, "bab", "platform", func(m *Manifest) error { |
| + m.Ledgers = append(m.Ledgers, "prompts") |
| + return refused |
| + }); !errors.Is(err, refused) { |
| + t.Fatalf("update = %v, want apply's own error returned untouched", err) |
| + } |
| + stored, err := ReadManifest(ctx, st, "bab", "platform") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !slices.Equal(stored.Ledgers, []string{"carbon"}) { |
| + t.Fatalf("stored = %+v, want the refused change never written", stored) |
| + } |
| +} |
| + |
| type alwaysConflict struct{ release.Store } |
| func (a alwaysConflict) Put(context.Context, string, []byte, string) (string, error) { |