Files
GTI_GERENCIADOR_FISCAL/central/app/datasets/updater.py
T

86 lines
4.2 KiB
Python

"""Automatic refresh of the official NCM dataset (Siscomex JSON): staging -> validate -> atomic promotion, keeping the last valid dataset on ANY failure.
Every attempt leaves a SourceRefreshRun (url, time, sha256, version label, record count, status, error) for the panel. Nothing here touches Agents or providers.
"""
from __future__ import annotations
import logging
import threading
import time
from datetime import datetime, timedelta, timezone
import httpx
from sqlalchemy import select
from ..config import settings
from ..models import Dataset, SourceRefreshRun
from .ncm import parse_ncm_payload
from .service import DatasetService
logger = logging.getLogger("gti_fiscal_hub")
MIN_RATIO = 0.9 # a payload with < 90% of the active record count is treated as a broken download, never promoted
INTERVAL = timedelta(hours=24)
RETRY_AFTER_FAILURE = timedelta(hours=6)
def _fetch(url: str) -> bytes:
response = httpx.get(url, timeout=max(settings.http_timeout_seconds, 60), follow_redirects=True, headers={"User-Agent": "GTIFiscalHub/1.0 (official dataset refresh)"})
response.raise_for_status()
return response.content
def refresh_ncm(session, fetch=_fetch, url: str | None = None) -> SourceRefreshRun:
url = url or settings.ncm_dataset_url
run = SourceRefreshRun(source="NCM", source_url=url, status="RUNNING")
session.add(run)
session.commit()
try:
content = fetch(url) # staging: bytes in memory, nothing written to the active dataset yet
parsed = parse_ncm_payload(content) # schema validation
run.sha256, run.version_label, run.record_count = parsed.sha256, (parsed.version or "")[:64], len(parsed.entries)
active = session.scalar(select(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE"))
if active is not None and active.sha256 == parsed.sha256:
run.status = "UNCHANGED"
else:
if active is not None and len(parsed.entries) < MIN_RATIO * (active.record_count or 0):
raise ValueError(f"implausible record count {len(parsed.entries)} vs active {active.record_count}")
DatasetService(session).import_payload(parsed, url, datetime.now(timezone.utc)) # atomic: one transaction, old ACTIVE superseded only at the end
run.status = "UPDATED"
except Exception as error: # keep the last valid dataset
session.rollback()
run = session.get(SourceRefreshRun, run.id)
run.status, run.error = "FAILED", f"{type(error).__name__}: {error}"[:300]
logger.warning("NCM refresh failed: %s", run.error)
run.finished_at = datetime.now(timezone.utc)
session.commit()
return run
def last_runs(session, source: str = "NCM", limit: int = 5):
return session.scalars(select(SourceRefreshRun).where(SourceRefreshRun.source == source).order_by(SourceRefreshRun.id.desc()).limit(limit)).all()
def _due(session) -> bool:
last = session.scalar(select(SourceRefreshRun).where(SourceRefreshRun.source == "NCM", SourceRefreshRun.status != "RUNNING").order_by(SourceRefreshRun.id.desc()).limit(1))
if last is None or last.finished_at is None:
return True
finished = last.finished_at if last.finished_at.tzinfo else last.finished_at.replace(tzinfo=timezone.utc)
return datetime.now(timezone.utc) - finished >= (RETRY_AFTER_FAILURE if last.status == "FAILED" else INTERVAL)
def start_scheduler(session_factory, first_delay: float = 90.0):
"""Daemon thread: checks hourly whether a refresh is due (survives restarts because due-ness is derived from SourceRefreshRun)."""
def loop():
time.sleep(first_delay)
while True:
try:
with session_factory() as session:
if _due(session):
refresh_ncm(session)
except Exception: # never let the scheduler die
logger.exception("official refresh loop error")
time.sleep(3600)
thread = threading.Thread(target=loop, name="official-refresh", daemon=True)
thread.start()
return thread