auth_cache_invalidation_outbox.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "log/slog"
  6. "math/rand/v2"
  7. "sync"
  8. "sync/atomic"
  9. "time"
  10. "github.com/google/uuid"
  11. )
  12. const (
  13. authInvalidationBatchSize = 100
  14. authInvalidationPollInterval = 500 * time.Millisecond
  15. authInvalidationLease = 30 * time.Second
  16. authInvalidationRedisTimeout = 2 * time.Second
  17. authInvalidationSafetyDelay = 30 * time.Second
  18. authInvalidationConcurrency = 16
  19. )
  20. type AuthCacheInvalidationEvent struct {
  21. ID int64
  22. CacheKey string
  23. Attempts int
  24. Stage int
  25. CreatedAt time.Time
  26. }
  27. type AuthCacheInvalidationOutboxStats struct {
  28. Pending int64
  29. OldestCreatedAt *time.Time
  30. MaxAttempts int
  31. LastError string
  32. }
  33. type AuthCacheInvalidationOutboxRepository interface {
  34. Claim(ctx context.Context, workerID string, limit int, lease time.Duration) ([]AuthCacheInvalidationEvent, error)
  35. DeleteClaimed(ctx context.Context, id int64, workerID string) error
  36. ScheduleSecondPass(ctx context.Context, id int64, workerID string, availableAt time.Time) error
  37. RetryClaimed(ctx context.Context, id int64, workerID string, availableAt time.Time, lastError string) error
  38. Stats(ctx context.Context) (AuthCacheInvalidationOutboxStats, error)
  39. }
  40. type AuthCacheInvalidationHealth struct {
  41. Running bool `json:"running"`
  42. Processed uint64 `json:"processed"`
  43. Failures uint64 `json:"failures"`
  44. Pending int64 `json:"pending"`
  45. OldestLag time.Duration `json:"oldest_lag"`
  46. LastError string `json:"last_error,omitempty"`
  47. StatsError string `json:"stats_error,omitempty"`
  48. // HealthySLA includes the delayed safety pass. RecoverySLA is the maximum
  49. // convergence time after Redis becomes healthy, including capped backoff.
  50. HealthySLA time.Duration `json:"healthy_sla"`
  51. RecoverySLA time.Duration `json:"recovery_sla"`
  52. MaxAttempts int `json:"max_attempts"`
  53. }
  54. type OpsAuthCacheInvalidationHealth struct {
  55. Outbox AuthCacheInvalidationHealth `json:"outbox"`
  56. Subscriber AuthCacheInvalidationSubscriberHealth `json:"subscriber"`
  57. Lookup APIKeyAuthLookupMetrics `json:"lookup"`
  58. InvalidAbuse InvalidAuthAbuseHealth `json:"invalid_abuse"`
  59. }
  60. func (s *OpsService) GetAuthCacheInvalidationHealth(ctx context.Context) OpsAuthCacheInvalidationHealth {
  61. if s == nil {
  62. return OpsAuthCacheInvalidationHealth{}
  63. }
  64. health := OpsAuthCacheInvalidationHealth{}
  65. if s.authCacheInvalidationWorker != nil {
  66. health.Outbox = s.authCacheInvalidationWorker.Health(ctx)
  67. }
  68. if s.apiKeyService != nil {
  69. health.Subscriber = s.apiKeyService.AuthCacheInvalidationSubscriberHealth()
  70. health.Lookup = s.apiKeyService.AuthLookupMetrics()
  71. health.InvalidAbuse = s.apiKeyService.InvalidAuthAbuseHealth()
  72. }
  73. return health
  74. }
  75. type AuthCacheInvalidationWorker struct {
  76. repo AuthCacheInvalidationOutboxRepository
  77. cache APIKeyCache
  78. local *APIKeyService
  79. workerID string
  80. ctx context.Context
  81. cancel context.CancelFunc
  82. wg sync.WaitGroup
  83. start sync.Once
  84. stop sync.Once
  85. running atomic.Bool
  86. processed atomic.Uint64
  87. failures atomic.Uint64
  88. lastError atomic.Value
  89. }
  90. func NewAuthCacheInvalidationWorker(repo AuthCacheInvalidationOutboxRepository, cache APIKeyCache, local ...*APIKeyService) *AuthCacheInvalidationWorker {
  91. ctx, cancel := context.WithCancel(context.Background())
  92. w := &AuthCacheInvalidationWorker{
  93. repo: repo, cache: cache, workerID: uuid.NewString(), ctx: ctx, cancel: cancel,
  94. }
  95. if len(local) > 0 {
  96. w.local = local[0]
  97. }
  98. w.lastError.Store("")
  99. return w
  100. }
  101. func (w *AuthCacheInvalidationWorker) Start() {
  102. if w == nil || w.repo == nil || w.cache == nil {
  103. return
  104. }
  105. w.start.Do(func() {
  106. w.running.Store(true)
  107. w.wg.Add(1)
  108. go w.run()
  109. })
  110. }
  111. func (w *AuthCacheInvalidationWorker) Stop() {
  112. if w == nil {
  113. return
  114. }
  115. w.stop.Do(func() {
  116. w.cancel()
  117. w.wg.Wait()
  118. w.running.Store(false)
  119. })
  120. }
  121. func (w *AuthCacheInvalidationWorker) run() {
  122. defer w.wg.Done()
  123. defer w.running.Store(false)
  124. ticker := time.NewTicker(authInvalidationPollInterval)
  125. defer ticker.Stop()
  126. for {
  127. if err := w.processBatch(w.ctx); err != nil && w.ctx.Err() == nil {
  128. w.recordFailure(err)
  129. }
  130. select {
  131. case <-w.ctx.Done():
  132. return
  133. case <-ticker.C:
  134. }
  135. }
  136. }
  137. func (w *AuthCacheInvalidationWorker) processBatch(ctx context.Context) error {
  138. events, err := w.repo.Claim(ctx, w.workerID, authInvalidationBatchSize, authInvalidationLease)
  139. if err != nil {
  140. return fmt.Errorf("claim auth cache invalidations: %w", err)
  141. }
  142. semaphore := make(chan struct{}, authInvalidationConcurrency)
  143. var wg sync.WaitGroup
  144. for i := range events {
  145. select {
  146. case <-ctx.Done():
  147. wg.Wait()
  148. return ctx.Err()
  149. case semaphore <- struct{}{}:
  150. }
  151. wg.Add(1)
  152. go func(event AuthCacheInvalidationEvent) {
  153. defer wg.Done()
  154. defer func() { <-semaphore }()
  155. w.processEvent(ctx, event)
  156. }(events[i])
  157. }
  158. wg.Wait()
  159. return nil
  160. }
  161. func (w *AuthCacheInvalidationWorker) processEvent(parent context.Context, event AuthCacheInvalidationEvent) {
  162. if w.local != nil {
  163. w.local.invalidateLocalAuthCache(event.CacheKey)
  164. }
  165. ctx, cancel := context.WithTimeout(parent, authInvalidationRedisTimeout)
  166. err := w.cache.DeleteAuthCache(ctx, event.CacheKey)
  167. if err == nil {
  168. err = w.cache.PublishAuthCacheInvalidation(ctx, event.CacheKey)
  169. }
  170. cancel()
  171. if err != nil {
  172. w.recordFailure(err)
  173. retryAt := time.Now().UTC().Add(authInvalidationRetryDelay(event.Attempts + 1))
  174. retryCtx, retryCancel := context.WithTimeout(context.Background(), 2*time.Second)
  175. retryErr := w.repo.RetryClaimed(retryCtx, event.ID, w.workerID, retryAt, boundedAuthInvalidationError(err))
  176. retryCancel()
  177. if retryErr != nil {
  178. w.recordFailure(fmt.Errorf("release failed auth invalidation %d: %w", event.ID, retryErr))
  179. }
  180. return
  181. }
  182. if event.Stage == 0 {
  183. nextCtx, nextCancel := context.WithTimeout(context.Background(), 2*time.Second)
  184. err = w.repo.ScheduleSecondPass(nextCtx, event.ID, w.workerID, time.Now().UTC().Add(authInvalidationSafetyDelay))
  185. nextCancel()
  186. if err != nil {
  187. w.recordFailure(fmt.Errorf("schedule second auth invalidation pass %d: %w", event.ID, err))
  188. return
  189. }
  190. w.processed.Add(1)
  191. w.lastError.Store("")
  192. return
  193. }
  194. ackCtx, ackCancel := context.WithTimeout(context.Background(), 2*time.Second)
  195. err = w.repo.DeleteClaimed(ackCtx, event.ID, w.workerID)
  196. ackCancel()
  197. if err != nil {
  198. w.recordFailure(fmt.Errorf("ack auth invalidation %d: %w", event.ID, err))
  199. return
  200. }
  201. w.processed.Add(1)
  202. w.lastError.Store("")
  203. }
  204. func authInvalidationRetryDelay(attempt int) time.Duration {
  205. if attempt < 1 {
  206. attempt = 1
  207. }
  208. if attempt > 9 {
  209. attempt = 9
  210. }
  211. base := time.Second * time.Duration(1<<(attempt-1))
  212. return time.Duration(float64(base) * (0.8 + rand.Float64()*0.4))
  213. }
  214. func boundedAuthInvalidationError(err error) string {
  215. if err == nil {
  216. return ""
  217. }
  218. message := err.Error()
  219. if len(message) > 1024 {
  220. return message[:1024]
  221. }
  222. return message
  223. }
  224. func (w *AuthCacheInvalidationWorker) recordFailure(err error) {
  225. if err == nil {
  226. return
  227. }
  228. w.failures.Add(1)
  229. w.lastError.Store(boundedAuthInvalidationError(err))
  230. slog.Warn("auth cache invalidation outbox processing failed", "error", err)
  231. }
  232. func (w *AuthCacheInvalidationWorker) Health(ctx context.Context) AuthCacheInvalidationHealth {
  233. health := AuthCacheInvalidationHealth{
  234. HealthySLA: authInvalidationSafetyDelay + 5*time.Second,
  235. RecoverySLA: 6 * time.Minute,
  236. }
  237. if w == nil {
  238. return health
  239. }
  240. health.Running = w.running.Load()
  241. health.Processed = w.processed.Load()
  242. health.Failures = w.failures.Load()
  243. if value := w.lastError.Load(); value != nil {
  244. health.LastError, _ = value.(string)
  245. }
  246. if w.repo == nil {
  247. return health
  248. }
  249. stats, err := w.repo.Stats(ctx)
  250. if err != nil {
  251. health.StatsError = boundedAuthInvalidationError(err)
  252. return health
  253. }
  254. health.Pending = stats.Pending
  255. health.MaxAttempts = stats.MaxAttempts
  256. if health.LastError == "" {
  257. health.LastError = stats.LastError
  258. }
  259. if stats.OldestCreatedAt != nil {
  260. health.OldestLag = time.Since(*stats.OldestCreatedAt)
  261. if health.OldestLag < 0 {
  262. health.OldestLag = 0
  263. }
  264. }
  265. return health
  266. }
  267. func ProvideAuthCacheInvalidationWorker(repo AuthCacheInvalidationOutboxRepository, cache APIKeyCache, apiKeyService *APIKeyService) *AuthCacheInvalidationWorker {
  268. worker := NewAuthCacheInvalidationWorker(repo, cache, apiKeyService)
  269. worker.Start()
  270. return worker
  271. }