Merge pull request 'fix(mera): витрина сделок лэндинга — в расписание, дата прогона на страницу, монитор свежести (#3469)' (#3509) from fix/3469-showcase-schedule into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m16s
Deploy Trade-In / test (push) Successful in 3m47s
Deploy Trade-In / build-backend (push) Successful in 1m6s
Deploy Trade-In / deploy (push) Successful in 2m37s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
Deploy Trade-In / deploy-status (push) Successful in 1s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m16s
Deploy Trade-In / test (push) Successful in 3m47s
Deploy Trade-In / build-backend (push) Successful in 1m6s
Deploy Trade-In / deploy (push) Successful in 2m37s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
Deploy Trade-In / deploy-status (push) Successful in 1s
This commit is contained in:
commit
8f06373e2f
7 changed files with 509 additions and 8 deletions
|
|
@ -325,6 +325,34 @@ async def _job_landing_stats(
|
||||||
await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params)
|
await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params)
|
||||||
|
|
||||||
|
|
||||||
|
# ── landing_showcase_deals — sync пересчёт витрины сделок в executor ─────────
|
||||||
|
async def _job_landing_showcase_deals(
|
||||||
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
) -> None:
|
||||||
|
"""Пересчёт витрины сделок публичного лэндинга (#3469).
|
||||||
|
|
||||||
|
ЛАЙФСАЙКЛ ПРОГОНА ВЕДЁТ HANDLER, а не задача. `refresh_landing_showcase_deals`
|
||||||
|
писалась под ручной запуск (`python -m app.tasks.landing_showcase_deals`) и про
|
||||||
|
`run_id` ничего не знает — тот же случай, что у `_job_refresh_search_matview`,
|
||||||
|
и решается так же: done/failed ставим здесь.
|
||||||
|
|
||||||
|
Параметры берём ИЗ РАСПИСАНИЯ только те, что в нём есть: дефолты живут в
|
||||||
|
сигнатуре задачи, и повтор их здесь дал бы два места, которые разъедутся.
|
||||||
|
"""
|
||||||
|
from app.tasks.landing_showcase_deals import refresh_landing_showcase_deals
|
||||||
|
|
||||||
|
kwargs = {k: params[k] for k in ("sample", "since", "limit", "city") if k in params}
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
counters = await loop.run_in_executor(
|
||||||
|
None, lambda: refresh_landing_showcase_deals(db, **kwargs)
|
||||||
|
)
|
||||||
|
ctx.runs.mark_done(db, run_id, counters)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id)
|
||||||
|
ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {})
|
||||||
|
|
||||||
|
|
||||||
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
|
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
|
||||||
async def _job_sber_freshness_monitor(
|
async def _job_sber_freshness_monitor(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
|
@ -911,6 +939,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
||||||
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
|
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
|
||||||
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
|
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
|
||||||
"landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"),
|
"landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"),
|
||||||
|
"landing_showcase_deals": Handler(_job_landing_showcase_deals, "landing_showcase_deals"),
|
||||||
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
|
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
|
||||||
"yandex_newbuilding_sweep": Handler(
|
"yandex_newbuilding_sweep": Handler(
|
||||||
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
|
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
-- 303_scrape_schedules_seed_landing_showcase_deals.sql
|
||||||
|
-- Расписание для пересчёта витрины сделок публичного лэндинга (issue #3469).
|
||||||
|
--
|
||||||
|
-- ЧТО БЫЛО. Задача `landing_showcase_deals` (миграции 276/277, таблицы
|
||||||
|
-- landing_showcase_deals + landing_showcase_runs) в scrape_schedules НЕ СТОЯЛА:
|
||||||
|
-- `SELECT * FROM scrape_schedules WHERE source LIKE '%showcase%'` — 0 строк
|
||||||
|
-- (замер на проде 12.09.2026). Пересчёт был ручным шагом, и за всё время его
|
||||||
|
-- запускали четырежды; на 12.09 лэндинг показывал прогон от 30.08 — тринадцать
|
||||||
|
-- суток. Handler в реестре тоже отсутствовал, то есть строка расписания без
|
||||||
|
-- него не помогла бы: обе половины регистрации задачи (Handler в
|
||||||
|
-- app/services/product_handlers.py + вот эта строка) едут одним PR.
|
||||||
|
--
|
||||||
|
-- ТАКТ — СУТКИ, И СЧИТАЕТСЯ ОН НЕ ОТ ДАННЫХ, А ОТ КОДА.
|
||||||
|
-- Вход витрины — ДКП-сделки Росреестра, они приезжают ПОКВАРТАЛЬНО, и по
|
||||||
|
-- входу хватило бы такта в квартал. Но витрина показывает не сделки, а
|
||||||
|
-- РАСХОЖДЕНИЕ прогноза МЕРЫ с ценой сделки, а прогноз пересчитывается тем же
|
||||||
|
-- спайном оценщика, что и боевой расчёт: любая правка оценщика, коэффициентов
|
||||||
|
-- СберИндекса, набора активных объявлений или правила отбора (миграция 276,
|
||||||
|
-- полоса −5..+20 % от 12.09.2026) меняет ЧИСЛА на странице, не трогая ни одной
|
||||||
|
-- сделки. Деплой у продукта чаще, чем квартал, — поэтому такт суточный: столько
|
||||||
|
-- живёт окно «код уже другой, а витрина ещё прежняя». Прогон дешёвый и без
|
||||||
|
-- внешних вызовов (200 сделок через спайн + запись 20 строк, ~минуты CPU
|
||||||
|
-- ночью), так что цена суточного такта — та же, что у соседнего
|
||||||
|
-- landing_stats_refresh (миграция 275).
|
||||||
|
--
|
||||||
|
-- ЭТА ЖЕ СТРОКА ЗАВОДИТ ВИТРИНУ В МОНИТОР СВЕЖЕСТИ. Сводка просроченных
|
||||||
|
-- источников (`emit_stale_digest`, scraper_kit/orchestration/scheduler.py,
|
||||||
|
-- #2670) ходит по ВКЛЮЧЁННЫМ расписаниям и бьёт тревогу (logger.error →
|
||||||
|
-- GlitchTip), когда источник не приносил данных дольше
|
||||||
|
-- STALE_DIGEST_INTERVAL_FACTOR × его такта — то есть здесь дольше ТРЁХ СУТОК.
|
||||||
|
-- Отдельного монитора для витрины не заводится намеренно: её молчание было
|
||||||
|
-- невидимо ровно потому, что источника не существовало для сводки, а не потому,
|
||||||
|
-- что сводка не умеет про него говорить (живой пример с прода 12.09.2026:
|
||||||
|
-- «1 источников не собирают дольше 3× своего такта — avito_newbuilding_sweep
|
||||||
|
-- 3.5d/1d»). interval_days в default_params стоит ЯВНО — им же сводка считает
|
||||||
|
-- порог (`_schedule_interval_days`), и умолчание «1» лучше не подразумевать.
|
||||||
|
--
|
||||||
|
-- ОКНО 06:00–07:00 UTC (11:00–12:00 по Екатеринбургу): после импорта сделок
|
||||||
|
-- Росреестра (rosreestr_dkp_import, окно 04–06) и после landing_stats_refresh
|
||||||
|
-- (05–06) — витрина считается по уже обновлённым за ночь данным; и за два часа
|
||||||
|
-- до deals_freshness_monitor (08–09), так что утренний пересчёт успевает
|
||||||
|
-- сняться с просрочки до утренней же проверки.
|
||||||
|
--
|
||||||
|
-- enabled=true — как у landing_stats_refresh: задача только читает базу и
|
||||||
|
-- перезаписывает две свои маленькие таблицы, внешних вызовов нет, цена ошибки —
|
||||||
|
-- минуты CPU. Дожидаться ручного включения тут значило бы оставить дефект
|
||||||
|
-- #3469 на месте, просто под другой причиной.
|
||||||
|
--
|
||||||
|
-- next_run_at на завтра 06:00 UTC — прогон не выстреливает в момент деплоя
|
||||||
|
-- (образец: 162_seed_deals_freshness_monitor.sql, 275_landing_stats.sql).
|
||||||
|
--
|
||||||
|
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 276/277
|
||||||
|
-- (таблицы витрины), Handler 'landing_showcase_deals' в product_handlers.py.
|
||||||
|
-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
-- Конвенция проекта (#2752): блокирующий DDL/DML под lock_timeout.
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
INSERT INTO scrape_schedules (
|
||||||
|
source,
|
||||||
|
enabled,
|
||||||
|
window_start_hour,
|
||||||
|
window_end_hour,
|
||||||
|
next_run_at,
|
||||||
|
default_params
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'landing_showcase_deals',
|
||||||
|
true,
|
||||||
|
6,
|
||||||
|
7,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
|
||||||
|
'{"interval_days": 1, "sample": 200, "limit": 20}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (source) DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -38,6 +38,7 @@ tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard
|
||||||
tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal
|
tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal
|
||||||
tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent
|
tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent
|
||||||
tests/test_user_events.py::test_real_record_event_inserts_row
|
tests/test_user_events.py::test_real_record_event_inserts_row
|
||||||
|
tests/test_3469_showcase_schedule.py::test_live_migration_puts_showcase_into_schedules_and_digest
|
||||||
|
|
||||||
# Приватность/ретеншн (#2547) — тот же `_live_session()`. Приехали в main
|
# Приватность/ретеншн (#2547) — тот же `_live_session()`. Приехали в main
|
||||||
# параллельно с самим списком, поэтому первым же прогоном deploy-лэйна хук их и
|
# параллельно с самим списком, поэтому первым же прогоном deploy-лэйна хук их и
|
||||||
|
|
|
||||||
324
tradein-mvp/backend/tests/test_3469_showcase_schedule.py
Normal file
324
tradein-mvp/backend/tests/test_3469_showcase_schedule.py
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
"""Витрина сделок лэндинга попадает в расписание и в монитор свежести (#3469).
|
||||||
|
|
||||||
|
ЧТО БЫЛО СЛОМАНО. `landing_showcase_deals` считает витрину публичного лэндинга
|
||||||
|
(«МЕРА сказала X — продали за Y»), но в `scrape_schedules` строки для неё не было
|
||||||
|
вовсе (`WHERE source LIKE '%showcase%'` — 0 строк на проде 12.09.2026), а в
|
||||||
|
реестре `product_handlers` — обработчика. То есть планировщик про задачу не знал
|
||||||
|
ни с какой стороны, пересчёт был ручным, и страница показывала прогон
|
||||||
|
тринадцатисуточной давности. Заметить это было неоткуда: под таблицей печатались
|
||||||
|
счётчики прогона, но не его дата, а сводка просроченных источников
|
||||||
|
(`emit_stale_digest`, #2670) ходит по ВКЛЮЧЁННЫМ РАСПИСАНИЯМ — источника, которого
|
||||||
|
в таблице нет, для неё не существует.
|
||||||
|
|
||||||
|
ЧТО ПРОВЕРЯЕТСЯ ЗДЕСЬ, И ПОЧЕМУ ИМЕННО ЭТО.
|
||||||
|
|
||||||
|
1. Обработчик резолвится ТЕМ ЖЕ `resolve_handler`, которым его ищет боевой
|
||||||
|
`_dispatch`. Одной строки расписания мало: без обработчика планировщик
|
||||||
|
нашёл бы задачу и не смог её запустить.
|
||||||
|
2. Миграция 303 сеет строку, и сеет её ВКЛЮЧЁННОЙ с явным `interval_days` —
|
||||||
|
сводка считает порог просрочки из этого же числа.
|
||||||
|
3. Сводка краснеет, когда витрина не пересчитывалась дольше ТРЁХ тактов
|
||||||
|
(приёмка #3469), и молчит на двух. Число тактов здесь — литерал, а такт
|
||||||
|
читается из миграции: ожидание, взятое из той же настройки, которую
|
||||||
|
проверяешь, уезжает вместе с ней — см. комментарий при _ACCEPTANCE_CYCLES.
|
||||||
|
4. Живой Postgres (само-скип): миграция реально вставляет строку в таблицу,
|
||||||
|
повторное применение её не задваивает, и настоящий запрос сводки
|
||||||
|
`_STALE_SOURCES_SQL` видит эту строку и отдаёт витрину просроченной.
|
||||||
|
|
||||||
|
ЖИВОЙ ТЕСТ НЕ УДАЛЯЕТ И НЕ ПРАВИТ НИЧЕГО ЧУЖОГО: он применяет ту же идемпотентную
|
||||||
|
миграцию, что применяет деплой (ON CONFLICT DO NOTHING). В CI он ИДЁТ —
|
||||||
|
ci-tradein.yml поднимает свой Postgres и кладёт DSN в DATABASE_URL; на машине без
|
||||||
|
базы само-скипается (запись в tests/skip_allowlist.txt).
|
||||||
|
|
||||||
|
ГОНЯТЬ ЕГО НАДО НА ПОЛНОЙ СХЕМЕ, А НЕ НА ПУСТОЙ БАЗЕ. На чистой базе он был
|
||||||
|
зелёным и при этом падал в CI: повтор `015_scrape_runs.sql` (его комментарий к
|
||||||
|
колонке, снесённой миграцией 214) на полной схеме валится, а на пустой — нет.
|
||||||
|
Поэтому зависимости применяются только когда таблицы ещё нет, а проверять надо
|
||||||
|
тем же путём, каким гоняет CI:
|
||||||
|
|
||||||
|
docker exec tradein-postgres psql -U tradein -d postgres -c 'CREATE DATABASE t3469full'
|
||||||
|
docker exec -i tradein-postgres psql -U tradein -d t3469full -c \\
|
||||||
|
'CREATE EXTENSION postgis; CREATE EXTENSION pg_trgm; CREATE ROLE gendesign_reader;'
|
||||||
|
for f in $(ls -1 data/sql/*.sql | sort); do docker exec -i tradein-postgres \\
|
||||||
|
psql -U tradein -d t3469full -v ON_ERROR_STOP=on -q < "$f"; done
|
||||||
|
DATABASE_URL="postgresql+psycopg://tradein:tradein@127.0.0.1:5433/t3469full" \\
|
||||||
|
uv run python -m pytest tests/test_3469_showcase_schedule.py -q
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from scraper_kit.orchestration import scheduler as sched
|
||||||
|
|
||||||
|
from app.services.product_handlers import _job_landing_showcase_deals, build_product_handlers
|
||||||
|
|
||||||
|
SOURCE = "landing_showcase_deals"
|
||||||
|
|
||||||
|
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
||||||
|
_MIGRATION = _SQL_DIR / "303_scrape_schedules_seed_landing_showcase_deals.sql"
|
||||||
|
# Таблицы, без которых строку расписания некуда класть (FK scrape_schedules →
|
||||||
|
# scrape_runs), — живой тест применяет их в том же порядке, что и деплой.
|
||||||
|
_DEPS = [
|
||||||
|
_SQL_DIR / "015_scrape_runs.sql",
|
||||||
|
# counters jsonb — по нему сводка судит, принёс ли прогон данные.
|
||||||
|
_SQL_DIR / "051_scrape_runs_extend.sql",
|
||||||
|
_SQL_DIR / "052_scrape_schedules.sql",
|
||||||
|
]
|
||||||
|
|
||||||
|
NOW = datetime(2026, 9, 12, 8, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_sql() -> str:
|
||||||
|
return _MIGRATION.read_text("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _seeded_interval_days() -> int:
|
||||||
|
"""Такт из САМОЙ миграции — порог сводки считается из него, не из литерала."""
|
||||||
|
m = re.search(r'"interval_days"\s*:\s*(\d+)', _migration_sql())
|
||||||
|
assert m is not None, "в default_params миграции 303 нет interval_days"
|
||||||
|
return int(m.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. Планировщик видит задачу ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_handler_resolves_for_scheduler() -> None:
|
||||||
|
"""`resolve_handler` находит витрину — тем же вызовом, что и боевой _dispatch.
|
||||||
|
|
||||||
|
Ломать так: убрать ключ из реестра в product_handlers — тест покраснеет, а
|
||||||
|
планировщик на проде заклеймил бы прогон и не нашёл, чем его выполнить.
|
||||||
|
"""
|
||||||
|
registry = build_product_handlers(ctx=None) # type: ignore[arg-type]
|
||||||
|
handler = sched.resolve_handler(SOURCE, registry)
|
||||||
|
assert handler is not None, f"{SOURCE} не резолвится реестром — задача невидима"
|
||||||
|
# СРАВНИВАЕМ САМ JOB, А НЕ `log_name`: имя — второй литерал конструктора
|
||||||
|
# Handler, и правильный ключ с чужим телом (`_job_landing_stats` под ключом
|
||||||
|
# витрины) проходил проверку по имени насквозь. Резолв ведёт к пересчёту
|
||||||
|
# витрины или не ведёт — это свойство функции, а не подписи в логе.
|
||||||
|
assert handler.job is _job_landing_showcase_deals, (
|
||||||
|
f"под ключом {SOURCE} стоит чужой job: {handler.job.__name__}"
|
||||||
|
)
|
||||||
|
assert handler.log_name == SOURCE
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. Миграция сеет строку ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_303_exists() -> None:
|
||||||
|
assert _MIGRATION.is_file(), f"missing migration: {_MIGRATION}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_303_seeds_source_enabled() -> None:
|
||||||
|
sql = _migration_sql()
|
||||||
|
assert f"'{SOURCE}'" in sql
|
||||||
|
assert "INSERT INTO scrape_schedules" in sql
|
||||||
|
# enabled=true — иначе сводка просроченных источников строку не увидит
|
||||||
|
# (_STALE_SOURCES_SQL: WHERE sch.enabled), и монитор молчал бы как раньше.
|
||||||
|
assert re.search(rf"'{SOURCE}',\s*\n\s*true", sql), "расписание засеяно выключенным"
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_303_is_idempotent_and_transactional() -> None:
|
||||||
|
sql = _migration_sql()
|
||||||
|
assert "ON CONFLICT (source) DO NOTHING" in sql
|
||||||
|
assert "BEGIN;" in sql
|
||||||
|
assert "COMMIT;" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_303_no_psycopg_trap() -> None:
|
||||||
|
assert not re.search(r":\w+::", _migration_sql())
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_303_interval_days_is_daily() -> None:
|
||||||
|
"""Такт суточный: витрина устаревает от КОДА (деплой), а не от квартальных сделок."""
|
||||||
|
assert _seeded_interval_days() == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. Сводка свежести краснеет на молчащей витрине ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _row(age_days: float, *, status: str | None = "done") -> Any:
|
||||||
|
"""Строка `_STALE_SOURCES_SQL`: прогон витрины `age_days` суток назад.
|
||||||
|
|
||||||
|
`status=None` (LEFT JOIN не нашёл прогонов) — витрину не пересчитывали ни разу
|
||||||
|
с момента появления расписания; тогда возраст считается от created_at строки.
|
||||||
|
"""
|
||||||
|
finished = None if status is None else NOW - timedelta(days=age_days)
|
||||||
|
return SimpleNamespace(
|
||||||
|
source=SOURCE,
|
||||||
|
interval_days=str(_seeded_interval_days()),
|
||||||
|
created_at=NOW - timedelta(days=age_days),
|
||||||
|
finished_at=finished,
|
||||||
|
status=status,
|
||||||
|
counters={"considered": 200, "eligible": 161, "written": 20},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stale_now(rows: list[Any]) -> list[sched.StaleSource]:
|
||||||
|
return sched.stale_sources(sched.freshness_rows(rows), NOW)
|
||||||
|
|
||||||
|
|
||||||
|
# Приёмка issue #3469 дословно: «отсутствие прогона дольше 3× такта даёт тревогу».
|
||||||
|
# ЧИСЛО ЗДЕСЬ ЛИТЕРАЛ, А НЕ `sched.STALE_DIGEST_INTERVAL_FACTOR`. Взятое из той же
|
||||||
|
# настройки, которую проверяем, ожидание уезжает вместе с ней: при факторе 3650
|
||||||
|
# ЭТИ ЖЕ тесты оставались зелёными (проверено руками), то есть проверяли ровно
|
||||||
|
# ничего. Такт (`interval_days`) при этом читается из миграции — правило «3×»
|
||||||
|
# и задано в тактах, а не в сутках.
|
||||||
|
_ACCEPTANCE_CYCLES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_flags_showcase_after_three_cycles() -> None:
|
||||||
|
"""Нет пересчёта дольше 3× такта → витрина в сводке просроченных."""
|
||||||
|
lag = _ACCEPTANCE_CYCLES * _seeded_interval_days() + 0.5
|
||||||
|
stale = _stale_now([_row(lag)])
|
||||||
|
assert [s.source for s in stale] == [SOURCE], (
|
||||||
|
f"витрина молчит {lag} суток при такте {_seeded_interval_days()} и не в тревоге"
|
||||||
|
)
|
||||||
|
assert stale[0].interval_days == _seeded_interval_days()
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_silent_within_cycle() -> None:
|
||||||
|
"""Контроль: два такта — ещё норма, иначе тревога кричала бы всегда."""
|
||||||
|
lag = 2 * _seeded_interval_days()
|
||||||
|
assert _stale_now([_row(lag)]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_flags_showcase_that_never_ran() -> None:
|
||||||
|
"""Расписание есть, прогонов нет — самый частый вид молчания (#3469 и был им)."""
|
||||||
|
lag = _ACCEPTANCE_CYCLES * _seeded_interval_days() + 1
|
||||||
|
stale = _stale_now([_row(lag, status=None)])
|
||||||
|
assert [s.source for s in stale] == [SOURCE]
|
||||||
|
assert stale[0].never_ok is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_showcase_counters_do_not_fake_freshness() -> None:
|
||||||
|
"""Свежесть даёт ПРОГОН, а не его счётчики.
|
||||||
|
|
||||||
|
`run_brought_data` судит по результатным ключам kit'а, а у витрины их нет
|
||||||
|
(`considered`/`eligible`/`written` — свой словарь). Значит мерой остаётся
|
||||||
|
успешный статус: прогон, свалившийся в failed, свежести не даёт.
|
||||||
|
"""
|
||||||
|
assert sched.run_brought_data("done", {"considered": 200, "written": 20}) is True
|
||||||
|
assert sched.run_brought_data("failed", {"considered": 200, "written": 0}) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. Живая БД: строка реально ложится в таблицу ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _live_session() -> Any | None:
|
||||||
|
"""Session на живой Postgres — та же проба, что у соседних живых тестов.
|
||||||
|
|
||||||
|
`TEST_DATABASE_URL` имеет приоритет; `localhost:5432/test` — заглушка модулей,
|
||||||
|
её не считаем базой. В CI сюда приезжает DSN поднятого в job'е контейнера
|
||||||
|
(ci-tradein.yml), поэтому проверка там ИДЁТ, а не тихо скипается.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||||
|
if not dsn or "localhost:5432/test" in dsn:
|
||||||
|
return None
|
||||||
|
engine = create_engine(dsn, future=True)
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
return sessionmaker(bind=engine, future=True)()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(db: Any, path: Path) -> None:
|
||||||
|
"""Прогнать файл миграции целиком, одним куском — как `psql -f` на деплое.
|
||||||
|
|
||||||
|
Через ДРАЙВЕРНОЕ соединение, а не `exec_driver_sql`: последний отдаёт текст
|
||||||
|
psycopg вместе с пустым набором параметров, и тот начинает искать в нём
|
||||||
|
плейсхолдеры — любой процент в комментарии миграции («полоса −5..+20 %»)
|
||||||
|
роняет запуск ошибкой про `%`. psql такого разбора не делает, так что это
|
||||||
|
артефакт теста, а не свойство файла.
|
||||||
|
"""
|
||||||
|
db.connection().connection.driver_connection.execute(path.read_text("utf-8"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||||
|
def test_live_migration_puts_showcase_into_schedules_and_digest() -> None:
|
||||||
|
"""Миграция кладёт строку в scrape_schedules, и сводка видит витрину просроченной.
|
||||||
|
|
||||||
|
Значение, а не текст файла: применяем 303 на живой базе (дважды — дублей быть
|
||||||
|
не должно), читаем строку обратно и прогоняем настоящий `_STALE_SOURCES_SQL` —
|
||||||
|
тот же запрос, которым сводка судит на проде.
|
||||||
|
|
||||||
|
Ничего не удаляем: обе миграции идемпотентны (CREATE TABLE IF NOT EXISTS /
|
||||||
|
ON CONFLICT DO NOTHING), то есть повтор здесь — ровно то же действие, что и
|
||||||
|
повторный деплой.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
db = _live_session()
|
||||||
|
assert db is not None
|
||||||
|
try:
|
||||||
|
# Зависимости — ТОЛЬКО на пустой базе. На базе, прошедшей всю цепочку
|
||||||
|
# (CI и прод), повтор 015 падает: `CREATE TABLE IF NOT EXISTS` — no-op,
|
||||||
|
# а `COMMENT ON COLUMN scrape_runs.returning_count` внизу того же файла
|
||||||
|
# обращается к колонке, которую снесла 214. Файл идемпотентен
|
||||||
|
# относительно себя, но не относительно схемы, прошедшей 214, — и
|
||||||
|
# прогон на чистой базе этого не видит по построению.
|
||||||
|
if db.execute(text("SELECT to_regclass('public.scrape_schedules')")).scalar() is None:
|
||||||
|
for dep in _DEPS:
|
||||||
|
_apply(db, dep)
|
||||||
|
_apply(db, _MIGRATION)
|
||||||
|
# ИДЕМПОТЕНТНОСТЬ МЕРЯЕТСЯ ПО СОСТОЯНИЮ СТРОКИ, А НЕ ПО ЧИСЛУ СТРОК.
|
||||||
|
# «DELETE + INSERT» тоже оставляет ровно одну строку, но на КАЖДОМ
|
||||||
|
# деплое стирает last_run_at/next_run_at и взводит расписание заново —
|
||||||
|
# счёт строк такую замену не отличает, а created_at отличает.
|
||||||
|
first_created_at = db.execute(
|
||||||
|
text("SELECT created_at FROM scrape_schedules WHERE source = :s"), {"s": SOURCE}
|
||||||
|
).scalar()
|
||||||
|
_apply(db, _MIGRATION)
|
||||||
|
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT enabled, window_start_hour, window_end_hour, created_at, "
|
||||||
|
" (next_run_at > now()) AS next_run_ahead, "
|
||||||
|
" default_params->>'interval_days' AS interval_days "
|
||||||
|
"FROM scrape_schedules WHERE source = :s"
|
||||||
|
),
|
||||||
|
{"s": SOURCE},
|
||||||
|
).fetchall()
|
||||||
|
assert len(rows) == 1, f"ожидалась одна строка расписания, получено {len(rows)}"
|
||||||
|
row = rows[0]
|
||||||
|
assert row.created_at == first_created_at, (
|
||||||
|
"повторное применение пересоздало строку расписания — на каждом деплое "
|
||||||
|
"это стирало бы состояние прогонов (last_run_at/next_run_at)"
|
||||||
|
)
|
||||||
|
assert row.enabled is True
|
||||||
|
assert (row.window_start_hour, row.window_end_hour) == (6, 7)
|
||||||
|
assert int(row.interval_days) == _seeded_interval_days()
|
||||||
|
# next_run_at в БУДУЩЕМ: сев расписания не должен выстреливать прогоном
|
||||||
|
# в момент деплоя (образец — 162/275). Стоит в приёмке, значит и здесь.
|
||||||
|
assert row.next_run_ahead is True, "next_run_at в прошлом — прогон стартует на деплое"
|
||||||
|
|
||||||
|
# Сводка: прогонов у витрины нет, возраст считается от created_at строки.
|
||||||
|
digest_rows = list(db.execute(sched._STALE_SOURCES_SQL).fetchall())
|
||||||
|
assert SOURCE in {r.source for r in digest_rows}, "сводка не видит витрину"
|
||||||
|
|
||||||
|
overdue_at = row.created_at + timedelta(
|
||||||
|
days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 0.5
|
||||||
|
)
|
||||||
|
stale = sched.stale_sources(sched.freshness_rows(digest_rows), overdue_at)
|
||||||
|
assert SOURCE in {s.source for s in stale}, "витрина без прогонов не попала в тревогу"
|
||||||
|
|
||||||
|
fresh_at = row.created_at + timedelta(hours=1)
|
||||||
|
fresh = sched.stale_sources(sched.freshness_rows(digest_rows), fresh_at)
|
||||||
|
assert SOURCE not in {s.source for s in fresh}, "тревога сразу после сева — ложная"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
@ -97,6 +97,41 @@ describe("витрина лэндинга v3 без данных", () => {
|
||||||
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
|
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ДАТА ПРОГОНА — НА СТРАНИЦЕ, РЯДОМ СО СЧЁТЧИКАМИ (#3469).
|
||||||
|
*
|
||||||
|
* Счётчики («рассмотрено 200, показано 20») не стареют на вид, а строки под
|
||||||
|
* ними стареют: на проде задачи не было в расписании, и витрина тринадцать
|
||||||
|
* суток показывала прогон от 30.08 — неотличимо от вчерашнего. Проверяется
|
||||||
|
* ПО ЗНАЧЕНИЮ: дата берётся из `computed_at` фикстуры, вписать её в разметку
|
||||||
|
* руками и остаться зелёным нельзя — второй кейс отдаёт другую дату.
|
||||||
|
*/
|
||||||
|
it("«Точность»: под таблицей стоит дата пересчёта витрины, и она из данных", () => {
|
||||||
|
const { unmount } = render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
|
||||||
|
expect(screen.getByText(/Витрина пересчитана 29\.08\.2026/u)).toBeTruthy();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AccuracyV3
|
||||||
|
stats={STATS}
|
||||||
|
showcase={{ ...SHOWCASE, computed_at: "2026-09-12T06:12:03+00:00" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Витрина пересчитана 12\.09\.2026/u)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Даты нет — нет и предложения про неё: то же правило «нет величины — нет
|
||||||
|
* подписи», что у всего блока. «Invalid Date» или «null» на публичной
|
||||||
|
* странице хуже отсутствия даты, а «сегодня» на её месте — враньё.
|
||||||
|
*/
|
||||||
|
it("«Точность»: без computed_at подпись не выдумывает дату", () => {
|
||||||
|
render(<AccuracyV3 stats={STATS} showcase={{ ...SHOWCASE, computed_at: null }} />);
|
||||||
|
const note = screen.getByText(/рассмотрено сделок: 4 000/u);
|
||||||
|
expect(note.textContent).not.toMatch(/Витрина пересчитана/u);
|
||||||
|
expect(note.textContent).not.toMatch(/Invalid Date|null|undefined/u);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Снятая величина не должна остаться на экране «по инерции»: её плитку занял
|
* Снятая величина не должна остаться на экране «по инерции»: её плитку занял
|
||||||
* свежий замер, и оговорка про уверенность без своей плитки объясняла бы
|
* свежий замер, и оговорка про уверенность без своей плитки объясняла бы
|
||||||
|
|
@ -136,7 +171,7 @@ describe("витрина лэндинга v3 без данных", () => {
|
||||||
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ ПРОВЕРЯЕТ САМО СЕБЯ ПО ПОКАЗАННЫМ СТРОКАМ.
|
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ ПРОВЕРЯЕТ САМО СЕБЯ ПО ПОКАЗАННЫМ СТРОКАМ.
|
||||||
*
|
*
|
||||||
* Границы стоят в коде фронта, строки приходят из БД от последнего прогона
|
* Границы стоят в коде фронта, строки приходят из БД от последнего прогона
|
||||||
* задачи, а задача в расписании не стоит. Между выкатом фронта и пересчётом
|
* задачи, а такт пересчёта — сутки (#3469). Между выкатом фронта и пересчётом
|
||||||
* витрины на странице лежат СТАРЫЕ строки: на проде 12 из 20 вне полосы,
|
* витрины на странице лежат СТАРЫЕ строки: на проде 12 из 20 вне полосы,
|
||||||
* худшая +75,71 %. Утверждение «показана полоса −5…+20 %» и строка +75,7 %
|
* худшая +75,71 %. Утверждение «показана полоса −5…+20 %» и строка +75,7 %
|
||||||
* под ним — хуже, чем отсутствие утверждения.
|
* под ним — хуже, чем отсутствие утверждения.
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,11 @@
|
||||||
* обещала бы точность, которой никто не мерил.
|
* обещала бы точность, которой никто не мерил.
|
||||||
*
|
*
|
||||||
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ САМОПРОВЕРЯЕМОЕ. Границы стоят в коде фронта, строки
|
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ САМОПРОВЕРЯЕМОЕ. Границы стоят в коде фронта, строки
|
||||||
* приходят из БД от последнего прогона задачи, а задача в расписании не стоит
|
* приходят из БД от последнего прогона задачи, и прогон отстаёт от выката:
|
||||||
* — её запускают руками. В окне «фронт выкачен, витрина не пересчитана»
|
* с #3469 задача стоит в расписании (суточный такт), но между деплоем фронта
|
||||||
* страница утверждала бы полосу над строками прежнего правила, а под
|
* и ближайшим ночным пересчётом окно всё равно есть. В нём страница утверждала
|
||||||
* утверждением стояла бы строка +75,7 % (на проде сейчас 12 таких из 20).
|
* бы полосу над строками прежнего правила, а под утверждением стояла бы
|
||||||
|
* строка +75,7 % (так на проде и было — 12 таких из 20).
|
||||||
* Поэтому обе подписи спрашивают сами строки (`allWithinBand` в `deal-view`):
|
* Поэтому обе подписи спрашивают сами строки (`allWithinBand` в `deal-view`):
|
||||||
* не соответствуют — про полосу не говорим, называем то, что есть, а правило
|
* не соответствуют — про полосу не говорим, называем то, что есть, а правило
|
||||||
* прогона и так печатается рядом и приезжает из ТОГО ЖЕ прогона, что строки.
|
* прогона и так печатается рядом и приезжает из ТОГО ЖЕ прогона, что строки.
|
||||||
|
|
@ -87,6 +88,7 @@ import {
|
||||||
dealTitle,
|
dealTitle,
|
||||||
errPct,
|
errPct,
|
||||||
rub,
|
rub,
|
||||||
|
runDate,
|
||||||
shownSpread,
|
shownSpread,
|
||||||
} from "./deal-view";
|
} from "./deal-view";
|
||||||
|
|
||||||
|
|
@ -174,6 +176,9 @@ export function AccuracyV3({
|
||||||
const deals = showcase?.deals ?? [];
|
const deals = showcase?.deals ?? [];
|
||||||
const showcaseStats = showcase?.stats ?? null;
|
const showcaseStats = showcase?.stats ?? null;
|
||||||
const spread = shownSpread(deals);
|
const spread = shownSpread(deals);
|
||||||
|
// Дата прогона, давшего эти строки. Стоит в той же подписи, что и счётчики:
|
||||||
|
// «рассмотрено 200, показано 20» не стареет на вид, а строки — стареют.
|
||||||
|
const computedOn = runDate(showcase?.computed_at ?? null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
|
@ -271,16 +276,26 @@ export function AccuracyV3({
|
||||||
(`rejection_rule`) приходит из того же прогона и стоит в этой же
|
(`rejection_rule`) приходит из того же прогона и стоит в этой же
|
||||||
подписи — счётчики и правило порознь не показываются.
|
подписи — счётчики и правило порознь не показываются.
|
||||||
*/}
|
*/}
|
||||||
|
{/*
|
||||||
|
ДАТА ПРОГОНА СТОИТ РЯДОМ СО СЧЁТЧИКАМИ (#3469). Счётчики описывают
|
||||||
|
прогон, но не говорят, КОГДА он был, — и замороженная витрина
|
||||||
|
выглядела ровно так же, как вчера пересчитанная. На проде это и
|
||||||
|
случилось: задачи не было в расписании, и страница тринадцать
|
||||||
|
суток показывала строки от 30.08 без единого признака возраста.
|
||||||
|
Даты нет (ручка отдала null) — предложения тоже нет: подставлять
|
||||||
|
на её место «сегодня» нельзя.
|
||||||
|
*/}
|
||||||
<p className={styles.accFootnote}>
|
<p className={styles.accFootnote}>
|
||||||
{showcaseStats
|
{showcaseStats
|
||||||
? `Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} собранных прогоном, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
|
? `${computedOn ? `Витрина пересчитана ${computedOn}. ` : ""}Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} собранных прогоном, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
|
||||||
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
|
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
|
||||||
</p>
|
</p>
|
||||||
{spread && (
|
{spread && (
|
||||||
// УТВЕРЖДЕНИЕ ПРО ПОЛОСУ — САМОПРОВЕРЯЕМОЕ. Границы стоят в коде
|
// УТВЕРЖДЕНИЕ ПРО ПОЛОСУ — САМОПРОВЕРЯЕМОЕ. Границы стоят в коде
|
||||||
// фронта, а строки приходят из БД, из последнего прогона задачи;
|
// фронта, а строки приходят из БД, из последнего прогона задачи;
|
||||||
// задача в расписании не стоит и запускается руками. Между
|
// такт пересчёта — сутки (#3469), то есть строки старше выката
|
||||||
// выкатом и пересчётом страница утверждала бы полосу над
|
// фронта почти всегда. Между выкатом и пересчётом страница
|
||||||
|
// утверждала бы полосу над
|
||||||
// строками, собранными до неё (на проде сейчас 12 из 20 вне
|
// строками, собранными до неё (на проде сейчас 12 из 20 вне
|
||||||
// полосы, худшая +75,7 %) — утверждение и его опровержение в
|
// полосы, худшая +75,7 %) — утверждение и его опровержение в
|
||||||
// одном экране. Поэтому про полосу говорим, только если ни одна
|
// одном экране. Поэтому про полосу говорим, только если ни одна
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,24 @@ export const count = (value: number): string => RUB.format(value);
|
||||||
/** Отклонение прогноза от факта — со знаком: плюс = МЕРА назвала дороже. */
|
/** Отклонение прогноза от факта — со знаком: плюс = МЕРА назвала дороже. */
|
||||||
export const errPct = (value: number): string => `${PCT.format(value)} %`;
|
export const errPct = (value: number): string => `${PCT.format(value)} %`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дата пересчёта витрины — «30.08.2026» из `computed_at`.
|
||||||
|
*
|
||||||
|
* РАЗБОР СТРОКИ, А НЕ `new Date().toLocaleDateString()`: подпись рендерит
|
||||||
|
* серверный компонент, и локальная дата там считалась бы по часовому поясу
|
||||||
|
* КОНТЕЙНЕРА — то есть дата на странице зависела бы от того, где её собрали.
|
||||||
|
* Дальше того же правила держится и формат: `landing-facts` печатает свои
|
||||||
|
* даты замеров так же, разбором ISO.
|
||||||
|
*
|
||||||
|
* `null` на неразобранном входе — та же дисциплина, что у всей витрины: нет
|
||||||
|
* величины, нет подписи. «Invalid Date» на публичной странице хуже её
|
||||||
|
* отсутствия.
|
||||||
|
*/
|
||||||
|
export const runDate = (iso: string | null): string | null => {
|
||||||
|
const m = iso === null ? null : /^(\d{4})-(\d{2})-(\d{2})/u.exec(iso);
|
||||||
|
return m === null ? null : `${m[3]}.${m[2]}.${m[1]}`;
|
||||||
|
};
|
||||||
|
|
||||||
/** «2-к, 52 м²» — всё, что про объект известно наверняка. */
|
/** «2-к, 52 м²» — всё, что про объект известно наверняка. */
|
||||||
/**
|
/**
|
||||||
* Заголовок сделки. `rooms = 0` — это СТУДИЯ, а не «ноль комнат»: так её
|
* Заголовок сделки. `rooms = 0` — это СТУДИЯ, а не «ноль комнат»: так её
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue