# 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 設計）
