# M6.5 Verification — Router-level dedupe collapse **Status:** shipped 2026-06-14 **Branch:** master **Commits:** see `git log --oneline | grep M6.5` This is the follow-on to M6. M6 was about deduplication at *ingest* (sliding TTL, free-for-dupes rate limit, ×N display). M6.5 deduplicates at *delivery*: a burst of 100 identical alerts now produces 1 message to the recipient, not 100 (the last tagged ×100). The recipient gets the *final* dedupe_count on the single message. ## What landed | Area | Change | | --- | --- | | Algorithm | `internal/dedupe/collapser.go`: per-(source, key) debounce with max-wait (2s default) | | Pipeline | `cmd/routerd/collapse.go`: Collapser wired between alert ingest and delivery fanout; cached targets skip duplicate `ResolveTargets` DB calls | | Config | `BA_ROUTERD_DEDUPE_FLUSH_MS` (default 2000ms) | | Passthrough | Alerts with empty `dedupe_key` skip the Collapser entirely (M2 behavior preserved) | | Operator visibility | Tail WS still sees every arrival (operators see the storm live, even though recipient gets 1) | ## New env var (routerd) | Var | Default | Purpose | | --- | --- | --- | | `BA_ROUTERD_DEDUPE_FLUSH_MS` | 2000 | max-wait window for the in-router collapse; a continuous stream re-flushes every window | ## Files ``` internal/dedupe/collapser.go # Collapser with Passthrough/CollapseNew/CollapseDupe internal/dedupe/collapser_test.go # 7 tests: passthrough, single collapse, per-source, # re-flush, empty-key no-block, concurrent storm, FlushAll cmd/routerd/collapse.go # wires the Collapser; cache targets by (source, key) cmd/routerd/main.go # refactored handleOne; Run goroutine; FlushAll on shutdown testfakes/tailcount/main.go # tail subscriber for the smoke (counts events) internal/config/config.go # Routerd struct + LoadRouterd + DedupeFlushMs docker-compose.yml # BA_ROUTERD_DEDUPE_FLUSH_MS=2000 .env.example # same env var loadgen/cmd/ws/main.go # makeAlert injects key into title for tail filterability M6.5_VERIFICATION.md M6.5_SMOKE_LOG.md scripts/m6.5_smoke.sh ``` ## Algorithm ``` On alert arrival (source, dedupe_key, alert): if dedupe_key is empty: resolve + publish immediately (M2 path) else: dec := Collapser.Observe(source, dedupe_key, alert) if dec == CollapseNew: resolve targets NOW and cache them ack the NATS message else (CollapseDupe): stored alert's dedupe_count already updated by Collapser ack the NATS message On Collapser flush (every 2s): for each (source, key) where firstSeen + flushMs <= now: publishDeliveries(logger, br, a.CompanyID, a, cachedTargets) remove from pending + cachedTargets ``` ## Design choices 1. **Router-side, not ingest-side** — ingestd has to publish to NATS unconditionally (so the alert hits durable storage + tail subscribers). The collapse has to happen *between* NATS and the deliverers, which means routerd. 2. **Debounce with max-wait** — once the burst is quiet for 2s, flush. A continuous stream re-flushes every 2s. This gives operators the live feed (via tail) and the recipient the consolidated "×N" message. 3. **Cache resolved targets** — the first arrival does the DB-bound `ResolveTargets` call. Subsequent dupes update the dedupe_count on the stored alert and return. The flush reads the cached targets. Net: 100-alert burst produces 1 DB call, not 100. 4. **Per-source isolation** — the Collapser keys on `(source_id, dedupe_key)`, not on `dedupe_key` alone. Two sources bursting with the same key don't collapse into one (matches M6 semantics). 5. **Trust the ingestd's count** — the router doesn't `INCR` the dedupe count itself; it takes the max of the stored and incoming values. Ingestd is the canonical counter; this avoids a second Redis round-trip per arrival. 6. **Graceful shutdown** — `collapser.FlushAll()` is called on routerd shutdown, so the last few pending collapses still get delivered. ## Scenarios (spec-style) ### Step 2 — single collapse (100 alerts → 1 message) Send 100 alerts with the same `--dedupe-key` at 50/s. After the 2s flush window, faketgmd's `/admin/sent` shows **count=1**, with the message text containing `(×100)`. **Asserted:** `/admin/sent` count = 1; text contains `(×100)`. ### Step 3 — tail still shows the storm The WS tail subscriber receives ~all 25 alerts of a smaller burst (filtered to a unique title prefix), even though the recipient only got 1 message. The tail *intentionally* bypasses the Collapser. **Asserted:** tail count ≥20 of 25; faketgmd count = 1. ### Step 4 — max-wait re-flush (continuous stream) A 6s continuous burst at 30/s (180 alerts) re-flushes every 2s. The recipient gets 2-5 messages (typically 3), each carrying the running count. **Asserted:** faketgmd count is between 2 and 5. ### Step 5 — per-source isolation Same `--dedupe-key` from acme (`prom-prod`) and globex (`grafana`) does NOT collapse. The Collapser's state is keyed on `(source_id, dedupe_key)`, so each source gets its own collapse window. Result: 2 distinct chat_ids each receive 1 message. **Asserted:** faketgmd count = 2; chat_ids = "1001,2001". ### Step 6 — passthrough (no-key alerts) The loadgen's normal profile gives each alert a unique key (`lg-m5-0`, `lg-m5-1`, `lg-m5-shared`). Each is a new (source, key) for the Collapser, so each gets a dedicated message. 3 alerts → 3 deliveries. **Asserted:** faketgmd count = 3. ## Run ``` cd /root/.openclaw/workspace/broad-announce bash scripts/m6.5_smoke.sh ``` Exit code = number of failed checks (0 on success). ## Out of scope for M6.5 (deferred) - **Per-source override of `BA_ROUTERD_DEDUPE_FLUSH_MS`** — a noisy source might want a longer window. The migration added the column but the M6.5 path uses the env default for all sources. M6.5.5 or M7 (with the per-source config dashboard). - **Collapse across sources** — currently per-source. A "global" collapse (across all sources with the same dedupe_key) could be a config flag. But it has bad blast-radius properties (one noisy source could shadow another), so we ship per-source-only. - **Tombstone on collapse** — the collapsed delivery currently looks like a normal alert. A future M7 could add a `collapsed_count: N` field to the payload so deliverers can render "5 messages collapsed" in addition to the `(×N)` suffix. - **Active collapse metrics** — we have no Prometheus metric for "how many collapses happened" or "how many alerts were collapsed". M7 dashboard work.