fix(tradein/admin): убрать показатели, которые не могут быть ненулевыми, и брать список источников из данных (#2674) #2684

Merged
bot-backend merged 2 commits from fix/2674-admin-metrics-honesty into main 2026-08-05 23:01:31 +00:00
8 changed files with 583 additions and 49 deletions

View file

@ -2189,12 +2189,24 @@ async def scrape_house_imv_backfill(
class UnifiedScrapeRunRow(BaseModel):
"""Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче)."""
"""Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче).
#2674: поля run_type больше нет. Вид прогона в БД всегда был дефолтом
'city_sweep' (3244 из 3244 строк, ни одно место кода его не задавало), и
таблица подписывала им прогоны, которые никаким sweep не были
proxy_healthcheck, deactivate_stale_*, sber_index_pull. Что именно бежало,
называет `source`.
"""
run_id: int
source: str
run_type: str | None = None
status: str
# #2674: чинить фильтр без этого флага было бы регрессом. Пока таблица была
# пуста на всех вкладках, кнопка отмены не рендерилась ни разу; теперь оператор
# видит все 53 источника — и без флага мог бы «отменить» задачу, которая отмену
# не опрашивает (см. scrape_runs.honors_cancel): статус соврал бы, а
# has_running_run перестал бы держать single-run guard.
cancellable: bool = False
params: dict | None = None
counters: dict | None = None
total_seen: int | None = None
@ -2210,6 +2222,12 @@ class UnifiedScrapeRunsResponse(BaseModel):
rows: list[UnifiedScrapeRunRow]
class ScrapeRunSourcesResponse(BaseModel):
"""Список source'ов для фильтра истории прогонов — из данных, не из литерала."""
sources: list[str]
class BrowserHealth(BaseModel):
reachable: bool
browsers: dict[str, bool] = Field(default_factory=dict)
@ -2294,8 +2312,8 @@ def list_scrape_runs_unified(
UnifiedScrapeRunRow(
run_id=r["run_id"],
source=r["source"],
run_type=r.get("run_type"),
status=r["status"],
cancellable=runs_mod.honors_cancel(str(r["source"])),
params=r.get("params"),
counters=r.get("counters"),
total_seen=r.get("total_seen"),
@ -2310,6 +2328,21 @@ def list_scrape_runs_unified(
)
@router.get("/scrape/runs/sources", response_model=ScrapeRunSourcesResponse)
def list_scrape_run_sources(
db: Annotated[Session, Depends(get_db)],
) -> ScrapeRunSourcesResponse:
"""Источники для фильтра истории прогонов — ровно те, что есть в scrape_runs.
#2674: фильтр в UI был захардкожен тремя значениями (avito/cian/yandex), а в
таблице 53 разных source и НИ ОДНОЙ строки с таким точным значением каждый
пункт фильтра давал пустую выдачу, и 76% прогонов (вся площадка Домклик в том
числе) были недоступны для вопроса «что там происходит». Список берётся из
данных: новый source появляется в фильтре сам, без правки кода.
"""
return ScrapeRunSourcesResponse(sources=runs_mod.distinct_sources(db))
async def _probe_browser_health() -> BrowserHealth:
"""GET tradein-browser /health (timeout 5с). reachable=False при ошибке."""
url = f"{settings.browser_http_endpoint.rstrip('/')}/health"

View file

@ -201,7 +201,9 @@ def _alert_on_run_id(
def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int:
"""INSERT scrape_runs(source, status='running', params, started_at=NOW()).
run_type DEFAULT 'city_sweep' (из 051 миграции).
Вид прогона несёт сам `source` (avito_city_sweep / domclick_detail_backfill / );
отдельной колонки run_type больше нет она 3244 прогона подряд молчала
дефолтом 'city_sweep' и подписывала им, например, proxy_healthcheck (#2674).
Returns run_id (bigint).
"""
row = db.execute(
@ -248,6 +250,27 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None
db.commit()
# Источники, чей джоб РЕАЛЬНО опрашивает status='cancelled' в своём цикле.
# Всё остальное отменить нельзя: строка стала бы 'cancelled', а задача продолжила бы
# работать — это, во-первых, ещё один врущий статус, во-вторых (хуже) обход guard'а
# has_running_run: он перестанет видеть прогон как running и пустит второй свип на том
# же прокси-IP → бан (инцидент 2026-05-31, runs #26+#27).
# Состав проверен по call-site'ам runs.is_cancelled: kit pipeline (city-sweep'ы всех
# площадок и городов, full-load'ы, avito_newbuilding_sweep) + rosreestr_dkp_import
# (scheduler.py). yandex_newbuilding_sweep отмену НЕ опрашивает — поэтому правило не
# «любой *_sweep». Актуально с #2674: до починки фильтра таблица прогонов была пуста
# на всех вкладках, кнопка отмены не рендерилась ни разу и дыра не проявлялась.
_CANCEL_HONORING_EXACT = frozenset({"avito_newbuilding_sweep", "rosreestr_dkp_import"})
_CANCEL_HONORING_SUBSTRINGS = ("city_sweep", "full_load")
def honors_cancel(source: str) -> bool:
"""True, если джоб этого source опрашивает отмену и реально остановится."""
return source in _CANCEL_HONORING_EXACT or any(
key in source for key in _CANCEL_HONORING_SUBSTRINGS
)
def is_cancelled(db: Session, run_id: int) -> bool:
"""Проверить status='cancelled' (cooperative cancel в long-running pipeline)."""
row = db.execute(
@ -372,7 +395,25 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
def mark_cancelled(db: Session, run_id: int) -> bool:
"""Set status='cancelled' если currently 'running'. Returns True если cancelled."""
"""Set status='cancelled' если currently 'running'. Returns True если cancelled.
Отказ (False) для source'ов, чей джоб отмену не опрашивает — см. honors_cancel:
там 'cancelled' был бы враньём в статусе и снял бы has_running_run-guard.
Ручки отмены source не проверяют (любая из пяти принимает любой run_id), поэтому
гейт стоит здесь на общем узле всех пяти.
"""
row = db.execute(
text("SELECT source FROM scrape_runs WHERE id = :run_id"),
{"run_id": run_id},
).fetchone()
if row is not None and not honors_cancel(str(row.source)):
logger.warning(
"mark_cancelled отказ: run_id=%d source=%s не опрашивает отмену — "
"задача продолжила бы работать под статусом 'cancelled'",
run_id,
row.source,
)
return False
result = db.execute(
text(
"""
@ -445,7 +486,7 @@ def list_all(
db.execute(
text(
f"""
SELECT id AS run_id, source, run_type, status, params, counters,
SELECT id AS run_id, source, status, params, counters,
total_seen, new_count, started_at, finished_at,
heartbeat_at, error AS error_text
FROM scrape_runs
@ -460,3 +501,22 @@ def list_all(
.all()
)
return total, [dict(r) for r in rows]
def distinct_sources(db: Session) -> list[str]:
"""Все значения source, которые РЕАЛЬНО есть в scrape_runs (по алфавиту).
#2674: фильтр источников в админке был захардкожен тремя площадками
(avito/cian/yandex), а в таблице 53 разных source и ни одной строки с таким
точным значением все три пункта фильтра давали пустую выдачу, а 76%
прогонов (включая всю площадку Домклик) отфильтровать было нечем.
Список обязан приходить из данных: новый source появляется в фильтре сам,
без правки кода.
Игнорирует фильтры /scrape/runs иначе выбор источника вырезал бы из
выпадающего списка все остальные.
"""
rows = db.execute(
text("SELECT DISTINCT source FROM scrape_runs WHERE source IS NOT NULL ORDER BY source")
).fetchall()
return [str(r.source) for r in rows]

View file

@ -0,0 +1,116 @@
-- 214_drop_dead_run_metrics.sql
-- Purpose (#2674): удалить показатели, которые не могут быть ненулевыми.
--
-- Один класс находок: колонка есть, админка её показывает, писателя нет ни одного —
-- и ноль читается как «всё чисто», а не как «мы это не считаем». Такой показатель
-- хуже отсутствующего: он создаёт ложную уверенность. Числа с прода 2026-08-06.
--
-- 1. listings.is_outlier + v_data_quality.outliers_flagged
-- 93 408 строк listings, is_outlier = false у ВСЕХ, NULL у нуля — то есть
-- только DEFAULT из 002, ни одного UPDATE за всю историю. Писателя нет и не
-- задумывалось: «выброс» у эстиматора вычисляется Tukey-фильтром по КОНКРЕТНОЙ
-- подборке аналогов (estimator._filter_outliers) и живёт ровно один запрос —
-- один и тот же лот выброс для одной оценки и нормальный аналог для соседней.
-- Persist-флаг на объявлении такое отношение выразить не может в принципе,
-- поэтому реализовать «пометку» нечем: это остаток отменённой затеи
-- («помечено модели после aggregation», 002_core_tables.sql), а не недоделка.
--
-- 2. scrape_runs.run_type
-- 3244 прогона, ровно одно значение 'city_sweep' — DEFAULT из 051; ни одно
-- место кода run_type не задаёт. Колонка подписывала «city_sweep» прогоны,
-- которые никаким sweep не были: proxy_healthcheck (1630 шт.),
-- deactivate_stale_*, sber_index_pull, rosreestr_dkp_import. Что именно бежало,
-- честно называет source — второй, вырожденной оси не нужно.
--
-- 3. scrape_runs.http_requests / http_errors / returning_count / disappeared_count
-- Ноль во всех 3244 прогонах с миграции 015, писателя нет вообще.
-- HTTP-запросы не считает никто ни в одном фетчере — заполнить нечем без
-- сквозной инструментации всех путей. Ошибки и «пропало/вернулось» УЖЕ
-- считает тот, кто их знает, и кладёт в counters jsonb: errors_count у
-- pipeline, deactivated/revived у deactivate_stale_*. Отдельные колонки были
-- бы вторым, иначе определённым счётчиком того же слова.
--
-- ⚠️ View-зависимость: v_data_quality (последний DDL — 095_dead_schema.sql) читает
-- listings.is_outlier и содержит CTE `SELECT * FROM listings`, что фиксирует
-- column-level зависимость на все колонки. Порядок тот же, что в 095:
-- DROP VIEW → DROP COLUMN → CREATE VIEW (уже без outliers_flagged).
--
-- Dependencies: 002_core_tables.sql, 015_scrape_runs.sql, 051_scrape_runs_extend.sql,
-- 095_dead_schema.sql (последний DDL v_data_quality).
-- Идемпотентно: DROP VIEW IF EXISTS / DROP COLUMN IF EXISTS / CREATE OR REPLACE VIEW.
BEGIN;
-- ── 1. is_outlier: снять зависимый view, дропнуть колонку, пересоздать view ───
DROP VIEW IF EXISTS v_data_quality;
ALTER TABLE IF EXISTS listings DROP COLUMN IF EXISTS is_outlier;
-- DDL идентичен 095, минус строка outliers_flagged (см. п.1 шапки).
CREATE OR REPLACE VIEW v_data_quality AS
WITH active_listings AS (
SELECT * FROM listings WHERE is_active = true
)
SELECT
(SELECT count(*) FROM houses) AS houses_total,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex,
(SELECT count(*) FROM (
SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2
) sub) AS houses_2plus_sources,
(SELECT count(*) FROM (
SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3
) sub) AS houses_3plus_sources,
(SELECT count(*) FROM active_listings) AS listings_active,
(SELECT count(*) FROM (
SELECT listing_id FROM listing_sources
WHERE listing_id IN (SELECT id FROM active_listings)
GROUP BY listing_id HAVING count(*) >= 2
) sub) AS listings_dedup_2sources,
(SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded,
(SELECT count(*) FROM active_listings WHERE cadastral_number IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr,
(SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description,
(SELECT count(*) FROM active_listings l
JOIN houses h ON h.id = l.house_id_fk
WHERE h.year_built IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago,
(SELECT count(*) FROM v_price_divergence) AS price_disagreements_count;
-- Комментарий из 095 утверждал, что view «refreshed on-demand by /api/v1/admin/
-- data-quality endpoint». Это неправда с момента переписывания ручки: живой
-- /api/v1/admin/scraper/data-quality строит собственный запрос по listings/houses и
-- этого view не касается, читателей в коде нет ни одного (проверено #2674). PR,
-- тезис которого «ложный показатель хуже отсутствующего», не имеет права нести
-- ложное утверждение о читателе — пишем как есть.
COMMENT ON VIEW v_data_quality IS
'KPI-снимок для РУЧНЫХ psql-запросов. Читателей в коде нет (проверено #2674): '
'/api/v1/admin/scraper/data-quality считает свои метрики сам и этот view не трогает. '
'#2674: outliers_flagged убран — is_outlier не писал никто, «выброс» определён только '
'внутри одной подборки аналогов (estimator._filter_outliers), не на объявлении.';
-- ── 2-3. scrape_runs: вырожденный run_type + четыре счётчика без писателя ─────
ALTER TABLE IF EXISTS scrape_runs
DROP COLUMN IF EXISTS run_type,
DROP COLUMN IF EXISTS http_requests,
DROP COLUMN IF EXISTS http_errors,
DROP COLUMN IF EXISTS returning_count,
DROP COLUMN IF EXISTS disappeared_count;
COMMIT;

View file

@ -0,0 +1,299 @@
"""#2674 — показатели админки, которые не могут быть ненулевыми.
Один класс: колонка есть, поверхность её показывает, писателя нет ни одного и
ноль читается оператором как «всё чисто», а не как «мы это не считаем». Такой
показатель хуже отсутствующего, поэтому «честно убрать» здесь такой же
правильный исход, как «реализовать».
Числа с прода (tradein-postgres, 2026-08-06, до фикса):
1. listings.is_outlier 93 408 строк, true у 0, NULL у 0: только DEFAULT из
миграции 002, ни одного UPDATE. v_data_quality.outliers_flagged рапортовал
этот ноль как «выбросов не помечено».
2. scrape_runs.http_requests / http_errors / returning_count /
disappeared_count ноль во всех 3244 прогонах с миграции 015 (74 дня у
самого старого), писателя нет вообще.
3. scrape_runs.run_type 3244 строки, ровно одно значение 'city_sweep'
(DEFAULT из 051); таблица админки подписывала им proxy_healthcheck (1630
прогонов), deactivate_stale_*, sber_index_pull.
4. Фильтр источников знал три значения ('avito'/'cian'/'yandex'), а
`source = :source` точное сравнение: строк с ТАКИМ source в таблице 0.
То есть каждый пункт фильтра давал пустую выдачу, а пустой выбор («Все»)
молча подставлял source вкладки и тоже ноль. 53 реальных источника,
2466 прогонов (76%) вне трёх площадок, включая весь Домклик.
БД и сеть замоканы реального Postgres не нужно.
"""
from __future__ import annotations
import inspect
import os
import re
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.orchestration import runs as kit_runs
from app.services import scrape_runs as runs_mod
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
_SQL_DIR = _BACKEND_ROOT / "data" / "sql"
_RUNS_TABLE_TSX = (
_BACKEND_ROOT.parent / "frontend" / "src" / "components" / "scrapers" / "RunsTable.tsx"
)
# Колонки scrape_runs без единого писателя (см. шапку). Плюс listings.is_outlier.
_DEAD_RUN_COLUMNS = (
"run_type",
"http_requests",
"http_errors",
"returning_count",
"disappeared_count",
)
# ── Фейковая БД: отдаёт ровно те source'ы, что ей задали ──────────────────────
class _FakeRow:
def __init__(self, source: str) -> None:
self.source = source
class _FakeResult:
def __init__(self, rows: list[_FakeRow]) -> None:
self._rows = rows
def fetchall(self) -> list[_FakeRow]:
return self._rows
class _FakeDB:
"""Минимальный Session-стаб: любой execute отдаёт заданный список source'ов."""
def __init__(self, sources: list[str]) -> None:
self.sources = sources
self.statements: list[str] = []
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
self.statements.append(str(stmt))
return _FakeResult([_FakeRow(s) for s in self.sources])
@pytest.fixture
def client_factory():
from app.api.v1 import admin as admin_module
from app.core.db import get_db
def _make(db: Any) -> TestClient:
app = FastAPI()
app.include_router(admin_module.router, prefix="/api/v1/admin")
app.dependency_overrides[get_db] = lambda: db
return TestClient(app)
return _make
# ══ 4. Фильтр источников берётся из данных ════════════════════════════════════
def test_sources_endpoint_returns_whatever_the_table_holds(client_factory) -> None:
"""Новый источник появляется в фильтре БЕЗ правки кода.
'domclick_city_sweep' площадка, которой не было в захардкоженной тройке
(46 прогонов на проде нельзя было отфильтровать). 'newmarket_2031_sweep'
заведомо несуществующий источник: если он доезжает до ответа, список
действительно приходит из данных, а не из литерала в коде.
"""
db = _FakeDB(["avito_city_sweep", "domclick_city_sweep", "newmarket_2031_sweep"])
r = client_factory(db).get("/api/v1/admin/scrape/runs/sources")
assert r.status_code == 200
assert r.json()["sources"] == [
"avito_city_sweep",
"domclick_city_sweep",
"newmarket_2031_sweep",
]
def test_sources_query_ignores_run_filters() -> None:
"""SELECT DISTINCT без WHERE по source/status — иначе выбор одного источника
вырезал бы из выпадающего списка все остальные."""
sql = inspect.getsource(runs_mod.distinct_sources)
assert "DISTINCT source" in sql
assert "WHERE source = " not in sql
assert ":status" not in sql
def test_frontend_source_filter_has_no_hardcoded_list() -> None:
"""RunsTable.tsx не содержит литерального списка площадок.
Red на origin/main: там был `["", "avito", "cian", "yandex"] as const`
три значения, которых в колонке source нет ни в одной из 3244 строк.
"""
tsx = _RUNS_TABLE_TSX.read_text(encoding="utf-8")
hardcoded = re.search(r'"avito"\s*,\s*"cian"\s*,\s*"yandex"', tsx)
assert hardcoded is None, (
"список источников снова захардкожен в RunsTable.tsx — он обязан приходить "
"из /api/v1/admin/scrape/runs/sources, иначе следующая площадка опять "
"останется невидимой для оператора"
)
assert "/scrape/runs/sources" in tsx
# ══ 3. run_type: вырожденная колонка ушла из API и из выдачи ══════════════════
def test_unified_run_row_has_no_run_type(client_factory) -> None:
"""Ответ /scrape/runs не несёт run_type — подписывать 'city_sweep' прогон
proxy_healthcheck честнее вообще нечем, кроме source."""
from unittest.mock import patch
row = {
"run_id": 5,
"source": "proxy_healthcheck",
"status": "done",
"params": None,
"counters": {"ok": 3},
"total_seen": None,
"new_count": None,
"started_at": None,
"finished_at": None,
"heartbeat_at": None,
"error_text": None,
}
with patch("app.services.scrape_runs.list_all", return_value=(1, [row])):
r = client_factory(MagicMock()).get("/api/v1/admin/scrape/runs")
assert r.status_code == 200
body_row = r.json()["rows"][0]
assert "run_type" not in body_row
assert body_row["source"] == "proxy_healthcheck"
@pytest.mark.parametrize("list_all", [runs_mod.list_all, kit_runs.list_all])
def test_list_all_selects_no_dropped_columns(list_all) -> None:
"""Обе копии list_all (app.services и scraper_kit) перестали читать колонки,
которых после миграции 214 в таблице нет иначе админка отдаёт 500."""
sql = inspect.getsource(list_all)
still_there = [c for c in _DEAD_RUN_COLUMNS if re.search(rf"\b{c}\b", sql)]
assert not still_there, f"list_all всё ещё выбирает дропнутые колонки: {still_there}"
# ══ 4b. Побочка починки фильтра: кнопка отмены открылась на все источники ═════
def test_cancel_flag_true_only_for_jobs_that_poll_cancellation() -> None:
"""honors_cancel = ровно те source'ы, чей джоб опрашивает runs.is_cancelled.
'yandex_newbuilding_sweep' в наборе НЕ должен быть, хотя и *_sweep: его таск
(app/tasks/yandex_newbuilding_sweep.py) отмену не опрашивает поэтому правило
не может быть «любой sweep».
"""
honoring = [
"avito_city_sweep",
"cian_city_sweep_nizhniy_tagil",
"domclick_city_sweep",
"avito_full_load_exhaustive",
"cian_full_load",
"avito_newbuilding_sweep",
"rosreestr_dkp_import",
]
ignoring = [
"proxy_healthcheck",
"deactivate_stale_avito",
"refresh_search_matview",
"sber_index_pull",
"yandex_newbuilding_sweep",
"house_imv_backfill",
]
assert [s for s in honoring if not runs_mod.honors_cancel(s)] == []
assert [s for s in ignoring if runs_mod.honors_cancel(s)] == []
def test_row_carries_cancellable_so_ui_hides_the_button(client_factory) -> None:
"""Строка отдаёт cancellable — без него UI показал бы «Отменить» у любого
running-прогона, включая proxy_healthcheck (1631 из 3245)."""
from unittest.mock import patch
base = {
"status": "running",
"params": None,
"counters": None,
"total_seen": None,
"new_count": None,
"started_at": None,
"finished_at": None,
"heartbeat_at": None,
"error_text": None,
}
rows = [
{"run_id": 1, "source": "avito_city_sweep", **base},
{"run_id": 2, "source": "proxy_healthcheck", **base},
]
with patch("app.services.scrape_runs.list_all", return_value=(2, rows)):
r = client_factory(MagicMock()).get("/api/v1/admin/scrape/runs")
assert r.status_code == 200
assert [row["cancellable"] for row in r.json()["rows"]] == [True, False]
def test_mark_cancelled_refuses_non_cooperating_source() -> None:
"""Гейт на общем узле всех пяти ручек отмены: 'cancelled' у задачи, которая
отмену не опрашивает, это враньё в статусе И снятие has_running_run-guard
(второй свип на том же прокси бан, инцидент 2026-05-31)."""
db = MagicMock()
db.execute.return_value.fetchone.return_value = MagicMock(source="proxy_healthcheck")
assert runs_mod.mark_cancelled(db, 42) is False
# UPDATE не выполнялся — только SELECT source.
assert db.execute.call_count == 1
db.commit.assert_not_called()
# ══ 1-3. Схема: колонок больше нет, и v_data_quality не рапортует выбросы ═════
def _migration_214() -> str:
matches = sorted(_SQL_DIR.glob("214_*.sql"))
assert matches, "не найдена миграция 214_* (удаление мёртвых показателей #2674)"
return matches[0].read_text(encoding="utf-8")
def test_migration_drops_every_dead_column() -> None:
"""Все пять колонок scrape_runs + listings.is_outlier дропаются идемпотентно."""
sql = _migration_214()
missing = [c for c in _DEAD_RUN_COLUMNS if f"DROP COLUMN IF EXISTS {c}" not in sql]
assert not missing, f"миграция не дропает: {missing}"
assert "DROP COLUMN IF EXISTS is_outlier" in sql
def test_latest_v_data_quality_no_longer_reports_outliers() -> None:
"""Действующее определение v_data_quality (последняя миграция, которая его
создаёт) не упоминает is_outlier.
Red на origin/main: там последним был 095_dead_schema.sql со строкой
`(SELECT count(*) FROM listings WHERE is_outlier = true) AS outliers_flagged`
показатель, который не мог быть ненулевым, потому что колонку не писал никто.
Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой
DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять эту
миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический:
деплой применяет файлы отсортированными, последний по имени последний в проде.
"""
marker = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b")
creators = sorted(p for p in _SQL_DIR.glob("*.sql") if marker.search(p.read_text("utf-8")))
assert creators, "не найдено ни одной миграции, создающей v_data_quality"
latest = creators[-1].read_text(encoding="utf-8")
hit = marker.search(latest)
assert hit is not None
body = latest[hit.end() :].split(";")[0]
assert "outliers_flagged" not in body
assert "is_outlier" not in body

View file

@ -150,7 +150,9 @@ def test_scrape_runs_mark_cancelled_returns_bool() -> None:
from app.services.scrape_runs import mark_cancelled
mock_db = MagicMock()
mock_db.execute.return_value.fetchone.return_value = MagicMock() # row found
# source обязателен: #2674 добавил гейт honors_cancel — отменять можно только то,
# что отмену опрашивает (иначе 'cancelled' у живой задачи + обход has_running_run).
mock_db.execute.return_value.fetchone.return_value = MagicMock(source="avito_city_sweep")
result = mark_cancelled(mock_db, 10)
assert result is True

View file

@ -42,12 +42,15 @@ def client() -> TestClient:
def test_unified_runs_returns_rows_and_total(client: TestClient) -> None:
"""list_all → total + rows; ISO-форматирование дат + error_text/run_type."""
"""list_all → total + rows; ISO-форматирование дат + error_text.
run_type из выдачи убран (#2674): вырожденная колонка (одно значение во всех
3244 прогонах), вид прогона называет source.
"""
fake_rows = [
{
"run_id": 5,
"source": "avito_city_sweep",
"run_type": "city_sweep",
"status": "done",
"params": {"pages_per_anchor": 3},
"counters": {"lots_fetched": 120},
@ -68,7 +71,7 @@ def test_unified_runs_returns_rows_and_total(client: TestClient) -> None:
row = body["rows"][0]
assert row["run_id"] == 5
assert row["source"] == "avito_city_sweep"
assert row["run_type"] == "city_sweep"
assert "run_type" not in row
assert row["total_seen"] == 120
assert row["new_count"] == 30
assert row["error_text"] is None

View file

@ -15,8 +15,9 @@ import {
export interface ScrapeRunFull {
run_id: number;
source: string;
run_type: string | null;
status: string;
/** Джоб этого источника реально опрашивает отмену (бэкенд, scrape_runs.honors_cancel). */
cancellable: boolean;
params: Record<string, unknown> | null;
counters: Record<string, number | string | null> | null;
total_seen: number | null;
@ -32,6 +33,10 @@ interface RunsListResp {
rows: ScrapeRunFull[];
}
interface RunSourcesResp {
sources: string[];
}
// ── Hook ───────────────────────────────────────────────────────────────────
// "skipped" (#2658) — пропущенное расписание (нет кук / уже бежит / нет handler'а);
@ -48,35 +53,35 @@ const RUN_STATUS_ALL = [
] as const;
type RunStatusFilter = (typeof RUN_STATUS_ALL)[number];
// "" means "all sources"; otherwise a specific source prefix (avito / cian / yandex)
const RUN_SOURCE_FILTERS = ["", "avito", "cian", "yandex"] as const;
type RunSourceFilter = (typeof RUN_SOURCE_FILTERS)[number];
// #2674: список источников приходит из данных, а не из литерала. Раньше здесь
// стояли три площадки (avito/cian/yandex), а в scrape_runs 53 разных source и НИ
// ОДНОЙ строки с таким точным значением — каждый пункт фильтра давал пустую
// выдачу, а 76% прогонов (вся площадка Домклик в том числе) отфильтровать было
// нечем. Новый источник теперь появляется в списке сам.
function useScrapeRunSources() {
return useQuery<RunSourcesResp>({
queryKey: ["scrape-run-sources"],
// Отдельный ключ (не зависит от фильтров) — иначе список схлопывался бы при
// каждой смене фильтра, пока летит запрос.
queryFn: () =>
apiFetch<RunSourcesResp>("/api/v1/admin/scrape/runs/sources"),
staleTime: 60_000,
retry: 1,
});
}
const SOURCE_FILTER_LABELS: Record<RunSourceFilter, string> = {
"": "Все",
avito: "Avito",
cian: "Cian",
yandex: "Yandex",
};
function useScraperRuns(
source: ScraperSource,
status: RunStatusFilter,
sourceFilter: RunSourceFilter,
limit = 20,
) {
// limit=50 (API допускает 200): при выдаче по всем источникам первые 20 строк по
// started_at — на три четверти сердцебиение proxy_healthcheck (1631 прогон из 3245),
// и часовой сбор мог не поместиться на страницу (#2674).
function useScraperRuns(status: RunStatusFilter, sourceFilter: string, limit = 50) {
return useQuery<RunsListResp>({
queryKey: ["scrape-runs", source, status, sourceFilter, limit],
queryKey: ["scrape-runs", status, sourceFilter, limit],
queryFn: () => {
const qs = new URLSearchParams({ limit: String(limit) });
// When a specific sourceFilter is chosen, ignore the tab-level source
// and pass it verbatim as the ?source= param
if (sourceFilter) {
qs.set("source", sourceFilter);
} else {
// fallback: filter by the current tab provider
qs.set("source", source);
}
// Пусто = «Все», и это буквально все источники. Раньше пустой выбор молча
// подставлял source вкладки ("avito"), которого в данных не существует, —
// подпись «Все» показывала ноль прогонов (#2674).
if (sourceFilter) qs.set("source", sourceFilter);
if (status) qs.set("status", status);
return apiFetch<RunsListResp>(
`/api/v1/admin/scrape/runs?${qs.toString()}`,
@ -186,16 +191,19 @@ interface RunsTableProps {
export function RunsTable({ source }: RunsTableProps) {
const [statusFilter, setStatusFilter] = useState<RunStatusFilter>("");
const [sourceFilter, setSourceFilter] = useState<RunSourceFilter>("");
const [sourceFilter, setSourceFilter] = useState<string>("");
const qc = useQueryClient();
const runsQ = useScraperRuns(source, statusFilter, sourceFilter);
const runsQ = useScraperRuns(statusFilter, sourceFilter);
const sourcesQ = useScrapeRunSources();
// mark_cancelled(run_id) source-агностичен — отменяется тот прогон, что выбран
// в таблице, независимо от вкладки, с которой нажали.
const cancelMut = useCancelCitySweep(source);
function handleCancel(runId: number) {
cancelMut.mutate(runId, {
onSuccess: () => {
void qc.invalidateQueries({
queryKey: ["scrape-runs", source, statusFilter, sourceFilter],
queryKey: ["scrape-runs", statusFilter, sourceFilter],
});
},
});
@ -205,7 +213,7 @@ export function RunsTable({ source }: RunsTableProps) {
<section className="scraper-section">
<h2>История прогонов</h2>
<p className="scraper-hint">
Последние 20 прогонов. Автообновление каждые 8 сек.
Последние 50 прогонов по всем источникам. Автообновление каждые 8 сек.
</p>
{/* Source filter */}
@ -219,7 +227,7 @@ export function RunsTable({ source }: RunsTableProps) {
<select
id="runs-source-filter"
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value as RunSourceFilter)}
onChange={(e) => setSourceFilter(e.target.value)}
style={{
fontSize: "0.8rem",
padding: "3px 8px",
@ -231,12 +239,18 @@ export function RunsTable({ source }: RunsTableProps) {
}}
aria-label="Фильтр по источнику"
>
{RUN_SOURCE_FILTERS.map((sf) => (
<option key={sf || "_all"} value={sf}>
{SOURCE_FILTER_LABELS[sf]}
<option value="">Все</option>
{(sourcesQ.data?.sources ?? []).map((sf) => (
<option key={sf} value={sf}>
{sf}
</option>
))}
</select>
{sourcesQ.isError && (
<span style={{ fontSize: "0.75rem", color: "var(--danger, #b3261e)" }}>
список источников не загрузился фильтр показывает только «Все»
</span>
)}
</div>
{/* Status filter */}
@ -301,7 +315,10 @@ export function RunsTable({ source }: RunsTableProps) {
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Тип</th>
{/* #2674: было «Тип» = run_type, а он у всех 3244 прогонов один
('city_sweep' по DEFAULT) и подписывал так даже
proxy_healthcheck. Что бежало говорит source. */}
<th scope="col">Источник</th>
<th scope="col">Статус</th>
<th scope="col">Старт</th>
<th scope="col">Финиш</th>
@ -323,7 +340,7 @@ export function RunsTable({ source }: RunsTableProps) {
color: "var(--fg-secondary, #5b6066)",
}}
>
{r.run_type ?? "sweep"}
{r.source}
</span>
</td>
<td>
@ -365,7 +382,9 @@ export function RunsTable({ source }: RunsTableProps) {
)}
</td>
<td>
{r.status === "running" && (
{/* cancellable от бэкенда (#2674): у остальных источников
отмена поставила бы статус 'cancelled' работающей задаче. */}
{r.status === "running" && r.cancellable && (
<button
type="button"
className="cancel-btn"

View file

@ -216,7 +216,9 @@ def _alert_on_run_id(
def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int:
"""INSERT scrape_runs(source, status='running', params, started_at=NOW()).
run_type DEFAULT 'city_sweep' (из 051 миграции).
Вид прогона несёт сам `source` (avito_city_sweep / domclick_detail_backfill / );
отдельной колонки run_type больше нет она 3244 прогона подряд молчала
дефолтом 'city_sweep' и подписывала им, например, proxy_healthcheck (#2674).
Returns run_id (bigint).
"""
row = db.execute(
@ -549,7 +551,7 @@ def list_all(
db.execute(
text(
f"""
SELECT id AS run_id, source, run_type, status, params, counters,
SELECT id AS run_id, source, status, params, counters,
total_seen, new_count, started_at, finished_at,
heartbeat_at, error AS error_text
FROM scrape_runs