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:
jze9
2026-08-28 16:51:25 +05:00
parent f1a8d07cb3
commit 99bf14fe6a
10 changed files with 255 additions and 9 deletions

View 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")

View File

@@ -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)

View File

@@ -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)

View File

@@ -13,8 +13,9 @@ interface Run {
target: number; fetched: number; processed: number;
added: number; duplicates: number; skipped: number; failed: number;
cancel_requested: boolean; error: string | null;
started_at: string; heartbeat_at: string | null; finished_at: string | null;
percent: number;
started_at: string; run_started_at: string | null;
heartbeat_at: string | null; finished_at: string | null;
percent: number; queued_s: number | null; duration_s: number | null;
}
interface LogEntry { ts: string; elapsed: number; level: string; msg: string }
@@ -360,6 +361,14 @@ export function Sources() {
);
}
/** Секунды → «45с» / «12 мин» / «2 ч 5 мин»: в отладке важен порядок, не точность. */
function fmtDuration(seconds: number): string {
if (seconds < 90) return `${Math.round(seconds)}с`;
const min = Math.round(seconds / 60);
if (min < 90) return `${min} мин`;
return `${Math.floor(min / 60)} ч ${min % 60} мин`;
}
const LOG_COLORS: Record<string, string> = {
error: 'text-red-600',
warning: 'text-amber-600',
@@ -389,7 +398,9 @@ function RunLog({ runId, live }: { runId?: number; live: boolean }) {
<Chip label="дублей" value={String(data.duplicates)} />
{data.skipped > 0 && <Chip label="без метаданных" value={String(data.skipped)} />}
{data.failed > 0 && <Chip label="ошибок" value={String(data.failed)} />}
<Chip label="начат" value={new Date(data.started_at).toLocaleString('ru-RU')} />
<Chip label="поставлен в очередь" value={new Date(data.started_at).toLocaleString('ru-RU')} />
{data.queued_s != null && <Chip label="ждал очереди" value={fmtDuration(data.queued_s)} />}
{data.duration_s != null && <Chip label="работал" value={fmtDuration(data.duration_s)} />}
{data.finished_at && <Chip label="завершён" value={new Date(data.finished_at).toLocaleString('ru-RU')} />}
</div>

View File

@@ -101,6 +101,7 @@ class ParseRun(Base):
error: Mapped[str | None] = mapped_column(Text, nullable=True)
log: Mapped[list | None] = mapped_column(JSON, default=list)
started_at: Mapped[datetime] = mapped_column(server_default=func.now())
run_started_at: Mapped[datetime | None] = mapped_column(nullable=True)
heartbeat_at: Mapped[datetime | None] = mapped_column(nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(nullable=True)

View File

@@ -625,7 +625,15 @@ def run_parser(self, source_id: int, run_id: int | None = None) -> dict[str, Any
prog.stage = "fetch"
prog.log("info", f"старт: {cfg['source_type']} q={cfg.get('query') or '—'} limit={cfg['limit']}")
_write_run(
run_id, status="running", celery_task_id=self.request.id, error=None, **prog.snapshot()
run_id,
status="running",
celery_task_id=self.request.id,
error=None,
# Отдельно от started_at (постановка в очередь): при массовом запуске
# между ними часы ожидания, и без этой отметки «длительность прогона»
# в отладке показывала очередь, а не работу
run_started_at=datetime.now(UTC),
**prog.snapshot(),
)
cancelled = False