M6.5_VERIFICATION.md 6.6 KB

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 shutdowncollapser.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.