"""source_health 테이블 ORM (A-5, plan crawl-24x7-1) news_sources 와 1:1. 소스별 fetch 성공/실패 기록 + circuit breaker 상태. silent skip 누적 방지의 가시성 기반 — A-8 헬스 패널이 읽는다. """ from datetime import datetime from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column from core.database import Base class SourceHealth(Base): __tablename__ = "source_health" id: Mapped[int] = mapped_column(primary_key=True) source_id: Mapped[int] = mapped_column( Integer, ForeignKey("news_sources.id", ondelete="CASCADE"), nullable=False ) consecutive_failures: Mapped[int] = mapped_column(Integer, default=0) total_fetches: Mapped[int] = mapped_column(BigInteger, default=0) total_failures: Mapped[int] = mapped_column(BigInteger, default=0) last_success_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_error: Mapped[str | None] = mapped_column(Text) last_error_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_fetch_items: Mapped[int | None] = mapped_column(Integer) # 200 인데 entries 0 인 연속 fetch 횟수 (304/해시동일은 미집계 — 피드 부패 신호 전용) empty_streak: Mapped[int] = mapped_column(Integer, default=0) # closed(정상) / open(연속 실패 → 지수 backoff) / disabled(임계 초과, 수동 복구 대상) circuit_state: Mapped[str] = mapped_column(String(10), default="closed") circuit_opened_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.now )