## 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>
6.5 KiB
6.5 KiB
ADR-113: AwoooP Active Revision Invalidation & Outbox
狀態:Accepted 日期:2026-05-03(台北) 決策者:統帥 範圍:active revision 切換時的 worker cache 失效通知機制 關聯:ADR-107(D4 Redis cache)、ADR-112(activation workflow)
背景
ADR-107 D4 規定:
- Redis 可作為 contract revision 的 cache
- 但 Redis 不能是 source of truth
- 任何 cached value 必須帶 source
revision_id和body_hash
問題:當 active revision 切換(activate 操作)時,正在運行的 worker 仍持有舊 revision 的 cache。 如果不通知,worker 可能繼續用舊 policy 執行,直到 TTL 過期(可能是數分鐘)。
這在 policy_routing contract 切換時特別危險:worker 可能用舊的 model routing policy 執行。
問題
- activate 操作是 DB 事務,成功後如何通知所有 worker 失效 cache?
- 如果 Redis pub/sub 通知丟失(Redis 重啟),worker 如何知道自己的 cache 過期了?
- 跨 Pod 的多個 worker 如何收到通知?
- 如何防止 split-brain(Worker A 用新 revision,Worker B 仍用舊 revision)?
決策
D1 — Transactional Outbox 模式
activate 操作必須在同一個 DB 事務中:
- 更新
awooop_active_revisions(切換 active pointer) - 在
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 必須:
- 帶 revision_id:
cache_value = (body_json, revision_id, body_hash, cached_at) - 訂閱 Redis pub/sub:每個 worker 訂閱
platform:contract:invalidate:* - 收到通知時:比對
revision_id,若有差異立即清除 local cache - Fallback(Redis 通知丟失時):每次使用 cache 前,對比 DB active pointer 的 revision_id
- 若 cache 的 revision_id ≠ DB 的 active revision_id → 清除 cache,重新讀 DB
- 比對頻率:低優先度 contract(channel_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 loop(31 個 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 cache(30s)+ 只在 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 切換走 outbox(ADR-110 整合)
- Redis pub/sub 失效模擬測試通過(worker 靠 fallback 比對恢復)
關聯
- ADR-107(Redis cache 規範)、ADR-112(activation workflow)
- ADR-110(GCP Ollama topology,特殊 platform_resource outbox 事件)