collapse.go 7.7 KB

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