| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119 |
- package service
- import (
- "context"
- "database/sql"
- "log/slog"
- "sync"
- "time"
- "github.com/google/uuid"
- )
- const expiryCheckTimeout = 30 * time.Second
- const (
- // paymentOrderExpiryLeaderLockKey gates the periodic reconcile + expiry sweep so
- // that only one instance issues the upstream payment-provider calls per cycle.
- paymentOrderExpiryLeaderLockKey = "payment:order:expiry:leader"
- // paymentOrderExpiryLeaderLockTTL must exceed the combined reconcile + expiry
- // timeouts (2 * expiryCheckTimeout) so the lock never expires mid-run.
- paymentOrderExpiryLeaderLockTTL = 3 * time.Minute
- )
- // PaymentOrderExpiryService periodically expires timed-out payment orders.
- type PaymentOrderExpiryService struct {
- paymentSvc *PaymentService
- interval time.Duration
- stopCh chan struct{}
- stopOnce sync.Once
- wg sync.WaitGroup
- lockCache LeaderLockCache
- db *sql.DB
- instanceID string
- }
- func NewPaymentOrderExpiryService(paymentSvc *PaymentService, interval time.Duration) *PaymentOrderExpiryService {
- return &PaymentOrderExpiryService{
- paymentSvc: paymentSvc,
- interval: interval,
- stopCh: make(chan struct{}),
- instanceID: uuid.NewString(),
- }
- }
- // SetLeaderLock injects the leader-lock cache and DB used to elect a single
- // instance for the periodic reconcile/expiry sweep. When both are nil the job
- // runs ungated (single-instance / test behavior).
- func (s *PaymentOrderExpiryService) SetLeaderLock(lockCache LeaderLockCache, db *sql.DB) {
- if s == nil {
- return
- }
- s.lockCache = lockCache
- s.db = db
- }
- func (s *PaymentOrderExpiryService) Start() {
- if s == nil || s.paymentSvc == nil || s.interval <= 0 {
- return
- }
- s.wg.Add(1)
- go func() {
- defer s.wg.Done()
- ticker := time.NewTicker(s.interval)
- defer ticker.Stop()
- s.runOnce()
- for {
- select {
- case <-ticker.C:
- s.runOnce()
- case <-s.stopCh:
- return
- }
- }
- }()
- }
- func (s *PaymentOrderExpiryService) Stop() {
- if s == nil {
- return
- }
- s.stopOnce.Do(func() {
- close(s.stopCh)
- })
- s.wg.Wait()
- }
- func (s *PaymentOrderExpiryService) runOnce() {
- // Multi-instance guard: only the leader reconciles/expires orders per cycle,
- // avoiding N× upstream payment-provider API calls and update races.
- lockCtx, lockCancel := context.WithTimeout(context.Background(), 2*time.Second)
- release, ok := tryAcquireSingletonLeaderLock(lockCtx, s.lockCache, s.db, paymentOrderExpiryLeaderLockKey, s.instanceID, paymentOrderExpiryLeaderLockTTL)
- lockCancel()
- if !ok {
- return
- }
- defer release()
- reconcileCtx, cancel := context.WithTimeout(context.Background(), expiryCheckTimeout)
- recovered, err := s.paymentSvc.ReconcilePendingPaymentOrders(reconcileCtx)
- cancel()
- if err != nil {
- slog.Warn("[PaymentOrderExpiry] failed to reconcile pending payment orders", "error", err)
- } else if recovered > 0 {
- slog.Info("[PaymentOrderExpiry] reconciled paid orders", "count", recovered)
- }
- expireCtx, cancel := context.WithTimeout(context.Background(), expiryCheckTimeout)
- defer cancel()
- expired, err := s.paymentSvc.ExpireTimedOutOrders(expireCtx)
- if err != nil {
- slog.Error("[PaymentOrderExpiry] failed to expire orders", "error", err)
- return
- }
- if expired > 0 {
- slog.Info("[PaymentOrderExpiry] expired timed-out orders", "count", expired)
- }
- }
|