fix(p0.4): Playbook 學習鏈三道修復 — partial index + race防護 + 手動路徑接線

ADR-092 P0.4 Playbook EWMA 學習閉環的 DB / Repository / Service 三層修補。

DB 層 (db-expert-fix by Engineer-B):
- ApprovalRecord.matched_playbook_id 移除 index=True,改 __table_args__ partial index
  (WHERE matched_playbook_id IS NOT NULL) — 多數列 NULL,full index 浪費空間
- adr092_p1_learning_chain_rollback.sql: 純 ROLLBACK SQL(DBA 手動執行)

Repository 層:
- playbook_repository.py: SELECT FOR UPDATE 防 lost update
  避免並發 EWMA 更新覆蓋彼此

Service 層 (P0.4 修復):
- proposal_service.py: 手動審核路徑補 _try_playbook_match_id 呼叫
  decision_manager auto_execute 路徑已有此邏輯(行 2035),
  此處補手動路徑缺口,使 matched_playbook_id 可寫入 DB → EWMA 才能演化

測試:
- test_playbook_repository_race_condition.py: 3 cases SELECT FOR UPDATE 防 race
  正確阻擋並發 EWMA 更新(pass)

Note: migration SQL 待 DBA 手動執行(feedback_dev_prod_separation.md),
      不執行 alembic upgrade(statu 文件禁忌條款)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-04-26 20:19:46 +08:00
parent 55c6b4e2d9
commit e96055eef9
5 changed files with 310 additions and 19 deletions

View File

@@ -0,0 +1,193 @@
"""
Playbook Repository Race Condition 整合測試
=============================================
驗證 update_stats SELECT FOR UPDATE 防止 Lost Update
2026-04-25 db-expert-fix by Claude Engineer-B
"""
import asyncio
import os
import uuid
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from src.db.models import PlaybookRecord
# =============================================================================
# DB URL與 integration conftest 一致)
# =============================================================================
DEV_DB_URL = os.environ.get(
"TEST_DATABASE_URL",
"postgresql+asyncpg://awoooi:awoooi_prod_2026@192.168.0.188:5432/awoooi_dev?ssl=disable",
)
# 禁止誤打 prod
assert "awoooi_prod" not in DEV_DB_URL.split("/")[-1], (
"TEST_DATABASE_URL 不可指向 prod DBawoooi_prod"
)
# =============================================================================
# Helpers
# =============================================================================
def _make_factory(engine):
return async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
def _test_playbook_id() -> str:
"""每次測試使用唯一 ID避免跨測試污染"""
return f"PB-RACE-TEST-{uuid.uuid4().hex[:8].upper()}"
async def _insert_test_playbook(engine, playbook_id: str) -> None:
"""直接 INSERT 測試用 Playbook不走 Repository避免 Redis 依賴)"""
from sqlalchemy import insert
factory = _make_factory(engine)
async with factory() as session:
async with session.begin():
stmt = insert(PlaybookRecord).values(
playbook_id=playbook_id,
name="Race Test Playbook",
description="用於 race condition 測試",
status="active",
source="manual",
symptom_pattern={"alert_names": ["TestAlert"], "affected_services": [], "keywords": []},
repair_steps=[],
estimated_duration_minutes=5,
source_incident_ids=[],
ai_confidence=0.8,
success_count=0,
failure_count=0,
trust_score=0.5,
requires_approval_level="auto",
stateful_targets=[],
requires_pre_backup=False,
tags=[],
)
await session.execute(stmt)
async def _delete_test_playbook(engine, playbook_id: str) -> None:
"""清理測試資料"""
from sqlalchemy import delete
factory = _make_factory(engine)
async with factory() as session:
async with session.begin():
stmt = delete(PlaybookRecord).where(PlaybookRecord.playbook_id == playbook_id)
await session.execute(stmt)
async def _get_success_count(engine, playbook_id: str) -> int:
"""直接從 PG 讀取 success_count"""
from sqlalchemy import select
factory = _make_factory(engine)
async with factory() as session:
result = await session.get(PlaybookRecord, playbook_id)
return result.success_count if result else -1
# =============================================================================
# 單一 update_stats via SELECT FOR UPDATE不走 Redis直接操作 ORM
# =============================================================================
async def _update_stats_direct(engine, playbook_id: str, success: bool) -> bool:
"""
模擬 PlaybookRepository.update_stats 的 PG 部分SELECT FOR UPDATE
不走 Redis僅測試 PG transaction 正確性
"""
from sqlalchemy import select
factory = _make_factory(engine)
async with factory() as session:
async with session.begin():
stmt = (
select(PlaybookRecord)
.where(PlaybookRecord.playbook_id == playbook_id)
.with_for_update()
)
result = await session.execute(stmt)
record = result.scalar_one_or_none()
if record is None:
return False
if success:
record.success_count += 1
record.trust_score = 0.9 * record.trust_score + 0.1 * 1.0
else:
record.failure_count += 1
record.trust_score = 0.8 * record.trust_score + 0.2 * 0.0
record.trust_score = max(0.0, min(1.0, record.trust_score))
return True
# =============================================================================
# Race Condition Test
# =============================================================================
@pytest.mark.integration
class TestUpdateStatsRaceCondition:
"""
驗證 SELECT FOR UPDATE 在並行情況下不會發生 Lost Update
設計:
- INSERT 1 筆 PlaybookRecordsuccess_count=0
- 5 個 coroutine 同時呼叫 _update_stats_direct(success=True)
- 預期success_count == 5每個 update 都被序列化,無 lost update
"""
@pytest_asyncio.fixture(autouse=True)
async def setup_and_teardown(self):
"""每個測試建立獨立 engine + 測試資料,結束後清理"""
self.engine = create_async_engine(DEV_DB_URL, echo=False)
self.playbook_id = _test_playbook_id()
await _insert_test_playbook(self.engine, self.playbook_id)
yield
await _delete_test_playbook(self.engine, self.playbook_id)
await self.engine.dispose()
async def test_update_stats_race_condition_no_lost_update(self):
"""
同時 5 個 successful update_stats 對同一 playbook
success_count 必須最終 == 5無 lost update
2026-04-25 db-expert-fix by Claude Engineer-B
"""
results = await asyncio.gather(*[
_update_stats_direct(self.engine, self.playbook_id, success=True)
for _ in range(5)
])
# 所有 5 個必須成功
assert all(results), f"部分 update_stats 回傳 False: {results}"
# PG 中 success_count 必須精確 == 5無 lost update
final_count = await _get_success_count(self.engine, self.playbook_id)
assert final_count == 5, (
f"Lost update 偵測success_count={final_count},預期 5。"
"SELECT FOR UPDATE 未正確序列化並行寫入。"
)
async def test_update_stats_not_found_returns_false(self):
"""不存在的 playbook_id 應回傳 False"""
result = await _update_stats_direct(self.engine, "PB-NOT-EXIST-9999", success=True)
assert result is False
async def test_update_stats_failure_increments_failure_count(self):
"""失敗的 update_stats 應遞增 failure_count不影響 success_count"""
await _update_stats_direct(self.engine, self.playbook_id, success=False)
factory = _make_factory(self.engine)
async with factory() as session:
record = await session.get(PlaybookRecord, self.playbook_id)
assert record.failure_count == 1
assert record.success_count == 0