feat(worker): ручной пересчёт метрик через очередь, диагностика шагов и heartbeat

POST /metrics/refresh ставит задачу в sync_job (source=METRICS_JOB) и отвечает 202, пересчёт делает worker; GET /metrics/status отдаёт refreshing и consistent. metric_refresh_log хранит failed_step и step_timings. Источники с needs="tinvest_token" не попадают в расписание без токена, tinvest/moex добавлены в default_schedule. Воркер трогает heartbeat-файл для healthcheck.
This commit is contained in:
Dmitry
2026-09-19 21:54:30 +03:00
parent 989a780f9f
commit 3b3ee4d682
11 changed files with 341 additions and 30 deletions
+55 -13
View File
@@ -4,9 +4,9 @@ from fastapi import APIRouter, status
from sqlalchemy import select
from fintracker.api.deps import CurrentUser, SessionDep
from fintracker.api.schemas.metrics import DataQualityRow, RefreshLogOut
from fintracker.metrics.refresh import refresh_all
from fintracker.models import MetricDataQuality, MetricRefreshLog
from fintracker.api.schemas.metrics import DataQualityRow, MetricsStatusOut, RefreshLogOut
from fintracker.api.schemas.sync import SyncJobOut
from fintracker.models import METRICS_JOB, JobStatus, MetricDataQuality, MetricRefreshLog, SyncJob
router = APIRouter(tags=["metrics"])
@@ -22,9 +22,10 @@ async def data_quality(session: SessionDep, _: CurrentUser) -> list[DataQualityR
@router.get("/metrics/status", name="status")
async def metrics_status(session: SessionDep, _: CurrentUser) -> RefreshLogOut | None:
"""When the metric tables were last rebuilt, and whether it failed."""
row = (
async def metrics_status(session: SessionDep, _: CurrentUser) -> MetricsStatusOut:
"""When the metric tables were last rebuilt, whether they are one consistent snapshot,
and whether another rebuild is on its way."""
latest = (
(
await session.execute(
select(MetricRefreshLog).order_by(MetricRefreshLog.started_at.desc()).limit(1)
@@ -33,13 +34,54 @@ async def metrics_status(session: SessionDep, _: CurrentUser) -> RefreshLogOut |
.scalars()
.first()
)
if row is None:
return None
return RefreshLogOut.model_validate(row, from_attributes=True)
last_finished = (
(
await session.execute(
select(MetricRefreshLog)
.where(MetricRefreshLog.finished_at.is_not(None))
.order_by(MetricRefreshLog.started_at.desc())
.limit(1)
)
)
.scalars()
.first()
)
queued = (
await session.execute(
select(SyncJob.id)
.where(
SyncJob.source == METRICS_JOB,
SyncJob.status.in_([JobStatus.queued, JobStatus.running]),
)
.limit(1)
)
).first()
return MetricsStatusOut(
last_refresh=RefreshLogOut.model_validate(latest, from_attributes=True) if latest else None,
consistent=last_finished is None or last_finished.error is None,
refreshing=queued is not None or (latest is not None and latest.finished_at is None),
)
@router.post("/metrics/refresh", name="refresh", status_code=status.HTTP_202_ACCEPTED)
async def metrics_refresh(session: SessionDep, _: CurrentUser) -> RefreshLogOut:
"""Rebuild every metric_* table inline (seconds at personal volumes)."""
entry = await refresh_all(session, trigger="manual")
return RefreshLogOut.model_validate(entry, from_attributes=True)
async def metrics_refresh(session: SessionDep, _: CurrentUser) -> SyncJobOut:
"""Queue a rebuild of every metric_* table; the worker runs it. Poll `/metrics/status`
until `refreshing` is false.
A request while one is already waiting shares it. One that arrives while a rebuild is
RUNNING queues another, because the running one may have read the data before the change
that prompted this call."""
waiting = (
await session.execute(
select(SyncJob)
.where(SyncJob.source == METRICS_JOB, SyncJob.status == JobStatus.queued)
.limit(1)
)
).scalar_one_or_none()
if waiting is not None:
return SyncJobOut.model_validate(waiting, from_attributes=True)
job = SyncJob(source=METRICS_JOB, status=JobStatus.queued)
session.add(job)
await session.commit()
await session.refresh(job)
return SyncJobOut.model_validate(job, from_attributes=True)
@@ -23,3 +23,17 @@ class RefreshLogOut(BaseModel):
finished_at: datetime | None
trigger: str
error: str | None
failed_step: str | None
"""The step that raised; earlier steps committed, later ones did not run."""
step_timings: dict[str, float] | None
"""Seconds per step that completed, in run order."""
class MetricsStatusOut(BaseModel):
last_refresh: RefreshLogOut | None
"""Latest run, finished or not; null before the first one."""
consistent: bool
"""False when the latest FINISHED run failed part-way: some metric tables are from it and
the rest from an earlier run, so numbers on different screens may disagree."""
refreshing: bool
"""A rebuild is queued or running, so `last_refresh` is about to be superseded."""