| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241 |
- // Package dedupe provides the M6.5 router-level dedupe Collapser.
- //
- // The Collapser is a per-source debounce with a max-wait. It lets
- // the router turn a burst of 100 identical alerts into 1 delivery,
- // with the final dedupe_count attached to that single message.
- //
- // Behavior:
- //
- // - Observe(sourceID, dedupeKey, alert) returns one of:
- // * Collapse: the alert is being held; the router should NOT
- // publish deliveries yet. The Collapser will publish a
- // single alert via the onFlush callback when the max-wait
- // elapses, with the latest dedupe_count.
- // * Passthrough: the dedupe_key was empty. The router should
- // publish deliveries immediately, same as the M2 path.
- //
- // - When Observe is called with a (source, key) we already hold:
- // * Stored alert's dedupe_count is updated to the max of the
- // stored value and the incoming value (defensive — Redis
- // is the canonical counter).
- // * The flush timer is reset to the full max-wait.
- //
- // - The flush loop wakes every `flushMs / 4` (or 100ms, whichever
- // is smaller) and publishes any pending entries where
- // firstSeen + flushMs <= now.
- //
- // - State is per (sourceID, dedupeKey). Two sources with the same
- // key are isolated (matches M6's per-source dedupe state).
- //
- // Concurrency:
- //
- // - All public methods are safe to call from multiple goroutines.
- // - The pending map is guarded by a single mutex. The collision
- // rate in production is bounded (a handful of dedupe keys
- // active at once per source), so a coarse mutex is fine.
- // - The flush loop runs in its own goroutine via Run(ctx).
- package dedupe
- import (
- "context"
- "sync"
- "time"
- "git3.techno-world.net/lrosales/broad-announce/internal/alert"
- )
- // Decision is the result of an Observe call.
- type Decision int
- const (
- // Passthrough means the alert had no dedupe_key and should
- // be delivered immediately, unchanged.
- Passthrough Decision = iota
- // CollapseNew is the first arrival of a (source, key)
- // pair. The router should do any expensive per-alert work
- // (e.g. resolve recipients) NOW and cache it. The Collapser
- // will publish a single alert via the onFlush callback
- // when the max-wait elapses.
- CollapseNew
- // CollapseDupe is a subsequent arrival of the same
- // (source, key) within the active window. The router
- // should NOT redo expensive work; the stored alert's
- // dedupe_count has already been updated in-place.
- CollapseDupe
- )
- // FlushFunc is called when a pending alert is ready to publish.
- // The Collapser hands back the final dedupe_count it observed for
- // that (source, key) pair. The router is responsible for the
- // fanout to deliveries.<channel>.<company_id> subjects.
- type FlushFunc func(sourceID, dedupeKey string, a alert.Alert)
- // pendingEntry is one in-flight collapse.
- type pendingEntry struct {
- sourceID string
- key string
- alert alert.Alert
- firstSeen time.Time
- lastSeen time.Time
- }
- // Collapser is a per-(source, key) debounce with max-wait.
- // Zero-value is NOT usable; use NewCollapser.
- type Collapser struct {
- flushMs time.Duration
- tickPeriod time.Duration
- onFlush FlushFunc
- mu sync.Mutex
- pending map[collapseKey]*pendingEntry
- // OnPassthrough fires when an alert with empty dedupe_key
- // passes through. Useful for tests/observability; nil OK.
- OnPassthrough func()
- // onCollapse fires when a new (source, key) collapse starts.
- // Useful for tests/observability; nil OK.
- onCollapse func()
- }
- // collapseKey is the dedupe state key. We use a single struct
- // value to avoid string allocation on every Observe.
- type collapseKey struct {
- sourceID string
- dedupeKey string
- }
- // NewCollapser constructs a Collapser that flushes after `flushMs`.
- //
- // - flushMs > 0: required, the max time a held alert waits
- // before being flushed (debounce window).
- // - onFlush: required, called for each pending entry at flush
- // time. Must not block; the flush loop runs in its own
- // goroutine and onFlush serializes with itself via the loop.
- func NewCollapser(flushMs time.Duration, onFlush FlushFunc) *Collapser {
- if flushMs <= 0 {
- flushMs = 2000 * time.Millisecond
- }
- // Tick at most every 100ms, or 1/4 of the flush window
- // (whichever is smaller). 100ms is a good upper bound for
- // responsiveness when flushMs is large.
- tick := flushMs / 4
- if tick > 100*time.Millisecond {
- tick = 100 * time.Millisecond
- }
- if tick < 5*time.Millisecond {
- tick = 5 * time.Millisecond
- }
- return &Collapser{
- flushMs: flushMs,
- tickPeriod: tick,
- onFlush: onFlush,
- pending: make(map[collapseKey]*pendingEntry),
- }
- }
- // Observe records an alert and returns the action the router
- // should take.
- //
- // - If dedupeKey is empty: returns Passthrough. The router
- // delivers immediately (no collapse, no state).
- // - If (source, key) is new: stores the alert, arms the
- // max-wait timer, returns CollapseNew. The router should
- // resolve recipients NOW.
- // - If (source, key) is held: updates the stored
- // dedupe_count to max(stored, incoming), updates
- // lastSeen (timer is reset at flush time), returns
- // CollapseDupe. The router should NOT re-resolve.
- func (c *Collapser) Observe(sourceID, dedupeKey string, a alert.Alert) Decision {
- if dedupeKey == "" {
- if c.OnPassthrough != nil {
- c.OnPassthrough()
- }
- return Passthrough
- }
- k := collapseKey{sourceID: sourceID, dedupeKey: dedupeKey}
- c.mu.Lock()
- defer c.mu.Unlock()
- if e, ok := c.pending[k]; ok {
- if a.DedupeCount > e.alert.DedupeCount {
- e.alert.DedupeCount = a.DedupeCount
- }
- // Refresh lastSeen so the flush window slides for
- // continuous bursts (matches M6 sliding-window TTL
- // semantics for the in-router collapse).
- e.lastSeen = time.Now()
- return CollapseDupe
- }
- c.pending[k] = &pendingEntry{
- sourceID: sourceID,
- key: dedupeKey,
- alert: a,
- firstSeen: time.Now(),
- lastSeen: time.Now(),
- }
- if c.onCollapse != nil {
- c.onCollapse()
- }
- return CollapseNew
- }
- // Pending returns the number of held entries. Used by tests
- // and the routerd /metrics endpoint.
- func (c *Collapser) Pending() int {
- c.mu.Lock()
- defer c.mu.Unlock()
- return len(c.pending)
- }
- // Run is the flush loop. It exits when ctx is canceled. Call
- // it in a goroutine:
- //
- // go collapser.Run(ctx)
- func (c *Collapser) Run(ctx context.Context) {
- t := time.NewTicker(c.tickPeriod)
- defer t.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case now := <-t.C:
- c.flushReady(now)
- }
- }
- }
- // flushReady publishes any entries whose firstSeen+flushMs <= now.
- // The sliding window is the simple "first arrival" version: once
- // the burst has been quiet for flushMs, flush. (The Observe path
- // updates lastSeen for metric/observability purposes only; the
- // flush uses firstSeen to keep the contract simple and testable.)
- func (c *Collapser) flushReady(now time.Time) {
- threshold := c.flushMs
- var ready []pendingEntry
- c.mu.Lock()
- for k, e := range c.pending {
- if now.Sub(e.firstSeen) >= threshold {
- ready = append(ready, *e)
- delete(c.pending, k)
- }
- }
- c.mu.Unlock()
- for _, e := range ready {
- c.onFlush(e.sourceID, e.key, e.alert)
- }
- }
- // FlushAll is for graceful shutdown — publishes every pending
- // entry immediately. Call from a defer after runCancel.
- func (c *Collapser) FlushAll() {
- c.mu.Lock()
- ready := make([]pendingEntry, 0, len(c.pending))
- for k, e := range c.pending {
- ready = append(ready, *e)
- delete(c.pending, k)
- }
- c.mu.Unlock()
- for _, e := range ready {
- c.onFlush(e.sourceID, e.key, e.alert)
- }
- }
|