collapse.go 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. // collapse.go wires the M6.5 router-level dedupe Collapser into
  2. // the alert-processing pipeline.
  3. //
  4. // What it does:
  5. // - On the first arrival of a (source, dedupe_key) pair, the
  6. // router resolves recipients (DB call), then stores the
  7. // targets alongside the alert. The Collapser holds the alert
  8. // until the flush window elapses, then fires onFlush.
  9. // - On subsequent arrivals of the same pair, the router skips
  10. // recipient resolution and just updates the stored
  11. // dedupe_count. This is the M6.5 win: a 100-alert burst
  12. // becomes 1 resolve-recipients call instead of 100.
  13. // - On flush, the router fans out exactly one delivery per
  14. // (target, channel) pair, with the latest dedupe_count on
  15. // the alert.
  16. //
  17. // What it does NOT do:
  18. // - It does NOT touch alerts with empty dedupe_key — those
  19. // pass through unchanged (M2 behavior preserved).
  20. // - It does NOT touch alerts whose recipient resolution
  21. // fails — those get Nack'd and retried by NATS, same as M2.
  22. // - It does NOT collapse across sources — per-source state is
  23. // enforced by the Collapser's keying.
  24. package main
  25. import (
  26. "context"
  27. "encoding/json"
  28. "log/slog"
  29. "sync"
  30. "time"
  31. "git3.techno-world.net/lrosales/broad-announce/internal/alert"
  32. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  33. "git3.techno-world.net/lrosales/broad-announce/internal/dedupe"
  34. "git3.techno-world.net/lrosales/broad-announce/internal/routing"
  35. )
  36. // fanoutState is the router's per-collapse-window state.
  37. // It holds the resolved recipients for each (source, key) pair
  38. // so duplicate alerts skip the expensive ResolveTargets call.
  39. type fanoutState struct {
  40. mu sync.Mutex
  41. pending map[collapseKey][]routing.Target
  42. }
  43. // collapseKey mirrors dedupe.collapseKey without exporting it.
  44. // We re-declare the struct here to keep the public surface of
  45. // internal/dedupe minimal.
  46. type collapseKey struct {
  47. sourceID string
  48. dedupeKey string
  49. }
  50. func newFanoutState() *fanoutState {
  51. return &fanoutState{pending: make(map[collapseKey][]routing.Target)}
  52. }
  53. func (f *fanoutState) put(sourceID, key string, targets []routing.Target) {
  54. f.mu.Lock()
  55. defer f.mu.Unlock()
  56. f.pending[collapseKey{sourceID, key}] = targets
  57. }
  58. func (f *fanoutState) take(sourceID, key string) []routing.Target {
  59. f.mu.Lock()
  60. defer f.mu.Unlock()
  61. t, ok := f.pending[collapseKey{sourceID, key}]
  62. if ok {
  63. delete(f.pending, collapseKey{sourceID, key})
  64. }
  65. return t
  66. }
  67. func (f *fanoutState) len() int {
  68. f.mu.Lock()
  69. defer f.mu.Unlock()
  70. return len(f.pending)
  71. }
  72. // observeAndFanout is the M6.5-aware alert handler. It is called
  73. // instead of the old "resolve then publish" path inside handleOne.
  74. //
  75. // Behavior:
  76. // - If a.DedupeKey is empty: resolves targets and publishes
  77. // immediately (M2 behavior).
  78. // - On the first arrival of (a.SourceID, a.DedupeKey): resolves
  79. // targets, stores them, returns. The Collapser's flush will
  80. // later call onFlushWithKey, which reads the stored targets
  81. // and publishes.
  82. // - On subsequent arrivals: returns immediately. The Collapser
  83. // already updated the stored dedupe_count.
  84. //
  85. // If recipient resolution fails on the first arrival, the alert
  86. // is Nack'd (M2 behavior). On subsequent arrivals we cannot
  87. // Nack — the first arrival was already acked.
  88. func observeAndFanout(
  89. ctx context.Context,
  90. logger *slog.Logger,
  91. collapser *dedupe.Collapser,
  92. state *fanoutState,
  93. resolver *routing.Resolver,
  94. br *broker.Client,
  95. companyID string,
  96. a alert.Alert,
  97. ) (handled bool, shouldAck bool, shouldNak bool) {
  98. // Empty dedupe_key → M2 path, no collapse.
  99. if a.DedupeKey == "" {
  100. t0 := time.Now()
  101. targets, err := resolver.ResolveTargets(ctx, &a)
  102. if routerdMetrics != nil {
  103. routerdMetrics.RecipientExpansionLatency.Observe(time.Since(t0).Seconds())
  104. }
  105. if err != nil {
  106. logger.Error("resolve targets", "err", err, "alert_id", a.ID, "company", companyID)
  107. return true, false, true
  108. }
  109. if len(targets) == 0 {
  110. logger.Warn("zero recipients, dropping",
  111. "alert_id", a.ID,
  112. "company", companyID,
  113. "source_id", a.SourceID,
  114. "severity", string(a.Severity),
  115. "category", a.Category,
  116. )
  117. return true, true, false
  118. }
  119. publishDeliveries(logger, br, companyID, a, targets)
  120. return true, true, false
  121. }
  122. // Collapse path: hand the alert to the Collapser.
  123. dec := collapser.Observe(a.SourceID, a.DedupeKey, a)
  124. switch dec {
  125. case dedupe.CollapseNew:
  126. // First arrival: resolve targets NOW and cache them.
  127. t0 := time.Now()
  128. targets, err := resolver.ResolveTargets(ctx, &a)
  129. if routerdMetrics != nil {
  130. routerdMetrics.RecipientExpansionLatency.Observe(time.Since(t0).Seconds())
  131. }
  132. if err != nil {
  133. logger.Error("resolve targets (new collapse)",
  134. "err", err,
  135. "alert_id", a.ID,
  136. "company", companyID,
  137. "source_id", a.SourceID,
  138. "dedupe_key", a.DedupeKey,
  139. )
  140. return true, false, true
  141. }
  142. if len(targets) == 0 {
  143. logger.Warn("zero recipients, dropping (new collapse)",
  144. "alert_id", a.ID,
  145. "company", companyID,
  146. "source_id", a.SourceID,
  147. "dedupe_key", a.DedupeKey,
  148. )
  149. return true, true, false
  150. }
  151. state.put(a.SourceID, a.DedupeKey, targets)
  152. return true, true, false // ack the original NATS msg; the flush will publish
  153. case dedupe.CollapseDupe:
  154. // Stored alert's dedupe_count already updated by
  155. // the Collapser. Nothing to do; the flush will
  156. // publish.
  157. return true, true, false
  158. case dedupe.Passthrough:
  159. // Should not happen here — Observe with empty
  160. // key returns Passthrough and we already handled
  161. // that above. Defensive: fall through to no-op.
  162. return true, true, false
  163. }
  164. return true, true, false
  165. }
  166. // onFlushWithKey is the Collapser's onFlush callback. It reads
  167. // the cached targets for the (source, key) pair, then publishes
  168. // one delivery per (target, channel) with the final dedupe_count
  169. // on the alert.
  170. func onFlushWithKey(
  171. logger *slog.Logger,
  172. state *fanoutState,
  173. br *broker.Client,
  174. ) func(sourceID, key string, a alert.Alert) {
  175. return func(sourceID, key string, a alert.Alert) {
  176. targets := state.take(sourceID, key)
  177. if len(targets) == 0 {
  178. // No targets cached — the original message
  179. // must have hit the zero-recipients path and
  180. // was dropped. Nothing to do.
  181. logger.Debug("flush with no cached targets",
  182. "source_id", sourceID,
  183. "dedupe_key", key,
  184. "alert_id", a.ID,
  185. )
  186. return
  187. }
  188. logger.Info("collapsed fanout",
  189. "alert_id", a.ID,
  190. "source_id", sourceID,
  191. "dedupe_key", key,
  192. "recipients", len(targets),
  193. "dedupe_count", a.DedupeCount,
  194. )
  195. publishDeliveries(logger, br, a.CompanyID, a, targets)
  196. }
  197. }
  198. // publishDeliveries is the existing "enqueue one delivery per
  199. // target" logic, lifted out of handleOne for reuse.
  200. func publishDeliveries(
  201. logger *slog.Logger,
  202. br *broker.Client,
  203. companyID string,
  204. a alert.Alert,
  205. targets []routing.Target,
  206. ) int {
  207. js, err := br.NC().JetStream()
  208. if err != nil {
  209. logger.Error("js ctx", "err", err)
  210. return 0
  211. }
  212. enqueued := 0
  213. for _, t := range targets {
  214. envelope := deliveryEnvelope{
  215. Alert: a,
  216. IndividualID: t.IndividualID,
  217. Channel: t.Channel,
  218. Endpoint: t.Endpoint,
  219. Locale: t.Locale,
  220. }
  221. body, err := json.Marshal(envelope)
  222. if err != nil {
  223. logger.Warn("marshal envelope", "err", err)
  224. continue
  225. }
  226. subject := broker.DeliveriesSubject(t.Channel, companyID)
  227. if _, err := js.PublishAsync(subject, body); err != nil {
  228. logger.Warn("publish delivery", "err", err, "subject", subject)
  229. continue
  230. }
  231. enqueued++
  232. }
  233. return enqueued
  234. }
  235. // deliveryEnvelope is the wire shape published on
  236. // deliveries.<channel>.<company_id>. Same as the one in main.go;
  237. // we redeclare it here so this file is self-contained for the
  238. // collapse code path. The fields are identical and the JSON
  239. // tags match — both forms serialize to the same bytes.
  240. type deliveryEnvelope struct {
  241. Alert alert.Alert `json:"alert"`
  242. IndividualID string `json:"individual_id"`
  243. Channel string `json:"channel"`
  244. Endpoint string `json:"endpoint"`
  245. Locale string `json:"locale,omitempty"`
  246. }