// 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" "time" "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 == "" { t0 := time.Now() targets, err := resolver.ResolveTargets(ctx, &a) if routerdMetrics != nil { routerdMetrics.RecipientExpansionLatency.Observe(time.Since(t0).Seconds()) } 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. t0 := time.Now() targets, err := resolver.ResolveTargets(ctx, &a) if routerdMetrics != nil { routerdMetrics.RecipientExpansionLatency.Observe(time.Since(t0).Seconds()) } 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... 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"` }