# ADR-123: Background Loop Migration Strategy **狀態**:Accepted **日期**:2026-05-03(台北) **決策者**:統帥 **範圍**:31 個背景 loop 的三分類、project_id 注入策略、退出時程表 **關聯**:ADR-111(bootstrap order)、ADR-114(worker lease)、INV-3/INV-8 --- ## 背景 INV-3 / INV-8 確認 `main.py` 中有 **31 個** background loop,全部在沒有 `project_id` 的情況下運行。這些 loop 是 AwoooP 多租戶化的最大阻礙之一。 **三個核心問題**: 1. 沒有 `project_id` → 無法使用 RLS(ADR-118) 2. 沒有 `trace_id` → OTel 追蹤不完整(ADR-121) 3. 31 個 loop 不可能同時遷移,需要策略性分批 --- ## 決策 ### D1 — 三分類(Loop 歸屬) **分類 A:Platform Internal(維持 `__platform__`)** 這些 loop 本就是平台層,不屬於任何 tenant,永遠使用 `project_id = __platform__`: | Loop 名稱 | main.py 行 | 說明 | |----------|-----------|------| | `run_ai_slo_watchdog_loop` | 623 | Platform 層 SLO 監控 | | `_run_model_version_tracker_loop` | 701 | AI Provider 版本追蹤 | | `run_contract_outbox_relay` | (Phase 2 新增)| ADR-113 outbox relay | | `run_stale_run_reaper` | (Phase 2 新增)| ADR-114 stale reaper | **分類 B:AWOOOI Tenant(遷移到 `project_id = awoooi`)** 這些 loop 實質上只服務 AWOOOI 這個 tenant,遷移後仍只服務 AWOOOI,但有正確的 project_id: | Loop 類型 | 範例 | 遷移難度 | |----------|------|---------| | 告警處理 loop | `run_alert_processor` | LOW(加 ctx var)| | Incident 管理 loop | `run_incident_timeline_loop` | LOW | | Approval resolver | `run_approval_timeout_resolver` | MEDIUM(P0 priority)| | KM / learning loop | `run_aol_writeback_loop` | MEDIUM | | Telegram 相關 | `resend_stale_ready_tokens` | MEDIUM | **分類 C:Per-Tenant(Phase 3+,需重構)** 這些 loop 未來需要為每個 tenant 運行一個實例: | Loop 類型 | 說明 | 目標架構 | |----------|------|---------| | Poller / receiver loop | 收取 channel 事件 | 每 tenant 一個 worker | | Heartbeat / health check | 監控 tenant 的 K8s 狀態 | 每 tenant 一個 worker | **Phase 3 前,分類 C 的 loop 暫時保留在 legacy_awoooi_default 狀態。** ### D2 — project_id 注入方式(三種) **方式 1:contextvars 注入(分類 B 優先)** ```python # 每個 loop 的頂層加入 project_id context async def run_approval_timeout_resolver(): project_id_ctx_var.set("awoooi") # ADR-111 contextvars trace_id = generate_trace_id() trace_id_ctx_var.set(trace_id) while True: await _resolve_approval_timeouts() await asyncio.sleep(30) ``` **方式 2:platform_admin DB role(分類 A)** 平台層 loop 使用 `awooop_platform_admin` role(BYPASSRLS),project_id 設為 `__platform__`: ```python async def run_contract_outbox_relay(): project_id_ctx_var.set("__platform__") async with get_platform_db_session() as db: # 使用 awooop_platform_admin role await _relay_outbox_events(db) ``` **方式 3:legacy_awoooi_default 過渡標籤(未遷移的 loop)** 未遷移的 loop 在 `audit_log` 中用 `project_id = legacy_awoooi_default` 標記,讓 Phase 3+ 的審計可追溯: ```python # 過渡期標記(PR-10) LOOP_PROJECT_ID = os.getenv("LOOP_PROJECT_ID", "legacy_awoooi_default") async def run_some_legacy_loop(): project_id_ctx_var.set(LOOP_PROJECT_ID) # ... loop body ``` ### D3 — 遷移優先序(與 PR 計畫對齊) **P0 優先(立即)**: - `run_approval_timeout_resolver`(main.py:490)— 缺 trace_id,approval 超時無法追蹤 - 加 `project_id_ctx_var.set("awoooi")` + `trace_id` 生成 **P1 優先(Phase 2)**: - `resend_stale_ready_tokens`(main.py:362)— Telegram 相關,缺 project_id 影響 RLS - `run_aol_writeback_loop`(main.py:540)— KM 寫入路徑缺 project_id **P2 優先(Phase 2,PR-10 批量)**: - 其餘 20+ 個分類 B 的 loop,加 `project_id_ctx_var.set("awoooi")` 標記 **Phase 3(重構)**: - 分類 C 的 loop 重構為 per-tenant worker 架構 ### D4 — 退出時程表(legacy_awoooi_default 清零) | 里程碑 | 條件 | 目標 Phase | |--------|------|-----------| | 分類 A 全部標記 `__platform__` | 4 個 loop | Phase 2 完成 | | 分類 B 全部標記 `awoooi` | 27 個 loop | Phase 2 完成(PR-10)| | `legacy_awoooi_default` loop 清零 | RLS 可真正強制 | Phase 3 完成前 | | 分類 C 重構為 per-tenant | EwoooC 支援前置 | Phase 3 | | K8s `LOOP_PROJECT_ID` env var 移除 | 不再需要過渡標籤 | Phase 3 | ### D5 — Loop Health 監控 每個 loop 必須有 Prometheus gauge 記錄最後一次執行時間: ```python # 標準 loop 健康監控模板 from prometheus_client import Gauge LOOP_LAST_RUN = Gauge( "awooop_background_loop_last_run_timestamp", "Unix timestamp of last successful loop execution", labelnames=["loop_name", "project_id"] ) async def run_approval_timeout_resolver(): project_id = "awoooi" project_id_ctx_var.set(project_id) while True: try: await _resolve_approval_timeouts() LOOP_LAST_RUN.labels( loop_name="approval_timeout_resolver", project_id=project_id ).set_to_current_time() except Exception as e: logger.error(f"Loop error: {e}") await asyncio.sleep(30) ``` **告警規則**: ```yaml # Prometheus alert: loop 超過 5 分鐘沒有更新 - alert: BackgroundLoopStale expr: time() - awooop_background_loop_last_run_timestamp > 300 labels: severity: warning ``` --- ## 後果 ### Benefits - 31 個 loop 的 project_id 遷移有明確分類和優先序 - Phase 2 完成後,`legacy_awoooi_default` loop 清零,RLS 可真正強制 - Loop 健康監控:每個 loop 都有 Prometheus gauge,SLO watchdog 可監控 ### Costs - PR-10 需要修改 ~27 個 loop 的頂部(逐個加 ctx var) - 分類 C 的 loop 重構(Phase 3)是大型工程,需要 planner 拆解 ### Risks - 過渡期有 `legacy_awoooi_default` loop 繞過 RLS(設計上允許) - 緩解:RLS 的空字串 policy 在 Phase 3 移除,Phase 1~2 可接受 - P0 priority loop(approval_timeout_resolver)若遷移失敗 → approval 超時告警無法送達 --- ## 驗收標準 - [ ] `run_approval_timeout_resolver` 加 project_id + trace_id(P0,立即) - [ ] 全部 31 個 loop 標記 project_id(PR-10,Phase 2) - [ ] `legacy_awoooi_default` loop 清零(Phase 3 前提條件) - [ ] 每個 loop 有 Prometheus gauge 健康監控(Phase 2) - [ ] Loop stale alert 觸發測試(Phase 2 驗收) ## 關聯 - ADR-111(三 identity markers,platform_internal / legacy_awoooi_default / requires_project_id) - ADR-114(stale reaper,新增 platform_internal loop) - ADR-118(RLS,loop project_id 正確性是 RLS 前置條件) - INV-3/INV-8(31 個 loop 詳細清單) - PR-10(批量加 loop tagging)