| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250 |
- // collapse.go wires the M6.5 router-level dedupe Collapser into
- // the alert-processing pipeline.
- //
- // What it does:
- // - On the first arrival of a (source, dedupe_key) pair, the
- // router resolves recipients (DB call), then stores the
- // targets alongside the alert. The Collapser holds the alert
- // until the flush window elapses, then fires onFlush.
- // - On subsequent arrivals of the same pair, the router skips
- // recipient resolution and just updates the stored
- // dedupe_count. This is the M6.5 win: a 100-alert burst
- // becomes 1 resolve-recipients call instead of 100.
- // - On flush, the router fans out exactly one delivery per
- // (target, channel) pair, with the latest dedupe_count on
- // the alert.
- //
- // What it does NOT do:
- // - It does NOT touch alerts with empty dedupe_key — those
- // pass through unchanged (M2 behavior preserved).
- // - It does NOT touch alerts whose recipient resolution
- // fails — those get Nack'd and retried by NATS, same as M2.
- // - It does NOT collapse across sources — per-source state is
- // enforced by the Collapser's keying.
- package main
- import (
- "context"
- "encoding/json"
- "log/slog"
- "sync"
- "git3.techno-world.net/lrosales/broad-announce/internal/alert"
- "git3.techno-world.net/lrosales/broad-announce/internal/broker"
- "git3.techno-world.net/lrosales/broad-announce/internal/dedupe"
- "git3.techno-world.net/lrosales/broad-announce/internal/routing"
- )
- // fanoutState is the router's per-collapse-window state.
- // It holds the resolved recipients for each (source, key) pair
- // so duplicate alerts skip the expensive ResolveTargets call.
- type fanoutState struct {
- mu sync.Mutex
- pending map[collapseKey][]routing.Target
- }
- // collapseKey mirrors dedupe.collapseKey without exporting it.
- // We re-declare the struct here to keep the public surface of
- // internal/dedupe minimal.
- type collapseKey struct {
- sourceID string
- dedupeKey string
- }
- func newFanoutState() *fanoutState {
- return &fanoutState{pending: make(map[collapseKey][]routing.Target)}
- }
- func (f *fanoutState) put(sourceID, key string, targets []routing.Target) {
- f.mu.Lock()
- defer f.mu.Unlock()
- f.pending[collapseKey{sourceID, key}] = targets
- }
- func (f *fanoutState) take(sourceID, key string) []routing.Target {
- f.mu.Lock()
- defer f.mu.Unlock()
- t, ok := f.pending[collapseKey{sourceID, key}]
- if ok {
- delete(f.pending, collapseKey{sourceID, key})
- }
- return t
- }
- func (f *fanoutState) len() int {
- f.mu.Lock()
- defer f.mu.Unlock()
- return len(f.pending)
- }
- // observeAndFanout is the M6.5-aware alert handler. It is called
- // instead of the old "resolve then publish" path inside handleOne.
- //
- // Behavior:
- // - If a.DedupeKey is empty: resolves targets and publishes
- // immediately (M2 behavior).
- // - On the first arrival of (a.SourceID, a.DedupeKey): resolves
- // targets, stores them, returns. The Collapser's flush will
- // later call onFlushWithKey, which reads the stored targets
- // and publishes.
- // - On subsequent arrivals: returns immediately. The Collapser
- // already updated the stored dedupe_count.
- //
- // If recipient resolution fails on the first arrival, the alert
- // is Nack'd (M2 behavior). On subsequent arrivals we cannot
- // Nack — the first arrival was already acked.
- func observeAndFanout(
- ctx context.Context,
- logger *slog.Logger,
- collapser *dedupe.Collapser,
- state *fanoutState,
- resolver *routing.Resolver,
- br *broker.Client,
- companyID string,
- a alert.Alert,
- ) (handled bool, shouldAck bool, shouldNak bool) {
- // Empty dedupe_key → M2 path, no collapse.
- if a.DedupeKey == "" {
- targets, err := resolver.ResolveTargets(ctx, &a)
- if err != nil {
- logger.Error("resolve targets", "err", err, "alert_id", a.ID, "company", companyID)
- return true, false, true
- }
- if len(targets) == 0 {
- logger.Warn("zero recipients, dropping",
- "alert_id", a.ID,
- "company", companyID,
- "source_id", a.SourceID,
- "severity", string(a.Severity),
- "category", a.Category,
- )
- return true, true, false
- }
- publishDeliveries(logger, br, companyID, a, targets)
- return true, true, false
- }
- // Collapse path: hand the alert to the Collapser.
- dec := collapser.Observe(a.SourceID, a.DedupeKey, a)
- switch dec {
- case dedupe.CollapseNew:
- // First arrival: resolve targets NOW and cache them.
- targets, err := resolver.ResolveTargets(ctx, &a)
- if err != nil {
- logger.Error("resolve targets (new collapse)",
- "err", err,
- "alert_id", a.ID,
- "company", companyID,
- "source_id", a.SourceID,
- "dedupe_key", a.DedupeKey,
- )
- return true, false, true
- }
- if len(targets) == 0 {
- logger.Warn("zero recipients, dropping (new collapse)",
- "alert_id", a.ID,
- "company", companyID,
- "source_id", a.SourceID,
- "dedupe_key", a.DedupeKey,
- )
- return true, true, false
- }
- state.put(a.SourceID, a.DedupeKey, targets)
- return true, true, false // ack the original NATS msg; the flush will publish
- case dedupe.CollapseDupe:
- // Stored alert's dedupe_count already updated by
- // the Collapser. Nothing to do; the flush will
- // publish.
- return true, true, false
- case dedupe.Passthrough:
- // Should not happen here — Observe with empty
- // key returns Passthrough and we already handled
- // that above. Defensive: fall through to no-op.
- return true, true, false
- }
- return true, true, false
- }
- // onFlushWithKey is the Collapser's onFlush callback. It reads
- // the cached targets for the (source, key) pair, then publishes
- // one delivery per (target, channel) with the final dedupe_count
- // on the alert.
- func onFlushWithKey(
- logger *slog.Logger,
- state *fanoutState,
- br *broker.Client,
- ) func(sourceID, key string, a alert.Alert) {
- return func(sourceID, key string, a alert.Alert) {
- targets := state.take(sourceID, key)
- if len(targets) == 0 {
- // No targets cached — the original message
- // must have hit the zero-recipients path and
- // was dropped. Nothing to do.
- logger.Debug("flush with no cached targets",
- "source_id", sourceID,
- "dedupe_key", key,
- "alert_id", a.ID,
- )
- return
- }
- logger.Info("collapsed fanout",
- "alert_id", a.ID,
- "source_id", sourceID,
- "dedupe_key", key,
- "recipients", len(targets),
- "dedupe_count", a.DedupeCount,
- )
- publishDeliveries(logger, br, a.CompanyID, a, targets)
- }
- }
- // publishDeliveries is the existing "enqueue one delivery per
- // target" logic, lifted out of handleOne for reuse.
- func publishDeliveries(
- logger *slog.Logger,
- br *broker.Client,
- companyID string,
- a alert.Alert,
- targets []routing.Target,
- ) int {
- js, err := br.NC().JetStream()
- if err != nil {
- logger.Error("js ctx", "err", err)
- return 0
- }
- enqueued := 0
- for _, t := range targets {
- envelope := deliveryEnvelope{
- Alert: a,
- IndividualID: t.IndividualID,
- Channel: t.Channel,
- Endpoint: t.Endpoint,
- Locale: t.Locale,
- }
- body, err := json.Marshal(envelope)
- if err != nil {
- logger.Warn("marshal envelope", "err", err)
- continue
- }
- subject := broker.DeliveriesSubject(t.Channel, companyID)
- if _, err := js.PublishAsync(subject, body); err != nil {
- logger.Warn("publish delivery", "err", err, "subject", subject)
- continue
- }
- enqueued++
- }
- return enqueued
- }
- // deliveryEnvelope is the wire shape published on
- // deliveries.<channel>.<company_id>. Same as the one in main.go;
- // we redeclare it here so this file is self-contained for the
- // collapse code path. The fields are identical and the JSON
- // tags match — both forms serialize to the same bytes.
- type deliveryEnvelope struct {
- Alert alert.Alert `json:"alert"`
- IndividualID string `json:"individual_id"`
- Channel string `json:"channel"`
- Endpoint string `json:"endpoint"`
- Locale string `json:"locale,omitempty"`
- }
|