feat: check source links during scheduled fetches
This commit is contained in:
@@ -14,6 +14,7 @@ from .config import settings
|
||||
from .database import SessionLocal
|
||||
from .logging_config import configure_logging
|
||||
from .models import CommunityImportRun, DataSource
|
||||
from .source_lifecycle import classify_source_failure, record_scheduled_source_check
|
||||
|
||||
logger = logging.getLogger("rf4.community_scheduler")
|
||||
MAX_BACKOFF_SECONDS = 24 * 60 * 60
|
||||
@@ -102,11 +103,27 @@ def run_source(source_system: str, *, now: datetime | None = None) -> bool:
|
||||
html = fetch_html(url)
|
||||
records = parser(html, source_url=url) if source_system in {"rf4map", "rf4posts-spot"} else parser(html)
|
||||
created, updated = stage_observations(session, [asdict(item) for item in records])
|
||||
record_scheduled_source_check(
|
||||
session, source_system=source_system, source_url=url,
|
||||
status="available", checked_at=current,
|
||||
)
|
||||
run.status, run.rows_seen, run.rows_created, run.rows_updated = "success", len(records), created, updated
|
||||
except Exception as exc:
|
||||
session.rollback()
|
||||
source_status = classify_source_failure(exc)
|
||||
checked = datetime.now(timezone.utc)
|
||||
affected = record_scheduled_source_check(
|
||||
session,
|
||||
source_system=source_system,
|
||||
source_url=url,
|
||||
status=source_status,
|
||||
checked_at=checked,
|
||||
)
|
||||
run.status, run.error_summary = "failed", f"{type(exc).__name__}: {str(exc)[:500]}"
|
||||
logger.exception("community import failed", extra={"event":"community_import_failed", "source_system":source_system})
|
||||
logger.exception("community import failed", extra={
|
||||
"event":"community_import_failed", "source_system":source_system,
|
||||
"source_check_status": source_status, "affected_observations": affected,
|
||||
})
|
||||
run.finished_at = datetime.now(timezone.utc); session.commit()
|
||||
return True
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
from urllib.error import HTTPError
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import ExternalObservation, ModerationStatus
|
||||
@@ -11,6 +13,16 @@ from .models import ExternalObservation, ModerationStatus
|
||||
SourceCheckStatus = Literal["available", "missing", "temporary_error", "blocked"]
|
||||
|
||||
|
||||
def classify_source_failure(exc: Exception) -> SourceCheckStatus:
|
||||
"""Classify the result of the scheduled request without retrying it."""
|
||||
if isinstance(exc, HTTPError):
|
||||
if exc.code in {404, 410}:
|
||||
return "missing"
|
||||
if exc.code in {401, 403, 429}:
|
||||
return "blocked"
|
||||
return "temporary_error"
|
||||
|
||||
|
||||
def record_source_check(
|
||||
session: Session,
|
||||
observation: ExternalObservation,
|
||||
@@ -24,7 +36,18 @@ def record_source_check(
|
||||
data. Transient errors and access blocks remain diagnostic and never remove
|
||||
an observation from activity.
|
||||
"""
|
||||
current = checked_at or datetime.now(timezone.utc)
|
||||
_apply_source_check(observation, status, checked_at or datetime.now(timezone.utc))
|
||||
session.commit()
|
||||
return observation
|
||||
|
||||
|
||||
def _apply_source_check(
|
||||
observation: ExternalObservation,
|
||||
status: SourceCheckStatus,
|
||||
checked_at: datetime,
|
||||
) -> None:
|
||||
"""Mutate one observation; the caller owns the transaction boundary."""
|
||||
current = checked_at
|
||||
observation.source_check_status = status
|
||||
observation.source_checked_at = current
|
||||
if status == "missing" and observation.status != "withdrawn":
|
||||
@@ -34,5 +57,27 @@ def record_source_check(
|
||||
observation.review_note = "Source record missing; withdrawn pending moderator review"
|
||||
observation.reviewed_at = current
|
||||
observation.moderation_version += 1
|
||||
|
||||
|
||||
def record_scheduled_source_check(
|
||||
session: Session,
|
||||
*,
|
||||
source_system: str,
|
||||
source_url: str,
|
||||
status: SourceCheckStatus,
|
||||
checked_at: datetime | None = None,
|
||||
) -> int:
|
||||
"""Apply one scheduled request result only to observations with that exact URL.
|
||||
|
||||
Aggregate pages cannot prove that an omitted record was deleted, so absence
|
||||
from a parsed listing is deliberately ignored.
|
||||
"""
|
||||
observations = list(session.scalars(select(ExternalObservation).where(
|
||||
ExternalObservation.source_system == source_system,
|
||||
ExternalObservation.source_url == source_url,
|
||||
)))
|
||||
current = checked_at or datetime.now(timezone.utc)
|
||||
for observation in observations:
|
||||
_apply_source_check(observation, status, current)
|
||||
session.commit()
|
||||
return observation
|
||||
return len(observations)
|
||||
|
||||
Reference in New Issue
Block a user