feat(admin): честная длительность прогона + добор эмбеддингов
Разбор итогов массовой заливки показал в отладке «среднюю длительность прогона» в 2.8 часа там, где парсинг занимал 50 секунд: started_at пишется в момент постановки в очередь, а очередь из 173 источников разбирается часами. Теперь момент реального старта пишется отдельно (run_started_at, миграция 006), а схема отдаёт обе величины — сколько ждал очереди и сколько работал. Плюс scripts/ops/reembed_missing.py: документ попадает в корпус сразу, а вектор для L3 считает отдельная задача; когда worker-gpu или Ollama недоступны, эти задачи теряются и документ остаётся невидимым для семантического поиска. Скрипт находит faiss_id IS NULL и переотправляет пачками (dry-run по умолчанию) — сейчас таких 23 101 из 177 147. Документация: актуальные цифры корпуса, дубли при повторном прогоне, лимит OpenAlex, и главное — гипервизор .254 зафиксирован в DR-HA как самая широкая единая точка отказа (брокер, эмбеддинги, секреты и прокси на одном железе; подтверждено аварией 28.08). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
31
services/api/alembic/versions/006_parse_run_started_at.py
Normal file
31
services/api/alembic/versions/006_parse_run_started_at.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Отдельная отметка старта выполнения прогона парсинга.
|
||||
|
||||
Revision ID: 006
|
||||
Revises: 005
|
||||
Create Date: 2026-08-28
|
||||
|
||||
`started_at` пишется в момент СОЗДАНИЯ строки, то есть постановки в очередь.
|
||||
При массовом запуске очередь разбирается часами, и «длительность» прогона по
|
||||
двум таймстампам показывала 2.8 часа там, где сам парсинг занял 50 секунд —
|
||||
для отладки это дезинформация. Момент, когда воркер реально взял задачу,
|
||||
пишем отдельно; разница со `started_at` — это ожидание в очереди.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "006"
|
||||
down_revision = "005"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Для уже прошедших прогонов остаётся NULL: подставлять им started_at
|
||||
# значило бы выдать время ожидания за время работы.
|
||||
op.add_column("parse_runs", sa.Column("run_started_at", sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("parse_runs", "run_started_at")
|
||||
@@ -75,7 +75,10 @@ class ParseRun(Base):
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# [{"ts": ISO8601, "level": "info|warning|error", "msg": str}]
|
||||
log: Mapped[list | None] = mapped_column(JSON, default=list)
|
||||
# Постановка в очередь (строку создаёт API) и реальный старт выполнения:
|
||||
# между ними при массовом запуске проходят часы, мешать их нельзя
|
||||
started_at: Mapped[datetime] = mapped_column(server_default=func.now(), index=True)
|
||||
run_started_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
# Последний признак жизни: по нему видно зависший прогон (running, но тишина)
|
||||
heartbeat_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
|
||||
@@ -80,7 +80,9 @@ class ParseRunResponse(BaseModel):
|
||||
failed: int
|
||||
cancel_requested: bool = False
|
||||
error: str | None = None
|
||||
# started_at — постановка в очередь, run_started_at — реальный старт работы
|
||||
started_at: datetime
|
||||
run_started_at: datetime | None = None
|
||||
heartbeat_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
|
||||
@@ -91,6 +93,26 @@ class ParseRunResponse(BaseModel):
|
||||
def percent(self) -> float:
|
||||
return run_percent(self.status, self.stage, self.target, self.fetched, self.processed)
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def queued_s(self) -> float | None:
|
||||
"""Сколько прогон ждал своей очереди, сек."""
|
||||
if self.run_started_at is None:
|
||||
return None
|
||||
return round((self.run_started_at - self.started_at).total_seconds(), 1)
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def duration_s(self) -> float | None:
|
||||
"""Сколько прогон реально работал, сек (None — ещё идёт или не начинался).
|
||||
|
||||
Прогоны до появления run_started_at (миграция 006) остаются без
|
||||
длительности: у них известен только момент постановки в очередь.
|
||||
"""
|
||||
if self.run_started_at is None or self.finished_at is None:
|
||||
return None
|
||||
return round((self.finished_at - self.run_started_at).total_seconds(), 1)
|
||||
|
||||
|
||||
class ParseRunDetail(ParseRunResponse):
|
||||
log: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
Reference in New Issue
Block a user