leader_lock_test.go 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. package service
  2. import (
  3. "context"
  4. "sync"
  5. "testing"
  6. "time"
  7. "github.com/stretchr/testify/require"
  8. )
  9. // fakeLeaderLockCache is an in-memory LeaderLockCache for unit tests. It models the
  10. // compare-and-delete release semantics of the real Redis-backed implementation.
  11. type fakeLeaderLockCache struct {
  12. mu sync.Mutex
  13. owners map[string]string
  14. acquireErr error
  15. }
  16. func (f *fakeLeaderLockCache) TryAcquireLeaderLock(_ context.Context, key, owner string, _ time.Duration) (bool, error) {
  17. if f.acquireErr != nil {
  18. return false, f.acquireErr
  19. }
  20. f.mu.Lock()
  21. defer f.mu.Unlock()
  22. if f.owners == nil {
  23. f.owners = map[string]string{}
  24. }
  25. if _, held := f.owners[key]; held {
  26. return false, nil
  27. }
  28. f.owners[key] = owner
  29. return true, nil
  30. }
  31. func (f *fakeLeaderLockCache) ReleaseLeaderLock(_ context.Context, key, owner string) error {
  32. f.mu.Lock()
  33. defer f.mu.Unlock()
  34. if f.owners[key] == owner {
  35. delete(f.owners, key)
  36. }
  37. return nil
  38. }
  39. func (f *fakeLeaderLockCache) heldBy(key string) string {
  40. f.mu.Lock()
  41. defer f.mu.Unlock()
  42. return f.owners[key]
  43. }
  44. func TestTryAcquireSingletonLeaderLock_NoBackendRunsUngated(t *testing.T) {
  45. release, ok := tryAcquireSingletonLeaderLock(context.Background(), nil, nil, "k", "inst", time.Minute)
  46. require.True(t, ok)
  47. require.NotNil(t, release)
  48. require.NotPanics(t, release)
  49. }
  50. func TestTryAcquireSingletonLeaderLock_ContendedThenReleased(t *testing.T) {
  51. cache := &fakeLeaderLockCache{}
  52. ctx := context.Background()
  53. const key = "leader:test:contended"
  54. releaseA, ok := tryAcquireSingletonLeaderLock(ctx, cache, nil, key, "A", time.Minute)
  55. require.True(t, ok, "first instance should acquire")
  56. require.Equal(t, "A", cache.heldBy(key))
  57. _, okB := tryAcquireSingletonLeaderLock(ctx, cache, nil, key, "B", time.Minute)
  58. require.False(t, okB, "peer must be locked out while the lock is held")
  59. releaseA()
  60. require.Empty(t, cache.heldBy(key), "release must free the lock")
  61. releaseB, okB := tryAcquireSingletonLeaderLock(ctx, cache, nil, key, "B", time.Minute)
  62. require.True(t, okB, "peer should acquire after the holder releases")
  63. releaseB()
  64. }
  65. // When the cache errors, the helper must fall through rather than acquire via the
  66. // cache. With no DB configured it runs ungated so the job is never starved by a
  67. // flaky Redis.
  68. func TestTryAcquireSingletonLeaderLock_CacheErrorFallsThrough(t *testing.T) {
  69. cache := &fakeLeaderLockCache{acquireErr: context.DeadlineExceeded}
  70. release, ok := tryAcquireSingletonLeaderLock(context.Background(), cache, nil, "k", "inst", time.Minute)
  71. require.True(t, ok, "cache error with no DB must run ungated, not skip")
  72. require.NotNil(t, release)
  73. require.NotPanics(t, release)
  74. }
  75. func TestSubscriptionExpiryService_ReminderSkipsScanWhenNotLeader(t *testing.T) {
  76. cache := &fakeLeaderLockCache{}
  77. // A peer already holds the reminder leader lock.
  78. _, _ = cache.TryAcquireLeaderLock(context.Background(), subscriptionExpiryReminderLeaderLockKey, "peer", time.Minute)
  79. repo := &subscriptionExpiryRepoStub{}
  80. settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{SettingKeySMTPHost: "smtp.example.com"}}
  81. svc := NewSubscriptionExpiryService(repo, time.Minute)
  82. svc.SetSettingRepository(settingRepo)
  83. svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, NewEmailService(settingRepo, nil)))
  84. svc.SetLeaderLock(cache, nil)
  85. svc.sendExpiryReminders(context.Background())
  86. require.Zero(t, repo.listCalls, "non-leader must not scan active subscriptions")
  87. }
  88. func TestSubscriptionExpiryService_ReminderScansWhenLeader(t *testing.T) {
  89. repo := &subscriptionExpiryRepoStub{}
  90. settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{SettingKeySMTPHost: "smtp.example.com"}}
  91. svc := NewSubscriptionExpiryService(repo, time.Minute)
  92. svc.SetSettingRepository(settingRepo)
  93. svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, NewEmailService(settingRepo, nil)))
  94. svc.SetLeaderLock(&fakeLeaderLockCache{}, nil)
  95. svc.sendExpiryReminders(context.Background())
  96. require.Equal(t, 1, repo.listCalls, "leader should scan active subscriptions once")
  97. }
  98. // Single-instance correctness: the lock is released at the end of each cycle, so
  99. // the same instance must re-acquire it and run on every subsequent cycle (no
  100. // self-lockout). Covers both the cache-backed path and the no-backend path.
  101. func TestSubscriptionExpiryService_ReminderRunsEveryCycleSingleInstance(t *testing.T) {
  102. cases := map[string]LeaderLockCache{
  103. "with_cache": &fakeLeaderLockCache{},
  104. "no_backend": nil,
  105. }
  106. for name, cache := range cases {
  107. t.Run(name, func(t *testing.T) {
  108. repo := &subscriptionExpiryRepoStub{}
  109. settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{SettingKeySMTPHost: "smtp.example.com"}}
  110. svc := NewSubscriptionExpiryService(repo, time.Minute)
  111. svc.SetSettingRepository(settingRepo)
  112. svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, NewEmailService(settingRepo, nil)))
  113. svc.SetLeaderLock(cache, nil)
  114. // Three consecutive cycles, mimicking the ticker loop.
  115. svc.sendExpiryReminders(context.Background())
  116. svc.sendExpiryReminders(context.Background())
  117. svc.sendExpiryReminders(context.Background())
  118. require.Equal(t, 3, repo.listCalls, "single instance must run every cycle")
  119. })
  120. }
  121. }