diff --git a/spindle/db/db.go b/spindle/db/db.go index 4649d6ea9..089ea9fe2 100644 --- a/spindle/db/db.go +++ b/spindle/db/db.go @@ -1304,6 +1304,7 @@ func runMigrations(_ context.Context, conn *sql.Conn, logger *slog.Logger) error used integer not null check (used >= 0), primary key (did, resource, period_start) ); + create index if not exists idx_quota_usage_periods_start on quota_usage_periods(period_start); create table if not exists quota_usage_ledger ( usage_id text primary key, diff --git a/spindle/db/quota_minutes.go b/spindle/db/quota_minutes.go index 57841b93b..1110b749c 100644 --- a/spindle/db/quota_minutes.go +++ b/spindle/db/quota_minutes.go @@ -14,10 +14,8 @@ var _ quota.MinutesStore = (*QuotaStore)(nil) // 90 days just has to outlast a duplicate finish, not a billing period const minutesLedgerRetention = 90 * 24 * time.Hour -func (d *QuotaStore) WithClock(now func() time.Time) *QuotaStore { - d.now = now - return d -} +// a year of history plus the month being read back +const minutesUsageRetentionMonths = 13 func (d *QuotaStore) CheckMinutes(ctx context.Context, id quota.Identity) error { conn, err := d.db.Conn(ctx) @@ -80,6 +78,11 @@ func (d *QuotaStore) RecordMinutes(ctx context.Context, usageID string, id quota `, now.Add(-minutesLedgerRetention).Unix()); err != nil { return err } + if _, err := conn.ExecContext(ctx, ` + DELETE FROM quota_usage_periods WHERE period_start < ? + `, now.AddDate(0, -minutesUsageRetentionMonths, 0).Unix()); err != nil { + return err + } for _, period := range quota.CIMinutesPeriods { start, _ := period.Window(now) diff --git a/spindle/db/quota_minutes_test.go b/spindle/db/quota_minutes_test.go index 1f88b89b8..11bc65490 100644 --- a/spindle/db/quota_minutes_test.go +++ b/spindle/db/quota_minutes_test.go @@ -125,7 +125,8 @@ func TestMinutesDeniedOnceTheOwnerIsUsedUpAcrossRepos(t *testing.T) { func TestMinutesDenialMessageNamesUsageAndReset(t *testing.T) { clock := newTestClock(time.Date(2026, time.October, 17, 12, 0, 0, 0, time.UTC)) - qs := NewQuotaStore(newTestDB(t), minutesDefaults(3000, 0)).WithClock(clock.Now) + qs := NewQuotaStore(newTestDB(t), minutesDefaults(3000, 0)) + qs.now = clock.Now recordMinutes(t, qs, "wf-1", alice1, 3000) got := requireMinutesDenied(t, qs, alice1).Error() @@ -165,7 +166,8 @@ func TestMinutesOverrideBeatsDefaultAndNullMeansUnlimited(t *testing.T) { func TestMinutesResetWhenTheMonthRollsOver(t *testing.T) { clock := newTestClock(time.Date(2026, time.October, 31, 23, 59, 0, 0, time.UTC)) - qs := NewQuotaStore(newTestDB(t), minutesDefaults(10, 0)).WithClock(clock.Now) + qs := NewQuotaStore(newTestDB(t), minutesDefaults(10, 0)) + qs.now = clock.Now recordMinutes(t, qs, "wf-1", alice1, 10) requireMinutesDenied(t, qs, alice1) @@ -225,7 +227,8 @@ func TestMinutesChargeOneRowWhenRepoIsItsOwnOwner(t *testing.T) { func TestMinutesLedgerIsPruned(t *testing.T) { clock := newTestClock(time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)) - qs := NewQuotaStore(newTestDB(t), minutesDefaults(0, 0)).WithClock(clock.Now) + qs := NewQuotaStore(newTestDB(t), minutesDefaults(0, 0)) + qs.now = clock.Now recordMinutes(t, qs, "old", alice1, 1) clock.Set(clock.Now().Add(minutesLedgerRetention + time.Hour)) @@ -301,3 +304,27 @@ func TestRecoverBillsNothingForOrphanedWorkflows(t *testing.T) { t.Fatalf("usage rows after recovery = %d, %v, want none", n, err) } } + +func TestMinutesUsageRowsOlderThanThirteenMonthsArePruned(t *testing.T) { + clock := newTestClock(time.Date(2025, time.January, 15, 0, 0, 0, 0, time.UTC)) + qs := NewQuotaStore(newTestDB(t), minutesDefaults(0, 0)) + qs.now = clock.Now + + recordMinutes(t, qs, "old", alice1, 1) + clock.Set(time.Date(2025, time.December, 15, 0, 0, 0, 0, time.UTC)) + recordMinutes(t, qs, "within-a-year", alice1, 1) + var n int + if err := qs.db.QueryRow(`SELECT count(*) FROM quota_usage_periods`).Scan(&n); err != nil || n != 4 { + t.Fatalf("usage rows after eleven months = %d, %v, want both months kept", n, err) + } + + clock.Set(time.Date(2026, time.March, 15, 0, 0, 0, 0, time.UTC)) + recordMinutes(t, qs, "new", alice1, 1) + if err := qs.db.QueryRow(`SELECT count(*) FROM quota_usage_periods WHERE period_start < ?`, + time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC).Unix()).Scan(&n); err != nil || n != 0 { + t.Fatalf("usage rows older than 13 months = %d, %v, want none", n, err) + } + if err := qs.db.QueryRow(`SELECT count(*) FROM quota_usage_periods`).Scan(&n); err != nil || n != 4 { + t.Fatalf("usage rows = %d, %v, want december and march for both scopes", n, err) + } +}