From edef1aa4c7aa423844a92b1a9460d48eba5dcc31 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 6 May 2026 21:27:35 +0800 Subject: [PATCH] fix(incidents): batch decision token lookup --- apps/api/src/api/v1/incidents.py | 5 +- apps/api/src/services/decision_manager.py | 46 +++++++++++++++++++ .../tests/test_incidents_list_pure_read.py | 9 ++++ docs/LOGBOOK.md | 1 + 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/v1/incidents.py b/apps/api/src/api/v1/incidents.py index 552b237e..b7c7ec9a 100644 --- a/apps/api/src/api/v1/incidents.py +++ b/apps/api/src/api/v1/incidents.py @@ -206,11 +206,14 @@ async def list_incidents( responses = [] background_tasks = [] + existing_tokens = await decision_manager._find_existing_tokens_for_incidents( + [incident.incident_id for incident in incidents] + ) for incident in incidents: try: # 只查已快取的決策 (不等待 AI,立即返回) - existing = await decision_manager._find_existing_token(incident.incident_id) + existing = existing_tokens.get(incident.incident_id) if existing: decision_info = DecisionInfo( token=existing.token, diff --git a/apps/api/src/services/decision_manager.py b/apps/api/src/services/decision_manager.py index 797f434c..ebf27ef3 100644 --- a/apps/api/src/services/decision_manager.py +++ b/apps/api/src/services/decision_manager.py @@ -2953,6 +2953,52 @@ class DecisionManager: return None + async def _find_existing_tokens_for_incidents( + self, + incident_ids: list[str], + ) -> dict[str, DecisionToken]: + """ + 批次查找現有決策令牌。 + + 2026-05-06 Codex: GET /api/v1/incidents 是前端輪詢路徑,不可對每個 + incident 都掃描一次 decision:*。這裡只掃一次 Redis keyspace,避免 + 200+ incidents 時形成 O(N×M) 延遲與前端控制台卡死。 + """ + wanted = set(incident_ids) + if not wanted: + return {} + + import json + + redis_client = get_redis() + found: dict[str, DecisionToken] = {} + cursor = 0 + while True: + cursor, keys = await redis_client.scan( + cursor=cursor, + match=f"{DECISION_TOKEN_PREFIX}*", + count=500, + ) + + for key in keys: + try: + data = await redis_client.get(key) + if not data: + continue + token_data = json.loads(data) + incident_id = token_data.get("incident_id") + if incident_id in wanted and incident_id not in found: + found[incident_id] = DecisionToken.from_dict(token_data) + if len(found) == len(wanted): + return found + except Exception: + continue + + if cursor == 0: + break + + return found + async def _persist_decision_to_db( self, incident_id: str, proposal_data: dict ) -> None: diff --git a/apps/api/tests/test_incidents_list_pure_read.py b/apps/api/tests/test_incidents_list_pure_read.py index 61f7e867..ade418d2 100644 --- a/apps/api/tests/test_incidents_list_pure_read.py +++ b/apps/api/tests/test_incidents_list_pure_read.py @@ -30,8 +30,15 @@ class _IncidentService: class _DecisionManager: def __init__(self) -> None: self.created = 0 + self.single_token_lookups = 0 + self.batch_token_lookups = 0 + + async def _find_existing_tokens_for_incidents(self, incident_ids: list[str]): + self.batch_token_lookups += 1 + return {} async def _find_existing_token(self, incident_id: str): + self.single_token_lookups += 1 return None async def get_or_create_decision(self, *args, **kwargs): @@ -51,3 +58,5 @@ async def test_list_incidents_does_not_trigger_ai_decision_by_default(monkeypatc assert result.incidents[0].incident_id == "INC-20260506-PURE01" assert result.incidents[0].decision is None assert decision_manager.created == 0 + assert decision_manager.batch_token_lookups == 1 + assert decision_manager.single_token_lookups == 0 diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 0463b62e..ea17e0b9 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -10,6 +10,7 @@ - `GET /api/v1/incidents` 新增 `generate_missing_decisions=false` 預設參數。 - 預設只讀取既有 decision token;缺少 token 時回傳 `decision=null`,不再背景觸發 Ollama / OpenClaw / Gemini。 - 若維運人員明確需要舊行為,可用 `generate_missing_decisions=true` 觸發背景生成;正式修復建議仍應走 `POST /api/v1/incidents/{incident_id}/proposal` 或 AwoooP Operator Run。 +- `DecisionManager` 新增批次 token 查詢;列表路徑只掃一次 Redis `decision:*`,避免 200+ incidents 時逐筆掃描造成 O(N×M) 延遲。 - 新增 regression test,鎖定列表查詢預設不會呼叫 `get_or_create_decision()`。 **驗證**: