# M6 Verification — Dedupe + ×N suffix + sliding TTL + free-for-dupes **Status:** shipped 2026-06-14 **Branch:** master **Commits:** see `git log --oneline | grep M6` This milestone completes the dedupe work started in M0. The fixed 60s TTL is now a sliding window that refreshes on every duplicate observation. Duplicates propagate through the rate limit for free (no token burned). The `dedupe_count` is displayed in delivery messages as an inline `(×N)` suffix on the title. Per-source max-observed and a counter for "how loud is the dupe noise" are exposed as Prometheus metrics. ## What landed | Area | Change | | --- | --- | | Algorithm | `internal/dedupe/dedupe.go`: single-Lua sliding-window script (was 2-RT SETNX+INCR) | | TTL | `BA_INGESTD_DEDUPE_TTL_SECONDS` (default 300s, was 60s) | | Rate limit | Dedupe runs BEFORE rate limit; only `isNew=true` burns a token in BOTH per-source and per-company buckets | | Display | `(×N)` suffix on Telegram message title and FCM notification title when `dedupe_count > 1` | | FCM data | New `data.dedupe_count` field for native clients to render | | Metrics | `ba_ingestd_dedupe_collapsed_total{source}` (counter) and `ba_ingestd_dedupe_count_max_observed{source}` (gauge) | | Loadgen | All three loadgens (http/mqtt/ws) gain `--dedupe-key` to force a specific key (used by the ×N smoke) | ## New env vars (ingestd) | Var | Default | Purpose | | --- | --- | --- | | `BA_INGESTD_DEDUPE_TTL_SECONDS` | 300 | sliding-window TTL for a dedupe entry | ## Files ``` internal/dedupe/dedupe.go # sliding-window Lua (was SETNX+INCR) internal/dedupe/dedupe_test.go # 6 tests: first/dup, isolation, empty-key, # window expires, sliding keeps alive, 1000-burst internal/observability/maxseen.go # sync.Map[source]uint32 monotonic max tracker internal/observability/maxseen_test.go # 5 tests incl. concurrent CAS internal/observability/metrics.go # +2 metrics: dedupe_collapsed, dedupe_count_max cmd/ingestd/process.go # dedupe before rate limit, only charge new cmd/ingestd/main.go # wires MaxSeen into processDeps cmd/deliverd-fcm/main.go # (×N) in notification title; data.dedupe_count cmd/deliverd-telegram/main.go # (×N) in title loadgen/cmd/{http,mqtt,ws}/main.go # --dedupe-key flag on all three internal/config/config.go # DedupeTTLSeconds docker-compose.yml # BA_INGESTD_DEDUPE_TTL_SECONDS=300 .env.example # BA_INGESTD_DEDUPE_TTL_SECONDS=300 ``` ## Algorithm (sliding window, atomic via Lua) ```lua -- KEYS[1] = dedupe:{source_id}:{dedupe_key} -- ARGV[1] = window in seconds (float; sub-second rounded up -- so test windows like 500ms still work) local ttl = tonumber(ARGV[1]) if ttl == nil or ttl <= 0 then return redis.error_reply("dedupe: invalid ttl") end local ttlInt = math.ceil(ttl) if ttlInt < 1 then ttlInt = 1 end local set = redis.call("SET", KEYS[1], 1, "NX", "EX", ttlInt) if set then return {1, 1} -- first arrival, isNew=1, count=1 end local n = redis.call("INCR", KEYS[1]) redis.call("EXPIRE", KEYS[1], ttlInt) -- <-- THE SLIDING PART return {0, n} -- duplicate, isNew=0, count=n ``` The `EXPIRE` refresh on every duplicate is what makes the window slide. A steady stream of duplicates keeps the key alive indefinitely; a pause longer than the window expires the entry, and the next arrival is "new" again with count=1. ## Scenarios (spec-style) ### Step 2 — ×N suffix on the 5th message Send 5 alerts with the same `--dedupe-key`. The faketgmd sent-log shows 5 distinct alert_ids, each with the dedupe _count climbing 1, 2, 3, 4, 5. The 5th message text contains the inline `(×5)` suffix on the title. **Asserted:** faketgmd's last message text contains `(×5)`. ### Step 3 — dedupe metrics tick The 5-burst from Step 2 should also tick: - `ba_ingestd_dedupe_collapsed_total{source="prom-prod"}` +4 (one new + four dupes) - `ba_ingestd_dedupe_count_max_observed{source="prom-prod"}` = 5 **Asserted:** collapsed delta ≥ 4 and max ≥ 5. ### Step 4 — fresh key, all new A new dedupe_key (timestamped) has no prior entry, so all 3 alerts are accepted as `isNew=true`. `ws_messages_total{result="accepted"}` +3. **Asserted:** received=3, accepted=3. ### Step 5 — free-for-dupes (200 dupes/s, cap=100/s) The default per-source rate limit is 100/s. We send 600 alerts with the same dedupe_key at 300/s for 2s. The first alert is new and burns 1 token. The other 599 are dupes and **do not burn any token**. We assert that `rate_limit_hits_total{scope="source"}` did NOT tick, proving the rate limit gate is "free" for duplicates. **Asserted:** `rate_limit_hits_total{scope="source"}` delta = 0; accepted delta ≥ 500. ### Step 6 — per-source isolation Same dedupe_key from acme (`prom-prod`) and globex (`grafana`) does NOT collide. After acme sends 3 and globex sends 3 with the same key, each source's `max_observed` gauge reads 3. **Asserted:** grafana max ≥ 3, prom-prod max ≥ 1. ## Run ``` cd /root/.openclaw/workspace/broad-announce bash scripts/m6_smoke.sh ``` Exit code = number of failed checks (0 on success). ## Curl sanity check (no test harness) ```bash # Send 5 alerts with the same dedupe_key, see the (×5) suffix. for i in 1 2 3 4 5; do BODY='{"company_id":"acme-001","source_id":"prom-prod","severity":"critical","title":"curl-burst","dedupe_key":"curl-burst-1"}' SIG="t=$(date +%s),v1=$(echo -n "$BODY" | openssl dgst -sha256 -hmac s3cret-acme | awk '{print $2}')" curl -sS -X POST http://localhost:8800/v1/ingest \ -H "X-BA-Key: acme-001:prom-prod:s3cret-acme" \ -H "X-BA-Signature: $SIG" \ -H "Content-Type: application/json" \ -d "$BODY" echo done # Then check the live tail or the faketgmd /admin/sent endpoint. ``` ## Out of scope for M6 (deferred) - **Router-level collapse** — the recipient currently gets 5 distinct messages, each with a `dedupe_count` in the title. A "true single message per burst" requires the router to collapse on `dedupe_key` and only emit one delivery per source. That's M6.5 (the M6 spec hinted at it but it's bigger than the dedupe rewrite itself). - **Per-source overrides of `max_concurrent_connections`** — the migration added the column but the M6 path doesn't yet read it. The default 32 from the env still applies. - **Hash-based dedupe_key** — clients can opt out of supplying a key by sending a hash of the title+body and we'll derive the dedupe_key server-side. M11 (security milestone). - **Per-company dedupe_max_observed** — the gauge is per-source only. A per-company rollup would be a small addition when Grafana dashboards land (M9).