diff --git a/api/handlers/cards_expire_test.go b/api/handlers/cards_expire_test.go index cc15608..f8a787c 100644 --- a/api/handlers/cards_expire_test.go +++ b/api/handlers/cards_expire_test.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "os" "testing" "time" @@ -58,7 +59,7 @@ func TestReconcileExpiredCards_RemovesPastDeadlineOnly(t *testing.T) { t.Fatalf("seed cart: %v", err) } - ReconcileExpiredCards() + ReconcileExpiredCards(context.Background()) var remaining []models.Card if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil { @@ -117,7 +118,7 @@ func TestReconcileExpiredCards_SweepsAcrossUsers(t *testing.T) { } } - ReconcileExpiredCards() + ReconcileExpiredCards(context.Background()) var count int64 if err := db.Model(&models.Card{}).Count(&count).Error; err != nil { diff --git a/api/handlers/reconcile.go b/api/handlers/reconcile.go index 3b21009..8488381 100644 --- a/api/handlers/reconcile.go +++ b/api/handlers/reconcile.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "fmt" "log/slog" "time" @@ -27,8 +28,8 @@ func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") } // and checks whether Stripe has a succeeded PaymentIntent for them. This fixes // the case where the server crashed after charging but before the DB transaction // completed. -func ReconcileOrders() { - db := database.GetDB() +func ReconcileOrders(ctx context.Context) { + db := database.GetDB().WithContext(ctx) var orders []models.Order cutoff := time.Now().Add(-10 * time.Minute) @@ -42,9 +43,14 @@ func ReconcileOrders() { reconcileLog().Info("checking pending orders", "count", len(orders)) for _, order := range orders { + if err := ctx.Err(); err != nil { + reconcileLog().Info("reconcile orders cancelled mid-loop", "error", err) + return + } params := &stripe.PaymentIntentSearchParams{ SearchParams: stripe.SearchParams{ - Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID), + Context: ctx, + Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID), }, } @@ -90,8 +96,8 @@ func ReconcileOrders() { // from ReconcileOrders: the empty-PI predicate there is load-bearing for // the crash-mid-create case (see sca-psd2-compliance.md caveat #20). // stripe.Key is wired once at startup in api/main.go. -func ReconcileStale3DSOrders(threshold time.Duration) { - db := database.GetDB() +func ReconcileStale3DSOrders(ctx context.Context, threshold time.Duration) { + db := database.GetDB().WithContext(ctx) var orders []models.Order // Predicate keys off updated_at, not created_at: respondRequiresAction @@ -110,7 +116,12 @@ func ReconcileStale3DSOrders(threshold time.Duration) { reconcileLog().Info("checking stale requires_action orders", "count", len(orders)) + piParams := &stripe.PaymentIntentParams{Params: stripe.Params{Context: ctx}} for i := range orders { + if err := ctx.Err(); err != nil { + reconcileLog().Info("stale 3ds sweep cancelled mid-loop", "error", err) + return + } order := &orders[i] if order.StripePaymentID == "" { // respondRequiresAction writes stripe_payment_id and status in @@ -120,7 +131,7 @@ func ReconcileStale3DSOrders(threshold time.Duration) { middleware.RecordAbandoned3DSSweep("missing_pi") continue } - actual, err := paymentIntentGetFn(order.StripePaymentID, nil) + actual, err := paymentIntentGetFn(order.StripePaymentID, piParams) if err != nil { reconcileLog().Error("stripe get failed", "order_id", order.ID, "pi", order.StripePaymentID, "error", err) @@ -187,8 +198,8 @@ func flipStale3DSToFailed(db *gorm.DB, order *models.Order, stripeStatus string) // Read paths filter out expired rows so users never see them; this job drives // the Stripe detach + audit + physical row delete out of band so request // handlers never block on Stripe API calls during a card sweep. -func ReconcileExpiredCards() { - db := database.GetDB() +func ReconcileExpiredCards(ctx context.Context) { + db := database.GetDB().WithContext(ctx) var cards []models.Card if err := db.Where( @@ -204,16 +215,20 @@ func ReconcileExpiredCards() { reconcileLog().Info("expiring inactive saved cards", "count", len(cards)) for i := range cards { + if err := ctx.Err(); err != nil { + reconcileLog().Info("card sweep cancelled mid-loop", "error", err) + return + } if err := expireStoredCard(db, &cards[i]); err != nil { reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err) } } } -// ReconcileUnshipped finds paid order orlder than 24h with no tracking -// number and posts single Slack reminder. Pure read + notif -func ReconcileUnshipped() { - db := database.GetDB() +// ReconcileUnshipped finds paid orders older than 24h with no tracking +// number and posts a single Slack reminder. Pure read + notify. +func ReconcileUnshipped(ctx context.Context) { + db := database.GetDB().WithContext(ctx) var orders []models.Order cutoff := time.Now().Add(-24 * time.Hour) diff --git a/api/handlers/reconcile_test.go b/api/handlers/reconcile_test.go index 59ed00f..e23c9d0 100644 --- a/api/handlers/reconcile_test.go +++ b/api/handlers/reconcile_test.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "fmt" "os" "testing" @@ -44,7 +45,7 @@ func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) { flipped := middleware.Abandoned3DSSweepCounter().WithLabelValues("flipped") before := testutil.ToFloat64(flipped) - ReconcileStale3DSOrders(30 * time.Minute) + ReconcileStale3DSOrders(context.Background(), 30*time.Minute) var reloaded models.Order if err := db.First(&reloaded, order.ID).Error; err != nil { @@ -93,7 +94,7 @@ func TestReconcileStale3DSOrders_SkipsRecent(t *testing.T) { }, nil } - ReconcileStale3DSOrders(30 * time.Minute) + ReconcileStale3DSOrders(context.Background(), 30*time.Minute) if called { t.Fatalf("stripe Get called for in-window row") @@ -135,7 +136,7 @@ func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) { skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("succeeded_skip") before := testutil.ToFloat64(skip) - ReconcileStale3DSOrders(30 * time.Minute) + ReconcileStale3DSOrders(context.Background(), 30*time.Minute) var reloaded models.Order if err := db.First(&reloaded, order.ID).Error; err != nil { @@ -178,7 +179,7 @@ func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) { noop := middleware.Abandoned3DSSweepCounter().WithLabelValues("flip_noop") before := testutil.ToFloat64(noop) - ReconcileStale3DSOrders(30 * time.Minute) + ReconcileStale3DSOrders(context.Background(), 30*time.Minute) var reloaded models.Order if err := db.First(&reloaded, order.ID).Error; err != nil { @@ -216,7 +217,7 @@ func TestReconcileStale3DSOrders_FlipsCanceledPI(t *testing.T) { }, nil } - ReconcileStale3DSOrders(30 * time.Minute) + ReconcileStale3DSOrders(context.Background(), 30*time.Minute) var reloaded models.Order if err := db.First(&reloaded, order.ID).Error; err != nil { @@ -252,7 +253,7 @@ func TestReconcileStale3DSOrders_DoesNotConsumeRetryCap(t *testing.T) { }, nil } - ReconcileStale3DSOrders(30 * time.Minute) + ReconcileStale3DSOrders(context.Background(), 30*time.Minute) var retryCount int64 if err := db.Model(&models.OrderEvent{}). @@ -298,7 +299,7 @@ func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) { skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("processing_skip") before := testutil.ToFloat64(skip) - ReconcileStale3DSOrders(30 * time.Minute) + ReconcileStale3DSOrders(context.Background(), 30*time.Minute) var reloaded models.Order if err := db.First(&reloaded, order.ID).Error; err != nil { @@ -341,7 +342,7 @@ func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) { unknown := middleware.Abandoned3DSSweepCounter().WithLabelValues("unrecognized_status") before := testutil.ToFloat64(unknown) - ReconcileStale3DSOrders(30 * time.Minute) + ReconcileStale3DSOrders(context.Background(), 30*time.Minute) var reloaded models.Order if err := db.First(&reloaded, order.ID).Error; err != nil { @@ -379,7 +380,7 @@ func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) { stripeErr := middleware.Abandoned3DSSweepCounter().WithLabelValues("stripe_error") before := testutil.ToFloat64(stripeErr) - ReconcileStale3DSOrders(30 * time.Minute) + ReconcileStale3DSOrders(context.Background(), 30*time.Minute) var reloaded models.Order if err := db.First(&reloaded, order.ID).Error; err != nil { @@ -422,7 +423,7 @@ func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) { missing := middleware.Abandoned3DSSweepCounter().WithLabelValues("missing_pi") before := testutil.ToFloat64(missing) - ReconcileStale3DSOrders(30 * time.Minute) + ReconcileStale3DSOrders(context.Background(), 30*time.Minute) if called { t.Fatalf("stripe Get called for empty-PI row") @@ -439,6 +440,52 @@ func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) { } } +// TestReconcileStale3DSOrders_BailsMidLoopOnCancel verifies the per-iteration +// ctx.Err() check stops the sweep once the supervising context cancels, so a +// SIGTERM during a large stale-3DS batch does not block the binary past the +// 30s graceful-shutdown window in api/main.go +func TestReconcileStale3DSOrders_BailsMidLoopOnCancel(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + db := database.GetDB() + audit.SetDB(db) + defer audit.SetDB(nil) + + seedRequiresActionOrder(t, user.ID, "pi_first", time.Now().Add(-1*time.Hour)) + seedRequiresActionOrder(t, user.ID, "pi_second", time.Now().Add(-1*time.Hour)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + calls := 0 + orig := paymentIntentGetFn + defer func() { paymentIntentGetFn = orig }() + paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) { + calls++ + if calls == 1 { + cancel() + } + return &stripe.PaymentIntent{ + ID: id, + Status: stripe.PaymentIntentStatusRequiresPaymentMethod, + }, nil + } + ReconcileStale3DSOrders(ctx, 30*time.Minute) + + if calls != 1 { + t.Fatalf("want exactly 1 stripe Get call (loop bails before row 2); got %d", calls) + } + var stillPending int64 + if err := db.Model(&models.Order{}).Where("status = ?", models.OrderStatusRequiresAction).Count(&stillPending).Error; err != nil { + t.Fatalf("count requires_action rows: %v", err) + } + if stillPending < 1 { + t.Fatalf("want at least 1 row left in requires_action after mid-loop cancel; got %d", stillPending) + } +} + // seedRequiresActionOrder inserts a card + order pair in the requires_action // state. updatedAt is forced via UpdateColumn so GORM's auto-stamping of // UpdatedAt on Create does not bump the row out of the sweep predicate. diff --git a/api/main.go b/api/main.go index 0140d4d..b9d629c 100644 --- a/api/main.go +++ b/api/main.go @@ -114,7 +114,7 @@ func main() { for { select { case <-ticker.C: - handlers.ReconcileOrders() + handlers.ReconcileOrders(ctx) case <-ctx.Done(): return } @@ -122,14 +122,18 @@ func main() { }() go func() { - time.Sleep(30 * time.Second) - handlers.ReconcileUnshipped() + select { + case <-time.After(30 * time.Second): + case <-ctx.Done(): + return + } + handlers.ReconcileUnshipped(ctx) ticker := time.NewTicker(24 * time.Hour) defer ticker.Stop() for { select { case <-ticker.C: - handlers.ReconcileUnshipped() + handlers.ReconcileUnshipped(ctx) case <-ctx.Done(): return } @@ -145,7 +149,7 @@ func main() { for { select { case <-ticker.C: - handlers.ReconcileExpiredCards() + handlers.ReconcileExpiredCards(ctx) case <-ctx.Done(): return } @@ -159,7 +163,7 @@ func main() { for { select { case <-ticker.C: - handlers.ReconcileStale3DSOrders(threshold) + handlers.ReconcileStale3DSOrders(ctx, threshold) case <-ctx.Done(): return } @@ -174,10 +178,10 @@ func main() { log.Println("Server is shutting down...") // Graceful shutdown with 30 second timeout - ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() - if err := server.Shutdown(ctx); err != nil { + if err := server.Shutdown(shutdownCtx); err != nil { log.Fatalf("Server forced to shutdown: %v", err) }