quarantine.go 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. // Package quarantine provides per-source error-rate enforcement
  2. // for the ingest pipeline (SPEC §22 layer 7). When a source
  3. // generates too many errors within a sliding window, it is
  4. // temporarily banned (quarantined) so it cannot submit alerts.
  5. //
  6. // Unlike the circuit breaker (layer 6, in-process), quarantine
  7. // uses Redis so the ban is shared across all ingestd instances.
  8. // A source banned by instance A is also banned by instance B.
  9. //
  10. // How it works:
  11. // - Every inbound alert that fails at any layer (rate limit,
  12. // circuit open, marshal error, publish error, etc.) records
  13. // one "hit" in Redis.
  14. // - A hit is a ZADD of (timestamp_ms as score, timestamp_ms as
  15. // value) into a sorted set keyed
  16. // "ingestd:quarantine_hits:<source_id>".
  17. // - A separate String key "ingestd:quarantine_banned:<source_id>"
  18. // with a TTL of QuarantineDuration holds the ban.
  19. // - On every ProcessAlert entry, we check the ban key first.
  20. // If present, reject immediately with result="quarantined".
  21. // - After every rejection (before returning from ProcessAlert),
  22. // we ZADD the hit. If the ban key is absent but the hit count
  23. // in the window >= QuarantineHitsThreshold, we SET the ban key
  24. // with TTL QuarantineDuration.
  25. //
  26. // This means a source accumulates hits continuously while it is
  27. // quarantined — as soon as the ban expires it can immediately be
  28. // re-quarantined if it keeps generating errors. This is intentional:
  29. // a source that is broken does not get a "free pass" after the
  30. // ban expires.
  31. package quarantine
  32. import (
  33. "context"
  34. "fmt"
  35. "strconv"
  36. "time"
  37. "github.com/redis/go-redis/v9"
  38. )
  39. // Config holds the static knobs. All fields must be set.
  40. type Config struct {
  41. // HitsThreshold is the number of error hits within
  42. // HitsWindow that triggers a ban.
  43. HitsThreshold int
  44. // HitsWindow is the rolling window for counting error hits.
  45. HitsWindow time.Duration
  46. // BanDuration is how long a ban lasts once triggered.
  47. BanDuration time.Duration
  48. }
  49. // DefaultConfig is a reasonable starting point: 100 errors in
  50. // 5 minutes triggers a 10-minute ban.
  51. func DefaultConfig() Config {
  52. return Config{
  53. HitsThreshold: 100,
  54. HitsWindow: 5 * time.Minute,
  55. BanDuration: 10 * time.Minute,
  56. }
  57. }
  58. // Manager owns the Redis connection and provides the Check/Record
  59. // API to the ingest pipeline. It is safe for concurrent use.
  60. type Manager struct {
  61. config Config
  62. rdb *redis.Client
  63. }
  64. // New creates a new quarantine manager.
  65. func New(rdb *redis.Client, cfg Config) *Manager {
  66. return &Manager{config: cfg, rdb: rdb}
  67. }
  68. // IsBanned returns true and the remaining TTL if sourceID is
  69. // currently banned. The remaining TTL is 0 if not banned.
  70. func (q *Manager) IsBanned(ctx context.Context, sourceID string) (bool, time.Duration, error) {
  71. key := bannedKey(sourceID)
  72. ttl, err := q.rdb.TTL(ctx, key).Result()
  73. if err != nil && err != redis.Nil {
  74. return false, 0, fmt.Errorf("quarantine ttl check: %w", err)
  75. }
  76. if ttl > 0 {
  77. return true, ttl, nil
  78. }
  79. return false, 0, nil
  80. }
  81. // RecordHit records one error for sourceID. If the hit count
  82. // in the window now exceeds HitsThreshold, the source is
  83. // immediately banned for BanDuration.
  84. //
  85. // RecordHit is idempotent: calling it twice for the same
  86. // timestamp counts as two hits (the hit timestamp is precise
  87. // to the millisecond, so two calls in the same millisecond
  88. // would need a unique member value to avoid being collapsed
  89. // by the sorted set's score uniqueness — we append a random
  90. // suffix to make each ZADD member unique).
  91. func (q *Manager) RecordHit(ctx context.Context, sourceID string) error {
  92. hitKey := hitsKey(sourceID)
  93. banKey := bannedKey(sourceID)
  94. now := time.Now()
  95. nowMs := now.UnixMilli()
  96. member := strconv.FormatInt(nowMs, 10) + ":" + randMember()
  97. pipe := q.rdb.Pipeline()
  98. // Add the hit to the sorted set with score = nowMs.
  99. pipe.ZAdd(ctx, hitKey, redis.Z{Score: float64(nowMs), Member: member})
  100. // Expire the hits key after HitsWindow + BanDuration so the
  101. // set auto-cleans and doesn't grow forever.
  102. pipe.Expire(ctx, hitKey, q.config.HitsWindow+q.config.BanDuration+time.Minute)
  103. // Prune hits older than HitsWindow.
  104. cutoff := now.Add(-q.config.HitsWindow).UnixMilli()
  105. pipe.ZRemRangeByScore(ctx, hitKey, "-inf", strconv.FormatInt(cutoff, 10))
  106. // Count hits in window.
  107. count, err := pipe.ZCard(ctx, hitKey).Result()
  108. if err != nil {
  109. return fmt.Errorf("quarantine zcard: %w", err)
  110. }
  111. if int(count) >= q.config.HitsThreshold {
  112. // Trip the ban.
  113. pipe.Set(ctx, banKey, "1", q.config.BanDuration)
  114. }
  115. _, err = pipe.Exec(ctx)
  116. if err != nil {
  117. return fmt.Errorf("quarantine exec: %w", err)
  118. }
  119. return nil
  120. }
  121. // HitsInWindow returns the current number of error hits for
  122. // sourceID within the configured hits window. Exposed for the
  123. // metrics emission site.
  124. func (q *Manager) HitsInWindow(ctx context.Context, sourceID string) (int, error) {
  125. hitKey := hitsKey(sourceID)
  126. now := time.Now()
  127. cutoff := now.Add(-q.config.HitsWindow).UnixMilli()
  128. count, err := q.rdb.ZCount(ctx, hitKey, strconv.FormatInt(cutoff, 10), "+inf").Result()
  129. if err != nil {
  130. return 0, fmt.Errorf("quarantine hits count: %w", err)
  131. }
  132. return int(count), nil
  133. }
  134. func hitsKey(sourceID string) string { return "ingestd:quarantine_hits:" + sourceID }
  135. func bannedKey(sourceID string) string {
  136. return "ingestd:quarantine_banned:" + sourceID
  137. }
  138. // randMember returns a short random string to ensure ZADD
  139. // members are unique when two calls happen in the same ms.
  140. func randMember() string {
  141. // 6 hex chars is plenty for uniqueness within a process.
  142. b := make([]byte, 3)
  143. for i := range b {
  144. b[i] = byte(time.Now().UnixNano() >> (i * 8) & 0xff)
  145. }
  146. return fmt.Sprintf("%x", b)
  147. }