## Phase 0(文件層,全部 Accepted) - ADR-106/107:AwoooP 平台架構 + 儲存策略 - ADR-111~118:Bootstrap → RLS 七項核心 ADR - ADR-119~124:SAGA → Singleton Decomposition 六項 ADR - ADR-UI-01~04:Operator Console 四個 UI ADR ## Phase 1(DB schema + migration) - awooop_phase1_control_plane_2026-05-04.sql:7 張新表 + trigger + RLS - Step 1:三角色(platform_admin/migration BYPASSRLS,awooop_app 受 RLS) - Step 13:GRANT awooop_app 最小權限(7 條) - Step 14:RLS fail-closed,移除 __platform__ 後門 - awooop_phase1_batch1_rls_2026-05-04.sql:高流量四表三步式 ADD COLUMN - awooop_phase1_batch1_backfill.py:SKIP LOCKED 分批回填腳本 - awooop_models.py:7 個 SQLAlchemy 2.x models ## Critic 修正(4 Critical + 3 Major) - C-1:ADD CONSTRAINT IF NOT EXISTS → DO 塊 + pg_constraint 查詢 - C-2:__mapper_args__ 字串 list → primary_key=True on mapped_column - C-3:__platform__ RLS 後門 → 全移除,改用 BYPASSRLS role - C-4:awooop_app role 從未建立 → Step 1 + 7 條 GRANT - M-1:active_pointer_guard SECURITY DEFINER(FORCE RLS 跨租戶保護) - M-2:pg_partman create_parent 加冪等防護 - M-3:immutability trigger 新增身份欄位保護(project_id/family/contract_id) ## Task 1.2 修補 - agent_loader.py:硬編碼 Mac 路徑 → AGENTS_DIR 環境變數 - Dockerfile:補 COPY .claude/agents/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
200 lines
6.9 KiB
Markdown
200 lines
6.9 KiB
Markdown
# 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)
|