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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user