Files
awoooi/docs/adr/ADR-113-awooop-active-revision-outbox.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.5 KiB
Raw Blame History

ADR-113: AwoooP Active Revision Invalidation & Outbox

狀態Accepted 日期2026-05-03台北 決策者:統帥 範圍active revision 切換時的 worker cache 失效通知機制 關聯ADR-107D4 Redis cache、ADR-112activation workflow


背景

ADR-107 D4 規定:

  • Redis 可作為 contract revision 的 cache
  • 但 Redis 不能是 source of truth
  • 任何 cached value 必須帶 source revision_idbody_hash

問題:當 active revision 切換activate 操作)時,正在運行的 worker 仍持有舊 revision 的 cache。 如果不通知worker 可能繼續用舊 policy 執行,直到 TTL 過期(可能是數分鐘)。

這在 policy_routing contract 切換時特別危險worker 可能用舊的 model routing policy 執行。


問題

  1. activate 操作是 DB 事務,成功後如何通知所有 worker 失效 cache
  2. 如果 Redis pub/sub 通知丟失Redis 重啟worker 如何知道自己的 cache 過期了?
  3. 跨 Pod 的多個 worker 如何收到通知?
  4. 如何防止 split-brainWorker A 用新 revisionWorker B 仍用舊 revision

決策

D1 — Transactional Outbox 模式

activate 操作必須在同一個 DB 事務中:

  1. 更新 awooop_active_revisions(切換 active pointer
  2. awooop_contract_outbox 寫入一筆事件
-- 在同一個事務中
BEGIN;
  UPDATE awooop_active_revisions
  SET active_revision_id = 'new-revision-uuid',
      updated_at = NOW()
  WHERE project_id = 'awoooi'
    AND contract_family = 'policy_routing'
    AND contract_id = 'awoooi-sre';

  INSERT INTO awooop_contract_outbox
    (event_id, event_type, project_id, contract_family, contract_id,
     old_revision_id, new_revision_id, created_at, delivered_at)
  VALUES
    (gen_random_uuid(), 'revision_activated', 'awoooi', 'policy_routing', 'awoooi-sre',
     'old-revision-uuid', 'new-revision-uuid', NOW(), NULL);
COMMIT;

D2 — Outbox Relay Worker

一個 outbox relay loop每 1 秒掃描):

async def run_contract_outbox_relay():
    while True:
        # 取未送出的 outbox 事件SELECT FOR UPDATE SKIP LOCKED
        pending = await get_pending_outbox_events(limit=10)
        for event in pending:
            # 1. 發布 Redis pub/sub 通知
            await redis.publish(
                f"platform:contract:invalidate:{event.project_id}",
                json.dumps({
                    "contract_family": event.contract_family,
                    "contract_id": event.contract_id,
                    "new_revision_id": str(event.new_revision_id)
                })
            )
            # 2. 更新 outbox delivered_at
            await mark_outbox_delivered(event.event_id)
        await asyncio.sleep(1)

D3 — Worker 端 Cache 設計

每個 worker 的 revision cache 必須:

  1. 帶 revision_idcache_value = (body_json, revision_id, body_hash, cached_at)
  2. 訂閱 Redis pub/sub:每個 worker 訂閱 platform:contract:invalidate:*
  3. 收到通知時:比對 revision_id,若有差異立即清除 local cache
  4. FallbackRedis 通知丟失時):每次使用 cache 前,對比 DB active pointer 的 revision_id
    • 若 cache 的 revision_id ≠ DB 的 active revision_id → 清除 cache重新讀 DB
    • 比對頻率:低優先度 contractchannel_event每 5min高優先度policy_routing每 30s

D4 — Split-Brain 防禦

Runtime 在每次 LLM call 前必須做一次 revision_id 比對:

async def get_effective_policy(project_id: str, contract_id: str) -> EffectivePolicy:
    cached = await get_from_cache(project_id, contract_id)
    if cached:
        # 驗證 revision_id 仍是當前 active
        db_active_id = await get_active_revision_id_from_db(project_id, contract_id)
        if cached.revision_id != db_active_id:
            await invalidate_cache(project_id, contract_id)
            cached = None
    if not cached:
        revision = await get_active_revision_from_db(project_id, contract_id)
        cached = build_effective_policy(revision)
        await set_cache(project_id, contract_id, cached)
    return cached

D5 — GCP Ollama 拓撲的 outbox

ADR-110 的 GCP Ollama topology 切換也走 outbox

  • contract_family = 'platform_resource'
  • contract_id = 'ollama_topology'
  • project_id = '__platform__'
  • 所有 worker 收到通知後重置 Ollama client 連線

awooop_contract_outbox 表設計

CREATE TABLE awooop_contract_outbox (
    event_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_type      VARCHAR(64) NOT NULL,   -- 'revision_activated', 'revision_revoked', etc.
    project_id      VARCHAR(64) NOT NULL,
    contract_family VARCHAR(32) NOT NULL,
    contract_id     VARCHAR(128) NOT NULL,
    old_revision_id UUID,
    new_revision_id UUID NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    delivered_at    TIMESTAMPTZ,            -- NULL = 未送出
    relay_attempts  INT NOT NULL DEFAULT 0,
    CONSTRAINT fk_new_revision
        FOREIGN KEY (new_revision_id)
        REFERENCES awooop_contract_revisions(revision_id)
);

-- Index for relay worker
CREATE INDEX idx_outbox_undelivered ON awooop_contract_outbox(created_at)
    WHERE delivered_at IS NULL;

後果

Benefits

  • 事務保證activate 操作失敗時outbox 也不會寫入(不會有假通知)
  • Redis pub/sub 丟失時fallback 比對機制保底
  • Split-brain 防禦:每次 LLM call 前比對 revision_id

Costs

  • 需要額外的 outbox relay loop31 個 loop 再加一個)
  • 每次 LLM call 前有一次 DB read可用短 TTL cache 降低頻率)

Risks

  • outbox relay worker 掛掉時Redis 通知延遲(但 fallback 比對仍有效)
  • 緩解outbox relay loop 加入 run_ai_slo_watchdog_loop() 的監控
  • DB active pointer 讀取在高 QPS 下可能成為瓶頸
  • 緩解Redis 短 TTL cache30s+ 只在 TTL 過期時才查 DB

驗收標準

  • awooop_contract_outbox 表建立Phase 1 migration
  • outbox relay loop 加入 main.py標記 platform_internal
  • worker cache 的 split-brain 防禦測試activate 後 ≤60s 所有 worker 使用新 revision
  • GCP Ollama topology 切換走 outboxADR-110 整合)
  • Redis pub/sub 失效模擬測試通過worker 靠 fallback 比對恢復)

關聯

  • ADR-107Redis cache 規範、ADR-112activation workflow
  • ADR-110GCP Ollama topology特殊 platform_resource outbox 事件)