All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
Ревью честного run-status нашло, что _RESULT_COUNTER_KEYS ловил не только целевой yandex_newbuilding_sweep, но и rosreestr_dkp_import (rows_inserted, 66 из 67 прод- прогонов = здоровый ноль догнавшего инкрементального импорта) и newbuilding_enrich (processed — счётчик попыток, ==limit даже при частичном провале). Первое завело бы практически непрерываемый ложный zero-стрик у здорового источника, второе маскировало бы реальные отказы под measured-N. Проверено по прод-БД (2026-08-15): "succeeded" пишут ТОЛЬКО yandex_newbuilding_sweep (42 прогона/90д) и newbuilding_enrich (65/90д) — ни разу rosreestr_dkp_import; у yandex_newbuilding_sweep succeeded численно совпадает с rows_inserted на всех 42/42 прогонах. Заменил "rows_inserted"+"processed" на "succeeded" в _RESULT_COUNTER_KEYS (app-копия и byte-эквивалентная kit-копия) — цель (b) исходной правки сохранена, ложный стрик у rosreestr_dkp_import снят, попутно newbuilding_enrich получает честное измерение вместо счётчика попыток. Также поправлены докстринги test_backfill_honest_status.py — два кейса (76%/72% отказов -> 'done') проверяют только выбор финализатора mark_backfill_finished (mark_done там замокан); реальный mark_done с honest-run-status переквалифицирует их в 'failed' через _failed_ratio_too_high — это не документировалось явно.
100 lines
6.4 KiB
Python
100 lines
6.4 KiB
Python
"""#2674 — detail-backfill с нулём обогащений перестаёт называться 'done'.
|
||
|
||
Все три backfill'а (avito/yandex/domclick) финализировались одним mark_done.
|
||
На проде 2026-08-06 это 78 прогонов из 158: avito 23/76 (включая 5 прогонов на
|
||
1500-1600 попыток без единого обогащения), yandex 31/52, domclick 24/30
|
||
(494 попытки → 0 обогащено, 63 блока, 431 fail — и все 30 'done').
|
||
|
||
Проверяем ровно ветвление mark_backfill_finished — БД замокана (mark_done/mark_failed/
|
||
mark_banned здесь fake-заглушки, регистрирующие ТОЛЬКО факт вызова). Это значит: кейсы
|
||
ниже с высокой долей отказов (attempted=50, failed=38 или 36 — 76%/72%), ожидающие
|
||
'done', проверяют лишь то, КАКОЙ финализатор ВЫБРАЛ mark_backfill_finished (#2674:
|
||
"обогатили хоть что-то — успех"), а НЕ то, что реально запишет в БД mark_done. С
|
||
honest-run-status (2026-08-15) mark_done САМ переквалифицирует такой прогон в 'failed'
|
||
через _failed_ratio_too_high (доля отказов >= 0.5) — реальный терминальный статус
|
||
для этих двух кейсов на проде теперь 'failed', не 'done'. Это намеренно проверяется
|
||
отдельно, БЕЗ мока mark_done, в tests/test_honest_run_status_failed_ratio.py
|
||
(test_prod_fact_avito_15_08_no_longer_done и соседние) — не читай эти два кейса как
|
||
"76%/72% отказов = 'done' в проде".
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services import scrape_runs as runs_mod
|
||
|
||
|
||
def _finish(counters: dict[str, int], *, aborted: bool = False) -> tuple[str, str | None]:
|
||
"""Вызвать mark_backfill_finished с замоканными mark_* → (статус, причина)."""
|
||
calls: list[tuple[str, str | None]] = []
|
||
with (
|
||
patch.object(runs_mod, "mark_done", lambda *a, **k: calls.append(("done", None))),
|
||
patch.object(
|
||
runs_mod, "mark_failed", lambda db, rid, err, c: calls.append(("failed", err))
|
||
),
|
||
# **k — mark_banned принимает ещё и ban_kind (#2686/#2764); этот тест про
|
||
# ВЫБОР финализатора, диагноз проверяет test_2764_ban_kind_no_default.py.
|
||
patch.object(
|
||
runs_mod, "mark_banned", lambda db, rid, err, c, **k: calls.append(("banned", err))
|
||
),
|
||
):
|
||
runs_mod.mark_backfill_finished(
|
||
MagicMock(),
|
||
1,
|
||
counters,
|
||
source="domclick_detail_backfill",
|
||
aborted_by_blocks=aborted,
|
||
)
|
||
assert len(calls) == 1, f"ожидался ровно один финализатор, получено {calls}"
|
||
return calls[0]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("counters", "aborted", "expected"),
|
||
[
|
||
# Прод-факт domclick: 16 прогонов attempted=3 blocked=3 → брейкер оборвал.
|
||
({"attempted": 3, "enriched": 0, "blocked": 3, "failed": 0}, True, "banned"),
|
||
# Прод-факт domclick: 100 попыток, все fail, блоки не распознаны (до #2645).
|
||
({"attempted": 100, "enriched": 0, "blocked": 0, "failed": 100}, False, "failed"),
|
||
# Прод-факт avito: 1500 попыток, 1499 fail + 1 блок, ноль обогащений.
|
||
({"attempted": 1500, "enriched": 0, "blocked": 1, "failed": 1499}, False, "banned"),
|
||
# Прод-факт yandex: 31 прогон attempted=5 failed=5, ключа blocked нет вовсе.
|
||
({"attempted": 5, "enriched": 0, "failed": 5}, False, "failed"),
|
||
# Кандидатов не было — честная пустота, это успех.
|
||
({"attempted": 0, "enriched": 0, "blocked": 0, "failed": 0}, False, "done"),
|
||
# Частичный прогон: обогатили хоть что-то → mark_backfill_finished ВЫБИРАЕТ
|
||
# mark_done как финализатор (#2674). 76% отказов (38 из 50) — здесь mark_done
|
||
# замокан, поэтому статус остаётся 'done'; в реальном mark_done с
|
||
# honest-run-status (2026-08-15) это переквалифицируется в 'failed'
|
||
# (_failed_ratio_too_high, доля >= 0.5) — см. докстринг модуля.
|
||
({"attempted": 50, "enriched": 12, "blocked": 0, "failed": 38}, False, "done"),
|
||
# Блоки были, но прогон доработал и обогатил — mark_backfill_finished выбирает
|
||
# НЕ 'banned'. 72% отказов (36 из 50) — та же оговорка: реальный mark_done
|
||
# переквалифицирует в 'failed', см. докстринг модуля выше.
|
||
({"attempted": 50, "enriched": 12, "blocked": 2, "failed": 36}, False, "done"),
|
||
# Блок оборвал прогон, хотя часть успели обогатить — работа не доделана.
|
||
({"attempted": 50, "enriched": 12, "blocked": 5, "failed": 33}, True, "banned"),
|
||
# avito: 404-«снято с продажи» — тоже результат, а не пустой прогон.
|
||
({"attempted": 30, "enriched": 0, "gone": 30, "blocked": 0, "failed": 0}, False, "done"),
|
||
],
|
||
)
|
||
def test_status_matches_reality(counters: dict[str, Any], aborted: bool, expected: str) -> None:
|
||
status, _ = _finish(counters, aborted=aborted)
|
||
assert status == expected
|
||
|
||
|
||
def test_reason_carries_numbers_and_marker() -> None:
|
||
"""Причина в scrape_runs.error должна быть читаемой человеком, не пустой."""
|
||
status, reason = _finish({"attempted": 3, "enriched": 0, "blocked": 3}, aborted=True)
|
||
assert status == "banned"
|
||
assert reason is not None
|
||
assert "backfill-honest-status" in reason
|
||
assert "domclick_detail_backfill" in reason
|
||
assert "blocked=3" in reason and "из 3 попыток" in reason
|