fix(api): tolerate legacy incident outcomes
This commit is contained in:
@@ -633,6 +633,8 @@ class AlertOperationLog(Base):
|
|||||||
"RESOLVED", "SILENCED", "ESCALATED", "GUARDRAIL_BLOCKED",
|
"RESOLVED", "SILENCED", "ESCALATED", "GUARDRAIL_BLOCKED",
|
||||||
"PRE_FLIGHT_PASSED", "PRE_FLIGHT_FAILED", "BACKUP_TRIGGERED",
|
"PRE_FLIGHT_PASSED", "PRE_FLIGHT_FAILED", "BACKUP_TRIGGERED",
|
||||||
"BACKUP_COMPLETED", "BACKUP_FAILED", "APPROVAL_ESCALATED", "CHANGE_APPLIED",
|
"BACKUP_COMPLETED", "BACKUP_FAILED", "APPROVAL_ESCALATED", "CHANGE_APPLIED",
|
||||||
|
"NOTIFICATION_CLASSIFIED", "MANUAL_FIX_RECORDED", "KM_CONVERTED",
|
||||||
|
"PLAYBOOK_DRAFT_CREATED", "STATE_GUARD_BLOCKED",
|
||||||
name="alert_event_type", create_type=False,
|
name="alert_event_type", create_type=False,
|
||||||
),
|
),
|
||||||
nullable=False, index=True,
|
nullable=False, index=True,
|
||||||
|
|||||||
@@ -528,6 +528,32 @@ def parse_decision_chain(value: Any, incident_id: str | None = None):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_incident_outcome(value: Any, incident_id: str | None = None):
|
||||||
|
"""Best-effort restore of legacy outcome payloads from PostgreSQL."""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from src.models.incident import IncidentOutcome
|
||||||
|
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
logger.warning(
|
||||||
|
"legacy_incident_outcome_skipped",
|
||||||
|
incident_id=incident_id,
|
||||||
|
value_type=type(value).__name__,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return IncidentOutcome(**value)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"incident_outcome_parse_failed",
|
||||||
|
incident_id=incident_id,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Constants
|
# Constants
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -816,8 +842,6 @@ class IncidentService:
|
|||||||
|
|
||||||
方案 C: 解析時正規化舊格式 Enum 值
|
方案 C: 解析時正規化舊格式 Enum 值
|
||||||
"""
|
"""
|
||||||
from src.models.incident import IncidentOutcome
|
|
||||||
|
|
||||||
# 方案 C: 正規化 signals 內的舊格式 severity
|
# 方案 C: 正規化 signals 內的舊格式 severity
|
||||||
signals = []
|
signals = []
|
||||||
for s in (record.signals or []):
|
for s in (record.signals or []):
|
||||||
@@ -830,10 +854,9 @@ class IncidentService:
|
|||||||
record.decision_chain,
|
record.decision_chain,
|
||||||
incident_id=record.incident_id,
|
incident_id=record.incident_id,
|
||||||
)
|
)
|
||||||
outcome = (
|
outcome = parse_incident_outcome(
|
||||||
IncidentOutcome(**record.outcome)
|
record.outcome,
|
||||||
if record.outcome
|
incident_id=record.incident_id,
|
||||||
else None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 方案 C: 正規化舊格式 Enum 值
|
# 方案 C: 正規化舊格式 Enum 值
|
||||||
@@ -1234,7 +1257,7 @@ class IncidentService:
|
|||||||
timeout=_effective_timeout,
|
timeout=_effective_timeout,
|
||||||
)
|
)
|
||||||
break # 成功,離開 retry loop
|
break # 成功,離開 retry loop
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"km_conversion_timeout",
|
"km_conversion_timeout",
|
||||||
incident_id=incident_id,
|
incident_id=incident_id,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from src.services.incident_service import (
|
|||||||
IncidentService,
|
IncidentService,
|
||||||
normalize_status,
|
normalize_status,
|
||||||
parse_decision_chain,
|
parse_decision_chain,
|
||||||
|
parse_incident_outcome,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -107,6 +108,11 @@ def test_parse_decision_chain_skips_legacy_list_payload() -> None:
|
|||||||
assert parse_decision_chain([{"stage": "router"}], "INC-LEGACY-CHAIN") is None
|
assert parse_decision_chain([{"stage": "router"}], "INC-LEGACY-CHAIN") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_incident_outcome_skips_legacy_string_payload() -> None:
|
||||||
|
"""舊資料 outcome 可能是字串;不應阻斷 incident hydrate / resolve。"""
|
||||||
|
assert parse_incident_outcome("resolved", "INC-LEGACY-OUTCOME") is None
|
||||||
|
|
||||||
|
|
||||||
def test_record_to_incident_tolerates_legacy_decision_chain_list() -> None:
|
def test_record_to_incident_tolerates_legacy_decision_chain_list() -> None:
|
||||||
"""DB fallback 必須能讀回舊 incident,即使 decision_chain 不是新 schema。"""
|
"""DB fallback 必須能讀回舊 incident,即使 decision_chain 不是新 schema。"""
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
@@ -134,3 +140,32 @@ def test_record_to_incident_tolerates_legacy_decision_chain_list() -> None:
|
|||||||
assert incident.incident_id == "INC-LEGACY-CHAIN"
|
assert incident.incident_id == "INC-LEGACY-CHAIN"
|
||||||
assert incident.status == IncidentStatus.INVESTIGATING
|
assert incident.status == IncidentStatus.INVESTIGATING
|
||||||
assert incident.decision_chain is None
|
assert incident.decision_chain is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_to_incident_tolerates_legacy_outcome_string() -> None:
|
||||||
|
"""DB fallback 必須能讀回舊 incident,即使 outcome 不是新 schema。"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
record = SimpleNamespace(
|
||||||
|
incident_id="INC-LEGACY-OUTCOME",
|
||||||
|
status="INVESTIGATING",
|
||||||
|
severity="P2",
|
||||||
|
signals=[],
|
||||||
|
affected_services=[],
|
||||||
|
decision_chain=None,
|
||||||
|
proposal_ids=[],
|
||||||
|
outcome="resolved",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
resolved_at=None,
|
||||||
|
closed_at=None,
|
||||||
|
ttl_days=7,
|
||||||
|
vectorized=False,
|
||||||
|
notification_type="TYPE-3",
|
||||||
|
alert_category="host_resource",
|
||||||
|
)
|
||||||
|
|
||||||
|
incident = IncidentService()._record_to_incident(record)
|
||||||
|
|
||||||
|
assert incident.incident_id == "INC-LEGACY-OUTCOME"
|
||||||
|
assert incident.status == IncidentStatus.INVESTIGATING
|
||||||
|
assert incident.outcome is None
|
||||||
|
|||||||
Reference in New Issue
Block a user