From a91120bbe704abf6644779d99f2901e793698d06 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Tue, 27 Oct 2026 23:19:37 +0000 Subject: [PATCH 1/2] fix(purchase): skip scheduled purchases due more than 24h ago in the fire and process sweeps The fire and process sweeps ship opt-in on most platforms, so the first tick after one is scheduled can find a long backlog of overdue rows. Until now every one of them was bought on that tick, on whatever recommendations and prices the row was created with. Both sweeps now check the row their "running" claim returned, right before the provider call. A row due more than the cap ago (scheduled_execution_at for a delayed purchase, scheduled_date otherwise) is refused before submission through the existing failClaimedBeforeSubmission path. It ends failed with no_submission set and the reason in its error, its suppressions are released, and operators get the purchase-failed alert once. It can be retried from History. The check runs after the claim, so overlapping sweeps, a revoke or an approve can't buy or double-report a row the cap refused. Rows still awaiting human approval are never claimed by the sweep, so the cap doesn't touch them. The SQS execute_purchase path keeps no cap. The cap defaults to 24h (matching LadderFireGrace) and can be set with SCHEDULED_PURCHASE_MAX_OVERDUE (a Go duration). A malformed or non-positive value fails startup. FireResult and ProcessResult report the refused rows as stale. Refs #712 Co-Authored-By: Claude Opus 5.5 --- .env.example | 1 + internal/purchase/due_age_cap.go | 56 +++++ .../purchase/due_age_cap_integration_test.go | 143 ++++++++++++ internal/purchase/due_age_cap_test.go | 219 ++++++++++++++++++ internal/purchase/manager.go | 47 +++- .../post_cas_guarded_save_integration_test.go | 5 +- .../purchase/post_cas_guarded_save_test.go | 8 +- internal/purchase/scheduled_fire.go | 69 ++++-- internal/server/app.go | 64 +++-- internal/server/app_test.go | 11 +- internal/server/handler.go | 6 +- 11 files changed, 579 insertions(+), 50 deletions(-) create mode 100644 internal/purchase/due_age_cap.go create mode 100644 internal/purchase/due_age_cap_integration_test.go create mode 100644 internal/purchase/due_age_cap_test.go diff --git a/.env.example b/.env.example index 46cd66add..1e57618b8 100644 --- a/.env.example +++ b/.env.example @@ -123,4 +123,5 @@ DASHBOARD_URL=http://localhost:3000 # CUDLY_MAX_ACCOUNT_PARALLELISM=8 # DEFAULT_PAYMENT_OPTION=no-upfront # DEFAULT_RAMP_SCHEDULE=quarterly +# SCHEDULED_PURCHASE_MAX_OVERDUE=24h # ENVIRONMENT=local diff --git a/internal/purchase/due_age_cap.go b/internal/purchase/due_age_cap.go new file mode 100644 index 000000000..284546c5f --- /dev/null +++ b/internal/purchase/due_age_cap.go @@ -0,0 +1,56 @@ +package purchase + +import ( + "errors" + "fmt" + "time" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" +) + +// DefaultMaxDueRowAge is how long after its due time the fire and process +// sweeps may still buy a row when ManagerConfig.MaxDueRowAge is unset. It +// matches config.LadderFireGrace, the same limit the ladder already applies to +// its own scheduled tranches. +const DefaultMaxDueRowAge = 24 * time.Hour + +// ErrDueRowTooOld marks a row the fire or process sweep refused because it was +// due more than the due-row age cap ago (issue #712). The sweeps are opt-in on +// most platforms, so the first tick after one is enabled can find a long +// backlog of overdue rows whose recommendations and prices are stale; buying +// them all at once is the outcome this cap prevents. The row ends failed with +// no_submission set, so it can be retried deliberately from History. +var ErrDueRowTooOld = errors.New("scheduled purchase skipped: it was due too long ago") + +// dueRowMaxAge is the sweeps' age cap, DefaultMaxDueRowAge when unset. +func (m *Manager) dueRowMaxAge() time.Duration { + if m.maxDueRowAge > 0 { + return m.maxDueRowAge + } + return DefaultMaxDueRowAge +} + +// dueAt is when the sweeps consider exec due: scheduled_execution_at for a +// delayed (status "scheduled") purchase, which only the delayed-approve path +// sets, and scheduled_date for every other row. +func dueAt(exec *config.PurchaseExecution) time.Time { + if exec.ScheduledExecutionAt != nil { + return *exec.ScheduledExecutionAt + } + return exec.ScheduledDate +} + +// dueRowAgeRefusal refuses exec when it was due more than maxAge before now. +// Callers run it on the row their "running" claim returned, right before the +// provider call: the claim already shut out every other executor, so a row +// cannot age past the cap between this check and the purchase. The message is +// what History shows as the row's error. +func dueRowAgeRefusal(exec *config.PurchaseExecution, maxAge time.Duration, now time.Time) error { + due := dueAt(exec) + late := now.Sub(due) + if late <= maxAge { + return nil + } + return fmt.Errorf("%w: it was due at %s, %s ago, which is more than the %s limit (SCHEDULED_PURCHASE_MAX_OVERDUE); nothing was submitted, retry it if the purchase is still wanted", + ErrDueRowTooOld, due.UTC().Format(time.RFC3339), late.Round(time.Minute), maxAge) +} diff --git a/internal/purchase/due_age_cap_integration_test.go b/internal/purchase/due_age_cap_integration_test.go new file mode 100644 index 000000000..c2cd375f7 --- /dev/null +++ b/internal/purchase/due_age_cap_integration_test.go @@ -0,0 +1,143 @@ +//go:build integration + +package purchase + +// Real PostgreSQL plus the ramp-step fixture's counting fake provider (labeled +// fixture; nothing here reaches a cloud API). Issue #712: when the fire and +// process sweeps are first scheduled, a backlog of overdue rows must not be +// bought. Rows due more than the age cap ago end failed and unbought, with +// the reason on the row, while rows inside the cap are still bought, each +// exactly once, however many sweeps overlap. + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/LeanerCloud/cloud-commitments-platform/internal/email" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// agedRow saves a row on accounts[0] that is due `late` ago: a "scheduled" +// (delayed) purchase is due at scheduled_execution_at, every other row at +// scheduled_date. +func agedRow(ctx context.Context, t *testing.T, f *rampStepFixture, status string, late time.Duration) *config.PurchaseExecution { + t.Helper() + due := time.Now().Add(-late) + row := &config.PurchaseExecution{ + PlanID: f.plan.ID, ExecutionID: uuid.New().String(), Status: status, StepNumber: 3, + ScheduledDate: due, CloudAccountID: &f.accounts[0].ID, + Recommendations: []config.RecommendationRecord{withAccount(rampStepRecommendation()[0], f.accounts[0].ID)}, + } + if status == "scheduled" { + row.ScheduledDate = time.Now() + row.ScheduledExecutionAt = &due + } + require.NoError(t, f.store.SavePurchaseExecution(ctx, row)) + return row +} + +// countFailureEmails records each purchase-failed email by execution ID. +func countFailureEmails(f *rampStepFixture) func() map[string]int { + var mu sync.Mutex + sent := map[string]int{} + f.email.On("SendPurchaseFailedNotification", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + mu.Lock() + defer mu.Unlock() + sent[args.Get(1).(email.NotificationData).ExecutionID]++ + }).Return(nil).Maybe() + return func() map[string]int { + mu.Lock() + defer mu.Unlock() + out := make(map[string]int, len(sent)) + for k, v := range sent { + out[k] = v + } + return out + } +} + +// Overlapping fire and process sweeps over a backlog that holds one overdue +// and one fresh row of each kind. Before the cap, every overdue row was bought. +func TestDueRowAgeCap_OverlappingSweepsBuyOnlyRowsInsideTheCap(t *testing.T) { + ctx := context.Background() + f := newRampStepFixture(ctx, t) + f.repairAccounts(ctx, t) + emails := countFailureEmails(f) + + overdue := []*config.PurchaseExecution{ + agedRow(ctx, t, f, "notified", 48*time.Hour), + agedRow(ctx, t, f, "scheduled", 48*time.Hour), + } + fresh := []*config.PurchaseExecution{ + agedRow(ctx, t, f, "notified", time.Hour), + agedRow(ctx, t, f, "scheduled", time.Hour), + } + + const sweeps = 4 + var mu sync.Mutex + fired := 0 + var wg sync.WaitGroup + for i := 0; i < sweeps; i++ { + wg.Add(2) + go func() { + defer wg.Done() + _, _ = f.manager.ProcessScheduledPurchases(ctx) + }() + go func() { + defer wg.Done() + if res, err := f.manager.FireScheduledDelayedPurchases(ctx); err == nil { + mu.Lock() + fired += res.Fired + mu.Unlock() + } + }() + } + wg.Wait() + + assert.Equal(t, len(fresh), f.purchaseCount(), "only the rows inside the cap reach the provider, each once") + assert.Equal(t, 1, fired, "the fire sweeps fire the fresh delayed purchase once and the overdue one never") + sent := emails() + for _, row := range overdue { + got := rowStatus(ctx, t, f, row.ExecutionID) + assert.Equal(t, config.StatusFailed, got.Status, "overdue %s row", row.Status) + assert.True(t, got.NoSubmission, "overdue %s row is proven unbought", row.Status) + assert.Contains(t, got.Error, "due too long ago", "overdue %s row says why it was skipped", row.Status) + assert.Equal(t, 1, sent[row.ExecutionID], "operators hear about overdue %s row exactly once", row.Status) + } + for _, row := range fresh { + assert.Equal(t, "completed", rowStatus(ctx, t, f, row.ExecutionID).Status, "fresh %s row", row.Status) + assert.Zero(t, sent[row.ExecutionID], "fresh %s row sends no failure email", row.Status) + } +} + +// A later tick finds nothing left to do: the refused rows are terminal and +// are neither bought nor reported again. +func TestDueRowAgeCap_RefusedRowsStayRefused(t *testing.T) { + ctx := context.Background() + f := newRampStepFixture(ctx, t) + f.repairAccounts(ctx, t) + emails := countFailureEmails(f) + notified := agedRow(ctx, t, f, "notified", 48*time.Hour) + scheduled := agedRow(ctx, t, f, "scheduled", 48*time.Hour) + + for tick := 0; tick < 2; tick++ { + _, err := f.manager.ProcessScheduledPurchases(ctx) + require.NoError(t, err) + _, err = f.manager.FireScheduledDelayedPurchases(ctx) + require.NoError(t, err) + } + + assert.Zero(t, f.purchaseCount()) + sent := emails() + for _, row := range []*config.PurchaseExecution{notified, scheduled} { + assert.Equal(t, config.StatusFailed, rowStatus(ctx, t, f, row.ExecutionID).Status) + assert.Equal(t, 1, sent[row.ExecutionID]) + } +} diff --git a/internal/purchase/due_age_cap_test.go b/internal/purchase/due_age_cap_test.go new file mode 100644 index 000000000..e29099a42 --- /dev/null +++ b/internal/purchase/due_age_cap_test.go @@ -0,0 +1,219 @@ +package purchase + +// Issue #712: the fire and process sweeps refuse a row that was due more than +// the due-row age cap ago. Mock store, mock email and a provider factory that +// fails if reached (labeled fixtures; nothing here reaches a cloud API). The +// Postgres replay, including concurrent sweeps, is in +// due_age_cap_integration_test.go. + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDueRowAgeRefusal(t *testing.T) { + now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) + ago := func(d time.Duration) *time.Time { v := now.Add(-d); return &v } + cases := []struct { + name string + exec config.PurchaseExecution + refused bool + }{ + {"pending row inside the cap", config.PurchaseExecution{ScheduledDate: *ago(23 * time.Hour)}, false}, + {"pending row exactly at the cap", config.PurchaseExecution{ScheduledDate: *ago(24 * time.Hour)}, false}, + {"pending row past the cap", config.PurchaseExecution{ScheduledDate: *ago(24*time.Hour + time.Second)}, true}, + {"not yet due", config.PurchaseExecution{ScheduledDate: now.Add(time.Hour)}, false}, + // A delayed purchase is due at scheduled_execution_at, whatever its + // step date says. + {"scheduled row fresh, old step date", config.PurchaseExecution{ScheduledDate: *ago(30 * 24 * time.Hour), ScheduledExecutionAt: ago(time.Hour)}, false}, + {"scheduled row past the cap, recent step date", config.PurchaseExecution{ScheduledDate: *ago(time.Hour), ScheduledExecutionAt: ago(25 * time.Hour)}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := dueRowAgeRefusal(&tc.exec, DefaultMaxDueRowAge, now) + if !tc.refused { + require.NoError(t, err) + return + } + require.ErrorIs(t, err, ErrDueRowTooOld) + assert.Contains(t, err.Error(), "nothing was submitted") + assert.Contains(t, err.Error(), "SCHEDULED_PURCHASE_MAX_OVERDUE") + }) + } +} + +func TestDueRowMaxAge_DefaultAndOverride(t *testing.T) { + assert.Equal(t, 24*time.Hour, DefaultMaxDueRowAge) + assert.Equal(t, DefaultMaxDueRowAge, (&Manager{}).dueRowMaxAge(), "an unset cap is the conservative default, never no cap") + assert.Equal(t, 2*time.Hour, NewManager(ManagerConfig{MaxDueRowAge: 2 * time.Hour}).dueRowMaxAge()) +} + +// staleRefusalStore expects the refusal's writes for exec-age: the guarded +// failed save (captured) and the suppression release in one transaction. +func staleRefusalStore(store *MockConfigStore, saved **config.PurchaseExecution) { + store.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{}, nil).Maybe() + store.On("SaveExecutionIfStatusTx", mock.Anything, mock.Anything, mock.Anything, executorOwnedStatuses). + Run(func(args mock.Arguments) { + c := *args.Get(2).(*config.PurchaseExecution) + *saved = &c + }).Return(nil).Once() + store.On("DeleteSuppressionsByExecutionTx", mock.Anything, mock.Anything, "exec-age").Return(nil).Once() +} + +func assertStaleFailure(t *testing.T, saved *config.PurchaseExecution) { + t.Helper() + require.NotNil(t, saved, "the refused row must be saved as failed") + assert.Equal(t, config.StatusFailed, saved.Status) + assert.True(t, saved.NoSubmission, "the refusal is proven before submission") + assert.Contains(t, saved.Error, ErrDueRowTooOld.Error(), "History must say why the row was skipped") +} + +func overdueScheduledRow(late time.Duration) config.PurchaseExecution { + row := dueExec("exec-age") + at := time.Now().Add(-late) + row.ScheduledExecutionAt = &at + row.ScheduledDate = time.Now() + row.Recommendations = approvalTestRecs() + return row +} + +func TestFireScheduledDelayedPurchases_PastTheCapIsFailedUnbought(t *testing.T) { + for _, tc := range []struct { + name string + maxAge time.Duration + late time.Duration + }{ + {"default cap", 0, 25 * time.Hour}, + {"configured cap", 2 * time.Hour, 3 * time.Hour}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + store := new(MockConfigStore) + factory := new(MockProviderFactory) + failOnProviderCall(factory) + mailer := new(MockEmailSender) + mailer.On("SendPurchaseFailedNotification", mock.Anything, mock.Anything).Return(nil).Once() + + row := overdueScheduledRow(tc.late) + claimed := row + claimed.Status = "running" + store.On("GetScheduledExecutionsDue", ctx).Return([]config.PurchaseExecution{row}, nil) + store.On("TransitionExecutionStatus", ctx, "exec-age", []string{"scheduled"}, "running", (*string)(nil)).Return(&claimed, nil).Once() + var saved *config.PurchaseExecution + staleRefusalStore(store, &saved) + + m := &Manager{config: store, email: mailer, providerFactory: factory, maxDueRowAge: tc.maxAge} + result, err := m.FireScheduledDelayedPurchases(ctx) + require.NoError(t, err) + assert.Equal(t, FireResult{Found: 1, Stale: 1}, *result) + assertStaleFailure(t, saved) + assert.Nil(t, saved.ApprovedBy, "a refused row is not stamped as approved by the scheduler") + factory.AssertNotCalled(t, "CreateAndValidateProvider", mock.Anything, mock.Anything, mock.Anything) + store.AssertExpectations(t) + mailer.AssertExpectations(t) + }) + } +} + +// Inside the cap the row goes on to the purchase: here the configured cap is +// wider than the default and the row is 30h late, so it reaches the stamp. +func TestFireScheduledDelayedPurchases_ConfiguredCapWiderThanDefaultStillFires(t *testing.T) { + ctx := context.Background() + store := new(MockConfigStore) + row := overdueScheduledRow(30 * time.Hour) + claimed := row + claimed.Status = "running" + store.On("GetScheduledExecutionsDue", ctx).Return([]config.PurchaseExecution{row}, nil) + store.On("TransitionExecutionStatus", ctx, "exec-age", []string{"scheduled"}, "running", (*string)(nil)).Return(&claimed, nil).Once() + // The stamp finds the claim gone, which stops the run right after the + // cap check: reaching it proves the cap let the row through. + store.On("SaveExecutionIfStatus", mock.Anything, mock.Anything, executorOwnedStatuses).Return(lostGuard()).Once() + + m := &Manager{config: store, maxDueRowAge: 48 * time.Hour} + result, err := m.FireScheduledDelayedPurchases(ctx) + require.NoError(t, err) + assert.Equal(t, FireResult{Found: 1, RaceLost: 1}, *result) + store.AssertExpectations(t) +} + +// A refusal whose failed save does not land leaves the row running for the +// reaper: that is a real error, not a clean stale skip. +func TestFireScheduledDelayedPurchases_StaleRefusalSaveFailureIsErrored(t *testing.T) { + ctx := context.Background() + store := new(MockConfigStore) + mailer := allowFailedNotification(new(MockEmailSender)) + row := overdueScheduledRow(25 * time.Hour) + claimed := row + claimed.Status = "running" + store.On("GetScheduledExecutionsDue", ctx).Return([]config.PurchaseExecution{row}, nil) + store.On("TransitionExecutionStatus", ctx, "exec-age", []string{"scheduled"}, "running", (*string)(nil)).Return(&claimed, nil).Once() + store.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{}, nil).Maybe() + store.On("SaveExecutionIfStatusTx", mock.Anything, mock.Anything, mock.Anything, executorOwnedStatuses).Return(errors.New("db down")).Once() + + m := &Manager{config: store, email: mailer} + result, err := m.FireScheduledDelayedPurchases(ctx) + require.NoError(t, err) + assert.Equal(t, FireResult{Found: 1, Errored: 1}, *result) + store.AssertExpectations(t) +} + +func TestProcessScheduledPurchases_PastTheCapIsFailedUnbought(t *testing.T) { + ctx := context.Background() + store := new(MockConfigStore) + factory := new(MockProviderFactory) + failOnProviderCall(factory) + mailer := new(MockEmailSender) + mailer.On("SendPurchaseFailedNotification", mock.Anything, mock.Anything).Return(nil).Once() + + plan := &config.PurchasePlan{ID: "plan-gate", AutoPurchase: true, MaxStepCommitment: f64(1000)} + row := dueRecExec("notified", upfrontRec(10)) + row.ExecutionID = "exec-age" + row.ScheduledDate = time.Now().Add(-25 * time.Hour) + claimed := row + claimed.Status = "running" + store.On("GetStaleApprovedExecutions", ctx, mock.Anything).Return([]config.PurchaseExecution{}, nil) + store.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{row}, nil) + store.On("GetPurchasePlan", mock.Anything, "plan-gate").Return(plan, nil) + store.On("GetGlobalConfig", mock.Anything).Return(autoPurchaseOnCfg(), nil) + store.On("TransitionExecutionStatusPinned", ctx, "exec-age", []string{"approved", "pending", "notified"}, "running", (*string)(nil), false). + Return(&claimed, nil).Once() + var saved *config.PurchaseExecution + staleRefusalStore(store, &saved) + + m := &Manager{config: store, email: mailer, providerFactory: factory} + result, err := m.ProcessScheduledPurchases(ctx) + require.NoError(t, err) + assert.Equal(t, 1, result.Processed) + assert.Equal(t, 0, result.Executed) + assert.Equal(t, 1, result.Failed, "a refused row is a failed row, as for the other post-claim refusals") + assert.Equal(t, 1, result.Stale) + require.Len(t, result.Errors, 1) + assert.Contains(t, result.Errors[0], ErrDueRowTooOld.Error()) + assertStaleFailure(t, saved) + factory.AssertNotCalled(t, "CreateAndValidateProvider", mock.Anything, mock.Anything, mock.Anything) + store.AssertExpectations(t) + mailer.AssertExpectations(t) +} + +// The cap only applies to rows the sweep would buy. An overdue row on a manual +// plan stays the human's to approve: the sweep never claims or fails it. +func TestProcessScheduledPurchases_CapNeverTouchesRowsAwaitingApproval(t *testing.T) { + row := dueRecExec("notified", upfrontRec(10)) + row.ScheduledDate = time.Now().Add(-10 * 24 * time.Hour) + result := gateHarness(t, &config.PurchasePlan{ID: "plan-gate"}, row, autoPurchaseOnCfg(), nil) + assert.Equal(t, ProcessResult{}, *result) +} + +// The SQS execute_purchase path shares the claim funnel but passes no cap. +func TestClaimedRowRefusal_NoCapWithoutMaxAge(t *testing.T) { + ancient := &config.PurchaseExecution{ScheduledDate: time.Now().Add(-365 * 24 * time.Hour)} + require.NoError(t, (&Manager{}).claimedRowRefusal(context.Background(), ancient, false, 0)) + require.ErrorIs(t, (&Manager{}).claimedRowRefusal(context.Background(), ancient, false, time.Hour), ErrDueRowTooOld) +} diff --git a/internal/purchase/manager.go b/internal/purchase/manager.go index 0466e754c..64f3e70ef 100644 --- a/internal/purchase/manager.go +++ b/internal/purchase/manager.go @@ -42,6 +42,10 @@ type ManagerConfig struct { NotificationDaysBefore int DefaultCoverage float64 DefaultTerm int + // MaxDueRowAge caps how long after its due time the fire and process + // sweeps may still buy a row (issue #712); an older row is failed + // unbought instead. Zero means DefaultMaxDueRowAge. + MaxDueRowAge time.Duration } // Manager handles purchase workflow. @@ -59,6 +63,8 @@ type Manager struct { oidcIssuerURL string notifyDays int resolver stepResolver + // maxDueRowAge is ManagerConfig.MaxDueRowAge; read it via dueRowMaxAge. + maxDueRowAge time.Duration // budgets lets tests shorten purchase deadlines. Production leaves it // zero, which means the default durations below. budgets purchaseBudgets @@ -79,6 +85,10 @@ type ProcessResult struct { Executed int `json:"executed"` Failed int `json:"failed"` Recovered int `json:"recovered,omitempty"` + // Stale counts the Failed rows refused because they were due more than + // the due-row age cap ago (ErrDueRowTooOld, issue #712). Each one is + // also in Failed and Errors. + Stale int `json:"stale,omitempty"` } // staleApprovedThreshold is how long an execution may sit in the "approved" @@ -126,6 +136,7 @@ func NewManager(cfg ManagerConfig) *Manager { dashboardURL: cfg.DashboardURL, oidcSigner: cfg.OIDCSigner, oidcIssuerURL: cfg.OIDCIssuerURL, + maxDueRowAge: cfg.MaxDueRowAge, } } @@ -189,6 +200,16 @@ func (m *Manager) finalizeExecution(exec *config.PurchaseExecution, execErr erro // // On a won claim it runs executeAndFinalize and returns (true, execErr). func (m *Manager) claimAndExecute(ctx context.Context, exec *config.PurchaseExecution) (claimed bool, err error) { + return m.claimAndExecuteWithin(ctx, exec, 0) +} + +// claimAndExecuteWithin is claimAndExecute with a due-row age cap: when maxAge +// is positive, a claimed row that was due more than maxAge ago is refused +// before submission (ErrDueRowTooOld) instead of bought (issue #712). The +// check runs on the claimed row, so a concurrent sweep that lost the claim +// never reaches it and the row is failed and reported exactly once. Only the +// process sweep passes a cap; the SQS execute_purchase path passes 0. +func (m *Manager) claimAndExecuteWithin(ctx context.Context, exec *config.PurchaseExecution, maxAge time.Duration) (claimed bool, err error) { gated := exec.Status == "pending" || exec.Status == "notified" gateSawEmpty := len(exec.Recommendations) == 0 // System-initiated: SQS/cron executor passes nil so transitioned_by = NULL. @@ -207,14 +228,27 @@ func (m *Manager) claimAndExecute(ctx context.Context, exec *config.PurchaseExec // the RETURNING clause) onto the caller's struct so the rest of the run // starts from the claimed row rather than the pre-claim snapshot. *exec = *updated - if gated && len(exec.Recommendations) > 0 { - if capErr := m.recheckStepCap(ctx, exec); capErr != nil { - return true, m.failClaimedBeforeSubmission(ctx, exec, capErr) - } + if refusal := m.claimedRowRefusal(ctx, exec, gated, maxAge); refusal != nil { + return true, m.failClaimedBeforeSubmission(ctx, exec, refusal) } return true, m.executeAndFinalize(ctx, exec) } +// claimedRowRefusal runs claimAndExecuteWithin's checks on the row its claim +// returned: the due-row age cap when maxAge is positive, then the step cap for +// a gated row with recommendations. +func (m *Manager) claimedRowRefusal(ctx context.Context, exec *config.PurchaseExecution, gated bool, maxAge time.Duration) error { + if maxAge > 0 { + if refusal := dueRowAgeRefusal(exec, maxAge, time.Now()); refusal != nil { + return refusal + } + } + if gated && len(exec.Recommendations) > 0 { + return m.recheckStepCap(ctx, exec) + } + return nil +} + // isMultiAccountAckable reports whether a multi-account execErr should be ACKed // (not redelivered/recounted as a flat failure) because at least one account // committed a real purchase (issue #1014). A nil error (full success) is also @@ -938,7 +972,7 @@ func (m *Manager) processOneExecution(ctx context.Context, exec config.PurchaseE // otherwise both execute the same due row. claimAndExecute CASes the row // to "running" and only the winner runs; a lost claim is skipped without // re-executing. - claimed, execErr := m.claimAndExecute(ctx, &exec) + claimed, execErr := m.claimAndExecuteWithin(ctx, &exec, m.dueRowMaxAge()) if !claimed { // execErr != nil here is a real DB error during the claim (count as // failed); execErr == nil is a benign CAS race-loss (skip silently). @@ -957,6 +991,9 @@ func (m *Manager) processOneExecution(ctx context.Context, exec config.PurchaseE result.Executed++ return } + if errors.Is(execErr, ErrDueRowTooOld) { + result.Stale++ + } result.Failed++ result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", exec.ExecutionID, execErr)) } diff --git a/internal/purchase/post_cas_guarded_save_integration_test.go b/internal/purchase/post_cas_guarded_save_integration_test.go index 996c44568..59ea576a3 100644 --- a/internal/purchase/post_cas_guarded_save_integration_test.go +++ b/internal/purchase/post_cas_guarded_save_integration_test.go @@ -178,10 +178,7 @@ func TestPostCASSave_ClaimLostBeforeStampBuysNothing(t *testing.T) { before := f.purchaseCount() reap(t, row) - fired, raceLost := f.manager.fireOneDue(ctx, rowStatusCopy(row)) - - assert.False(t, fired) - assert.True(t, raceLost) + assert.Equal(t, fireRaceLost, f.manager.fireOneDue(ctx, rowStatusCopy(row))) assert.Equal(t, 0, f.purchaseCount()-before, "a run that lost its claim must not reach the provider") assert.Equal(t, config.StatusFailed, rowStatus(ctx, t, f, row.ExecutionID).Status) }) diff --git a/internal/purchase/post_cas_guarded_save_test.go b/internal/purchase/post_cas_guarded_save_test.go index 71047ee85..4c5089a03 100644 --- a/internal/purchase/post_cas_guarded_save_test.go +++ b/internal/purchase/post_cas_guarded_save_test.go @@ -14,6 +14,7 @@ import ( "context" "errors" "testing" + "time" "github.com/LeanerCloud/cloud-commitments-platform/internal/config" "github.com/stretchr/testify/assert" @@ -105,7 +106,7 @@ func stampLostManager(t *testing.T, factory *MockProviderFactory, stampErr error t.Helper() store := new(MockConfigStore) failOnProviderCall(factory) - claimed := &config.PurchaseExecution{ExecutionID: "exec-st", PlanID: "plan-st", Status: "running", StepNumber: 1, Recommendations: approvalTestRecs()} + claimed := &config.PurchaseExecution{ExecutionID: "exec-st", PlanID: "plan-st", Status: "running", StepNumber: 1, ScheduledDate: time.Now(), Recommendations: approvalTestRecs()} store.On("GetExecutionByID", mock.Anything, "exec-st").Return(&config.PurchaseExecution{ExecutionID: "exec-st", PlanID: "plan-st", Status: "notified"}, nil).Maybe() store.On("TransitionExecutionStatus", mock.Anything, "exec-st", mock.Anything, "running", (*string)(nil)).Return(claimed, nil).Once() store.On("SaveExecutionIfStatus", mock.Anything, claimed, executorOwnedStatuses).Return(stampErr).Once() @@ -139,10 +140,9 @@ func TestFireOneDue_StampFindsClaimLostBuysNothing(t *testing.T) { factory := new(MockProviderFactory) m, store := stampLostManager(t, factory, lostGuard()) - fired, raceLost := m.fireOneDue(ctx, &config.PurchaseExecution{ExecutionID: "exec-st", Status: "scheduled"}) + outcome := m.fireOneDue(ctx, &config.PurchaseExecution{ExecutionID: "exec-st", Status: "scheduled"}) - assert.False(t, fired) - assert.True(t, raceLost, "a claim lost before the purchase is a race loss, not an error") + assert.Equal(t, fireRaceLost, outcome, "a claim lost before the purchase is a race loss, not an error") factory.AssertNotCalled(t, "CreateAndValidateProvider", mock.Anything, mock.Anything, mock.Anything) store.AssertExpectations(t) store.AssertNumberOfCalls(t, "SaveExecutionIfStatus", 1) diff --git a/internal/purchase/scheduled_fire.go b/internal/purchase/scheduled_fire.go index df04b3f6e..aa9929ae0 100644 --- a/internal/purchase/scheduled_fire.go +++ b/internal/purchase/scheduled_fire.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/LeanerCloud/cloud-commitments-go/pkg/logging" "github.com/LeanerCloud/cloud-commitments-platform/internal/config" @@ -26,8 +27,22 @@ type FireResult struct { // Errored is the number of rows where the fire attempt failed for a reason // other than a CAS race (DB error, provider error). Worth alerting on. Errored int `json:"errored"` + // Stale is the number of claimed rows refused, unbought, because they were + // due more than the due-row age cap ago (ErrDueRowTooOld, issue #712). They + // end failed with no_submission and are not counted in Errored. + Stale int `json:"stale"` } +// fireOutcome is what fireOneDue did with one due row. +type fireOutcome int + +const ( + fireErrored fireOutcome = iota // a real error (DB, provider) + fireFired // claimed and run through executeAndFinalize + fireRaceLost // another actor owned the row first + fireStale // claimed but refused by the due-row age cap +) + // FireScheduledDelayedPurchases runs a sweep that fires all purchase_executions // with status="scheduled" and scheduled_execution_at <= NOW(). For each row: // @@ -38,6 +53,10 @@ type FireResult struct { // 3. Run executeAndFinalize to call the cloud SDK and flip the row to // completed/failed. // +// A claimed row whose scheduled_execution_at is more than the due-row age cap +// in the past is failed unbought instead (issue #712), so the first tick after +// the sweep is scheduled cannot fire a backlog of stale delayed purchases. +// // Safety: idempotent across duplicate invocations (the CAS guard prevents // double-firing). Per-row error isolation: a failure on row N never blocks // rows N+1..K. Counts are aggregated in FireResult for the caller to log. @@ -55,27 +74,30 @@ func (m *Manager) FireScheduledDelayedPurchases(ctx context.Context) (*FireResul logging.Infof("FireScheduledDelayedPurchases: found %d row(s) due for execution", len(due)) for i := range due { - exec := &due[i] - if fired, raceLost := m.fireOneDue(ctx, exec); fired { + switch m.fireOneDue(ctx, &due[i]) { + case fireFired: result.Fired++ - } else if raceLost { + case fireRaceLost: result.RaceLost++ - } else { + case fireStale: + result.Stale++ + default: result.Errored++ } } - logging.Infof("FireScheduledDelayedPurchases: found=%d fired=%d race_lost=%d errored=%d", - result.Found, result.Fired, result.RaceLost, result.Errored) + logging.Infof("FireScheduledDelayedPurchases: found=%d fired=%d race_lost=%d stale=%d errored=%d", + result.Found, result.Fired, result.RaceLost, result.Stale, result.Errored) return result, nil } -// fireOneDue attempts to fire a single due scheduled execution. Returns -// (true, false) on success, (false, true) when the CAS was lost to a -// concurrent revoke or the claim was lost before the provider call (the -// guarded audit stamp found the row no longer running), or (false, false) on -// a real error. -func (m *Manager) fireOneDue(ctx context.Context, exec *config.PurchaseExecution) (fired, raceLost bool) { +// fireOneDue attempts to fire a single due scheduled execution. It returns +// fireFired on success, fireRaceLost when the CAS was lost to a concurrent +// revoke or the claim was lost before the provider call (the guarded audit +// stamp found the row no longer running), fireStale when the claimed row was +// past the due-row age cap and was failed unbought, or fireErrored on a real +// error. +func (m *Manager) fireOneDue(ctx context.Context, exec *config.PurchaseExecution) fireOutcome { // CAS: scheduled -> running (the executor claim). If this fails with ErrExecutionNotInExpectedStatus // the revoke handler already transitioned the row to "canceled" — that is // not an error, just a CAS race loss. @@ -84,10 +106,23 @@ func (m *Manager) fireOneDue(ctx context.Context, exec *config.PurchaseExecution if err != nil { if errors.Is(err, config.ErrExecutionNotInExpectedStatus) || errors.Is(err, config.ErrNotFound) { logging.Infof("fireOneDue[%s]: CAS lost (execution already transitioned by another actor)", exec.ExecutionID) - return false, true + return fireRaceLost } logging.Errorf("fireOneDue[%s]: TransitionExecutionStatus failed: %v", exec.ExecutionID, err) - return false, false + return fireErrored + } + + // The age cap is checked on the claimed row (issue #712): this run now + // owns it, so no other sweep can buy or fail it, and the refusal fails it + // with no_submission and notifies operators exactly once. + if refusal := dueRowAgeRefusal(updated, m.dueRowMaxAge(), time.Now()); refusal != nil { + if failErr := m.failClaimedBeforeSubmission(ctx, updated, refusal); errors.Is(failErr, config.ErrAuditLoss) { + // Nothing was bought, but the failed status was not saved: the row + // stays running for the reaper, so this is a real error. + logging.Errorf("fireOneDue[%s]: %v", exec.ExecutionID, failErr) + return fireErrored + } + return fireStale } // Stamp ApprovedBy for the audit trail before executing. @@ -96,13 +131,13 @@ func (m *Manager) fireOneDue(ctx context.Context, exec *config.PurchaseExecution // Guarded on "running". A store error stays best-effort, but a lost guard // means the claim is gone, so nothing is bought (issue #835). if stampErr := m.stampClaimedAudit(ctx, updated, "approved_by"); stampErr != nil { - return false, true + return fireRaceLost } if execErr := m.executeAndFinalize(ctx, updated); execErr != nil { logging.Errorf("fireOneDue[%s]: executeAndFinalize failed: %v", exec.ExecutionID, execErr) - return false, false + return fireErrored } - return true, false + return fireFired } diff --git a/internal/server/app.go b/internal/server/app.go index f4e9b732b..4099f8c0d 100644 --- a/internal/server/app.go +++ b/internal/server/app.go @@ -139,10 +139,14 @@ type ApplicationConfig struct { APIKeySecretARN string Analytics AnalyticsConfig NotificationDaysBefore int - DefaultCoverage float64 - DefaultTerm int - EnableDashboard bool - IsLambda bool + // ScheduledPurchaseMaxOverdue is SCHEDULED_PURCHASE_MAX_OVERDUE, the + // fire and process sweeps' due-row age cap (issue #712). Zero means + // purchase.DefaultMaxDueRowAge. + ScheduledPurchaseMaxOverdue time.Duration + DefaultCoverage float64 + DefaultTerm int + EnableDashboard bool + IsLambda bool // ReportSQSBatchItemFailures gates whether handleLambdaSQSEvent returns // the partial events.SQSEventResponse{BatchItemFailures} shape (true) or // the old whole-batch aggregate error (false, default). See @@ -307,24 +311,29 @@ func LoadApplicationConfig() (ApplicationConfig, error) { } coverage = value } + maxOverdue, err := loadScheduledPurchaseMaxOverdue() + if err != nil { + return ApplicationConfig{}, err + } analyticsConfig, err := LoadAnalyticsConfig() if err != nil { return ApplicationConfig{}, err } return ApplicationConfig{ - Version: version, - NotificationDaysBefore: getEnvInt("NOTIFICATION_DAYS_BEFORE", 3), - DefaultTerm: term, - DefaultPaymentOption: os.Getenv("DEFAULT_PAYMENT_OPTION"), - DefaultCoverage: coverage, - DefaultRampSchedule: os.Getenv("DEFAULT_RAMP_SCHEDULE"), - APIKeySecretARN: os.Getenv("API_KEY_SECRET_ARN"), - EnableDashboard: os.Getenv("ENABLE_DASHBOARD") == "true", - DashboardBucket: os.Getenv("DASHBOARD_BUCKET"), - DashboardURL: os.Getenv("DASHBOARD_URL"), - IssuerURL: os.Getenv("CUDLY_ISSUER_URL"), - CORSAllowedOrigin: os.Getenv("CORS_ALLOWED_ORIGIN"), + Version: version, + NotificationDaysBefore: getEnvInt("NOTIFICATION_DAYS_BEFORE", 3), + ScheduledPurchaseMaxOverdue: maxOverdue, + DefaultTerm: term, + DefaultPaymentOption: os.Getenv("DEFAULT_PAYMENT_OPTION"), + DefaultCoverage: coverage, + DefaultRampSchedule: os.Getenv("DEFAULT_RAMP_SCHEDULE"), + APIKeySecretARN: os.Getenv("API_KEY_SECRET_ARN"), + EnableDashboard: os.Getenv("ENABLE_DASHBOARD") == "true", + DashboardBucket: os.Getenv("DASHBOARD_BUCKET"), + DashboardURL: os.Getenv("DASHBOARD_URL"), + IssuerURL: os.Getenv("CUDLY_ISSUER_URL"), + CORSAllowedOrigin: os.Getenv("CORS_ALLOWED_ORIGIN"), // SCHEDULED_TASK_SECRET (plaintext) is the legacy dev-only path. // SCHEDULED_TASK_SECRET_NAME (secret-store name) is preferred in prod; // NewApplicationFromDeps resolves it via the SecretResolver at init. @@ -335,6 +344,27 @@ func LoadApplicationConfig() (ApplicationConfig, error) { }, nil } +// loadScheduledPurchaseMaxOverdue parses SCHEDULED_PURCHASE_MAX_OVERDUE, a Go +// duration such as "24h" or "36h": how long after its due time the fire and +// process sweeps may still buy a row (issue #712). Unset returns 0, which the +// purchase manager reads as purchase.DefaultMaxDueRowAge. A malformed or +// non-positive value fails startup rather than silently changing which overdue +// purchases get bought. +func loadScheduledPurchaseMaxOverdue() (time.Duration, error) { + raw := os.Getenv("SCHEDULED_PURCHASE_MAX_OVERDUE") + if raw == "" { + return 0, nil + } + d, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("invalid SCHEDULED_PURCHASE_MAX_OVERDUE: %w", err) + } + if d <= 0 { + return 0, fmt.Errorf("invalid SCHEDULED_PURCHASE_MAX_OVERDUE: %q must be a positive duration", raw) + } + return d, nil +} + // validateAppConfigEnvDefaults validates the money-moving env-sourced defaults // at the startup boundary. Both DEFAULT_PAYMENT_OPTION and DEFAULT_RAMP_SCHEDULE // flow into the purchase manager as system-wide defaults; a typo propagates @@ -493,6 +523,7 @@ func NewApplicationFromDeps(ctx context.Context, cfg ApplicationConfig, deps Ext EmailSender: deps.EmailSender, STSClient: deps.STSClient, NotificationDaysBefore: cfg.NotificationDaysBefore, + MaxDueRowAge: cfg.ScheduledPurchaseMaxOverdue, DefaultTerm: cfg.DefaultTerm, DefaultPaymentOption: cfg.DefaultPaymentOption, DefaultCoverage: cfg.DefaultCoverage, @@ -876,6 +907,7 @@ func (app *Application) reinitializeAfterConnect(ctx context.Context, dbConn *da AmbientAWSCreds: awsCfg.Credentials, CredentialStore: credStore, NotificationDaysBefore: app.appConfig.NotificationDaysBefore, + MaxDueRowAge: app.appConfig.ScheduledPurchaseMaxOverdue, DefaultTerm: app.appConfig.DefaultTerm, DefaultPaymentOption: app.appConfig.DefaultPaymentOption, DefaultCoverage: app.appConfig.DefaultCoverage, diff --git a/internal/server/app_test.go b/internal/server/app_test.go index 285eb867e..3fbbafdef 100644 --- a/internal/server/app_test.go +++ b/internal/server/app_test.go @@ -317,6 +317,7 @@ func TestLoadApplicationConfig(t *testing.T) { "DEFAULT_PAYMENT_OPTION", "DEFAULT_COVERAGE", "DEFAULT_RAMP_SCHEDULE", "API_KEY_SECRET_ARN", "ENABLE_DASHBOARD", "DASHBOARD_BUCKET", "DASHBOARD_URL", "CORS_ALLOWED_ORIGIN", "AWS_LAMBDA_RUNTIME_API", + "SCHEDULED_PURCHASE_MAX_OVERDUE", } for _, key := range envVars { testutil.SetEnv(t, key, "") @@ -327,6 +328,8 @@ func TestLoadApplicationConfig(t *testing.T) { testutil.AssertEqual(t, "dev", cfg.Version) testutil.AssertEqual(t, 3, cfg.NotificationDaysBefore) + // Zero is read by the purchase manager as its 24h default cap. + testutil.AssertEqual(t, time.Duration(0), cfg.ScheduledPurchaseMaxOverdue) testutil.AssertEqual(t, 3, cfg.DefaultTerm) testutil.AssertEqual(t, "", cfg.DefaultPaymentOption) testutil.AssertEqual(t, 80.0, cfg.DefaultCoverage) @@ -348,6 +351,7 @@ func TestLoadApplicationConfig(t *testing.T) { testutil.SetEnv(t, "DASHBOARD_URL", "https://dash.example.com") testutil.SetEnv(t, "CORS_ALLOWED_ORIGIN", "https://example.com") testutil.SetEnv(t, "AWS_LAMBDA_RUNTIME_API", "localhost:9001") + testutil.SetEnv(t, "SCHEDULED_PURCHASE_MAX_OVERDUE", "36h") cfg, err := LoadApplicationConfig() testutil.AssertNoError(t, err) @@ -364,11 +368,12 @@ func TestLoadApplicationConfig(t *testing.T) { testutil.AssertEqual(t, "https://dash.example.com", cfg.DashboardURL) testutil.AssertEqual(t, "https://example.com", cfg.CORSAllowedOrigin) testutil.AssertEqual(t, true, cfg.IsLambda) + testutil.AssertEqual(t, 36*time.Hour, cfg.ScheduledPurchaseMaxOverdue) }) } func TestNewApplicationRejectsMalformedEnvDefaults(t *testing.T) { - for _, key := range []string{"DEFAULT_TERM", "DEFAULT_COVERAGE", "ANALYTICS_COLLECTION_ENABLED", "ANALYTICS_RETENTION_MONTHS", "ANALYTICS_PARTITIONS_AHEAD", "SQS_REPORT_BATCH_ITEM_FAILURES", "DB_HOST"} { + for _, key := range []string{"DEFAULT_TERM", "DEFAULT_COVERAGE", "SCHEDULED_PURCHASE_MAX_OVERDUE", "ANALYTICS_COLLECTION_ENABLED", "ANALYTICS_RETENTION_MONTHS", "ANALYTICS_PARTITIONS_AHEAD", "SQS_REPORT_BATCH_ITEM_FAILURES", "DB_HOST"} { t.Setenv(key, "") } for _, tc := range []struct{ key, value string }{ @@ -376,6 +381,10 @@ func TestNewApplicationRejectsMalformedEnvDefaults(t *testing.T) { {"DEFAULT_TERM", "999999999999999999999999999"}, {"DEFAULT_COVERAGE", "80%"}, {"DEFAULT_COVERAGE", "1e999"}, + {"SCHEDULED_PURCHASE_MAX_OVERDUE", "24"}, + {"SCHEDULED_PURCHASE_MAX_OVERDUE", "1d"}, + {"SCHEDULED_PURCHASE_MAX_OVERDUE", "0s"}, + {"SCHEDULED_PURCHASE_MAX_OVERDUE", "-1h"}, {"ANALYTICS_COLLECTION_ENABLED", "no"}, {"ANALYTICS_COLLECTION_ENABLED", "off"}, {"ANALYTICS_COLLECTION_ENABLED", "False "}, diff --git a/internal/server/handler.go b/internal/server/handler.go index 429dbbf8e..624078f7f 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -255,7 +255,7 @@ func (app *Application) handleProcessScheduledPurchases(ctx context.Context) (*p log.Printf("Failed to process scheduled purchases: %v", err) return nil, err } - log.Printf("Purchases processed: %d processed, %d executed, %d failed, %d stranded-approvals recovered", result.Processed, result.Executed, result.Failed, result.Recovered) + log.Printf("Purchases processed: %d processed, %d executed, %d failed (%d past the due-row age cap), %d stranded-approvals recovered", result.Processed, result.Executed, result.Failed, result.Stale, result.Recovered) if result.Failed > 0 { // A partial failure must not read as success to the scheduler (#714). // The result still goes back for callers that want the counts. @@ -354,8 +354,8 @@ func (app *Application) handleFireScheduledPurchases(ctx context.Context) (*purc log.Printf("Failed to fire scheduled purchases: %v", err) return nil, err } - log.Printf("Fire sweep complete: found=%d fired=%d race_lost=%d errored=%d", - result.Found, result.Fired, result.RaceLost, result.Errored) + log.Printf("Fire sweep complete: found=%d fired=%d race_lost=%d stale=%d errored=%d", + result.Found, result.Fired, result.RaceLost, result.Stale, result.Errored) return result, nil } From be5cf131852bd83aa3aa2b3b31ca2ace6acbfe8d Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Wed, 28 Oct 2026 00:19:39 +0000 Subject: [PATCH 2/2] fix(deps): bump golang.org/x/net to v0.62.0 (GO-2026-6860, GO-2026-6866) govulncheck in the Docker image build now fails on these two advisories, both fixed in x/net v0.62.0. Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5fb3a2f61..abcf4fb6c 100644 --- a/go.mod +++ b/go.mod @@ -59,7 +59,7 @@ require ( go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/trace v1.45.0 // indirect golang.org/x/crypto v0.58.0 - golang.org/x/net v0.61.0 // indirect + golang.org/x/net v0.62.0 // indirect golang.org/x/oauth2 v0.37.0 golang.org/x/sync v0.24.0 golang.org/x/sys v0.49.0 // indirect diff --git a/go.sum b/go.sum index 06deac0b9..83df52e66 100644 --- a/go.sum +++ b/go.sum @@ -367,8 +367,8 @@ golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=