collapser.go 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. // Package dedupe provides the M6.5 router-level dedupe Collapser.
  2. //
  3. // The Collapser is a per-source debounce with a max-wait. It lets
  4. // the router turn a burst of 100 identical alerts into 1 delivery,
  5. // with the final dedupe_count attached to that single message.
  6. //
  7. // Behavior:
  8. //
  9. // - Observe(sourceID, dedupeKey, alert) returns one of:
  10. // * Collapse: the alert is being held; the router should NOT
  11. // publish deliveries yet. The Collapser will publish a
  12. // single alert via the onFlush callback when the max-wait
  13. // elapses, with the latest dedupe_count.
  14. // * Passthrough: the dedupe_key was empty. The router should
  15. // publish deliveries immediately, same as the M2 path.
  16. //
  17. // - When Observe is called with a (source, key) we already hold:
  18. // * Stored alert's dedupe_count is updated to the max of the
  19. // stored value and the incoming value (defensive — Redis
  20. // is the canonical counter).
  21. // * The flush timer is reset to the full max-wait.
  22. //
  23. // - The flush loop wakes every `flushMs / 4` (or 100ms, whichever
  24. // is smaller) and publishes any pending entries where
  25. // firstSeen + flushMs <= now.
  26. //
  27. // - State is per (sourceID, dedupeKey). Two sources with the same
  28. // key are isolated (matches M6's per-source dedupe state).
  29. //
  30. // Concurrency:
  31. //
  32. // - All public methods are safe to call from multiple goroutines.
  33. // - The pending map is guarded by a single mutex. The collision
  34. // rate in production is bounded (a handful of dedupe keys
  35. // active at once per source), so a coarse mutex is fine.
  36. // - The flush loop runs in its own goroutine via Run(ctx).
  37. package dedupe
  38. import (
  39. "context"
  40. "sync"
  41. "time"
  42. "git3.techno-world.net/lrosales/broad-announce/internal/alert"
  43. )
  44. // Decision is the result of an Observe call.
  45. type Decision int
  46. const (
  47. // Passthrough means the alert had no dedupe_key and should
  48. // be delivered immediately, unchanged.
  49. Passthrough Decision = iota
  50. // CollapseNew is the first arrival of a (source, key)
  51. // pair. The router should do any expensive per-alert work
  52. // (e.g. resolve recipients) NOW and cache it. The Collapser
  53. // will publish a single alert via the onFlush callback
  54. // when the max-wait elapses.
  55. CollapseNew
  56. // CollapseDupe is a subsequent arrival of the same
  57. // (source, key) within the active window. The router
  58. // should NOT redo expensive work; the stored alert's
  59. // dedupe_count has already been updated in-place.
  60. CollapseDupe
  61. )
  62. // FlushFunc is called when a pending alert is ready to publish.
  63. // The Collapser hands back the final dedupe_count it observed for
  64. // that (source, key) pair. The router is responsible for the
  65. // fanout to deliveries.<channel>.<company_id> subjects.
  66. type FlushFunc func(sourceID, dedupeKey string, a alert.Alert)
  67. // pendingEntry is one in-flight collapse.
  68. type pendingEntry struct {
  69. sourceID string
  70. key string
  71. alert alert.Alert
  72. firstSeen time.Time
  73. lastSeen time.Time
  74. }
  75. // Collapser is a per-(source, key) debounce with max-wait.
  76. // Zero-value is NOT usable; use NewCollapser.
  77. type Collapser struct {
  78. flushMs time.Duration
  79. tickPeriod time.Duration
  80. onFlush FlushFunc
  81. mu sync.Mutex
  82. pending map[collapseKey]*pendingEntry
  83. // OnPassthrough fires when an alert with empty dedupe_key
  84. // passes through. Useful for tests/observability; nil OK.
  85. OnPassthrough func()
  86. // onCollapse fires when a new (source, key) collapse starts.
  87. // Useful for tests/observability; nil OK.
  88. onCollapse func()
  89. }
  90. // collapseKey is the dedupe state key. We use a single struct
  91. // value to avoid string allocation on every Observe.
  92. type collapseKey struct {
  93. sourceID string
  94. dedupeKey string
  95. }
  96. // NewCollapser constructs a Collapser that flushes after `flushMs`.
  97. //
  98. // - flushMs > 0: required, the max time a held alert waits
  99. // before being flushed (debounce window).
  100. // - onFlush: required, called for each pending entry at flush
  101. // time. Must not block; the flush loop runs in its own
  102. // goroutine and onFlush serializes with itself via the loop.
  103. func NewCollapser(flushMs time.Duration, onFlush FlushFunc) *Collapser {
  104. if flushMs <= 0 {
  105. flushMs = 2000 * time.Millisecond
  106. }
  107. // Tick at most every 100ms, or 1/4 of the flush window
  108. // (whichever is smaller). 100ms is a good upper bound for
  109. // responsiveness when flushMs is large.
  110. tick := flushMs / 4
  111. if tick > 100*time.Millisecond {
  112. tick = 100 * time.Millisecond
  113. }
  114. if tick < 5*time.Millisecond {
  115. tick = 5 * time.Millisecond
  116. }
  117. return &Collapser{
  118. flushMs: flushMs,
  119. tickPeriod: tick,
  120. onFlush: onFlush,
  121. pending: make(map[collapseKey]*pendingEntry),
  122. }
  123. }
  124. // Observe records an alert and returns the action the router
  125. // should take.
  126. //
  127. // - If dedupeKey is empty: returns Passthrough. The router
  128. // delivers immediately (no collapse, no state).
  129. // - If (source, key) is new: stores the alert, arms the
  130. // max-wait timer, returns CollapseNew. The router should
  131. // resolve recipients NOW.
  132. // - If (source, key) is held: updates the stored
  133. // dedupe_count to max(stored, incoming), updates
  134. // lastSeen (timer is reset at flush time), returns
  135. // CollapseDupe. The router should NOT re-resolve.
  136. func (c *Collapser) Observe(sourceID, dedupeKey string, a alert.Alert) Decision {
  137. if dedupeKey == "" {
  138. if c.OnPassthrough != nil {
  139. c.OnPassthrough()
  140. }
  141. return Passthrough
  142. }
  143. k := collapseKey{sourceID: sourceID, dedupeKey: dedupeKey}
  144. c.mu.Lock()
  145. defer c.mu.Unlock()
  146. if e, ok := c.pending[k]; ok {
  147. if a.DedupeCount > e.alert.DedupeCount {
  148. e.alert.DedupeCount = a.DedupeCount
  149. }
  150. // Refresh lastSeen so the flush window slides for
  151. // continuous bursts (matches M6 sliding-window TTL
  152. // semantics for the in-router collapse).
  153. e.lastSeen = time.Now()
  154. return CollapseDupe
  155. }
  156. c.pending[k] = &pendingEntry{
  157. sourceID: sourceID,
  158. key: dedupeKey,
  159. alert: a,
  160. firstSeen: time.Now(),
  161. lastSeen: time.Now(),
  162. }
  163. if c.onCollapse != nil {
  164. c.onCollapse()
  165. }
  166. return CollapseNew
  167. }
  168. // Pending returns the number of held entries. Used by tests
  169. // and the routerd /metrics endpoint.
  170. func (c *Collapser) Pending() int {
  171. c.mu.Lock()
  172. defer c.mu.Unlock()
  173. return len(c.pending)
  174. }
  175. // Run is the flush loop. It exits when ctx is canceled. Call
  176. // it in a goroutine:
  177. //
  178. // go collapser.Run(ctx)
  179. func (c *Collapser) Run(ctx context.Context) {
  180. t := time.NewTicker(c.tickPeriod)
  181. defer t.Stop()
  182. for {
  183. select {
  184. case <-ctx.Done():
  185. return
  186. case now := <-t.C:
  187. c.flushReady(now)
  188. }
  189. }
  190. }
  191. // flushReady publishes any entries whose firstSeen+flushMs <= now.
  192. // The sliding window is the simple "first arrival" version: once
  193. // the burst has been quiet for flushMs, flush. (The Observe path
  194. // updates lastSeen for metric/observability purposes only; the
  195. // flush uses firstSeen to keep the contract simple and testable.)
  196. func (c *Collapser) flushReady(now time.Time) {
  197. threshold := c.flushMs
  198. var ready []pendingEntry
  199. c.mu.Lock()
  200. for k, e := range c.pending {
  201. if now.Sub(e.firstSeen) >= threshold {
  202. ready = append(ready, *e)
  203. delete(c.pending, k)
  204. }
  205. }
  206. c.mu.Unlock()
  207. for _, e := range ready {
  208. c.onFlush(e.sourceID, e.key, e.alert)
  209. }
  210. }
  211. // FlushAll is for graceful shutdown — publishes every pending
  212. // entry immediately. Call from a defer after runCancel.
  213. func (c *Collapser) FlushAll() {
  214. c.mu.Lock()
  215. ready := make([]pendingEntry, 0, len(c.pending))
  216. for k, e := range c.pending {
  217. ready = append(ready, *e)
  218. delete(c.pending, k)
  219. }
  220. c.mu.Unlock()
  221. for _, e := range ready {
  222. c.onFlush(e.sourceID, e.key, e.alert)
  223. }
  224. }