test(admin): закрепить смысл таймингов прогона + время работы в отладке
Тесты на queued_s/duration_s фиксируют ровно ту путаницу, из-за которой метрика и разъехалась: ожидание в очереди и время работы — разные величины, а у прогонов до миграции 006 длительности просто нет (вместо неё раньше показывалось время в очереди). Панель отладки теперь показывает, сколько идущий прогон уже работает — по этому и виден застрявший, а не только по отсутствию heartbeat. README: фактические числа тестов (150, проверено прогоном run_tests.sh). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -232,7 +232,7 @@ docker compose -f docker-compose.prod.yml --profile observability up -d promethe
|
|||||||
|
|
||||||
1. **Линт** — `ruff` (весь Python) + `mypy` (чистая доменная логика).
|
1. **Линт** — `ruff` (весь Python) + `mypy` (чистая доменная логика).
|
||||||
Конфиги: [`ruff.toml`](ruff.toml), [`mypy.ini`](mypy.ini).
|
Конфиги: [`ruff.toml`](ruff.toml), [`mypy.ini`](mypy.ini).
|
||||||
2. **Юнит-тесты** — `pytest` по сервисам: 132 теста на ядро детекции, скоринга,
|
2. **Юнит-тесты** — `pytest` по сервисам: 150 тестов на ядро детекции, скоринга,
|
||||||
парсеров, форматирования, OAuth и прогресса заливки, без внешней инфры
|
парсеров, форматирования, OAuth и прогресса заливки, без внешней инфры
|
||||||
(БД/Redis/GPU/Ollama замоканы либо не нужны).
|
(БД/Redis/GPU/Ollama замоканы либо не нужны).
|
||||||
|
|
||||||
@@ -265,7 +265,7 @@ make test-one SVC=worker-gost # тесты одного сервиса
|
|||||||
| Парсеры источников (CyberLeninka, PMC, прогресс-колбэк) | `scripts/parsers/` | 19 |
|
| Парсеры источников (CyberLeninka, PMC, прогресс-колбэк) | `scripts/parsers/` | 19 |
|
||||||
| OAuth-ссылки (Google/Яндекс) | `api/app/core/oauth.py` | 6 |
|
| OAuth-ссылки (Google/Яндекс) | `api/app/core/oauth.py` | 6 |
|
||||||
| Прогресс заливки (счётчики, бюджет) | `worker-indexer/app/progress.py` | 7 |
|
| Прогресс заливки (счётчики, бюджет) | `worker-indexer/app/progress.py` | 7 |
|
||||||
| Шкала загрузки источников | `api/app/core/progress.py` | 7 |
|
| Шкала загрузки и тайминги прогонов | `api/app/core/progress.py`, `schemas/admin.py` | 11 |
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""Юнит-тесты шкалы загрузки источников (app.core.progress) — чистая логика."""
|
"""Юнит-тесты шкалы загрузки и таймингов прогонов — чистая логика, без БД."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from app.core.progress import run_percent
|
from app.core.progress import run_percent
|
||||||
|
from app.schemas.admin import ParseRunResponse
|
||||||
|
|
||||||
|
|
||||||
def test_queued_run_shows_nothing_done():
|
def test_queued_run_shows_nothing_done():
|
||||||
@@ -39,3 +42,45 @@ def test_finished_runs_are_always_full():
|
|||||||
"""Шкала показывает «работа окончена», исход виден по статусу рядом."""
|
"""Шкала показывает «работа окончена», исход виден по статусу рядом."""
|
||||||
for status in ("done", "partial", "error", "cancelled"):
|
for status in ("done", "partial", "error", "cancelled"):
|
||||||
assert run_percent(status, "finished", target=100, fetched=3, processed=1) == 100.0
|
assert run_percent(status, "finished", target=100, fetched=3, processed=1) == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Тайминги прогона в схеме ответа ─────────────────────────────────────────
|
||||||
|
# Регрессия, ради которой они и разделены: в отладке «длительность прогона»
|
||||||
|
# показывала время ожидания в очереди (часы) вместо времени работы (секунды).
|
||||||
|
|
||||||
|
|
||||||
|
def _run(**over) -> ParseRunResponse:
|
||||||
|
base = {
|
||||||
|
"id": 1, "source_id": 1, "status": "done", "stage": "finished", "target": 100,
|
||||||
|
"fetched": 100, "processed": 100, "added": 10, "duplicates": 90,
|
||||||
|
"skipped": 0, "failed": 0,
|
||||||
|
"started_at": datetime(2026, 8, 27, 12, 0, 0),
|
||||||
|
"run_started_at": datetime(2026, 8, 27, 14, 0, 0),
|
||||||
|
"finished_at": datetime(2026, 8, 27, 14, 0, 50),
|
||||||
|
}
|
||||||
|
base.update(over)
|
||||||
|
return ParseRunResponse(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def test_queued_and_duration_are_measured_separately():
|
||||||
|
r = _run()
|
||||||
|
assert r.queued_s == 7200.0 # два часа в очереди
|
||||||
|
assert r.duration_s == 50.0 # полминуты работы
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_durations_until_worker_picked_run_up():
|
||||||
|
r = _run(status="queued", stage="queued", run_started_at=None, finished_at=None)
|
||||||
|
assert r.queued_s is None
|
||||||
|
assert r.duration_s is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_run_has_wait_but_no_duration_yet():
|
||||||
|
r = _run(status="running", stage="index", finished_at=None)
|
||||||
|
assert r.queued_s == 7200.0
|
||||||
|
assert r.duration_s is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_runs_report_no_duration_instead_of_queue_time():
|
||||||
|
"""Прогоны до миграции 006: длительности нет — но и вранья тоже."""
|
||||||
|
r = _run(run_started_at=None)
|
||||||
|
assert r.duration_s is None
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ interface Run {
|
|||||||
id: number; source_id: number; status: string; stage: string; target: number;
|
id: number; source_id: number; status: string; stage: string; target: number;
|
||||||
fetched: number; processed: number; added: number; duplicates: number;
|
fetched: number; processed: number; added: number; duplicates: number;
|
||||||
skipped: number; failed: number; error: string | null; percent: number;
|
skipped: number; failed: number; error: string | null; percent: number;
|
||||||
started_at: string; heartbeat_at: string | null;
|
started_at: string; run_started_at: string | null; heartbeat_at: string | null;
|
||||||
|
queued_s: number | null; duration_s: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DebugData {
|
interface DebugData {
|
||||||
@@ -42,6 +43,13 @@ function fmtNum(n: number | null | undefined): string {
|
|||||||
return n == null ? '—' : n.toLocaleString('ru-RU');
|
return n == null ? '—' : n.toLocaleString('ru-RU');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сколько идущий прогон уже работает — по нему видно застрявший. */
|
||||||
|
function runningFor(run: Run): string {
|
||||||
|
if (!run.run_started_at) return 'ещё не начат';
|
||||||
|
const sec = Math.max(0, (Date.now() - new Date(run.run_started_at + 'Z').getTime()) / 1000);
|
||||||
|
return sec < 90 ? `${Math.round(sec)}с` : `${Math.round(sec / 60)} мин`;
|
||||||
|
}
|
||||||
|
|
||||||
export function Debug() {
|
export function Debug() {
|
||||||
const { data, isFetching, error } = useQuery({
|
const { data, isFetching, error } = useQuery({
|
||||||
queryKey: ['admin-debug'],
|
queryKey: ['admin-debug'],
|
||||||
@@ -87,8 +95,9 @@ export function Debug() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{data.sources.active_runs.map((r) => (
|
{data.sources.active_runs.map((r) => (
|
||||||
<div key={r.id} className="flex items-center gap-4">
|
<div key={r.id} className="flex items-center gap-4">
|
||||||
<div className="w-40 shrink-0 text-sm text-gray-600 truncate">
|
<div className="w-52 shrink-0 text-sm text-gray-600 truncate">
|
||||||
#{r.id} · источник {r.source_id}
|
#{r.id} · источник {r.source_id}
|
||||||
|
<span className="text-gray-400"> · {runningFor(r)}</span>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
percent={r.percent}
|
percent={r.percent}
|
||||||
@@ -189,6 +198,7 @@ export function Debug() {
|
|||||||
<span className={r.status === 'error' ? 'text-red-600' : 'text-amber-600'}>{r.status}</span>
|
<span className={r.status === 'error' ? 'text-red-600' : 'text-amber-600'}>{r.status}</span>
|
||||||
<span className="text-gray-500">
|
<span className="text-gray-500">
|
||||||
получено {r.fetched}/{r.target}, добавлено {r.added}, ошибок {r.failed}
|
получено {r.fetched}/{r.target}, добавлено {r.added}, ошибок {r.failed}
|
||||||
|
{r.duration_s != null && ` · работал ${Math.round(r.duration_s)}с`}
|
||||||
</span>
|
</span>
|
||||||
{r.error && <span className="w-full text-xs font-mono text-red-600 break-all">{r.error}</span>}
|
{r.error && <span className="w-full text-xs font-mono text-red-600 break-all">{r.error}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user