## 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>
188 lines
6.0 KiB
Markdown
188 lines
6.0 KiB
Markdown
# ADR-114: AwoooP Idempotency, Worker Lease & Run Recovery
|
||
|
||
**狀態**:Accepted
|
||
**日期**:2026-05-03(台北)
|
||
**決策者**:統帥
|
||
**範圍**:channel event 去重、run state 的 worker lease、stale run 回收
|
||
**關聯**:ADR-106(Run State contract)、ADR-107(儲存)、ADR-119(SAGA)
|
||
|
||
---
|
||
|
||
## 背景
|
||
|
||
AwoooP 的 async runtime 面臨兩個核心挑戰:
|
||
|
||
**挑戰 1:Idempotency(冪等性)**
|
||
- Telegram bot retry / Alertmanager retry 可能同一個事件送來多次
|
||
- 若不去重,會建立多個重複的 run(造成重複告警、重複 approval 請求)
|
||
|
||
**挑戰 2:Worker 可靠性**
|
||
- Worker pod crash 後,`RUNNING` 狀態的 run 會永遠卡住
|
||
- 需要 lease 機制:worker 定期續租,超時的 run 被回收
|
||
|
||
---
|
||
|
||
## 決策
|
||
|
||
### D1 — Channel Event Idempotency Key
|
||
|
||
每個 channel event 的 idempotency key 由三個欄位組成複合唯一鍵:
|
||
|
||
```
|
||
(project_id, channel_type, provider_event_id)
|
||
```
|
||
|
||
- `project_id`:tenant 隔離
|
||
- `channel_type`:`telegram` / `alertmanager` / `gitea` / `line` / `api`
|
||
- `provider_event_id`:
|
||
- Telegram:`update_id`
|
||
- Alertmanager:`groupKey` + fingerprint hash
|
||
- Gitea webhook:`X-Gitea-Delivery` header
|
||
- LINE:`webhookEventId`
|
||
- API:caller-provided `idempotency_key` header
|
||
|
||
```sql
|
||
CREATE TABLE awooop_channel_event_dedupe (
|
||
dedupe_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
project_id VARCHAR(64) NOT NULL,
|
||
channel_type VARCHAR(32) NOT NULL,
|
||
provider_event_id VARCHAR(256) NOT NULL,
|
||
run_id UUID NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
UNIQUE(project_id, channel_type, provider_event_id)
|
||
);
|
||
```
|
||
|
||
**重複事件的處理**:
|
||
- 第一次到達 → INSERT dedupe 記錄 + 建立 run → 返回 run_id
|
||
- 重複到達 → ON CONFLICT DO NOTHING → 查詢已有 run_id → 返回 E-IDEMPOTENT-001 (HTTP 200)
|
||
|
||
### D2 — Worker Lease 機制
|
||
|
||
`awooop_run_state` 表的 lease 相關欄位:
|
||
|
||
```sql
|
||
-- 在 awooop_run_state 表中
|
||
lease_until TIMESTAMPTZ, -- worker 必須在此時間前 renew,否則 run 被視為 stale
|
||
heartbeat_at TIMESTAMPTZ, -- worker 最後一次 heartbeat 時間
|
||
attempt_count INT NOT NULL DEFAULT 0, -- 總嘗試次數
|
||
worker_id VARCHAR(128), -- 目前持有 lease 的 worker pod ID
|
||
```
|
||
|
||
**取單規則(SKIP LOCKED)**:
|
||
|
||
```sql
|
||
-- Worker 取 run 時
|
||
SELECT * FROM awooop_run_state
|
||
WHERE status = 'PENDING'
|
||
AND (lease_until IS NULL OR lease_until < NOW())
|
||
ORDER BY created_at ASC
|
||
LIMIT 1
|
||
FOR UPDATE SKIP LOCKED;
|
||
|
||
-- 取到後,更新 lease
|
||
UPDATE awooop_run_state
|
||
SET status = 'RUNNING',
|
||
lease_until = NOW() + INTERVAL '5 minutes',
|
||
heartbeat_at = NOW(),
|
||
attempt_count = attempt_count + 1,
|
||
worker_id = '{this_pod_id}'
|
||
WHERE run_id = '{run_id}';
|
||
```
|
||
|
||
**Heartbeat renew(每 30 秒)**:
|
||
|
||
```sql
|
||
UPDATE awooop_run_state
|
||
SET lease_until = NOW() + INTERVAL '5 minutes',
|
||
heartbeat_at = NOW()
|
||
WHERE run_id = '{run_id}'
|
||
AND worker_id = '{this_pod_id}'
|
||
AND status = 'RUNNING';
|
||
-- 若 rows affected = 0,表示 lease 已被 stale reaper 搶走,worker 應停止執行
|
||
```
|
||
|
||
### D3 — Stale Run Reaper
|
||
|
||
一個 platform_internal loop(每 1 分鐘),掃描並回收 stale run:
|
||
|
||
```sql
|
||
-- Stale 條件:RUNNING 狀態 + lease 已過期
|
||
UPDATE awooop_run_state
|
||
SET status = 'PENDING', -- 回到 PENDING,等待 worker 重新取
|
||
lease_until = NULL,
|
||
worker_id = NULL,
|
||
reaped_at = NOW()
|
||
WHERE status = 'RUNNING'
|
||
AND lease_until < NOW() -- lease 過期
|
||
AND attempt_count < 3; -- 最多 3 次嘗試
|
||
|
||
-- 超過 3 次仍失敗 → FAILED
|
||
UPDATE awooop_run_state
|
||
SET status = 'FAILED',
|
||
failure_reason = 'Max retry attempts exceeded'
|
||
WHERE status = 'RUNNING'
|
||
AND lease_until < NOW()
|
||
AND attempt_count >= 3;
|
||
```
|
||
|
||
### D4 — Run 狀態機(有效狀態列表)
|
||
|
||
```
|
||
PENDING → 等待 worker 取單
|
||
RUNNING → worker 持有 lease 中
|
||
WAITING_TOOL → 等待 MCP tool 回應(非 blocking,worker 可以做其他事)
|
||
WAITING_APPROVAL → 等待人工 approval(blocking,lease renew 停止)
|
||
RESUMED → approval 後從 WAITING_APPROVAL 繼續
|
||
COMPLETED → 正常結束
|
||
FAILED → 失敗(含超過最大重試)
|
||
CANCELLED → 主動取消
|
||
```
|
||
|
||
**特別注意 WAITING_APPROVAL**:
|
||
- worker 停止 lease renew(approval 可能等很久)
|
||
- run 不會被 stale reaper 回收(stale reaper 不處理 WAITING_APPROVAL 狀態)
|
||
- approval 過期(超過 `approval_token` TTL 15min)→ 自動 → FAILED(E-APPROVAL-002)
|
||
|
||
### D5 — 最大重試次數與 SAGA
|
||
|
||
- 預設最大重試:3 次(可由 agent contract 配置)
|
||
- 每次 worker 取單:`attempt_count + 1`
|
||
- `attempt_count >= max_attempts` → 不再回收,直接 FAILED
|
||
- SAGA journal(ADR-119):每次 attempt 的 step 結果記錄在 `saga_steps` JSONB
|
||
|
||
---
|
||
|
||
## 後果
|
||
|
||
### Benefits
|
||
- Telegram/Alertmanager retry 不會建立重複 run(idempotency key)
|
||
- Worker crash 後 stale run 1 分鐘內被回收
|
||
- SKIP LOCKED 確保多 worker 不會競爭同一個 run
|
||
|
||
### Costs
|
||
- 每個 channel event 需要查詢 dedupe 表(一次 DB read)
|
||
- Lease renew 每 30 秒一次 DB write(高 QPS 下需要監控)
|
||
|
||
### Risks
|
||
- dedupe 表若不 partition,會隨時間無限增長
|
||
- 緩解:dedupe 記錄 TTL 7 天(超過 7 天的 event 重複送達視為新 event)
|
||
- lease TTL 設太短 → 正常 run 被回收;設太長 → crash 後恢復慢
|
||
- 緩解:5 分鐘 lease + 30 秒 heartbeat = 可接受的平衡
|
||
|
||
---
|
||
|
||
## 驗收標準
|
||
|
||
- [ ] `awooop_channel_event_dedupe` 表建立(Phase 1 migration)
|
||
- [ ] 重複 Telegram update_id 不建立重複 run(整合測試)
|
||
- [ ] Worker crash 後 stale run 1 分鐘內被回收(模擬測試)
|
||
- [ ] SKIP LOCKED:多 worker 不競爭同一 run(並發測試 10 workers)
|
||
- [ ] WAITING_APPROVAL run 不被 stale reaper 誤回收
|
||
|
||
## 關聯
|
||
|
||
- ADR-106(Run State contract)、ADR-116(approval token security)
|
||
- ADR-119(SAGA 補償,`saga_steps` JSONB 在 run_state 表)
|
||
- INV-1(Redis dedupe key 設計)
|