carlos / platform Public

console: storage pass fixes — real store-record errors, truncation guard

Review findings on the storage sweep (Task 9):

- storagePass no longer swallows objstore.Get's error. Absent is
  (nil, "", nil) by that function's own idiom, so a non-nil error is a
  real failure (transient bucket read, malformed store.json) — previously
  treated the same as "no store", writing StoreBytes:0/StoreUnswept:false
  into the create-only day object, forever indistinguishable from an app
  that never had one. Now logs and marks StoreUnswept, same as a failing
  sizer, and keeps sweeping the rest of the apps.

- sizeProvisionedStore now breaks its ListObjectsV2 pagination loop if a
  page claims IsTruncated but returns a nil NextContinuationToken, rather
  than refetching page one forever. The pass runs synchronously inside
  StartEconomicsPass, so an unguarded loop against a misbehaving
  S3-compatible endpoint would hang census/statements/bill-pull
  permanently, not just the storage sweep.

Added TestStoragePassDegradesOnStoreRecordReadError (malformed store.json
via a direct Put, asserts StoreUnswept). The truncation guard has no test
seam of its own — sizeProvisionedStore builds its S3 client internally via
objstore.S3ClientFor and is only reachable through the real client, not
the storeSizer test seam — so it's covered by the guard + comment only,
per review's own fallback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9ccc83aa91b9c091e273f0f4f7ae422050e7af5e parent aaa157b
2 files changed, +56 −1
  • internal/console/economicspass.go +19 −1
  • internal/console/economicspass_test.go +37 −0
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go
index fe09f3f0d..f6be211fc 100644
--- a/internal/console/economicspass.go
+++ b/internal/console/economicspass.go
@@ -149,7 +149,17 @@ func (s *Server) storagePass(ctx context.Context, date string) error {
for _, ks := range sizes {
st.BucketBytes += ks.Size
}
- if rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct, app); err == nil && rec != nil && rec.Provisioned != nil {
+ rec, _, gerr := objstore.Get(ctx, s.cfg.Releases, acct, app)
+ if gerr != nil {
+ // Absent is (nil, "", nil) — objstore.Get's own idiom — so a
+ // non-nil error here is a REAL failure (transient bucket
+ // read, malformed store.json), not "no store". Treating it
+ // as "no store" would write StoreBytes:0/StoreUnswept:false
+ // into a create-only object, indistinguishable from an app
+ // that never had a store and never correctable afterward.
+ log.Printf("console: storage pass: store record %s/%s: %v", acct, app, gerr)
+ st.StoreUnswept = true
+ } else if rec != nil && rec.Provisioned != nil {
n, serr := s.sizeStore(ctx, rec.Provisioned)
if serr != nil {
log.Printf("console: storage pass: store %s/%s: %v", acct, app, serr)
@@ -198,6 +208,14 @@ func sizeProvisionedStore(ctx context.Context, p *objstore.Provisioned) (int64,
if !aws.ToBool(out.IsTruncated) {
break
}
+ if out.NextContinuationToken == nil {
+ // A misbehaving S3-compatible endpoint that claims IsTruncated
+ // but omits the continuation token would otherwise refetch page
+ // one forever: this pass runs synchronously inside
+ // StartEconomicsPass, so that hang takes census/statements/
+ // bill-pull down with it, permanently.
+ break
+ }
token = out.NextContinuationToken
}
return total, nil
diff --git a/internal/console/economicspass_test.go b/internal/console/economicspass_test.go
index 9d18c3b9d..c89e34cf7 100644
--- a/internal/console/economicspass_test.go
+++ b/internal/console/economicspass_test.go
@@ -219,3 +219,40 @@ func TestStoragePassMarksUnreachableStoreUnswept(t *testing.T) {
t.Fatalf("app.StoreBytes = %d, want 0 when unswept", app.StoreBytes)
}
}
+
+// TestStoragePassDegradesOnStoreRecordReadError: objstore.Get's "absent"
+// return is (nil, "", nil) — a non-nil error means a REAL failure (bucket
+// read, malformed store.json), not "no store". A pass that treated the two
+// alike would write StoreBytes:0/StoreUnswept:false into a create-only
+// object, forever indistinguishable from an app that never had a store.
+func TestStoragePassDegradesOnStoreRecordReadError(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+
+ // Corrupt the record directly so objstore.Get's json.Unmarshal fails —
+ // the fixture bucket makes this easier than injecting a wrapped store
+ // whose Get errors, and it exercises the exact malformed-store.json case
+ // the finding names.
+ if _, err := fx.rel.Put(ctx, objstore.Key(fixtureAcct, fixtureApp), []byte("{not valid json"), ""); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := fx.srv.storagePass(ctx, "2026-08-11"); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err := fx.rel.Get(ctx, usage.StorageDayKey("2026-08-11"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var day usage.StorageDay
+ if err := json.Unmarshal(raw, &day); err != nil {
+ t.Fatal(err)
+ }
+ app := day.Apps[fixtureAcct+"/"+fixtureApp]
+ if !app.StoreUnswept {
+ t.Fatalf("app with an unreadable store record = %+v, want StoreUnswept", app)
+ }
+ if app.StoreBytes != 0 {
+ t.Fatalf("app.StoreBytes = %d, want 0 when unswept", app.StoreBytes)
+ }
+}