user_msg_queue_cache_integration_test.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. //go:build integration
  2. package repository
  3. import (
  4. "errors"
  5. "testing"
  6. "time"
  7. "github.com/redis/go-redis/v9"
  8. "github.com/stretchr/testify/require"
  9. "github.com/stretchr/testify/suite"
  10. )
  11. type UserMsgQueueCacheSuite struct {
  12. IntegrationRedisSuite
  13. cache *userMsgQueueCache
  14. }
  15. func TestUserMsgQueueCacheSuite(t *testing.T) {
  16. suite.Run(t, new(UserMsgQueueCacheSuite))
  17. }
  18. func (s *UserMsgQueueCacheSuite) SetupTest() {
  19. s.IntegrationRedisSuite.SetupTest()
  20. s.cache = NewUserMsgQueueCache(s.rdb).(*userMsgQueueCache)
  21. }
  22. func (s *UserMsgQueueCacheSuite) TestAcquireLockWritesIndexAndReleaseRemovesIt() {
  23. accountID := int64(701)
  24. nowMs, err := s.cache.GetCurrentTimeMs(s.ctx)
  25. require.NoError(s.T(), err)
  26. acquired, err := s.cache.AcquireLock(s.ctx, accountID, "req-701", 10_000)
  27. require.NoError(s.T(), err)
  28. require.True(s.T(), acquired)
  29. score, err := s.rdb.ZScore(s.ctx, umqLockIndexKey, "701").Result()
  30. require.NoError(s.T(), err)
  31. require.Greater(s.T(), int64(score), nowMs)
  32. released, err := s.cache.ReleaseLock(s.ctx, accountID, "req-701")
  33. require.NoError(s.T(), err)
  34. require.True(s.T(), released)
  35. _, err = s.rdb.ZScore(s.ctx, umqLockIndexKey, "701").Result()
  36. require.ErrorIs(s.T(), err, redis.Nil)
  37. }
  38. func (s *UserMsgQueueCacheSuite) TestReconcileExpiredLockCandidatesRemovesNaturallyExpiredLockIndex() {
  39. accountID := int64(702)
  40. acquired, err := s.cache.AcquireLock(s.ctx, accountID, "req-702", 20)
  41. require.NoError(s.T(), err)
  42. require.True(s.T(), acquired)
  43. _, err = s.rdb.ZScore(s.ctx, umqLockIndexKey, "702").Result()
  44. require.NoError(s.T(), err)
  45. require.Eventually(s.T(), func() bool {
  46. _, err := s.rdb.Get(s.ctx, umqLockKey(accountID)).Result()
  47. return errors.Is(err, redis.Nil)
  48. }, time.Second, 10*time.Millisecond)
  49. cleaned, err := s.cache.ReconcileExpiredLockCandidates(s.ctx, 1000)
  50. require.NoError(s.T(), err)
  51. require.Equal(s.T(), 0, cleaned)
  52. _, err = s.rdb.ZScore(s.ctx, umqLockIndexKey, "702").Result()
  53. require.ErrorIs(s.T(), err, redis.Nil)
  54. }
  55. func (s *UserMsgQueueCacheSuite) TestReconcileExpiredLockCandidatesRefreshesLiveLockIndex() {
  56. accountID := int64(703)
  57. nowMs, err := s.cache.GetCurrentTimeMs(s.ctx)
  58. require.NoError(s.T(), err)
  59. require.NoError(s.T(), s.rdb.Set(s.ctx, umqLockKey(accountID), "req-703", time.Minute).Err())
  60. require.NoError(s.T(), s.rdb.ZAdd(s.ctx, umqLockIndexKey, redis.Z{
  61. Score: float64(nowMs - 1),
  62. Member: "703",
  63. }).Err())
  64. cleaned, err := s.cache.ReconcileExpiredLockCandidates(s.ctx, 1000)
  65. require.NoError(s.T(), err)
  66. require.Equal(s.T(), 0, cleaned)
  67. score, err := s.rdb.ZScore(s.ctx, umqLockIndexKey, "703").Result()
  68. require.NoError(s.T(), err)
  69. require.Greater(s.T(), int64(score), nowMs)
  70. exists, err := s.rdb.Exists(s.ctx, umqLockKey(accountID)).Result()
  71. require.NoError(s.T(), err)
  72. require.EqualValues(s.T(), 1, exists)
  73. }
  74. func (s *UserMsgQueueCacheSuite) TestReconcileExpiredLockCandidatesDeletesNoTTLLock() {
  75. accountID := int64(704)
  76. nowMs, err := s.cache.GetCurrentTimeMs(s.ctx)
  77. require.NoError(s.T(), err)
  78. require.NoError(s.T(), s.rdb.Set(s.ctx, umqLockKey(accountID), "req-704", 0).Err())
  79. require.NoError(s.T(), s.rdb.ZAdd(s.ctx, umqLockIndexKey, redis.Z{
  80. Score: float64(nowMs),
  81. Member: "704",
  82. }).Err())
  83. cleaned, err := s.cache.ReconcileExpiredLockCandidates(s.ctx, 1000)
  84. require.NoError(s.T(), err)
  85. require.Equal(s.T(), 1, cleaned)
  86. exists, err := s.rdb.Exists(s.ctx, umqLockKey(accountID)).Result()
  87. require.NoError(s.T(), err)
  88. require.EqualValues(s.T(), 0, exists)
  89. _, err = s.rdb.ZScore(s.ctx, umqLockIndexKey, "704").Result()
  90. require.ErrorIs(s.T(), err, redis.Nil)
  91. }
  92. func (s *UserMsgQueueCacheSuite) TestReconcileExpiredLockCandidatesRemovesInvalidMember() {
  93. nowMs, err := s.cache.GetCurrentTimeMs(s.ctx)
  94. require.NoError(s.T(), err)
  95. require.NoError(s.T(), s.rdb.ZAdd(s.ctx, umqLockIndexKey, redis.Z{
  96. Score: float64(nowMs),
  97. Member: "not-an-account-id",
  98. }).Err())
  99. cleaned, err := s.cache.ReconcileExpiredLockCandidates(s.ctx, 1000)
  100. require.NoError(s.T(), err)
  101. require.Equal(s.T(), 0, cleaned)
  102. _, err = s.rdb.ZScore(s.ctx, umqLockIndexKey, "not-an-account-id").Result()
  103. require.True(s.T(), errors.Is(err, redis.Nil))
  104. }
  105. func (s *UserMsgQueueCacheSuite) TestAcquireLockBusyPathReindexesUnindexedLiveLock() {
  106. // 模拟索引丢失的存量锁(升级窗口/索引写失败/释放竞态误删):
  107. // 锁存在且有 TTL,但索引里没有对应 member。
  108. accountID := int64(705)
  109. nowMs, err := s.cache.GetCurrentTimeMs(s.ctx)
  110. require.NoError(s.T(), err)
  111. require.NoError(s.T(), s.rdb.Set(s.ctx, umqLockKey(accountID), "holder-705", time.Minute).Err())
  112. // 另一个请求争锁失败,应顺手把观测到的持有者锁回填进索引。
  113. acquired, err := s.cache.AcquireLock(s.ctx, accountID, "contender-705", 10_000)
  114. require.NoError(s.T(), err)
  115. require.False(s.T(), acquired)
  116. score, err := s.rdb.ZScore(s.ctx, umqLockIndexKey, "705").Result()
  117. require.NoError(s.T(), err, "busy acquire should re-index the observed live lock")
  118. require.Greater(s.T(), int64(score), nowMs)
  119. // 锁本身不应被争锁方改动。
  120. val, err := s.rdb.Get(s.ctx, umqLockKey(accountID)).Result()
  121. require.NoError(s.T(), err)
  122. require.Equal(s.T(), "holder-705", val)
  123. }
  124. func (s *UserMsgQueueCacheSuite) TestAcquireLockBusyPathMakesNoTTLLockReconcilable() {
  125. // PTTL == -1 的异常锁若不在索引中,永远不会被 reconcile 发现;
  126. // 争锁失败路径必须以“已到期候选”的 score 回填它,形成自愈闭环。
  127. accountID := int64(706)
  128. require.NoError(s.T(), s.rdb.Set(s.ctx, umqLockKey(accountID), "holder-706", 0).Err())
  129. acquired, err := s.cache.AcquireLock(s.ctx, accountID, "contender-706", 10_000)
  130. require.NoError(s.T(), err)
  131. require.False(s.T(), acquired)
  132. nowMs, err := s.cache.GetCurrentTimeMs(s.ctx)
  133. require.NoError(s.T(), err)
  134. score, err := s.rdb.ZScore(s.ctx, umqLockIndexKey, "706").Result()
  135. require.NoError(s.T(), err, "busy acquire should index the anomalous lock")
  136. require.LessOrEqual(s.T(), int64(score), nowMs, "anomalous lock should be an immediately-expired candidate")
  137. cleaned, err := s.cache.ReconcileExpiredLockCandidates(s.ctx, 1000)
  138. require.NoError(s.T(), err)
  139. require.Equal(s.T(), 1, cleaned, "reconcile should delete the no-TTL lock")
  140. exists, err := s.rdb.Exists(s.ctx, umqLockKey(accountID)).Result()
  141. require.NoError(s.T(), err)
  142. require.EqualValues(s.T(), 0, exists, "queue is unblocked after reconcile")
  143. _, err = s.rdb.ZScore(s.ctx, umqLockIndexKey, "706").Result()
  144. require.ErrorIs(s.T(), err, redis.Nil)
  145. }