system_operation_lock_service_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "sync/atomic"
  6. "testing"
  7. "time"
  8. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  9. "github.com/stretchr/testify/require"
  10. )
  11. func TestSystemOperationLockService_AcquireBusyAndRelease(t *testing.T) {
  12. repo := newInMemoryIdempotencyRepo()
  13. svc := NewSystemOperationLockService(repo, IdempotencyConfig{
  14. SystemOperationTTL: 10 * time.Second,
  15. ProcessingTimeout: 2 * time.Second,
  16. })
  17. lock1, err := svc.Acquire(context.Background(), "op-1")
  18. require.NoError(t, err)
  19. require.NotNil(t, lock1)
  20. _, err = svc.Acquire(context.Background(), "op-2")
  21. require.Error(t, err)
  22. require.Equal(t, infraerrors.Code(ErrSystemOperationBusy), infraerrors.Code(err))
  23. appErr := infraerrors.FromError(err)
  24. require.Equal(t, "op-1", appErr.Metadata["operation_id"])
  25. require.NotEmpty(t, appErr.Metadata["retry_after"])
  26. require.NoError(t, svc.Release(context.Background(), lock1, true, ""))
  27. lock2, err := svc.Acquire(context.Background(), "op-2")
  28. require.NoError(t, err)
  29. require.NotNil(t, lock2)
  30. require.NoError(t, svc.Release(context.Background(), lock2, true, ""))
  31. }
  32. func TestSystemOperationLockService_RenewLease(t *testing.T) {
  33. repo := newInMemoryIdempotencyRepo()
  34. svc := NewSystemOperationLockService(repo, IdempotencyConfig{
  35. SystemOperationTTL: 5 * time.Second,
  36. ProcessingTimeout: 1200 * time.Millisecond,
  37. })
  38. lock, err := svc.Acquire(context.Background(), "op-renew")
  39. require.NoError(t, err)
  40. require.NotNil(t, lock)
  41. defer func() {
  42. _ = svc.Release(context.Background(), lock, true, "")
  43. }()
  44. keyHash := HashIdempotencyKey(systemOperationLockKey)
  45. initial, _ := repo.GetByScopeAndKeyHash(context.Background(), systemOperationLockScope, keyHash)
  46. require.NotNil(t, initial)
  47. require.NotNil(t, initial.LockedUntil)
  48. initialLockedUntil := *initial.LockedUntil
  49. time.Sleep(1500 * time.Millisecond)
  50. updated, _ := repo.GetByScopeAndKeyHash(context.Background(), systemOperationLockScope, keyHash)
  51. require.NotNil(t, updated)
  52. require.NotNil(t, updated.LockedUntil)
  53. require.True(t, updated.LockedUntil.After(initialLockedUntil), "locked_until should be renewed while lock is held")
  54. }
  55. type flakySystemLockRenewRepo struct {
  56. *inMemoryIdempotencyRepo
  57. extendCalls int32
  58. }
  59. func (r *flakySystemLockRenewRepo) ExtendProcessingLock(ctx context.Context, id int64, requestFingerprint string, newLockedUntil, newExpiresAt time.Time) (bool, error) {
  60. call := atomic.AddInt32(&r.extendCalls, 1)
  61. if call == 1 {
  62. return false, errors.New("transient extend failure")
  63. }
  64. return r.inMemoryIdempotencyRepo.ExtendProcessingLock(ctx, id, requestFingerprint, newLockedUntil, newExpiresAt)
  65. }
  66. func TestSystemOperationLockService_RenewLeaseContinuesAfterTransientFailure(t *testing.T) {
  67. repo := &flakySystemLockRenewRepo{inMemoryIdempotencyRepo: newInMemoryIdempotencyRepo()}
  68. svc := NewSystemOperationLockService(repo, IdempotencyConfig{
  69. SystemOperationTTL: 5 * time.Second,
  70. ProcessingTimeout: 2400 * time.Millisecond,
  71. })
  72. lock, err := svc.Acquire(context.Background(), "op-renew-transient")
  73. require.NoError(t, err)
  74. require.NotNil(t, lock)
  75. defer func() {
  76. _ = svc.Release(context.Background(), lock, true, "")
  77. }()
  78. keyHash := HashIdempotencyKey(systemOperationLockKey)
  79. initial, _ := repo.GetByScopeAndKeyHash(context.Background(), systemOperationLockScope, keyHash)
  80. require.NotNil(t, initial)
  81. require.NotNil(t, initial.LockedUntil)
  82. initialLockedUntil := *initial.LockedUntil
  83. // 首次续租失败后,下一轮应继续尝试并成功更新锁过期时间。
  84. require.Eventually(t, func() bool {
  85. updated, _ := repo.GetByScopeAndKeyHash(context.Background(), systemOperationLockScope, keyHash)
  86. if updated == nil || updated.LockedUntil == nil {
  87. return false
  88. }
  89. return atomic.LoadInt32(&repo.extendCalls) >= 2 && updated.LockedUntil.After(initialLockedUntil)
  90. }, 4*time.Second, 100*time.Millisecond, "renew loop should continue after transient error")
  91. }
  92. func TestSystemOperationLockService_SameOperationIDRetryWhileRunning(t *testing.T) {
  93. repo := newInMemoryIdempotencyRepo()
  94. svc := NewSystemOperationLockService(repo, IdempotencyConfig{
  95. SystemOperationTTL: 10 * time.Second,
  96. ProcessingTimeout: 2 * time.Second,
  97. })
  98. lock1, err := svc.Acquire(context.Background(), "op-same")
  99. require.NoError(t, err)
  100. require.NotNil(t, lock1)
  101. _, err = svc.Acquire(context.Background(), "op-same")
  102. require.Error(t, err)
  103. require.Equal(t, infraerrors.Code(ErrSystemOperationBusy), infraerrors.Code(err))
  104. appErr := infraerrors.FromError(err)
  105. require.Equal(t, "op-same", appErr.Metadata["operation_id"])
  106. require.NoError(t, svc.Release(context.Background(), lock1, true, ""))
  107. lock2, err := svc.Acquire(context.Background(), "op-same")
  108. require.NoError(t, err)
  109. require.NotNil(t, lock2)
  110. require.NoError(t, svc.Release(context.Background(), lock2, true, ""))
  111. }
  112. func TestSystemOperationLockService_RecoverAfterLeaseExpired(t *testing.T) {
  113. repo := newInMemoryIdempotencyRepo()
  114. svc := NewSystemOperationLockService(repo, IdempotencyConfig{
  115. SystemOperationTTL: 5 * time.Second,
  116. ProcessingTimeout: 300 * time.Millisecond,
  117. })
  118. lock1, err := svc.Acquire(context.Background(), "op-crashed")
  119. require.NoError(t, err)
  120. require.NotNil(t, lock1)
  121. // 模拟实例异常:停止续租,不调用 Release。
  122. lock1.stopOnce.Do(func() {
  123. close(lock1.stopCh)
  124. })
  125. time.Sleep(450 * time.Millisecond)
  126. lock2, err := svc.Acquire(context.Background(), "op-recovered")
  127. require.NoError(t, err, "expired lease should allow a new operation to reclaim lock")
  128. require.NotNil(t, lock2)
  129. require.NoError(t, svc.Release(context.Background(), lock2, true, ""))
  130. }
  131. type systemLockRepoStub struct {
  132. createOwner bool
  133. createErr error
  134. existing *IdempotencyRecord
  135. getErr error
  136. reclaimOK bool
  137. reclaimErr error
  138. markSuccErr error
  139. markFailErr error
  140. }
  141. func (s *systemLockRepoStub) CreateProcessing(context.Context, *IdempotencyRecord) (bool, error) {
  142. if s.createErr != nil {
  143. return false, s.createErr
  144. }
  145. return s.createOwner, nil
  146. }
  147. func (s *systemLockRepoStub) GetByScopeAndKeyHash(context.Context, string, string) (*IdempotencyRecord, error) {
  148. if s.getErr != nil {
  149. return nil, s.getErr
  150. }
  151. return cloneRecord(s.existing), nil
  152. }
  153. func (s *systemLockRepoStub) TryReclaim(context.Context, int64, string, time.Time, time.Time, time.Time) (bool, error) {
  154. if s.reclaimErr != nil {
  155. return false, s.reclaimErr
  156. }
  157. return s.reclaimOK, nil
  158. }
  159. func (s *systemLockRepoStub) ExtendProcessingLock(context.Context, int64, string, time.Time, time.Time) (bool, error) {
  160. return true, nil
  161. }
  162. func (s *systemLockRepoStub) MarkSucceeded(context.Context, int64, int, string, time.Time) error {
  163. return s.markSuccErr
  164. }
  165. func (s *systemLockRepoStub) MarkFailedRetryable(context.Context, int64, string, time.Time, time.Time) error {
  166. return s.markFailErr
  167. }
  168. func (s *systemLockRepoStub) DeleteExpired(context.Context, time.Time, int) (int64, error) {
  169. return 0, nil
  170. }
  171. func TestSystemOperationLockService_InputAndStoreErrorBranches(t *testing.T) {
  172. var nilSvc *SystemOperationLockService
  173. _, err := nilSvc.Acquire(context.Background(), "x")
  174. require.Error(t, err)
  175. require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
  176. svc := &SystemOperationLockService{repo: nil}
  177. _, err = svc.Acquire(context.Background(), "x")
  178. require.Error(t, err)
  179. require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
  180. svc = NewSystemOperationLockService(newInMemoryIdempotencyRepo(), IdempotencyConfig{
  181. SystemOperationTTL: 10 * time.Second,
  182. ProcessingTimeout: 2 * time.Second,
  183. })
  184. _, err = svc.Acquire(context.Background(), "")
  185. require.Error(t, err)
  186. require.Equal(t, "SYSTEM_OPERATION_ID_REQUIRED", infraerrors.Reason(err))
  187. badStore := &systemLockRepoStub{createErr: errors.New("db down")}
  188. svc = NewSystemOperationLockService(badStore, IdempotencyConfig{
  189. SystemOperationTTL: 10 * time.Second,
  190. ProcessingTimeout: 2 * time.Second,
  191. })
  192. _, err = svc.Acquire(context.Background(), "x")
  193. require.Error(t, err)
  194. require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
  195. }
  196. func TestSystemOperationLockService_ExistingNilAndReclaimBranches(t *testing.T) {
  197. now := time.Now()
  198. repo := &systemLockRepoStub{
  199. createOwner: false,
  200. }
  201. svc := NewSystemOperationLockService(repo, IdempotencyConfig{
  202. SystemOperationTTL: 10 * time.Second,
  203. ProcessingTimeout: 2 * time.Second,
  204. })
  205. _, err := svc.Acquire(context.Background(), "op")
  206. require.Error(t, err)
  207. require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
  208. repo.existing = &IdempotencyRecord{
  209. ID: 1,
  210. Scope: systemOperationLockScope,
  211. IdempotencyKeyHash: HashIdempotencyKey(systemOperationLockKey),
  212. RequestFingerprint: "other-op",
  213. Status: IdempotencyStatusFailedRetryable,
  214. LockedUntil: ptrTime(now.Add(-time.Second)),
  215. ExpiresAt: now.Add(time.Hour),
  216. }
  217. repo.reclaimErr = errors.New("reclaim failed")
  218. _, err = svc.Acquire(context.Background(), "op")
  219. require.Error(t, err)
  220. require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
  221. repo.reclaimErr = nil
  222. repo.reclaimOK = false
  223. _, err = svc.Acquire(context.Background(), "op")
  224. require.Error(t, err)
  225. require.Equal(t, infraerrors.Code(ErrSystemOperationBusy), infraerrors.Code(err))
  226. }
  227. func TestSystemOperationLockService_ReleaseBranchesAndOperationID(t *testing.T) {
  228. require.Equal(t, "", (*SystemOperationLock)(nil).OperationID())
  229. svc := NewSystemOperationLockService(newInMemoryIdempotencyRepo(), IdempotencyConfig{
  230. SystemOperationTTL: 10 * time.Second,
  231. ProcessingTimeout: 2 * time.Second,
  232. })
  233. lock, err := svc.Acquire(context.Background(), "op")
  234. require.NoError(t, err)
  235. require.NotNil(t, lock)
  236. require.NoError(t, svc.Release(context.Background(), lock, false, ""))
  237. require.NoError(t, svc.Release(context.Background(), lock, true, ""))
  238. repo := &systemLockRepoStub{
  239. createOwner: true,
  240. markSuccErr: errors.New("mark succeeded failed"),
  241. markFailErr: errors.New("mark failed failed"),
  242. }
  243. svc = NewSystemOperationLockService(repo, IdempotencyConfig{
  244. SystemOperationTTL: 10 * time.Second,
  245. ProcessingTimeout: 2 * time.Second,
  246. })
  247. lock = &SystemOperationLock{recordID: 1, operationID: "op2", stopCh: make(chan struct{})}
  248. require.Error(t, svc.Release(context.Background(), lock, true, ""))
  249. lock = &SystemOperationLock{recordID: 1, operationID: "op3", stopCh: make(chan struct{})}
  250. require.Error(t, svc.Release(context.Background(), lock, false, "BAD"))
  251. var nilLockSvc *SystemOperationLockService
  252. require.NoError(t, nilLockSvc.Release(context.Background(), nil, true, ""))
  253. err = svc.busyError("", nil, time.Now())
  254. require.Equal(t, infraerrors.Code(ErrSystemOperationBusy), infraerrors.Code(err))
  255. }