dedupe.go 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. // Package dedupe implements the sliding-window dedupe with
  2. // dedupe_count return per SPEC §5 and M6.
  3. //
  4. // M0–M5 algorithm (fixed window):
  5. //
  6. // key = dedupe:{source_id}:{dedupe_key}
  7. // SET key 1 NX EX 60 ← round-trip 1
  8. // OK -> first arrival, return (count=1, isNew=true)
  9. // nil -> key existed; INCR; return (count=N, isNew=false) ← round-trip 2
  10. //
  11. // M6 algorithm (sliding window, atomic via Lua):
  12. //
  13. // The whole claim is a single EVAL: SET NX EX <window>, else
  14. // INCR + EXPIRE <window> (refreshes the TTL from this observation).
  15. // A steady stream of duplicates keeps the key alive; if it
  16. // stops for `window` seconds, the entry expires and the next
  17. // arrival is "new" again with count=1.
  18. //
  19. // Returns:
  20. //
  21. // (true, 1, nil) – first arrival in the window
  22. // (false, n, nil) – duplicate; n is the 1-indexed count in this window
  23. // (false, 0, err) – redis error; caller should fail open
  24. package dedupe
  25. import (
  26. "context"
  27. "errors"
  28. "fmt"
  29. "time"
  30. "github.com/redis/go-redis/v9"
  31. )
  32. const (
  33. // DefaultWindow is the default sliding TTL for a dedupe entry.
  34. // M6 bumped it from 60s to 300s to match the
  35. // BA_INGESTD_DEDUPE_TTL_SECONDS default. Operators with
  36. // high-frequency Prometheus alerts can extend it further
  37. // (e.g. 3600) without code changes.
  38. DefaultWindow = 300 * time.Second
  39. keyPrefix = "dedupe:"
  40. )
  41. // slidingScript implements the sliding-window claim atomically.
  42. //
  43. // KEYS[1] = dedupe key
  44. // ARGV[1] = window as a number of seconds (float; Redis EX
  45. // takes an integer, so we round up via math.ceil so
  46. // sub-second windows used in tests still work)
  47. //
  48. // Returns: { isNew (0|1), count (1-indexed) }
  49. //
  50. // The script is safe to call concurrently from any number of
  51. // clients: Redis runs it under a single thread per shard, so
  52. // the SET-NX-or-INCR decision is atomic.
  53. //
  54. // We refresh the TTL on every duplicate observation (the
  55. // "sliding" property): if the alert keeps coming back, the
  56. // window keeps moving forward, and the key never expires.
  57. var slidingScript = redis.NewScript(`
  58. local key = KEYS[1]
  59. local ttl = tonumber(ARGV[1])
  60. if ttl == nil or ttl <= 0 then
  61. return redis.error_reply("dedupe: invalid ttl")
  62. end
  63. -- Redis EX takes an integer. Round sub-second windows up
  64. -- so test windows like 500ms still work.
  65. local ttlInt = math.ceil(ttl)
  66. if ttlInt < 1 then ttlInt = 1 end
  67. local set = redis.call("SET", key, 1, "NX", "EX", ttlInt)
  68. if set then
  69. return {1, 1}
  70. end
  71. local n = redis.call("INCR", key)
  72. redis.call("EXPIRE", key, ttlInt)
  73. return {0, n}
  74. `)
  75. type Deduper struct {
  76. rdb *redis.Client
  77. window time.Duration
  78. }
  79. func New(rdb *redis.Client, window time.Duration) *Deduper {
  80. if window <= 0 {
  81. window = DefaultWindow
  82. }
  83. return &Deduper{rdb: rdb, window: window}
  84. }
  85. // Window returns the configured sliding TTL. Callers may use
  86. // this for log lines or to format the X-Dedupe-TTL response
  87. // header (M11+).
  88. func (d *Deduper) Window() time.Duration { return d.window }
  89. // Check atomically claims a dedupe slot using the sliding-
  90. // window Lua script. See package doc for the algorithm.
  91. func (d *Deduper) Check(ctx context.Context, sourceID, dedupeKey string) (isNew bool, count uint32, err error) {
  92. if dedupeKey == "" {
  93. // No dedupe key => not dedupable. Caller treats it as a new alert.
  94. return true, 1, nil
  95. }
  96. key := keyPrefix + sourceID + ":" + dedupeKey
  97. res, err := slidingScript.Run(ctx, d.rdb, []string{key}, d.window.Seconds()).Result()
  98. if err != nil {
  99. return false, 0, fmt.Errorf("dedupe sliding: %w", err)
  100. }
  101. pair, ok := res.([]any)
  102. if !ok || len(pair) != 2 {
  103. return false, 0, fmt.Errorf("dedupe sliding: unexpected reply %T %v", res, res)
  104. }
  105. flag, _ := pair[0].(int64)
  106. n, _ := pair[1].(int64)
  107. if n < 1 {
  108. n = 1
  109. }
  110. return flag == 1, uint32(n), nil
  111. }
  112. // ErrInvalidSource is returned for a missing sourceID.
  113. var ErrInvalidSource = errors.New("dedupe: empty source id")