diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index f536f684..3777218c 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -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" diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index 39c91d5a..b89812e1 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -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] diff --git a/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql b/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql new file mode 100644 index 00000000..5b8095e6 --- /dev/null +++ b/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql @@ -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; diff --git a/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py new file mode 100644 index 00000000..ced39ac9 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_city_sweep.py b/tradein-mvp/backend/tests/test_city_sweep.py index 2e763b20..3f132e1f 100644 --- a/tradein-mvp/backend/tests/test_city_sweep.py +++ b/tradein-mvp/backend/tests/test_city_sweep.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py index ab6d29f7..1a2db7e6 100644 --- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py +++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py @@ -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 diff --git a/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx b/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx index 3544eb47..ceb10b56 100644 --- a/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx +++ b/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx @@ -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 | null; counters: Record | 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({ + queryKey: ["scrape-run-sources"], + // Отдельный ключ (не зависит от фильтров) — иначе список схлопывался бы при + // каждой смене фильтра, пока летит запрос. + queryFn: () => + apiFetch("/api/v1/admin/scrape/runs/sources"), + staleTime: 60_000, + retry: 1, + }); +} -const SOURCE_FILTER_LABELS: Record = { - "": "Все", - 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({ - 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( `/api/v1/admin/scrape/runs?${qs.toString()}`, @@ -186,16 +191,19 @@ interface RunsTableProps { export function RunsTable({ source }: RunsTableProps) { const [statusFilter, setStatusFilter] = useState(""); - const [sourceFilter, setSourceFilter] = useState(""); + const [sourceFilter, setSourceFilter] = useState(""); 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) {

История прогонов

- Последние 20 прогонов. Автообновление каждые 8 сек. + Последние 50 прогонов по всем источникам. Автообновление каждые 8 сек.

{/* Source filter */} @@ -219,7 +227,7 @@ export function RunsTable({ source }: RunsTableProps) { + {sourcesQ.isError && ( + + список источников не загрузился — фильтр показывает только «Все» + + )} {/* Status filter */} @@ -301,7 +315,10 @@ export function RunsTable({ source }: RunsTableProps) { # - Тип + {/* #2674: было «Тип» = run_type, а он у всех 3244 прогонов один + ('city_sweep' по DEFAULT) и подписывал так даже + proxy_healthcheck. Что бежало — говорит source. */} + Источник Статус Старт Финиш @@ -323,7 +340,7 @@ export function RunsTable({ source }: RunsTableProps) { color: "var(--fg-secondary, #5b6066)", }} > - {r.run_type ?? "sweep"} + {r.source} @@ -365,7 +382,9 @@ export function RunsTable({ source }: RunsTableProps) { )} - {r.status === "running" && ( + {/* cancellable — от бэкенда (#2674): у остальных источников + отмена поставила бы статус 'cancelled' работающей задаче. */} + {r.status === "running" && r.cancellable && (