Skip to content

fix(notification): NotificationBatcher dedup 吞 TRIGGERED + shutdown silent-loss#147

Open
csp0924 wants to merge 2 commits into
mainfrom
fix/notification-batcher-silent-loss
Open

fix(notification): NotificationBatcher dedup 吞 TRIGGERED + shutdown silent-loss#147
csp0924 wants to merge 2 commits into
mainfrom
fix/notification-batcher-silent-loss

Conversation

@csp0924

@csp0924 csp0924 commented May 13, 2026

Copy link
Copy Markdown
Owner

Bug

兩個獨立 bug,都在 csp_lib/notification/batcher.py,bundle 一起修。

H2 — _final_flush retry 整段死碼

_send_to_channels per-channel try/except 自己 swallow 所有 exception → flush() 在 channel 失敗時不會 raise → _final_flushtry/except + sleep + retry + log "重試仍失敗 N notifications dropped" 結構不可達。Shutdown 時 channel 全掛 = in-flight notification silent discard,無 aggregate ERROR log

Sandbox:500 events、所有 channel raise → 0 events received, 1 channel attempt, retry-warning log。

H3 — dedup 吞掉 TRIGGERED

_dedup_key 只用 item.alarm_key沒含 item.event (NotificationEvent: TRIGGERED / RESOLVED / ACK / ...)。_deduplicate 「keep latest」邏輯下,同 alarm_key 不同 event 在 flush window (default 5s) 內 → 後到覆蓋先到。Operator 看到「alarm resolved」但從未收到 alarm 觸發。短促 alarm(< flush_interval)trigger 訊號完全消失。

Sandbox:200 個 TRIGGERED+RESOLVED pair → 0/200 TRIGGERED, 200/200 RESOLVED delivered。

Fix

H3(one-line)

_dedup_key: return f"{item.alarm_key}:{item.event.value}"

同 alarm_key + 同 event 仍 dedup(保護原意圖避免轟炸);不同 event 不再互殺。

H2(方案 B:aggregate-in-batcher,保守)

  • _send_to_channels 維持 per-channel swallow(保 dispatch() happy path 不 raise)但改回傳 list[tuple[channel_name, error_repr]] 失敗清單
  • flush() 累計失敗、emit aggregate WARNING log
  • _final_flush 刪掉 dead retry/sleep,改成「flush 後若失敗清單非空 → ERROR log(含 pending 數 + failure summary)」
  • 新增兩個 read-only property:last_flush_failure_count / last_flush_failures 提供 caller 可觀測 metric

Sandbox 對比

sandbox pre-fix post-fix
dedup_drops_triggered (200 pair) 0/200 TRIGGERED 200/200 TRIGGERED
shutdown_inflight_loss (500 events all-failing) 僅 per-channel WARNING flush WARNING + final_flush ERROR (含 failure metric)

Tests

tests/notification/test_batcher_silent_loss.py(新檔,4 case):

  • test_dedup_preserves_triggered_and_resolved(H3 核心)
  • test_dedup_still_collapses_repeated_same_event(H3 保護 case)
  • test_final_flush_logs_aggregate_error_when_channels_fail(H2 loguru capture)
  • test_send_to_channels_reports_failure_count(H2 metric property)

Pre-fix 3 fail / 1 pass → Post-fix 4/4 pass。

Public API

  • 新增 read-only property(兩個)— patch-friendly addition
  • 無既有 method signature / exception 類型 / return-type 語義變更

Quality gates

  • ruff check / format / mypy 全 pass
  • pytest 完整回歸 4855 passed / 7 skipped (41.79s)

Copilot AI review requested due to automatic review settings May 13, 2026 00:54

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes two independent bugs in NotificationBatcher that could (1) drop TRIGGERED notifications during the dedup window and (2) hide shutdown-time delivery failures by swallowing per-channel exceptions without any aggregate signal.

Changes:

  • Update dedup key to include (alarm_key, event) so TRIGGERED/RESOLVED don’t overwrite each other in the same flush window.
  • Make _send_to_channels() report failures back to flush(), emit an aggregate WARNING for flush-time failures, and emit an aggregate ERROR during _final_flush() on shutdown.
  • Add tests covering the dedup bug and the shutdown/visibility behavior, plus new observability properties (last_flush_failure_count, last_flush_failures).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
csp_lib/notification/batcher.py Fixes dedup key semantics and adds aggregate failure reporting/logging + exposes last-flush failure metrics.
tests/notification/test_batcher_silent_loss.py Adds regression tests for dedup preserving TRIGGERED/RESOLVED and for shutdown-time aggregate failure visibility.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread csp_lib/notification/batcher.py Outdated
Comment on lines +190 to +210
aggregated_failures: list[tuple[str, str]] = []
self._last_flush_failures = aggregated_failures

if not items:
return

# 去重
if self._config.deduplicate_by_key:
items = self._deduplicate(items)

# 分組
groups = self._group(items)

# 逐組發送
for group_items in groups.values():
await self._send_to_channels(group_items)
aggregated_failures.extend(await self._send_to_channels(group_items))

