auth_cache_invalidation_outbox_repo.go 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "fmt"
  7. "strings"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. )
  11. type authCacheInvalidationOutboxRepository struct {
  12. db *sql.DB
  13. }
  14. func NewAuthCacheInvalidationOutboxRepository(db *sql.DB) service.AuthCacheInvalidationOutboxRepository {
  15. return &authCacheInvalidationOutboxRepository{db: db}
  16. }
  17. func (r *authCacheInvalidationOutboxRepository) Claim(ctx context.Context, workerID string, limit int, lease time.Duration) ([]service.AuthCacheInvalidationEvent, error) {
  18. if r == nil || r.db == nil {
  19. return nil, errors.New("nil auth cache invalidation outbox database")
  20. }
  21. if limit <= 0 {
  22. limit = 100
  23. }
  24. leaseSeconds := int64(lease / time.Second)
  25. if leaseSeconds < 1 {
  26. leaseSeconds = 30
  27. }
  28. rows, err := r.db.QueryContext(ctx, `
  29. WITH candidates AS (
  30. SELECT id
  31. FROM auth_cache_invalidation_outbox
  32. WHERE available_at <= NOW()
  33. AND (claimed_at IS NULL OR claimed_at < NOW() - ($3 * INTERVAL '1 second'))
  34. ORDER BY id ASC
  35. LIMIT $2
  36. FOR UPDATE SKIP LOCKED
  37. )
  38. UPDATE auth_cache_invalidation_outbox AS o
  39. SET claimed_at = NOW(), claimed_by = $1
  40. FROM candidates AS c
  41. WHERE o.id = c.id
  42. RETURNING o.id, o.cache_key, o.attempts, o.delivery_stage, o.created_at
  43. `, workerID, limit, leaseSeconds)
  44. if err != nil {
  45. return nil, err
  46. }
  47. defer func() { _ = rows.Close() }()
  48. events := make([]service.AuthCacheInvalidationEvent, 0, limit)
  49. for rows.Next() {
  50. var event service.AuthCacheInvalidationEvent
  51. if err := rows.Scan(&event.ID, &event.CacheKey, &event.Attempts, &event.Stage, &event.CreatedAt); err != nil {
  52. return nil, err
  53. }
  54. event.CacheKey = strings.TrimSpace(event.CacheKey)
  55. events = append(events, event)
  56. }
  57. if err := rows.Err(); err != nil {
  58. return nil, err
  59. }
  60. return events, nil
  61. }
  62. func (r *authCacheInvalidationOutboxRepository) ScheduleSecondPass(ctx context.Context, id int64, workerID string, availableAt time.Time) error {
  63. result, err := r.db.ExecContext(ctx, `
  64. UPDATE auth_cache_invalidation_outbox
  65. SET delivery_stage = 1,
  66. available_at = $3,
  67. last_error = NULL,
  68. claimed_at = NULL,
  69. claimed_by = NULL
  70. WHERE id = $1 AND claimed_by = $2 AND delivery_stage = 0
  71. `, id, workerID, availableAt)
  72. if err != nil {
  73. return err
  74. }
  75. affected, err := result.RowsAffected()
  76. if err != nil {
  77. return err
  78. }
  79. if affected != 1 {
  80. return fmt.Errorf("auth cache invalidation claim %d cannot schedule second pass", id)
  81. }
  82. return nil
  83. }
  84. func (r *authCacheInvalidationOutboxRepository) DeleteClaimed(ctx context.Context, id int64, workerID string) error {
  85. result, err := r.db.ExecContext(ctx, `
  86. DELETE FROM auth_cache_invalidation_outbox
  87. WHERE id = $1 AND claimed_by = $2
  88. `, id, workerID)
  89. if err != nil {
  90. return err
  91. }
  92. affected, err := result.RowsAffected()
  93. if err != nil {
  94. return err
  95. }
  96. if affected != 1 {
  97. return fmt.Errorf("auth cache invalidation claim %d is no longer owned by %s", id, workerID)
  98. }
  99. return nil
  100. }
  101. func (r *authCacheInvalidationOutboxRepository) RetryClaimed(ctx context.Context, id int64, workerID string, availableAt time.Time, lastError string) error {
  102. result, err := r.db.ExecContext(ctx, `
  103. UPDATE auth_cache_invalidation_outbox
  104. SET attempts = attempts + 1,
  105. available_at = $3,
  106. last_error = $4,
  107. claimed_at = NULL,
  108. claimed_by = NULL
  109. WHERE id = $1 AND claimed_by = $2
  110. `, id, workerID, availableAt, lastError)
  111. if err != nil {
  112. return err
  113. }
  114. affected, err := result.RowsAffected()
  115. if err != nil {
  116. return err
  117. }
  118. if affected != 1 {
  119. return fmt.Errorf("auth cache invalidation claim %d is no longer owned by %s", id, workerID)
  120. }
  121. return nil
  122. }
  123. func (r *authCacheInvalidationOutboxRepository) Stats(ctx context.Context) (service.AuthCacheInvalidationOutboxStats, error) {
  124. var (
  125. stats service.AuthCacheInvalidationOutboxStats
  126. oldest sql.NullTime
  127. lastError sql.NullString
  128. )
  129. err := r.db.QueryRowContext(ctx, `
  130. SELECT COUNT(*), MIN(created_at), COALESCE(MAX(attempts), 0),
  131. (SELECT last_error
  132. FROM auth_cache_invalidation_outbox
  133. WHERE last_error IS NOT NULL
  134. ORDER BY available_at DESC, id DESC
  135. LIMIT 1)
  136. FROM auth_cache_invalidation_outbox
  137. `).Scan(&stats.Pending, &oldest, &stats.MaxAttempts, &lastError)
  138. if err != nil {
  139. return stats, err
  140. }
  141. if oldest.Valid {
  142. value := oldest.Time
  143. stats.OldestCreatedAt = &value
  144. }
  145. if lastError.Valid {
  146. stats.LastError = lastError.String
  147. }
  148. return stats, nil
  149. }