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

6.0 KiB
Raw Blame History

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_idtenant 隔離
  • channel_typetelegram / alertmanager / gitea / line / api
  • provider_event_id
    • Telegramupdate_id
    • AlertmanagergroupKey + fingerprint hash
    • Gitea webhookX-Gitea-Delivery header
    • LINEwebhookEventId
    • APIcaller-provided idempotency_key header
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 相關欄位:

-- 在 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

-- 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 秒)

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

-- 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 設計)