fix(tradein/admin): убрать показатели, которые не могут быть ненулевыми, и брать список источников из данных (#2674) #2684
8 changed files with 583 additions and 49 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
116
tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql
Normal file
116
tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql
Normal 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;
|
||||
299
tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py
Normal file
299
tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue