| new file mode 100644 |
| index 000000000..30e13cd2a |
| --- /dev/null |
| +++ b/internal/console/economicstorageretry_test.go |
| @@ -0,0 +1,438 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "io" |
| + "os" |
| + "reflect" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +type storageRetryTestStore struct { |
| + release.Store |
| + get func(context.Context, string) ([]byte, string, error) |
| + put func(context.Context, string, []byte, string) (string, error) |
| + dirs func(context.Context, string) ([]string, error) |
| + sizes func(context.Context, string) ([]release.KeySize, error) |
| +} |
| + |
| +func (s *storageRetryTestStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if s.get != nil { |
| + return s.get(ctx, key) |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| +func (s *storageRetryTestStore) Put(ctx context.Context, key string, body []byte, etag string) (string, error) { |
| + if s.put != nil { |
| + return s.put(ctx, key, body, etag) |
| + } |
| + return s.Store.Put(ctx, key, body, etag) |
| +} |
| +func (s *storageRetryTestStore) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| + if s.dirs != nil { |
| + return s.dirs(ctx, prefix) |
| + } |
| + return s.Store.ListDirs(ctx, prefix) |
| +} |
| +func (s *storageRetryTestStore) ListSizes(ctx context.Context, prefix string) ([]release.KeySize, error) { |
| + if s.sizes != nil { |
| + return s.sizes(ctx, prefix) |
| + } |
| + return s.Store.(release.SizeLister).ListSizes(ctx, prefix) |
| +} |
| + |
| +func storageRetryFixture(t *testing.T, now *time.Time) (*consoleFixture, *storageRetryTestStore, storageRetryRunner) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + fx.srv.ceCosts = func(context.Context, time.Time, time.Time) (map[string]float64, error) { |
| + return nil, io.ErrUnexpectedEOF |
| + } |
| + store := &storageRetryTestStore{Store: fx.rel} |
| + fx.srv.cfg.Releases = store |
| + previous := storageClock |
| + storageClock = func() time.Time { return *now } |
| + t.Cleanup(func() { storageClock = previous }) |
| + runner := fx.srv.storageRetryRunner() |
| + return fx, store, runner |
| +} |
| + |
| +func TestStorageRetryWakesAtFinalHour(t *testing.T) { |
| + now := time.Date(2026, 10, 3, 22, 30, 0, 0, time.UTC) |
| + fx, store, runner := storageRetryFixture(t, &now) |
| + failures := 3 |
| + store.dirs = func(ctx context.Context, prefix string) ([]string, error) { |
| + if prefix == "apps/" && failures > 0 { |
| + failures-- |
| + return nil, io.ErrUnexpectedEOF |
| + } |
| + return store.Store.ListDirs(ctx, prefix) |
| + } |
| + var waits []time.Duration |
| + runner.wait = func(ctx context.Context, d time.Duration) bool { |
| + if _, _, err := fx.rel.Get(ctx, usage.StorageDayKey("2026-10-03")); err == nil { |
| + return false |
| + } |
| + waits = append(waits, d) |
| + if len(waits) > 4 { |
| + t.Fatal("retry did not publish") |
| + } |
| + now = now.Add(d) |
| + return true |
| + } |
| + runner.run(t.Context()) |
| + if !reflect.DeepEqual(waits, []time.Duration{30 * time.Minute, time.Minute, time.Minute}) { |
| + t.Fatalf("wake delays %v", waits) |
| + } |
| + if _, _, err := fx.rel.Get(t.Context(), usage.StorageDayKey("2026-10-03")); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestStorageRetryRetainsBytesAcrossMidnight(t *testing.T) { |
| + for _, lostResponse := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "failed write", true: "lost answer then CAS"}[lostResponse], func(t *testing.T) { |
| + now := time.Date(2026, 10, 3, 23, 59, 30, 0, time.UTC) |
| + fx, store, runner := storageRetryFixture(t, &now) |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + defer cancel() |
| + var bodies [][]byte |
| + var keys []string |
| + listings := 0 |
| + atFirstPut := 0 |
| + store.sizes = func(ctx context.Context, prefix string) ([]release.KeySize, error) { |
| + listings++ |
| + return store.Store.(release.SizeLister).ListSizes(ctx, prefix) |
| + } |
| + store.put = func(ctx context.Context, key string, body []byte, etag string) (string, error) { |
| + keys = append(keys, key) |
| + bodies = append(bodies, append([]byte(nil), body...)) |
| + if etag != "" { |
| + t.Fatal("publication was not create-only") |
| + } |
| + if len(bodies) == 1 { |
| + atFirstPut = listings |
| + if lostResponse { |
| + if _, err := store.Store.Put(ctx, key, body, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if _, err := fx.rel.Put(ctx, "apps/"+fixtureAcct+"/"+fixtureApp+"/late", make([]byte, 1000), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + return "", io.ErrUnexpectedEOF |
| + } |
| + out, err := store.Store.Put(ctx, key, body, etag) |
| + return out, err |
| + } |
| + runner.wait = func(ctx context.Context, d time.Duration) bool { |
| + if len(bodies) == 2 { |
| + cancel() |
| + return false |
| + } |
| + if d != 30*time.Second { |
| + t.Fatalf("delay at midnight %v", d) |
| + } |
| + now = now.Add(d) |
| + return true |
| + } |
| + // Stop before a fresh current-day collection, after the old day's successful publication. |
| + collect := runner.collect |
| + runner.collect = func(ctx context.Context, date string) (*usage.StorageDay, error) { |
| + if date != "2026-10-03" { |
| + cancel() |
| + return nil, context.Canceled |
| + } |
| + return collect(ctx, date) |
| + } |
| + runner.run(ctx) |
| + if len(bodies) != 2 || string(bodies[0]) != string(bodies[1]) { |
| + t.Fatal("publication retry changed the collected payload") |
| + } |
| + if listings != atFirstPut || listings == 0 { |
| + t.Fatalf("old date recollected: first=%d final=%d", atFirstPut, listings) |
| + } |
| + if keys[0] != usage.StorageDayKey("2026-10-03") || keys[1] != keys[0] { |
| + t.Fatalf("publication dates %v", keys) |
| + } |
| + raw, _, err := fx.rel.Get(t.Context(), keys[0]) |
| + if err != nil || string(raw) != string(bodies[0]) { |
| + t.Fatalf("published payload changed: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestStorageRetryGraceExpiresWithoutYesterdayMeasurements(t *testing.T) { |
| + now := time.Date(2026, 10, 3, 23, 59, 30, 0, time.UTC) |
| + fx, store, runner := storageRetryFixture(t, &now) |
| + var collected []string |
| + collect := runner.collect |
| + runner.collect = func(ctx context.Context, date string) (*usage.StorageDay, error) { |
| + collected = append(collected, date) |
| + return collect(ctx, date) |
| + } |
| + oldWrites := 0 |
| + store.put = func(ctx context.Context, key string, body []byte, etag string) (string, error) { |
| + if key == usage.StorageDayKey("2026-10-03") { |
| + oldWrites++ |
| + if !now.Before(time.Date(2026, 10, 4, 0, 5, 0, 0, time.UTC)) { |
| + t.Fatal("old payload retried past grace") |
| + } |
| + return "", io.ErrUnexpectedEOF |
| + } |
| + return store.Store.Put(ctx, key, body, etag) |
| + } |
| + waits := 0 |
| + runner.wait = func(ctx context.Context, d time.Duration) bool { |
| + if _, _, err := fx.rel.Get(ctx, usage.StorageDayKey("2026-10-04")); err == nil { |
| + return false |
| + } |
| + waits++ |
| + if waits > 8 { |
| + t.Fatal("grace did not expire") |
| + } |
| + now = now.Add(d) |
| + return true |
| + } |
| + runner.run(t.Context()) |
| + if oldWrites != 6 || !reflect.DeepEqual(collected, []string{"2026-10-03", "2026-10-04"}) { |
| + t.Fatalf("writes=%d collections=%v", oldWrites, collected) |
| + } |
| + if _, _, err := fx.rel.Get(t.Context(), usage.StorageDayKey("2026-10-03")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("fabricated yesterday: %v", err) |
| + } |
| +} |
| + |
| +func TestStorageRetryDiscardsCollectionFinishingAfterMidnight(t *testing.T) { |
| + now := time.Date(2026, 10, 3, 23, 59, 30, 0, time.UTC) |
| + fx, store, runner := storageRetryFixture(t, &now) |
| + limits := []time.Duration{} |
| + runner.limit = func(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) { |
| + limits = append(limits, d) |
| + return context.WithCancel(ctx) |
| + } |
| + jumped := false |
| + store.sizes = func(ctx context.Context, prefix string) ([]release.KeySize, error) { |
| + if !jumped { |
| + jumped = true |
| + now = now.Add(time.Minute) |
| + } |
| + return store.Store.(release.SizeLister).ListSizes(ctx, prefix) |
| + } |
| + waits := 0 |
| + runner.wait = func(ctx context.Context, d time.Duration) bool { |
| + waits++ |
| + if waits > 2 { |
| + t.Fatal("did not finish current day") |
| + } |
| + if _, _, err := fx.rel.Get(ctx, usage.StorageDayKey("2026-10-04")); err == nil { |
| + return false |
| + } |
| + now = now.Add(d) |
| + return true |
| + } |
| + runner.run(t.Context()) |
| + if len(limits) < 1 || limits[0] != 30*time.Second { |
| + t.Fatalf("collection was not bounded by midnight: %v", limits) |
| + } |
| + if _, _, err := fx.rel.Get(t.Context(), usage.StorageDayKey("2026-10-03")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("late collection labeled yesterday: %v", err) |
| + } |
| + if _, _, err := fx.rel.Get(t.Context(), usage.StorageDayKey("2026-10-04")); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestStorageRetryConcurrentCreatorWins(t *testing.T) { |
| + now := time.Date(2026, 10, 3, 23, 30, 0, 0, time.UTC) |
| + fx, store, runner := storageRetryFixture(t, &now) |
| + winner := []byte(`{"v":1,"date":"2026-10-03","apps":{}}`) |
| + puts := 0 |
| + store.put = func(ctx context.Context, key string, body []byte, etag string) (string, error) { |
| + puts++ |
| + if _, err := store.Store.Put(ctx, key, winner, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + return store.Store.Put(ctx, key, body, etag) |
| + } |
| + runner.wait = func(_ context.Context, d time.Duration) bool { |
| + if d != 30*time.Minute { |
| + t.Fatalf("CAS loser scheduled retry: %v", d) |
| + } |
| + return false |
| + } |
| + runner.run(t.Context()) |
| + raw, _, err := fx.rel.Get(t.Context(), usage.StorageDayKey("2026-10-03")) |
| + if puts != 1 || err != nil || string(raw) != string(winner) { |
| + t.Fatalf("concurrent winner overwritten: puts=%d err=%v", puts, err) |
| + } |
| +} |
| + |
| +func TestStorageRetryCollectionCancellation(t *testing.T) { |
| + now := time.Date(2026, 10, 3, 23, 30, 0, 0, time.UTC) |
| + fx, store, runner := storageRetryFixture(t, &now) |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + defer cancel() |
| + entered := make(chan struct{}) |
| + opCancel := make(chan context.CancelFunc, 1) |
| + var once sync.Once |
| + store.dirs = func(ctx context.Context, prefix string) ([]string, error) { |
| + blocked := false |
| + once.Do(func() { blocked = true; close(entered) }) |
| + if blocked { |
| + <-ctx.Done() |
| + return nil, ctx.Err() |
| + } |
| + return store.Store.ListDirs(ctx, prefix) |
| + } |
| + runner.limit = func(parent context.Context, d time.Duration) (context.Context, context.CancelFunc) { |
| + if d != 2*time.Minute { |
| + t.Errorf("unbounded operation: %v", d) |
| + } |
| + op, stop := context.WithCancel(parent) |
| + select { |
| + case opCancel <- stop: |
| + default: |
| + } |
| + return op, stop |
| + } |
| + runner.wait = func(ctx context.Context, d time.Duration) bool { |
| + if _, _, err := fx.rel.Get(ctx, usage.StorageDayKey("2026-10-03")); err == nil { |
| + return false |
| + } |
| + now = now.Add(d) |
| + return true |
| + } |
| + done := make(chan struct{}) |
| + go func() { defer close(done); runner.run(ctx) }() |
| + <-entered |
| + (<-opCancel)() |
| + select { |
| + case <-done: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("canceled collection kept retry owner blocked") |
| + } |
| + if _, _, err := fx.rel.Get(t.Context(), usage.StorageDayKey("2026-10-03")); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestStorageRetryIndependentOfHungCensus(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.ceCosts = func(context.Context, time.Time, time.Time) (map[string]float64, error) { |
| + return nil, io.ErrUnexpectedEOF |
| + } |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + defer cancel() |
| + census := make(chan struct{}) |
| + published := make(chan struct{}) |
| + var censusOnce, publishOnce sync.Once |
| + store := &storageRetryTestStore{Store: fx.rel} |
| + store.get = func(ctx context.Context, key string) ([]byte, string, error) { |
| + if key == usage.CensusDayKey(usage.DayOf(time.Now())) { |
| + censusOnce.Do(func() { close(census) }) |
| + <-ctx.Done() |
| + return nil, "", ctx.Err() |
| + } |
| + return store.Store.Get(ctx, key) |
| + } |
| + store.put = func(ctx context.Context, key string, body []byte, etag string) (string, error) { |
| + out, err := store.Store.Put(ctx, key, body, etag) |
| + if strings.HasPrefix(key, "usage/storage/") && err == nil { |
| + publishOnce.Do(func() { close(published) }) |
| + } |
| + return out, err |
| + } |
| + fx.srv.cfg.Releases = store |
| + done := make(chan struct{}) |
| + go func() { defer close(done); fx.srv.StartEconomicsPass(ctx) }() |
| + select { |
| + case <-census: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("census did not enter") |
| + } |
| + select { |
| + case <-published: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("census blocked storage worker") |
| + } |
| + cancel() |
| + select { |
| + case <-done: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("economics scheduler did not stop") |
| + } |
| +} |
| + |
| +func TestStorageRetryRejectsCanceledSuccessfulCollection(t *testing.T) { |
| + now := time.Date(2026, 10, 3, 23, 30, 0, 0, time.UTC) |
| + _, _, runner := storageRetryFixture(t, &now) |
| + published := false |
| + runner.publish = func(context.Context, string, []byte) error { published = true; return nil } |
| + runner.limit = func(parent context.Context, d time.Duration) (context.Context, context.CancelFunc) { |
| + ctx, cancel := context.WithCancel(parent) |
| + cancel() |
| + return ctx, cancel |
| + } |
| + runner.collect = func(context.Context, string) (*usage.StorageDay, error) { |
| + return &usage.StorageDay{}, nil |
| + } |
| + runner.wait = func(context.Context, time.Duration) bool { return false } |
| + runner.run(t.Context()) |
| + if published { |
| + t.Fatal("canceled collection was published") |
| + } |
| +} |
| + |
| +func TestStorageRetrySchedulerJoinsWorker(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.ceCosts = func(context.Context, time.Time, time.Time) (map[string]float64, error) { |
| + return nil, io.ErrUnexpectedEOF |
| + } |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + defer cancel() |
| + entered, releaseWorker := make(chan struct{}), make(chan struct{}) |
| + defer close(releaseWorker) |
| + var once sync.Once |
| + store := &storageRetryTestStore{Store: fx.rel} |
| + store.get = func(ctx context.Context, key string) ([]byte, string, error) { |
| + if key == usage.StorageDayKey(usage.DayOf(time.Now())) { |
| + once.Do(func() { close(entered) }) |
| + <-ctx.Done() |
| + <-releaseWorker |
| + return nil, "", ctx.Err() |
| + } |
| + if key == usage.CensusDayKey(usage.DayOf(time.Now())) { |
| + <-ctx.Done() |
| + return nil, "", ctx.Err() |
| + } |
| + return store.Store.Get(ctx, key) |
| + } |
| + fx.srv.cfg.Releases = store |
| + done := make(chan struct{}) |
| + go func() { defer close(done); fx.srv.StartEconomicsPass(ctx) }() |
| + select { |
| + case <-entered: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("worker did not enter") |
| + } |
| + cancel() |
| + select { |
| + case <-done: |
| + t.Fatal("scheduler returned while worker still owns the store") |
| + case <-time.After(50 * time.Millisecond): |
| + } |
| + releaseWorker <- struct{}{} |
| + select { |
| + case <-done: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("scheduler did not join finished worker") |
| + } |
| +} |