payment_order_expiry_service.go 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. package service
  2. import (
  3. "context"
  4. "database/sql"
  5. "log/slog"
  6. "sync"
  7. "time"
  8. "github.com/google/uuid"
  9. )
  10. const expiryCheckTimeout = 30 * time.Second
  11. const (
  12. // paymentOrderExpiryLeaderLockKey gates the periodic reconcile + expiry sweep so
  13. // that only one instance issues the upstream payment-provider calls per cycle.
  14. paymentOrderExpiryLeaderLockKey = "payment:order:expiry:leader"
  15. // paymentOrderExpiryLeaderLockTTL must exceed the combined reconcile + expiry
  16. // timeouts (2 * expiryCheckTimeout) so the lock never expires mid-run.
  17. paymentOrderExpiryLeaderLockTTL = 3 * time.Minute
  18. )
  19. // PaymentOrderExpiryService periodically expires timed-out payment orders.
  20. type PaymentOrderExpiryService struct {
  21. paymentSvc *PaymentService
  22. interval time.Duration
  23. stopCh chan struct{}
  24. stopOnce sync.Once
  25. wg sync.WaitGroup
  26. lockCache LeaderLockCache
  27. db *sql.DB
  28. instanceID string
  29. }
  30. func NewPaymentOrderExpiryService(paymentSvc *PaymentService, interval time.Duration) *PaymentOrderExpiryService {
  31. return &PaymentOrderExpiryService{
  32. paymentSvc: paymentSvc,
  33. interval: interval,
  34. stopCh: make(chan struct{}),
  35. instanceID: uuid.NewString(),
  36. }
  37. }
  38. // SetLeaderLock injects the leader-lock cache and DB used to elect a single
  39. // instance for the periodic reconcile/expiry sweep. When both are nil the job
  40. // runs ungated (single-instance / test behavior).
  41. func (s *PaymentOrderExpiryService) SetLeaderLock(lockCache LeaderLockCache, db *sql.DB) {
  42. if s == nil {
  43. return
  44. }
  45. s.lockCache = lockCache
  46. s.db = db
  47. }
  48. func (s *PaymentOrderExpiryService) Start() {
  49. if s == nil || s.paymentSvc == nil || s.interval <= 0 {
  50. return
  51. }
  52. s.wg.Add(1)
  53. go func() {
  54. defer s.wg.Done()
  55. ticker := time.NewTicker(s.interval)
  56. defer ticker.Stop()
  57. s.runOnce()
  58. for {
  59. select {
  60. case <-ticker.C:
  61. s.runOnce()
  62. case <-s.stopCh:
  63. return
  64. }
  65. }
  66. }()
  67. }
  68. func (s *PaymentOrderExpiryService) Stop() {
  69. if s == nil {
  70. return
  71. }
  72. s.stopOnce.Do(func() {
  73. close(s.stopCh)
  74. })
  75. s.wg.Wait()
  76. }
  77. func (s *PaymentOrderExpiryService) runOnce() {
  78. // Multi-instance guard: only the leader reconciles/expires orders per cycle,
  79. // avoiding N× upstream payment-provider API calls and update races.
  80. lockCtx, lockCancel := context.WithTimeout(context.Background(), 2*time.Second)
  81. release, ok := tryAcquireSingletonLeaderLock(lockCtx, s.lockCache, s.db, paymentOrderExpiryLeaderLockKey, s.instanceID, paymentOrderExpiryLeaderLockTTL)
  82. lockCancel()
  83. if !ok {
  84. return
  85. }
  86. defer release()
  87. reconcileCtx, cancel := context.WithTimeout(context.Background(), expiryCheckTimeout)
  88. recovered, err := s.paymentSvc.ReconcilePendingPaymentOrders(reconcileCtx)
  89. cancel()
  90. if err != nil {
  91. slog.Warn("[PaymentOrderExpiry] failed to reconcile pending payment orders", "error", err)
  92. } else if recovered > 0 {
  93. slog.Info("[PaymentOrderExpiry] reconciled paid orders", "count", recovered)
  94. }
  95. expireCtx, cancel := context.WithTimeout(context.Background(), expiryCheckTimeout)
  96. defer cancel()
  97. expired, err := s.paymentSvc.ExpireTimedOutOrders(expireCtx)
  98. if err != nil {
  99. slog.Error("[PaymentOrderExpiry] failed to expire orders", "error", err)
  100. return
  101. }
  102. if expired > 0 {
  103. slog.Info("[PaymentOrderExpiry] expired timed-out orders", "count", expired)
  104. }
  105. }