if aggregated_failures:
failure_summary = ", ".join(f"{name}: {err}" for name, err in aggregated_failures)
logger.warning(
"NotificationBatcher: flush 期間 {failure_count} 個 channel 發送失敗 ({failure_summary})",
failure_count=len(aggregated_failures),
failure_summary=failure_summary,
)
Comment thread csp_lib/notification/batcher.py Outdated
Comment on lines +118 to +130
pending = len(self._queue)
if pending == 0:
return
try:
await self.flush()
except Exception:
logger.opt(exception=True).warning(f"NotificationBatcher: 停止時 flush 失敗,{pending} 則通知待重試")
await asyncio.sleep(1)
remaining = len(self._queue)
if remaining == 0:
return
try:
await self.flush()
except Exception:
dropped = len(self._queue)
self._queue.clear()
logger.error(f"NotificationBatcher: 重試仍失敗,{dropped} notifications dropped")
await self.flush()
if self._last_flush_failures:
failure_summary = ", ".join(f"{name}: {err}" for name, err in self._last_flush_failures)
logger.error(
"NotificationBatcher: 停止時 flush 完成但 {failure_count} 個 channel 失敗,"
"{pending} 則 in-flight 通知未送達(failures={failure_summary})",
failure_count=len(self._last_flush_failures),
pending=pending,
failure_summary=failure_summary,
)
Comment on lines 102 to 108
await self._flush_task
except asyncio.CancelledError:
pass
self._flush_task = None
# 確保所有殘留通知都已發送(含重試)
await self._final_flush()
logger.info("NotificationBatcher: 已停止")
Comment on lines +167 to +170
error_msgs = [m for lvl, m in records if lvl in ("ERROR", "WARNING")]
aggregate = [m for m in error_msgs if "channel" in m.lower() and "fail" in m.lower()]
assert aggregate, (
f"Expected aggregate ERROR/WARNING log about channel failures during shutdown, got records: {records}"
csp0924 added a commit that referenced this pull request May 13, 2026
PR #147 review fixes:

(a) flush() 改成按 channel name 聚合失敗(dict 去重),
    避免同一 channel 在多 group 失敗時 over-count;
    新增 last_flush_failure_attempts 屬性記錄 attempt 次數,
    aggregate WARNING 同時呈現「獨特失敗 channel」和「總失敗次數」。

(b) _final_flush() 在 _lock 內取 pending 計數,
    避免與 shutdown 期間殘留 dispatch() race;
    log wording 從「in-flight 通知未送達」改為
    「通知未送達到失敗 channel」,反映 partial-fail 語義。

(c) _on_stop 殘留 comment 已不再 retry,更新為
    「對殘留通知做一次最終 flush;channel 失敗會走 aggregate ERROR log」。

(d) test_final_flush_logs_aggregate_error_when_channels_fail
    改用穩定信號:
    - 先 assert batcher.last_flush_failure_count >= 1(不依賴 log 文字)
    - log 比對改 match "NotificationBatcher" + "停止時",
      避開依賴 exception 文字("simulated ... failure")。
csp0924 added 2 commits May 13, 2026 09:24
…RIGGERED

兩個獨立 bug 在同一檔,bundle 一個 PR 修。

H3 dedup 吞 TRIGGERED:
- _dedup_key 原本只用 alarm_key 作 key;_deduplicate keep-latest 邏輯下,
  同 alarm_key 但不同 event (TRIGGERED + RESOLVED) 在同一 flush window 內
  會互蓋。Alarm chatter 場景 (5s flush window 內 TRIGGERED→RESOLVED)
  operator 從未收到 TRIGGERED,只看到 RESOLVED。
- Fix: dedup key 改為 f"{alarm_key}:{event.value}",保留 same-event keep-latest 行為。
- Sandbox: 200 對 TRIGGERED/RESOLVED → 修前 0/200 TRIGGERED、修後 200/200。

H2 _final_flush retry path 不可達:
- _send_to_channels 對每個 channel 包 per-channel try/except、swallow exception,
  所以 flush() 對 channel 失敗永遠不 raise → _final_flush 的 try/except + sleep +
  retry + "重試仍失敗 N notifications dropped" 整段是 dead code。
  Shutdown 時 channel 全掛 (如 MongoDB 連線收掉) → flush 看似成功、in-flight 整批
  silently discard、無 aggregate ERROR log。
- Fix (方案 B aggregate-in-batcher,保 dispatch happy path 不 raise):
  * _send_to_channels 改回傳 list[(channel_name, error_repr)] 失敗清單
  * flush() 累計 per-channel 失敗、若 >0 emit aggregate WARNING
  * _final_flush 移除 dead retry/sleep code,改成「flush 後檢查失敗清單,>0 emit ERROR」
  * 新增 last_flush_failure_count / last_flush_failures property 提供可觀測 metric
- Sandbox: 500 events all-failing channel shutdown → 修前無 aggregate ERROR、
  修後同時有 flush WARNING + final_flush ERROR (含 failure summary)。

驗證:
- 新增 tests/notification/test_batcher_silent_loss.py (4 case,未修前 3 FAIL/1 PASS)
- tests/notification/ 全 64 PASS
- 完整回歸 4855 PASS / 7 skipped
- ruff + format + mypy 全綠
PR #147 review fixes:

(a) flush() 改成按 channel name 聚合失敗(dict 去重),
    避免同一 channel 在多 group 失敗時 over-count;
    新增 last_flush_failure_attempts 屬性記錄 attempt 次數,
    aggregate WARNING 同時呈現「獨特失敗 channel」和「總失敗次數」。

(b) _final_flush() 在 _lock 內取 pending 計數,
    避免與 shutdown 期間殘留 dispatch() race;
    log wording 從「in-flight 通知未送達」改為
    「通知未送達到失敗 channel」,反映 partial-fail 語義。

(c) _on_stop 殘留 comment 已不再 retry,更新為
    「對殘留通知做一次最終 flush;channel 失敗會走 aggregate ERROR log」。

(d) test_final_flush_logs_aggregate_error_when_channels_fail
    改用穩定信號:
    - 先 assert batcher.last_flush_failure_count >= 1(不依賴 log 文字)
    - log 比對改 match "NotificationBatcher" + "停止時",
      避開依賴 exception 文字("simulated ... failure")。
@csp0924 csp0924 force-pushed the fix/notification-batcher-silent-loss branch from 00e1963 to a440fc6 Compare May 13, 2026 01:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants