## 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>
6.9 KiB
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 多租戶化的最大阻礙之一。
三個核心問題:
- 沒有
project_id→ 無法使用 RLS(ADR-118) - 沒有
trace_id→ OTel 追蹤不完整(ADR-121) - 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 優先)
# 每個 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__:
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+ 的審計可追溯:
# 過渡期標記(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 影響 RLSrun_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 記錄最後一次執行時間:
# 標準 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)
告警規則:
# 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_defaultloop 清零,RLS 可真正強制 - Loop 健康監控:每個 loop 都有 Prometheus gauge,SLO watchdog 可監控
Costs
- PR-10 需要修改 ~27 個 loop 的頂部(逐個加 ctx var)
- 分類 C 的 loop 重構(Phase 3)是大型工程,需要 planner 拆解
Risks
- 過渡期有
legacy_awoooi_defaultloop 繞過 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_defaultloop 清零(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)