Files
awoooi/docs/adr/ADR-114-awooop-idempotency-worker-lease.md
Your Name 13e51802fe feat(awooop): Phase 0 全 ADR + Phase 1 control plane schema(含 critic 四項修正)
## 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>
2026-05-04 13:37:11 +08:00

188 lines
6.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-114: AwoooP Idempotency, Worker Lease & Run Recovery
**狀態**Accepted
**日期**2026-05-03台北
**決策者**:統帥
**範圍**channel event 去重、run state 的 worker lease、stale run 回收
**關聯**ADR-106Run State contract、ADR-107儲存、ADR-119SAGA
---
## 背景
AwoooP 的 async runtime 面臨兩個核心挑戰:
**挑戰 1Idempotency冪等性**
- Telegram bot retry / Alertmanager retry 可能同一個事件送來多次
- 若不去重,會建立多個重複的 run造成重複告警、重複 approval 請求)
**挑戰 2Worker 可靠性**
- 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`
- APIcaller-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 回應(非 blockingworker 可以做其他事)
WAITING_APPROVAL → 等待人工 approvalblockinglease renew 停止)
RESUMED → approval 後從 WAITING_APPROVAL 繼續
COMPLETED → 正常結束
FAILED → 失敗(含超過最大重試)
CANCELLED → 主動取消
```
**特別注意 WAITING_APPROVAL**
- worker 停止 lease renewapproval 可能等很久)
- run 不會被 stale reaper 回收stale reaper 不處理 WAITING_APPROVAL 狀態)
- approval 過期(超過 `approval_token` TTL 15min→ 自動 → FAILEDE-APPROVAL-002
### D5 — 最大重試次數與 SAGA
- 預設最大重試3 次(可由 agent contract 配置)
- 每次 worker 取單:`attempt_count + 1`
- `attempt_count >= max_attempts` → 不再回收,直接 FAILED
- SAGA journalADR-119每次 attempt 的 step 結果記錄在 `saga_steps` JSONB
---
## 後果
### Benefits
- Telegram/Alertmanager retry 不會建立重複 runidempotency 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-106Run State contract、ADR-116approval token security
- ADR-119SAGA 補償,`saga_steps` JSONB 在 run_state 表)
- INV-1Redis dedupe key 設計)