auth_cache_invalidation_outbox_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "sync"
  6. "testing"
  7. "time"
  8. "github.com/dgraph-io/ristretto"
  9. "github.com/stretchr/testify/require"
  10. )
  11. type authInvalidationRepoStub struct {
  12. mu sync.Mutex
  13. events []AuthCacheInvalidationEvent
  14. claimLimit int
  15. scheduled []int64
  16. deleted []int64
  17. retried []int64
  18. retryError string
  19. stats AuthCacheInvalidationOutboxStats
  20. statsErr error
  21. }
  22. func (r *authInvalidationRepoStub) Claim(_ context.Context, _ string, limit int, _ time.Duration) ([]AuthCacheInvalidationEvent, error) {
  23. r.mu.Lock()
  24. defer r.mu.Unlock()
  25. r.claimLimit = limit
  26. return append([]AuthCacheInvalidationEvent(nil), r.events...), nil
  27. }
  28. func (r *authInvalidationRepoStub) DeleteClaimed(_ context.Context, id int64, _ string) error {
  29. r.mu.Lock()
  30. defer r.mu.Unlock()
  31. r.deleted = append(r.deleted, id)
  32. return nil
  33. }
  34. func (r *authInvalidationRepoStub) ScheduleSecondPass(_ context.Context, id int64, _ string, _ time.Time) error {
  35. r.mu.Lock()
  36. defer r.mu.Unlock()
  37. r.scheduled = append(r.scheduled, id)
  38. return nil
  39. }
  40. func (r *authInvalidationRepoStub) RetryClaimed(_ context.Context, id int64, _ string, _ time.Time, lastError string) error {
  41. r.mu.Lock()
  42. defer r.mu.Unlock()
  43. r.retried = append(r.retried, id)
  44. r.retryError = lastError
  45. return nil
  46. }
  47. func (r *authInvalidationRepoStub) Stats(context.Context) (AuthCacheInvalidationOutboxStats, error) {
  48. return r.stats, r.statsErr
  49. }
  50. type authInvalidationCacheStub struct {
  51. mu sync.Mutex
  52. deleteFn func(context.Context, string) error
  53. publishFn func(context.Context, string) error
  54. subscribeFn func(context.Context, func(string)) error
  55. deleted []string
  56. published []string
  57. }
  58. func (*authInvalidationCacheStub) GetCreateAttemptCount(context.Context, int64) (int, error) {
  59. return 0, nil
  60. }
  61. func (*authInvalidationCacheStub) IncrementCreateAttemptCount(context.Context, int64) error {
  62. return nil
  63. }
  64. func (*authInvalidationCacheStub) DeleteCreateAttemptCount(context.Context, int64) error { return nil }
  65. func (*authInvalidationCacheStub) IncrementDailyUsage(context.Context, string) error { return nil }
  66. func (*authInvalidationCacheStub) SetDailyUsageExpiry(context.Context, string, time.Duration) error {
  67. return nil
  68. }
  69. func (*authInvalidationCacheStub) GetAuthCache(context.Context, string) (*APIKeyAuthCacheEntry, error) {
  70. return nil, errors.New("miss")
  71. }
  72. func (*authInvalidationCacheStub) SetAuthCache(context.Context, string, *APIKeyAuthCacheEntry, time.Duration) error {
  73. return nil
  74. }
  75. func (c *authInvalidationCacheStub) DeleteAuthCache(ctx context.Context, key string) error {
  76. c.mu.Lock()
  77. c.deleted = append(c.deleted, key)
  78. c.mu.Unlock()
  79. if c.deleteFn != nil {
  80. return c.deleteFn(ctx, key)
  81. }
  82. return nil
  83. }
  84. func (c *authInvalidationCacheStub) PublishAuthCacheInvalidation(ctx context.Context, key string) error {
  85. c.mu.Lock()
  86. c.published = append(c.published, key)
  87. c.mu.Unlock()
  88. if c.publishFn != nil {
  89. return c.publishFn(ctx, key)
  90. }
  91. return nil
  92. }
  93. func (c *authInvalidationCacheStub) SubscribeAuthCacheInvalidation(ctx context.Context, handler func(string)) error {
  94. if c.subscribeFn != nil {
  95. return c.subscribeFn(ctx, handler)
  96. }
  97. return nil
  98. }
  99. func TestAuthCacheInvalidationWorker_FirstPassSchedulesSafetyPass(t *testing.T) {
  100. repo := &authInvalidationRepoStub{}
  101. cache := &authInvalidationCacheStub{}
  102. worker := NewAuthCacheInvalidationWorker(repo, cache)
  103. worker.processEvent(context.Background(), AuthCacheInvalidationEvent{ID: 7, CacheKey: "hash", Stage: 0})
  104. require.Equal(t, []string{"hash"}, cache.deleted)
  105. require.Equal(t, []string{"hash"}, cache.published)
  106. require.Equal(t, []int64{7}, repo.scheduled)
  107. require.Empty(t, repo.deleted)
  108. }
  109. func TestAuthCacheInvalidationWorker_SecondPassCleansEvent(t *testing.T) {
  110. repo := &authInvalidationRepoStub{}
  111. cache := &authInvalidationCacheStub{}
  112. worker := NewAuthCacheInvalidationWorker(repo, cache)
  113. worker.processEvent(context.Background(), AuthCacheInvalidationEvent{ID: 8, CacheKey: "hash", Stage: 1})
  114. require.Equal(t, []int64{8}, repo.deleted)
  115. require.Equal(t, uint64(1), worker.Health(context.Background()).Processed)
  116. }
  117. func TestAuthCacheInvalidationWorker_RetriesRedisAndPublishFailures(t *testing.T) {
  118. for _, tc := range []struct {
  119. name string
  120. deleteErr error
  121. publishErr error
  122. published int
  123. }{
  124. {name: "redis down", deleteErr: errors.New("redis unavailable")},
  125. {name: "publish failure after delete", publishErr: errors.New("publish failed"), published: 1},
  126. } {
  127. t.Run(tc.name, func(t *testing.T) {
  128. repo := &authInvalidationRepoStub{}
  129. cache := &authInvalidationCacheStub{
  130. deleteFn: func(context.Context, string) error { return tc.deleteErr },
  131. publishFn: func(context.Context, string) error { return tc.publishErr },
  132. }
  133. worker := NewAuthCacheInvalidationWorker(repo, cache)
  134. worker.processEvent(context.Background(), AuthCacheInvalidationEvent{ID: 9, CacheKey: "hash"})
  135. require.Equal(t, []int64{9}, repo.retried)
  136. require.Len(t, cache.published, tc.published)
  137. require.NotEmpty(t, repo.retryError)
  138. require.Empty(t, repo.deleted)
  139. require.Equal(t, uint64(1), worker.Health(context.Background()).Failures)
  140. })
  141. }
  142. }
  143. func TestAuthCacheInvalidationWorker_RedisSlowIsTimedOut(t *testing.T) {
  144. repo := &authInvalidationRepoStub{}
  145. cache := &authInvalidationCacheStub{deleteFn: func(ctx context.Context, _ string) error {
  146. <-ctx.Done()
  147. return ctx.Err()
  148. }}
  149. worker := NewAuthCacheInvalidationWorker(repo, cache)
  150. started := time.Now()
  151. worker.processEvent(context.Background(), AuthCacheInvalidationEvent{ID: 10, CacheKey: "hash"})
  152. require.Less(t, time.Since(started), 3*time.Second)
  153. require.Equal(t, []int64{10}, repo.retried)
  154. require.Contains(t, repo.retryError, "deadline")
  155. }
  156. func TestAuthCacheInvalidationWorker_BoundedBatchAndHealth(t *testing.T) {
  157. oldest := time.Now().Add(-time.Minute)
  158. repo := &authInvalidationRepoStub{stats: AuthCacheInvalidationOutboxStats{
  159. Pending: 12, OldestCreatedAt: &oldest, MaxAttempts: 4, LastError: "redis down",
  160. }}
  161. worker := NewAuthCacheInvalidationWorker(repo, &authInvalidationCacheStub{})
  162. require.NoError(t, worker.processBatch(context.Background()))
  163. require.Equal(t, authInvalidationBatchSize, repo.claimLimit)
  164. health := worker.Health(context.Background())
  165. require.Equal(t, int64(12), health.Pending)
  166. require.Equal(t, 4, health.MaxAttempts)
  167. require.Equal(t, "redis down", health.LastError)
  168. require.GreaterOrEqual(t, health.OldestLag, time.Minute)
  169. require.Equal(t, 35*time.Second, health.HealthySLA)
  170. require.Equal(t, 6*time.Minute, health.RecoverySLA)
  171. }
  172. func TestAuthCacheInvalidationWorker_ProcessesClaimedBatchConcurrently(t *testing.T) {
  173. events := make([]AuthCacheInvalidationEvent, 32)
  174. for i := range events {
  175. events[i] = AuthCacheInvalidationEvent{ID: int64(i + 1), CacheKey: "hash", Stage: 1}
  176. }
  177. repo := &authInvalidationRepoStub{events: events}
  178. cache := &authInvalidationCacheStub{deleteFn: func(context.Context, string) error {
  179. time.Sleep(100 * time.Millisecond)
  180. return nil
  181. }}
  182. worker := NewAuthCacheInvalidationWorker(repo, cache)
  183. started := time.Now()
  184. require.NoError(t, worker.processBatch(context.Background()))
  185. require.Less(t, time.Since(started), time.Second)
  186. require.Len(t, repo.deleted, 32)
  187. }
  188. func TestAuthCacheInvalidationWorker_LifecycleIsManagedAndIdempotent(t *testing.T) {
  189. worker := NewAuthCacheInvalidationWorker(&authInvalidationRepoStub{}, &authInvalidationCacheStub{})
  190. worker.Start()
  191. require.Eventually(t, func() bool { return worker.Health(context.Background()).Running }, time.Second, 10*time.Millisecond)
  192. require.NotPanics(t, func() { worker.Stop(); worker.Stop() })
  193. require.False(t, worker.Health(context.Background()).Running)
  194. }
  195. func TestAuthInvalidationRetryDelayIsBoundedAndJittered(t *testing.T) {
  196. for attempt := 1; attempt <= 20; attempt++ {
  197. delay := authInvalidationRetryDelay(attempt)
  198. require.GreaterOrEqual(t, delay, 800*time.Millisecond)
  199. require.LessOrEqual(t, delay, 308*time.Second)
  200. }
  201. }
  202. func TestAuthCacheInvalidationSubscriber_RetriesInitialFailureAndStops(t *testing.T) {
  203. ready := make(chan struct{})
  204. var calls int
  205. cache := &authInvalidationCacheStub{subscribeFn: func(ctx context.Context, _ func(string)) error {
  206. calls++
  207. if calls == 1 {
  208. return errors.New("redis starting")
  209. }
  210. NotifyAuthCacheSubscriptionReady(ctx)
  211. close(ready)
  212. <-ctx.Done()
  213. return ctx.Err()
  214. }}
  215. svc := NewAPIKeyService(nil, nil, nil, nil, nil, cache, nil)
  216. localCache, err := ristretto.NewCache(&ristretto.Config{NumCounters: 10, MaxCost: 1, BufferItems: 64})
  217. require.NoError(t, err)
  218. defer localCache.Close()
  219. svc.authNegativeCacheL1 = localCache
  220. svc.StartAuthCacheInvalidationSubscriber(context.Background())
  221. select {
  222. case <-ready:
  223. case <-time.After(2 * time.Second):
  224. t.Fatal("subscriber did not retry")
  225. }
  226. require.Eventually(t, func() bool { return svc.AuthCacheInvalidationSubscriberHealth().Connected }, time.Second, 10*time.Millisecond)
  227. require.Equal(t, uint64(1), svc.AuthCacheInvalidationSubscriberHealth().Failures)
  228. require.NotPanics(t, func() { svc.StopAuthCacheInvalidationSubscriber(); svc.StopAuthCacheInvalidationSubscriber() })
  229. }
  230. func TestAuthCacheInvalidationSubscriber_ReconnectsAfterRuntimeDisconnect(t *testing.T) {
  231. ready := make(chan int, 2)
  232. var calls int
  233. cache := &authInvalidationCacheStub{subscribeFn: func(ctx context.Context, _ func(string)) error {
  234. calls++
  235. NotifyAuthCacheSubscriptionReady(ctx)
  236. ready <- calls
  237. if calls == 1 {
  238. return errors.New("connection dropped")
  239. }
  240. <-ctx.Done()
  241. return ctx.Err()
  242. }}
  243. svc := NewAPIKeyService(nil, nil, nil, nil, nil, cache, nil)
  244. localCache, err := ristretto.NewCache(&ristretto.Config{NumCounters: 10, MaxCost: 1, BufferItems: 64})
  245. require.NoError(t, err)
  246. defer localCache.Close()
  247. svc.authNegativeCacheL1 = localCache
  248. svc.StartAuthCacheInvalidationSubscriber(context.Background())
  249. select {
  250. case call := <-ready:
  251. require.Equal(t, 1, call)
  252. case <-time.After(time.Second):
  253. t.Fatal("initial subscription did not start")
  254. }
  255. select {
  256. case call := <-ready:
  257. require.Equal(t, 2, call)
  258. case <-time.After(2 * time.Second):
  259. t.Fatal("subscriber did not reconnect after runtime disconnect")
  260. }
  261. require.Eventually(t, func() bool { return svc.AuthCacheInvalidationSubscriberHealth().Connected }, time.Second, 10*time.Millisecond)
  262. require.Equal(t, uint64(1), svc.AuthCacheInvalidationSubscriberHealth().Failures)
  263. svc.StopAuthCacheInvalidationSubscriber()
  264. }