"""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, Boolean, 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 ) # ── B-3 구독 세션 상태 계약 — migration 325 ── # 쓰기 1종 플래그: A-8 버튼이 기록만, 어댑터가 소비(수동 half-open). # 소비 위치 = open-스킵 분기보다 앞 (r5 함정 고정 — 데드 버튼 방지). relogin_requested: Mapped[bool] = mapped_column(Boolean, default=False) # 내용 기반 probe 결과 (시간 기반 만료 판정 금지 — 페이월 안내문 silent corruption 차단) last_probe_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_probe_ok: Mapped[bool | None] = mapped_column(Boolean)