| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165 |
- // Package quarantine provides per-source error-rate enforcement
- // for the ingest pipeline (SPEC §22 layer 7). When a source
- // generates too many errors within a sliding window, it is
- // temporarily banned (quarantined) so it cannot submit alerts.
- //
- // Unlike the circuit breaker (layer 6, in-process), quarantine
- // uses Redis so the ban is shared across all ingestd instances.
- // A source banned by instance A is also banned by instance B.
- //
- // How it works:
- // - Every inbound alert that fails at any layer (rate limit,
- // circuit open, marshal error, publish error, etc.) records
- // one "hit" in Redis.
- // - A hit is a ZADD of (timestamp_ms as score, timestamp_ms as
- // value) into a sorted set keyed
- // "ingestd:quarantine_hits:<source_id>".
- // - A separate String key "ingestd:quarantine_banned:<source_id>"
- // with a TTL of QuarantineDuration holds the ban.
- // - On every ProcessAlert entry, we check the ban key first.
- // If present, reject immediately with result="quarantined".
- // - After every rejection (before returning from ProcessAlert),
- // we ZADD the hit. If the ban key is absent but the hit count
- // in the window >= QuarantineHitsThreshold, we SET the ban key
- // with TTL QuarantineDuration.
- //
- // This means a source accumulates hits continuously while it is
- // quarantined — as soon as the ban expires it can immediately be
- // re-quarantined if it keeps generating errors. This is intentional:
- // a source that is broken does not get a "free pass" after the
- // ban expires.
- package quarantine
- import (
- "context"
- "fmt"
- "strconv"
- "time"
- "github.com/redis/go-redis/v9"
- )
- // Config holds the static knobs. All fields must be set.
- type Config struct {
- // HitsThreshold is the number of error hits within
- // HitsWindow that triggers a ban.
- HitsThreshold int
- // HitsWindow is the rolling window for counting error hits.
- HitsWindow time.Duration
- // BanDuration is how long a ban lasts once triggered.
- BanDuration time.Duration
- }
- // DefaultConfig is a reasonable starting point: 100 errors in
- // 5 minutes triggers a 10-minute ban.
- func DefaultConfig() Config {
- return Config{
- HitsThreshold: 100,
- HitsWindow: 5 * time.Minute,
- BanDuration: 10 * time.Minute,
- }
- }
- // Manager owns the Redis connection and provides the Check/Record
- // API to the ingest pipeline. It is safe for concurrent use.
- type Manager struct {
- config Config
- rdb *redis.Client
- }
- // New creates a new quarantine manager.
- func New(rdb *redis.Client, cfg Config) *Manager {
- return &Manager{config: cfg, rdb: rdb}
- }
- // IsBanned returns true and the remaining TTL if sourceID is
- // currently banned. The remaining TTL is 0 if not banned.
- func (q *Manager) IsBanned(ctx context.Context, sourceID string) (bool, time.Duration, error) {
- key := bannedKey(sourceID)
- ttl, err := q.rdb.TTL(ctx, key).Result()
- if err != nil && err != redis.Nil {
- return false, 0, fmt.Errorf("quarantine ttl check: %w", err)
- }
- if ttl > 0 {
- return true, ttl, nil
- }
- return false, 0, nil
- }
- // RecordHit records one error for sourceID. If the hit count
- // in the window now exceeds HitsThreshold, the source is
- // immediately banned for BanDuration.
- //
- // RecordHit is idempotent: calling it twice for the same
- // timestamp counts as two hits (the hit timestamp is precise
- // to the millisecond, so two calls in the same millisecond
- // would need a unique member value to avoid being collapsed
- // by the sorted set's score uniqueness — we append a random
- // suffix to make each ZADD member unique).
- func (q *Manager) RecordHit(ctx context.Context, sourceID string) error {
- hitKey := hitsKey(sourceID)
- banKey := bannedKey(sourceID)
- now := time.Now()
- nowMs := now.UnixMilli()
- member := strconv.FormatInt(nowMs, 10) + ":" + randMember()
- pipe := q.rdb.Pipeline()
- // Add the hit to the sorted set with score = nowMs.
- pipe.ZAdd(ctx, hitKey, redis.Z{Score: float64(nowMs), Member: member})
- // Expire the hits key after HitsWindow + BanDuration so the
- // set auto-cleans and doesn't grow forever.
- pipe.Expire(ctx, hitKey, q.config.HitsWindow+q.config.BanDuration+time.Minute)
- // Prune hits older than HitsWindow.
- cutoff := now.Add(-q.config.HitsWindow).UnixMilli()
- pipe.ZRemRangeByScore(ctx, hitKey, "-inf", strconv.FormatInt(cutoff, 10))
- // Count hits in window.
- count, err := pipe.ZCard(ctx, hitKey).Result()
- if err != nil {
- return fmt.Errorf("quarantine zcard: %w", err)
- }
- if int(count) >= q.config.HitsThreshold {
- // Trip the ban.
- pipe.Set(ctx, banKey, "1", q.config.BanDuration)
- }
- _, err = pipe.Exec(ctx)
- if err != nil {
- return fmt.Errorf("quarantine exec: %w", err)
- }
- return nil
- }
- // HitsInWindow returns the current number of error hits for
- // sourceID within the configured hits window. Exposed for the
- // metrics emission site.
- func (q *Manager) HitsInWindow(ctx context.Context, sourceID string) (int, error) {
- hitKey := hitsKey(sourceID)
- now := time.Now()
- cutoff := now.Add(-q.config.HitsWindow).UnixMilli()
- count, err := q.rdb.ZCount(ctx, hitKey, strconv.FormatInt(cutoff, 10), "+inf").Result()
- if err != nil {
- return 0, fmt.Errorf("quarantine hits count: %w", err)
- }
- return int(count), nil
- }
- func hitsKey(sourceID string) string { return "ingestd:quarantine_hits:" + sourceID }
- func bannedKey(sourceID string) string {
- return "ingestd:quarantine_banned:" + sourceID
- }
- // randMember returns a short random string to ensure ZADD
- // members are unique when two calls happen in the same ms.
- func randMember() string {
- // 6 hex chars is plenty for uniqueness within a process.
- b := make([]byte, 3)
- for i := range b {
- b[i] = byte(time.Now().UnixNano() >> (i * 8) & 0xff)
- }
- return fmt.Sprintf("%x", b)
- }
|