system_operation_lock_service.go 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "strconv"
  6. "sync"
  7. "time"
  8. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  10. )
  11. const (
  12. systemOperationLockScope = "admin.system.operations.global_lock"
  13. systemOperationLockKey = "global-system-operation-lock"
  14. )
  15. var (
  16. ErrSystemOperationBusy = infraerrors.Conflict("SYSTEM_OPERATION_BUSY", "another system operation is in progress")
  17. )
  18. type SystemOperationLock struct {
  19. recordID int64
  20. operationID string
  21. stopOnce sync.Once
  22. stopCh chan struct{}
  23. }
  24. func (l *SystemOperationLock) OperationID() string {
  25. if l == nil {
  26. return ""
  27. }
  28. return l.operationID
  29. }
  30. type SystemOperationLockService struct {
  31. repo IdempotencyRepository
  32. lease time.Duration
  33. renewInterval time.Duration
  34. ttl time.Duration
  35. }
  36. func NewSystemOperationLockService(repo IdempotencyRepository, cfg IdempotencyConfig) *SystemOperationLockService {
  37. lease := cfg.ProcessingTimeout
  38. if lease <= 0 {
  39. lease = 30 * time.Second
  40. }
  41. renewInterval := lease / 3
  42. if renewInterval < time.Second {
  43. renewInterval = time.Second
  44. }
  45. ttl := cfg.SystemOperationTTL
  46. if ttl <= 0 {
  47. ttl = time.Hour
  48. }
  49. return &SystemOperationLockService{
  50. repo: repo,
  51. lease: lease,
  52. renewInterval: renewInterval,
  53. ttl: ttl,
  54. }
  55. }
  56. func (s *SystemOperationLockService) Acquire(ctx context.Context, operationID string) (*SystemOperationLock, error) {
  57. if s == nil || s.repo == nil {
  58. return nil, ErrIdempotencyStoreUnavail
  59. }
  60. if operationID == "" {
  61. return nil, infraerrors.BadRequest("SYSTEM_OPERATION_ID_REQUIRED", "operation id is required")
  62. }
  63. now := time.Now()
  64. expiresAt := now.Add(s.ttl)
  65. lockedUntil := now.Add(s.lease)
  66. keyHash := HashIdempotencyKey(systemOperationLockKey)
  67. record := &IdempotencyRecord{
  68. Scope: systemOperationLockScope,
  69. IdempotencyKeyHash: keyHash,
  70. RequestFingerprint: operationID,
  71. Status: IdempotencyStatusProcessing,
  72. LockedUntil: &lockedUntil,
  73. ExpiresAt: expiresAt,
  74. }
  75. owner, err := s.repo.CreateProcessing(ctx, record)
  76. if err != nil {
  77. return nil, ErrIdempotencyStoreUnavail.WithCause(err)
  78. }
  79. if !owner {
  80. existing, getErr := s.repo.GetByScopeAndKeyHash(ctx, systemOperationLockScope, keyHash)
  81. if getErr != nil {
  82. return nil, ErrIdempotencyStoreUnavail.WithCause(getErr)
  83. }
  84. if existing == nil {
  85. return nil, ErrIdempotencyStoreUnavail
  86. }
  87. if existing.Status == IdempotencyStatusProcessing && existing.LockedUntil != nil && existing.LockedUntil.After(now) {
  88. return nil, s.busyError(existing.RequestFingerprint, existing.LockedUntil, now)
  89. }
  90. reclaimed, reclaimErr := s.repo.TryReclaim(
  91. ctx,
  92. existing.ID,
  93. existing.Status,
  94. now,
  95. lockedUntil,
  96. expiresAt,
  97. )
  98. if reclaimErr != nil {
  99. return nil, ErrIdempotencyStoreUnavail.WithCause(reclaimErr)
  100. }
  101. if !reclaimed {
  102. latest, _ := s.repo.GetByScopeAndKeyHash(ctx, systemOperationLockScope, keyHash)
  103. if latest != nil {
  104. return nil, s.busyError(latest.RequestFingerprint, latest.LockedUntil, now)
  105. }
  106. return nil, ErrSystemOperationBusy
  107. }
  108. record.ID = existing.ID
  109. }
  110. if record.ID == 0 {
  111. return nil, ErrIdempotencyStoreUnavail
  112. }
  113. lock := &SystemOperationLock{
  114. recordID: record.ID,
  115. operationID: operationID,
  116. stopCh: make(chan struct{}),
  117. }
  118. go s.renewLoop(lock)
  119. return lock, nil
  120. }
  121. func (s *SystemOperationLockService) Release(ctx context.Context, lock *SystemOperationLock, succeeded bool, failureReason string) error {
  122. if s == nil || s.repo == nil || lock == nil {
  123. return nil
  124. }
  125. lock.stopOnce.Do(func() {
  126. close(lock.stopCh)
  127. })
  128. if ctx == nil {
  129. ctx = context.Background()
  130. }
  131. expiresAt := time.Now().Add(s.ttl)
  132. if succeeded {
  133. responseBody := fmt.Sprintf(`{"operation_id":"%s","released":true}`, lock.operationID)
  134. return s.repo.MarkSucceeded(ctx, lock.recordID, 200, responseBody, expiresAt)
  135. }
  136. reason := failureReason
  137. if reason == "" {
  138. reason = "SYSTEM_OPERATION_FAILED"
  139. }
  140. return s.repo.MarkFailedRetryable(ctx, lock.recordID, reason, time.Now(), expiresAt)
  141. }
  142. func (s *SystemOperationLockService) renewLoop(lock *SystemOperationLock) {
  143. ticker := time.NewTicker(s.renewInterval)
  144. defer ticker.Stop()
  145. for {
  146. select {
  147. case <-ticker.C:
  148. now := time.Now()
  149. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  150. ok, err := s.repo.ExtendProcessingLock(
  151. ctx,
  152. lock.recordID,
  153. lock.operationID,
  154. now.Add(s.lease),
  155. now.Add(s.ttl),
  156. )
  157. cancel()
  158. if err != nil {
  159. logger.LegacyPrintf("service.system_operation_lock", "[SystemOperationLock] renew failed operation_id=%s err=%v", lock.operationID, err)
  160. // 瞬时故障不应导致续租协程退出,下一轮继续尝试续租。
  161. continue
  162. }
  163. if !ok {
  164. logger.LegacyPrintf("service.system_operation_lock", "[SystemOperationLock] renew stopped operation_id=%s reason=ownership_lost", lock.operationID)
  165. return
  166. }
  167. case <-lock.stopCh:
  168. return
  169. }
  170. }
  171. }
  172. func (s *SystemOperationLockService) busyError(operationID string, lockedUntil *time.Time, now time.Time) error {
  173. metadata := make(map[string]string)
  174. if operationID != "" {
  175. metadata["operation_id"] = operationID
  176. }
  177. if lockedUntil != nil {
  178. sec := int(lockedUntil.Sub(now).Seconds())
  179. if sec <= 0 {
  180. sec = 1
  181. }
  182. metadata["retry_after"] = strconv.Itoa(sec)
  183. }
  184. if len(metadata) == 0 {
  185. return ErrSystemOperationBusy
  186. }
  187. return ErrSystemOperationBusy.WithMetadata(metadata)
  188. }