fix(tradein/deactivate): resolve migration 264 renumber collision on merge with main
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m27s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m27s
fix/tradein-ttl-effective-cap (PR #2907, merged into main while this branch was in flight) already claimed 264/265 for deactivate_stale_avito_cap_mult / deactivate_stale_yandex_cap_mult. Renumbers this branch's 264_seed_deactivate_stale_null_segment_yandex_cian.sql -> 266_seed_... (git mv + _manifest_applied.txt entry moved after 264/265 + self-references in the migration header and in test_deactivate_stale_listings.py's _MIGRATION_264 constant/test names). Merges main's bool-guard (ttl_days/cap_mult reject bool) + CAP_MULT ceiling + per-source cap_mult calibration with this branch's null_segment_only kwarg (explicit `listing_segment IS NULL` predicate, since ANY(:segments) never matches NULL) -- both features apply to the same deactivate_stale_listings() call site in product_handlers.py and the same function signature/docstring in deactivate_stale_avito.py, so every conflict was signature/docstring-level, not logic-level (git already auto-merged the function body correctly since the two features touch disjoint lines below the signature). Also reconciled the module docstring's "known gap" note (main) to reflect that the NULL-segment slice it measured (cian 211 / yandex 523 rows >60d) is now closed by this migration -- novostroyki stays open, unrelated to this branch.
This commit is contained in:
commit
e00dcac177
9 changed files with 898 additions and 37 deletions
|
|
@ -217,6 +217,7 @@ async def _job_deactivate_stale(
|
|||
) -> None:
|
||||
from app.core.config import settings as _settings
|
||||
from app.tasks.deactivate_stale_avito import (
|
||||
CAP_MULT,
|
||||
DEFAULT_MIN_CONFIRMATIONS,
|
||||
DEFAULT_REVISIT_FLOOR_QUANTILE,
|
||||
deactivate_stale_listings,
|
||||
|
|
@ -241,6 +242,11 @@ async def _job_deactivate_stale(
|
|||
# listing_segment на повторном скрейпе). Отдельный явный предикат IS NULL, а не
|
||||
# элемент :segments (ANY(...) никогда не матчит NULL) -- см. deactivate_stale_avito.py.
|
||||
null_segment_only: bool = params.get("null_segment_only", False)
|
||||
# Потолок эффективного TTL (см. CAP_MULT в deactivate_stale_avito.py) — множитель,
|
||||
# а не голая константа: источник с непропорционально длинным хвостом переобхода
|
||||
# относительно своего ttl_days переопределяет его через default_params (ключ
|
||||
# "cap_mult"), не трогая дефолт для остальных источников.
|
||||
cap_mult: float = params.get("cap_mult", CAP_MULT)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
|
|
@ -255,6 +261,7 @@ async def _job_deactivate_stale(
|
|||
min_confirmations=min_confirmations,
|
||||
revisit_floor_quantile=revisit_floor_quantile,
|
||||
null_segment_only=null_segment_only,
|
||||
cap_mult=cap_mult,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,22 @@
|
|||
- Cian/Yandex не поддерживают full-coverage sweep -> паушальный TTL сломает живой
|
||||
инвентарь. DECISION: для yandex/cian деактивировать ТОЛЬКО listing_segment='vtorichka',
|
||||
TTL=30. novostroyki (9659 активных первичных строк) не трогаем.
|
||||
ИЗВЕСТНЫЙ ПРОБЕЛ (ревью TTL-CAP круг 2, 2026-08-15): этот скоуп уже, чем множество
|
||||
реально протухших строк -- живой замер на проде даёт cian/novostroyki 9 483 активных
|
||||
строки старше 60 суток, ни одна из них не деактивируется НИ ОДНОЙ джобой (внутри
|
||||
скоупа cian/vtorichka и yandex/vtorichka таких строк 0). Потолок cap_mult (см.
|
||||
CAP_MULT ниже) этот пробел не закрывает и закрыть не может -- он сжимает пул ВНУТРИ
|
||||
скоупа джобы, а не расширяет сам скоуп. NULL-сегмент (тот же замер круга 2 давал
|
||||
cian/NULL 211, yandex/NULL 523 строки старше 60 суток) закрыт отдельно ниже
|
||||
(null_segment_only, миграция 266) -- novostroyki-часть пробела остаётся: расширение
|
||||
скоупа туда отдельная задача (нужно сперва выяснить, поддерживает ли cian/yandex
|
||||
full-coverage sweep для novostroyki СЕЙЧАС, иначе паушальный TTL повторит инцидент,
|
||||
ради которого этот DECISION и принят) и намеренно НЕ входит в TTL-CAP.
|
||||
- avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений.
|
||||
- NULL-сегмент (легаси-строки до миграции 011 + жертвы бага в ON CONFLICT -- upsert
|
||||
никогда не пишет listing_segment повторно, поэтому раз рождённая NULL-строка сама
|
||||
себя не чинит даже при живой ежедневной досдаче) деактивируется ОТДЕЛЬНОЙ джобой per
|
||||
source (null_segment_only=True, миграция 264): явный `listing_segment IS NULL`
|
||||
source (null_segment_only=True, миграция 266): явный `listing_segment IS NULL`
|
||||
предикат, а не ANY(:segments) -- этот оператор NULL никогда не матчит. Гейт
|
||||
здоровья/пол переобхода для этой джобы выключены (min_confirmations=0,
|
||||
revisit_floor_quantile=0) -- население нерепрезентативно мало (единицы подтверждений
|
||||
|
|
@ -205,6 +216,66 @@ _REVISIT_FLOOR_SEGMENT_FILTER = "\n AND l.listing_segment = ANY(CAST(:s
|
|||
_REVISIT_FLOOR_NULL_SEGMENT_FILTER = "\n AND l.listing_segment IS NULL"
|
||||
|
||||
|
||||
# ── Потолок эффективного TTL (положительная обратная связь пола, найдено 2026-08-15) ──
|
||||
# У пола выше нет верхней границы: max(ttl_days, пол) может расти неограниченно.
|
||||
# ЗАМЕР НА ПРОДЕ (уточнён 2026-08-15 после разбора): у yandex counters держали
|
||||
# ttl_days_effective 75/75/75/39/52/54 шесть прогонов подряд при deactivated=0 —
|
||||
# пол реально разгонялся без верхней границы, и потолок закрывает именно это.
|
||||
# ЧЕГО ПОТОЛОК НЕ ДЕЛАЕТ: он НЕ сжимает пул «активных». Замер показал 0
|
||||
# деактивируемых строк на всех четырёх джобах и до, и после калибровки. Цифра
|
||||
# «23 687 из 44 744 не подтверждались >7 суток» относится ко ВСЕМ источникам
|
||||
# сразу, и две трети её — новостройки, которых оценщик не берёт. У avito
|
||||
# просроченных ноль. Раздутый пул, влияющий на оценку, лежит в строках с ПУСТЫМ
|
||||
# сегментом и чинится отдельной джобой, не этим потолком.
|
||||
#
|
||||
# МЕХАНИЗМ ПЕТЛИ: медленный обход поднимает пол (он же квантиль разрывов переобхода)
|
||||
# -> высокий пол продлевает жизнь снятым лотам дольше, чем к ним успевает вернуться
|
||||
# свежий обход -> пул «активных» раздувается «протухшими» строками -> следующий замер
|
||||
# пола на том же раздутом пуле оказывается ещё выше. Без верхней границы это не
|
||||
# самокорректирующийся пол, а положительная обратная связь.
|
||||
#
|
||||
# CAP_MULT = 2 -- эффективный TTL не может превысить удвоенный заданный оператором
|
||||
# ttl_days. Пол по-прежнему может его поднять (ради #2659 -- см. комментарий выше:
|
||||
# ложные снятия при неполном покрытии обхода), но не бесконечно. Почему именно 2, а
|
||||
# не 3 или 1.5: вдвое — это ещё «мы искренне не уверены, что молчание значит
|
||||
# снятие», не «источник вообще умер». Дальнейший рост пола сигнализирует не о
|
||||
# медленном, но живом обходе, а о мёртвом источнике -- для ЭТОГО случая уже есть
|
||||
# отдельный гейт по здоровью (min_confirmations) выше в этой же функции, который
|
||||
# выключает деактивацию целиком, а не растягивает TTL до бесконечности. Калибровочная
|
||||
# ручка, не догма -- при новом замере можно пересмотреть, как и revisit_floor_quantile.
|
||||
#
|
||||
# ПОЧЕМУ MULT, А НЕ ФИКСИРОВАННОЕ ЧИСЛО СУТОК -- И ГДЕ ЭТА ФОРМА ЛОМАЕТСЯ. Множитель
|
||||
# от ttl_days даёт разный АБСОЛЮТНЫЙ потолок на разных источниках: cian/yandex
|
||||
# (ttl=30) -> 60 суток, avito (ttl=10) -> 20 суток, domklik (ttl=14) -> 28 суток. Это
|
||||
# ломается ровно там, где абсолютный хвост переобхода источника НЕ пропорционален его
|
||||
# ttl_days. Замер (_REVISIT_TAIL, 40 суток): avito p99 = 42.1 сут -- ВЫШЕ его же
|
||||
# потолка 20. То есть для avito дефолтный CAP_MULT=2 может резать ttl ниже
|
||||
# собственного хвоста обхода -- ровно тот false-kill, ради которого пол вообще
|
||||
# заведён (см. комментарий выше). domklik (потолок 28 при хвосте 3.1) разрыва не
|
||||
# имеет -- множитель 2 для него калиброван верно.
|
||||
#
|
||||
# YANDEX -- ТА ЖЕ ДЫРА, НАЙДЕНА ПОЗЖЕ (ревью круга 3, 2026-08-15). Строка выше до
|
||||
# этой правки утверждала, что cian/yandex с потолком 60 тоже в порядке -- это было
|
||||
# верно для cian (live-пол сейчас 31.1), но НЕ для yandex: ЖИВЫЕ полы из
|
||||
# scrape_runs.counters (deactivate_stale_yandex, 2026-08-10..08-15) -- 75/75/75/39/
|
||||
# 52/54, а прямой live-замер той же percentile_disc(0.99)-формулы сегодня даёт 79.2
|
||||
# (n=1961 подтверждений за 3 суток). И то, и другое ВЫШЕ потолка 60 -- тот же
|
||||
# false-kill класс, что у avito, статический p99=43.0 (_REVISIT_TAIL) для yandex
|
||||
# устарел и вводит в заблуждение. cap_mult для yandex откалиброван отдельной
|
||||
# миграцией (265_deactivate_stale_yandex_cap_mult.sql, cap_mult=3 -> потолок 90) --
|
||||
# см. её комментарий про то, почему это НЕ меняет число деактивированных строк
|
||||
# следующим прогоном (0 активных строк источника старше 39 суток на момент замера).
|
||||
#
|
||||
# ПОЭТОМУ cap_mult -- параметр функции (как revisit_floor_quantile, min_confirmations),
|
||||
# не голая константа: default = CAP_MULT для источников, где 2x достаточно (cian,
|
||||
# domklik), но расписание может переопределить через default_params (JSON-колонка
|
||||
# scrape_schedules, ключ "cap_mult") для источника с непропорционально длинным
|
||||
# хвостом -- см. миграции для avito (cap_mult=6, потолок 60, с запасом выше
|
||||
# статического p99=42.1 и живого прод-пика 52, замеренного 2026-08-10..12) и yandex
|
||||
# (cap_mult=3, потолок 90, с запасом выше живого пола 79.2, замеренного 2026-08-15).
|
||||
CAP_MULT = 2
|
||||
|
||||
|
||||
def _build_revisit_floor_sql(
|
||||
staleness_column: str, *, with_segments: bool, null_segment_only: bool = False
|
||||
) -> Any:
|
||||
|
|
@ -378,6 +449,7 @@ def deactivate_stale_listings(
|
|||
health_window_days: int = _HEALTH_WINDOW_DAYS,
|
||||
revisit_floor_quantile: float = 0.0,
|
||||
null_segment_only: bool = False,
|
||||
cap_mult: float = CAP_MULT,
|
||||
) -> dict[str, int]:
|
||||
"""Пометить is_active=false объявления, чья свежесть старше ttl_days дней.
|
||||
|
||||
|
|
@ -401,15 +473,25 @@ def deactivate_stale_listings(
|
|||
health_window_days: окно подтверждений для гейта, суток. Дефолт 3.
|
||||
revisit_floor_quantile: пол TTL по измеренному циклу переобхода (#2659).
|
||||
Квантиль возраста, при котором свип за окно ДОКАЗАЛ строку живой;
|
||||
эффективный TTL = max(ttl_days, этот пол). 0 -> пол выключен (так
|
||||
эффективный TTL = min(max(ttl_days, этот пол), ttl_days * cap_mult) --
|
||||
пол поднимает TTL, но не выше потолка. 0 -> пол выключен (так
|
||||
вызывают старые тесты и совместимая обёртка), рабочее значение —
|
||||
DEFAULT_REVISIT_FLOOR_QUANTILE, см. комментарий выше.
|
||||
null_segment_only: True -> WHERE фильтрует `listing_segment IS NULL` вместо
|
||||
ANY(:segments). Требует segments=None (иначе ValueError -- смешивать
|
||||
бессмысленно, это два непересекающихся среза). Для этого среза гейт/пол
|
||||
обычно держат выключенными (min_confirmations=0, revisit_floor_quantile=0,
|
||||
см. миграцию 264 и модульный докстринг) -- население слишком мало для
|
||||
см. миграцию 266 и модульный докстринг) -- население слишком мало для
|
||||
откалиброванных под полноценный vtorichka-свип порогов.
|
||||
cap_mult: множитель потолка эффективного TTL (см. комментарий у модульной
|
||||
константы CAP_MULT). Дефолт -- сама CAP_MULT=2, но параметр, а НЕ голая
|
||||
константа: источник с непропорционально длинным хвостом переобхода
|
||||
относительно своего ttl_days (avito: p99=42.1 при ttl=10 -> дефолтный
|
||||
потолок 20 режет ниже хвоста) может переопределить его через
|
||||
default_params расписания (ключ "cap_mult"), не трогая остальные
|
||||
источники. Итоговый потолок = ttl_days * cap_mult. Применяется и к
|
||||
null_segment_only-джобе, но там гейт/пол выключены (см. выше), так что
|
||||
на практике не участвует.
|
||||
|
||||
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
|
||||
Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots
|
||||
|
|
@ -418,15 +500,58 @@ def deactivate_stale_listings(
|
|||
Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками).
|
||||
Если гейт не пропустил прогон: {"deactivated": 0, "confirmations": N,
|
||||
"skipped_unhealthy": 1} и НИ ОДНА строка не тронута. Если пол переобхода поднял
|
||||
TTL: дополнительно {"revisit_floor_days": N, "ttl_days_effective": N}.
|
||||
TTL: дополнительно {"revisit_floor_days": N, "ttl_days_effective": N}. Если пол
|
||||
упёрся в потолок cap_mult: дополнительно {"ttl_floor_capped": 1,
|
||||
"ttl_days_floor_raw": N} -- N это то, во что пол поднял бы TTL БЕЗ потолка.
|
||||
|
||||
Raises:
|
||||
ValueError: если staleness_column не входит в whitelist (проверка ДО SQL,
|
||||
никакой интерполяции пользовательского ввода в запрос), либо если заданы
|
||||
одновременно null_segment_only=True и segments (взаимоисключающие срезы).
|
||||
ValueError: если staleness_column не входит в whitelist, ИЛИ ttl_days <= 0
|
||||
(проверка ДО SQL, никакой интерполяции пользовательского ввода в запрос;
|
||||
ttl_days<=0 в WHERE-условии last_seen_at < NOW() - INTERVAL 'N days'
|
||||
матчит практически весь активный пул -- без явного guard'а потолок
|
||||
(ttl_days * cap_mult <= 0) к тому же перебивал бы пол в формуле min(),
|
||||
снимая защиту, которую max(ttl_days, floor) давал раньше), ИЛИ cap_mult < 1
|
||||
(тот же класс дыры, но со стороны потолка, а не пола: cap_mult приходит из
|
||||
jsonb default_params расписания -- ЕДИНСТВЕННЫЙ запланированный способ его
|
||||
задать, т.е. именно там опечатка 0 / 0.5 вместо 6 доходит до прода. cap_mult=0
|
||||
даёт capped=0 -> effective_ttl_days=0 -> UPDATE снимает практически весь
|
||||
активный пул источника; cap_mult<1 (например 0.5) опускает потолок НИЖЕ
|
||||
заданного оператором ttl_days -- прямое нарушение инварианта «потолок не
|
||||
может понизить TTL ниже настроенного», который проверяет
|
||||
test_cap_never_lowers_ttl_below_configured_value), ИЛИ ttl_days/cap_mult --
|
||||
bool (найдено ревью круга 3, 2026-08-15: `cap_mult < 1` пропускает `True` --
|
||||
`bool` наследует `int`, `True < 1` ложно, а `ttl_days * True` == `ttl_days`,
|
||||
то есть потолок = сам ttl_days и пол молча отключается, никакого ValueError.
|
||||
jsonb `true`/`false` вместо числа -- ровно та опечатка в расписании, ради
|
||||
которой оба guard'а вообще написаны, поэтому bool отклоняется явной
|
||||
type-проверкой ДО числового сравнения для обоих параметров), ЛИБО если
|
||||
заданы одновременно null_segment_only=True и segments (взаимоисключающие
|
||||
срезы -- IS NULL и ANY(:segments) не композируются).
|
||||
"""
|
||||
counters: dict[str, int] = {"deactivated": 0}
|
||||
try:
|
||||
# bool -- подкласс int в Python, поэтому `True < 1` (False) и `False <= 0`
|
||||
# (True) НЕ ловят опечатку `"ttl_days": true` / `"cap_mult": true` в jsonb:
|
||||
# `ttl_days * True` == `ttl_days`, `cap_mult=True` даёт потолок == ttl_days и
|
||||
# молча отключает пол (см. Raises выше). Проверка типа -- ДО числового
|
||||
# сравнения, иначе bool проскакивает мимо него необнаруженным.
|
||||
if isinstance(ttl_days, bool):
|
||||
raise ValueError(f"ttl_days must be a number, not bool: {ttl_days!r}")
|
||||
if ttl_days <= 0:
|
||||
raise ValueError(f"ttl_days must be positive, got {ttl_days!r}")
|
||||
|
||||
# Тот же класс дыры, что и ttl_days<=0 выше, только со стороны потолка:
|
||||
# cap_mult < 1 может опустить потолок (ttl_days * cap_mult) НИЖЕ заданного
|
||||
# ttl_days, а cap_mult <= 0 -- сделать капнутый потолок <= 0 и победить пол
|
||||
# в min() молча (ровно та дыра, ради которой заведён guard выше). Единственный
|
||||
# запланированный способ задать cap_mult -- вписать его руками в jsonb
|
||||
# default_params расписания (см. миграцию для avito), т.е. именно там опечатка
|
||||
# 0 / 0.5 вместо 6 -- реальный риск, а не гипотетика.
|
||||
if isinstance(cap_mult, bool):
|
||||
raise ValueError(f"cap_mult must be a number, not bool: {cap_mult!r}")
|
||||
if cap_mult < 1:
|
||||
raise ValueError(f"cap_mult must be >= 1, got {cap_mult!r}")
|
||||
|
||||
# Whitelist-проверка ДО построения/выполнения SQL: только после неё имя колонки
|
||||
# интерполируется f-string'ом. Значения по-прежнему идут через param-binding.
|
||||
# Внутри try -> невалидная колонка финализирует run как failed (mark_failed),
|
||||
|
|
@ -487,7 +612,9 @@ def deactivate_stale_listings(
|
|||
return counters
|
||||
|
||||
# Пол TTL по измеренному циклу переобхода (#2659) — тоже ДО UPDATE и по тому же
|
||||
# срезу. Поднимает порог, никогда не опускает: max(), а не замена.
|
||||
# срезу. Поднимает порог (max), но не выше потолка cap_mult * ttl_days (min) —
|
||||
# см. комментарий у CAP_MULT про петлю с положительной обратной связью и про
|
||||
# то, почему cap_mult -- параметр, а не голая константа.
|
||||
effective_ttl_days = ttl_days
|
||||
if revisit_floor_quantile > 0:
|
||||
floor_params: dict[str, Any] = {
|
||||
|
|
@ -509,9 +636,39 @@ def deactivate_stale_listings(
|
|||
# Тогда пола нет и TTL остаётся как задан: выдумывать пол не из чего.
|
||||
if floor_days is not None:
|
||||
counters["revisit_floor_days"] = ceil(float(floor_days))
|
||||
effective_ttl_days = max(ttl_days, counters["revisit_floor_days"])
|
||||
# Пол поднимает TTL (max), потолок cap_mult его не пускает выше
|
||||
# ttl_days * cap_mult (min) — без этого пол растёт без ограничения
|
||||
# (см. комментарий у CAP_MULT). capped_ttl_days может быть float,
|
||||
# если cap_mult переопределён нецелым значением из default_params —
|
||||
# effective_ttl_days приводим к int (UPDATE ждёт целые сутки).
|
||||
raw_effective_ttl_days = max(ttl_days, counters["revisit_floor_days"])
|
||||
capped_ttl_days = ttl_days * cap_mult
|
||||
effective_ttl_days = int(min(raw_effective_ttl_days, capped_ttl_days))
|
||||
counters["ttl_days_effective"] = effective_ttl_days
|
||||
if effective_ttl_days > ttl_days:
|
||||
|
||||
if raw_effective_ttl_days > capped_ttl_days:
|
||||
# Пол упёрся в потолок -- оба числа в counters (не только в логе),
|
||||
# чтобы это было видно в витрине прогонов, а не только в логах.
|
||||
# 1, а не True -- counters типизирован dict[str, int] (тот же
|
||||
# идиом, что skipped_unhealthy выше).
|
||||
counters["ttl_floor_capped"] = 1
|
||||
counters["ttl_days_floor_raw"] = raw_effective_ttl_days
|
||||
logger.warning(
|
||||
"deactivate_stale source=%s run_id=%d TTL пол упёрся в потолок "
|
||||
"cap_mult=%s: пол поднял бы TTL до %d сут, потолок ограничивает "
|
||||
"заданные %d сут значением %d (квантиль %.3f, segments=%r) — "
|
||||
"растущий без ограничения пол это петля с положительной обратной "
|
||||
"связью, см. комментарий у CAP_MULT",
|
||||
listing_source,
|
||||
run_id,
|
||||
cap_mult,
|
||||
raw_effective_ttl_days,
|
||||
ttl_days,
|
||||
effective_ttl_days,
|
||||
revisit_floor_quantile,
|
||||
segments,
|
||||
)
|
||||
elif effective_ttl_days > ttl_days:
|
||||
logger.warning(
|
||||
"deactivate_stale source=%s run_id=%d TTL поднят с %d до %d сут: "
|
||||
"свип за %d сут доказал живой строку, молчавшую %d сут "
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
-- 264_deactivate_stale_avito_cap_mult.sql
|
||||
-- Калибрует потолок эффективного TTL (cap_mult) для avito (#TTL-CAP, 2026-08-15).
|
||||
--
|
||||
-- ЗАЧЕМ. Пол TTL по измеренному циклу переобхода (#2659, deactivate_stale_avito.py)
|
||||
-- поднимает эффективный TTL через max(ttl_days, пол) без верхней границы -- на проде
|
||||
-- это оказалось петлёй с положительной обратной связью: медленный обход поднимает
|
||||
-- пол, высокий пол продлевает жизнь снятым лотам дольше, чем к ним успевает
|
||||
-- вернуться свежий обход, пул «активных» раздувается протухшими строками. ВАЖНАЯ
|
||||
-- ОГОВОРКА (перепроверено 2026-08-15): цифра «23 687 из 44 744» -- это ВСЕ источники
|
||||
-- вместе, и две трети её -- новостройки, которые оценщик не берёт вообще. У самого
|
||||
-- avito просроченных строк НОЛЬ (8 663 активных, максимальный возраст 10 суток) --
|
||||
-- его деактивация работает исправно. Этот потолок существует не ради сжатия пула
|
||||
-- (он деактивирует 0 строк, замерено), а как защита от опечатки в расписании и от
|
||||
-- будущего разгона пола. Потолок cap_mult ограничивает пол сверху: эффективный TTL не
|
||||
-- может превысить ttl_days * cap_mult (код -- app/tasks/deactivate_stale_avito.py,
|
||||
-- CAP_MULT).
|
||||
--
|
||||
-- ПОЧЕМУ ИМЕННО AVITO. Дефолт CAP_MULT=2 даёт разный АБСОЛЮТНЫЙ потолок на разных
|
||||
-- источниках (множитель от ttl_days), и ломается там, где хвост переобхода
|
||||
-- источника НЕ пропорционален его ttl_days. Таблица ниже -- ЖИВЫЕ полы из
|
||||
-- scrape_runs.counters (ttl_days_effective/revisit_floor_days по каждой job'е за
|
||||
-- 2026-08-10..08-15, ПЕРЕСЧИТАНО ревью круга 3 2026-08-15 -- прежняя версия таблицы
|
||||
-- брала статический p99 из _REVISIT_TAIL (40-суточный замер на более раннюю дату)
|
||||
-- и по нему ошибочно утверждала «yandex 43.0 -> потолок 60, запас есть»; live-полы
|
||||
-- показывают обратное, см. ниже), а не по статической константе:
|
||||
-- источник/сегмент живой пол (6 прогонов) ttl_days потолок cap_mult=2
|
||||
-- domklik vtorichka 23/24/25/skip/skip/skip 14 28 (запас есть)
|
||||
-- cian vtorichka 34/34/37/27/27/32 30 60 (запас есть)
|
||||
-- yandex vtorichka 75/75/75/39/52/54 30 60 (ХВОСТ ВЫШЕ)
|
||||
-- avito все сегменты 52/52/52/7/8/9 10 20 (ХВОСТ ВЫШЕ)
|
||||
-- У avito p99=42.1 суток (_REVISIT_TAIL) и живой пик 52 -- ВЫШЕ его же дефолтного
|
||||
-- потолка 20: дефолтный cap_mult=2 может резать пол ниже собственного хвоста
|
||||
-- обхода, то есть ровно тот false-kill, ради которого пол вообще заведён.
|
||||
--
|
||||
-- YANDEX -- ТА ЖЕ ДЫРА, что и у avito, но найдена ПОЗЖЕ (при первой версии этой
|
||||
-- миграции статический p99=43.0 ошибочно считался достаточным запасом). Живой пол
|
||||
-- yandex/vtorichka держится 39-75 суток шесть прогонов подряд, а прямой live-замер
|
||||
-- 2026-08-15 (та же percentile_disc(0.99)-формула, что и в проде) даёт 79.2 суток
|
||||
-- (n=1961 подтверждений за 3 суток) -- выше потолка 60 при дефолтном cap_mult=2.
|
||||
-- Калибровка yandex вынесена в ОТДЕЛЬНУЮ миграцию
|
||||
-- (265_deactivate_stale_yandex_cap_mult.sql, cap_mult=3 -> потолок 90), не сюда --
|
||||
-- эта миграция специфична для avito по имени и назначению, смешивать источники в
|
||||
-- одном файле хуже для git-истории калибровок. cian и domklik разрыва не имеют,
|
||||
-- дефолт cap_mult=2 для них по-прежнему калиброван верно, эта миграция их не трогает.
|
||||
--
|
||||
-- ЧИСЛЕННЫЙ ЭФФЕКТ (обе миграции, 264+265, live-замер 2026-08-15): на пул активных
|
||||
-- строк не влияет ни у одного из четырёх источников -- next-run deactivated=0 что до,
|
||||
-- что после калибровки. У avito и cian живой пол (12/32 суток) уже ниже потолка --
|
||||
-- калибровка cap_mult просто не участвует в min(). У yandex 0 активных строк старше
|
||||
-- 39 суток вообще (весь "просроченный" хвост младше того возраста, где потолок
|
||||
-- 60 vs 90 может разойтись), поэтому даже БЕЗ калибровки (дефолт cap_mult=2,
|
||||
-- потолок 60 < живой пол 79.2) next-run deactivated тоже 0 -- калибровка убирает
|
||||
-- будущий риск (потолок бы капал ttl_days_effective 79->60 в counters и резал бы
|
||||
-- ниже собственного хвоста обхода, как только появятся строки в возрастной полосе
|
||||
-- 60-90 суток), а не текущее число. domklik заблокирован гейтом здоровья
|
||||
-- (confirmations 94 < min_confirmations 200) -- до потолка/пола дело не доходит.
|
||||
--
|
||||
-- ПОЧЕМУ 6. Потолок 60 = 10 * 6 -- тот же порядок, что у cian (60, дефолт cap_mult=2),
|
||||
-- с запасом выше и статического p99=42.1 (_REVISIT_TAIL, tests/test_deactivate_stale_revisit_floor.py),
|
||||
-- и живого прод-пика: floor=52 три прогона подряд 2026-08-10..08-12
|
||||
-- (scrape_runs.counters, status=done, confirmations 6934..7138, гейт здоровья
|
||||
-- пропустил). Без этой калибровки в проде остаётся дефолт cap_mult=2 (потолок 20)
|
||||
-- -- именно тот случай, для которого потолок и его собственная калибровочная ручка
|
||||
-- заведены, но не применены к единственному источнику, ради которого ручка сделана.
|
||||
--
|
||||
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 219 (тот же
|
||||
-- приём -- UPDATE default_params через jsonb ?, min_confirmations).
|
||||
-- ТОЛЬКО данные (UPDATE default_params), DDL нет.
|
||||
-- Идемпотентность + уважение к ручной настройке: ключ проставляется лишь там, где
|
||||
-- его ещё нет, поэтому повторный прогон файла не затирает подкрученное оператором
|
||||
-- значение. Снять/поднять потолок вручную: cap_mult в default_params
|
||||
-- (deactivate_stale_avito), 1 -> потолок = сам ttl_days (см. guard cap_mult < 1
|
||||
-- в deactivate_stale_listings -- ниже 1 отклоняется до любого SQL).
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE scrape_schedules
|
||||
SET default_params = default_params || jsonb_build_object('cap_mult', 6),
|
||||
updated_at = NOW()
|
||||
WHERE source = 'deactivate_stale_avito'
|
||||
AND NOT default_params ? 'cap_mult';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
-- 265_deactivate_stale_yandex_cap_mult.sql
|
||||
-- Калибрует потолок эффективного TTL (cap_mult) для yandex (#TTL-CAP круг 3, 2026-08-15).
|
||||
--
|
||||
-- ЗАЧЕМ. Та же дыра, что закрыта для avito миграцией
|
||||
-- 264_deactivate_stale_avito_cap_mult.sql (см. её комментарий про механизм петли),
|
||||
-- но обнаружена на yandex позже: первая версия 264 утверждала, что дефолтный
|
||||
-- CAP_MULT=2 (потолок 60 при ttl_days=30) для yandex "калиброван верно" на
|
||||
-- основании статического p99=43.0 (_REVISIT_TAIL, замер на более раннюю дату).
|
||||
--
|
||||
-- ЖИВОЙ ЗАМЕР, из-за которого миграция существует. scrape_runs.counters
|
||||
-- (deactivate_stale_yandex, 2026-08-10..08-15) держал ttl_days_effective 75/75/75/
|
||||
-- 39/52/54 шесть прогонов подряд при deactivated=0 -- то есть пол ВСЕ ЭТИ ДНИ был
|
||||
-- выше потолка 60. Прямой live-замер той же percentile_disc(0.99)-формулы, что и в
|
||||
-- коде (app/tasks/deactivate_stale_avito.py, _build_revisit_floor_sql), 2026-08-15
|
||||
-- даёт 79.2 суток (n=1961 подтверждений за окно 3 суток). Оба замера выше потолка
|
||||
-- 60 -- ровно тот false-kill, ради которого пол #2659 вообще заведён: без калибровки
|
||||
-- потолок капал бы ttl_days_effective yandex до 60 в counters уже сегодня и резал бы
|
||||
-- ниже собственного хвоста обхода, как только в пуле появятся строки возрастом
|
||||
-- 60-90 суток (сейчас таких 0 -- см. ЧИСЛЕННЫЙ ЭФФЕКТ ниже).
|
||||
--
|
||||
-- ПОЧЕМУ 3. Потолок 90 = 30 * 3 -- запас ~14% над живым пиком 79.2, той же
|
||||
-- пропорции, что и у avito (потолок 60 против пика 52 -- запас ~15%, см. 264).
|
||||
-- Меньший cap_mult=2 (потолок 60) уже сейчас ниже пика 79.2. Больший cap_mult
|
||||
-- намеренно не берём -- дальнейший рост пола означает не "медленный, но живой
|
||||
-- обход", а кандидата в mёртвый источник, для которого есть отдельный гейт
|
||||
-- здоровья (min_confirmations), а не растягивание потолка до бесконечности (см.
|
||||
-- комментарий у CAP_MULT в deactivate_stale_avito.py).
|
||||
--
|
||||
-- ЧИСЛЕННЫЙ ЭФФЕКТ (live-замер 2026-08-15): 0 активных строк yandex/vtorichka
|
||||
-- старше 39 суток вообще (запрос: count(*) FROM listings WHERE source='yandex' AND
|
||||
-- listing_segment='vtorichka' AND is_active=true AND last_seen_at < NOW() -
|
||||
-- INTERVAL 'N days', N=39/52/54/60/75/79 -- везде 0). Next-run deactivated=0 что
|
||||
-- при дефолтном cap_mult=2 (потолок 60, капает пол), что при cap_mult=3 из этой
|
||||
-- миграции (потолок 90, не капает) -- эта миграция убирает БУДУЩИЙ риск
|
||||
-- false-kill при появлении строк в полосе 60-90 суток, а не текущее число
|
||||
-- деактиваций. Ветка #TTL-CAP не сжимает пул ни у одного из четырёх источников --
|
||||
-- см. 264 для остальных трёх.
|
||||
--
|
||||
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 219 (тот же
|
||||
-- приём -- UPDATE default_params через jsonb ?, min_confirmations), 264 (тот же
|
||||
-- приём для avito, cap_mult -- параметр deactivate_stale_listings).
|
||||
-- ТОЛЬКО данные (UPDATE default_params), DDL нет.
|
||||
-- Идемпотентность + уважение к ручной настройке: ключ проставляется лишь там, где
|
||||
-- его ещё нет, поэтому повторный прогон файла не затирает подкрученное оператором
|
||||
-- значение. Снять/поднять потолок вручную: cap_mult в default_params
|
||||
-- (deactivate_stale_yandex), 1 -> потолок = сам ttl_days (см. guard cap_mult < 1
|
||||
-- в deactivate_stale_listings -- ниже 1 отклоняется до любого SQL).
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE scrape_schedules
|
||||
SET default_params = default_params || jsonb_build_object('cap_mult', 3),
|
||||
updated_at = NOW()
|
||||
WHERE source = 'deactivate_stale_yandex'
|
||||
AND NOT default_params ? 'cap_mult';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
-- 264_seed_deactivate_stale_null_segment_yandex_cian.sql
|
||||
-- 266_seed_deactivate_stale_null_segment_yandex_cian.sql
|
||||
-- Деактивация протухших yandex/cian объявлений с ПУСТЫМ listing_segment.
|
||||
--
|
||||
-- Замер на проде 2026-08-15 (is_active=true, listing_segment IS NULL):
|
||||
|
|
@ -252,4 +252,6 @@
|
|||
261_listings_search_mv_drop_placeholder_columns.sql
|
||||
262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql
|
||||
263_scrape_schedules_wave2_cian_newbuilding_only_false.sql
|
||||
264_seed_deactivate_stale_null_segment_yandex_cian.sql
|
||||
264_deactivate_stale_avito_cap_mult.sql
|
||||
265_deactivate_stale_yandex_cap_mult.sql
|
||||
266_seed_deactivate_stale_null_segment_yandex_cian.sql
|
||||
|
|
|
|||
|
|
@ -534,63 +534,65 @@ def test_null_segment_only_default_is_false(monkeypatch: pytest.MonkeyPatch) ->
|
|||
assert "listing_segment IS NULL" not in sql
|
||||
|
||||
|
||||
# ── Migration 264 (deactivate_stale_yandex_null_segment / _cian_null_segment) ───────
|
||||
# ── Migration 266 (deactivate_stale_yandex_null_segment / _cian_null_segment) ───────
|
||||
# Renumbered 264 -> 266 (collision with forgejo/main's 264_deactivate_stale_avito_cap_mult.sql
|
||||
# / 265_deactivate_stale_yandex_cap_mult.sql, merged после того как эта ветка забрала 264).
|
||||
|
||||
_MIGRATION_264 = _SQL_DIR / "264_seed_deactivate_stale_null_segment_yandex_cian.sql"
|
||||
_MIGRATION_266 = _SQL_DIR / "266_seed_deactivate_stale_null_segment_yandex_cian.sql"
|
||||
|
||||
|
||||
def test_migration_264_exists() -> None:
|
||||
assert _MIGRATION_264.is_file(), f"missing migration: {_MIGRATION_264}"
|
||||
def test_migration_266_exists() -> None:
|
||||
assert _MIGRATION_266.is_file(), f"missing migration: {_MIGRATION_266}"
|
||||
|
||||
|
||||
def test_migration_264_seeds_yandex_and_cian_null_segment() -> None:
|
||||
sql = _MIGRATION_264.read_text("utf-8")
|
||||
def test_migration_266_seeds_yandex_and_cian_null_segment() -> None:
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert "'deactivate_stale_yandex_null_segment'" in sql
|
||||
assert "'deactivate_stale_cian_null_segment'" in sql
|
||||
|
||||
|
||||
def test_migration_264_null_segment_only_true() -> None:
|
||||
sql = _MIGRATION_264.read_text("utf-8")
|
||||
def test_migration_266_null_segment_only_true() -> None:
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert '"null_segment_only":true' in sql
|
||||
|
||||
|
||||
def test_migration_264_ttl_60_days() -> None:
|
||||
sql = _MIGRATION_264.read_text("utf-8")
|
||||
def test_migration_266_ttl_60_days() -> None:
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert '"ttl_days":60' in sql
|
||||
|
||||
|
||||
def test_migration_264_gates_disabled() -> None:
|
||||
def test_migration_266_gates_disabled() -> None:
|
||||
"""min_confirmations/revisit_floor_quantile выключены явно -- население слишком
|
||||
мало для порогов, откалиброванных под полноценный vtorichka-свип (см. файл)."""
|
||||
sql = _MIGRATION_264.read_text("utf-8")
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert '"min_confirmations":0' in sql
|
||||
assert '"revisit_floor_quantile":0' in sql
|
||||
|
||||
|
||||
def test_migration_264_is_idempotent() -> None:
|
||||
sql = _MIGRATION_264.read_text("utf-8")
|
||||
def test_migration_266_is_idempotent() -> None:
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert "ON CONFLICT (source) DO NOTHING" in sql
|
||||
|
||||
|
||||
def test_migration_264_is_transactional() -> None:
|
||||
sql = _MIGRATION_264.read_text("utf-8")
|
||||
def test_migration_266_is_transactional() -> None:
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert "BEGIN;" in sql
|
||||
assert "COMMIT;" in sql
|
||||
|
||||
|
||||
def test_migration_264_enabled_true() -> None:
|
||||
sql = _MIGRATION_264.read_text("utf-8")
|
||||
def test_migration_266_enabled_true() -> None:
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert "true" in sql
|
||||
|
||||
|
||||
def test_migration_264_window_7_to_8_utc() -> None:
|
||||
sql = _MIGRATION_264.read_text("utf-8")
|
||||
def test_migration_266_window_7_to_8_utc() -> None:
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert re.search(r"\b7\b", sql), "window_start_hour 7 missing"
|
||||
assert re.search(r"\b8\b", sql), "window_end_hour 8 missing"
|
||||
|
||||
|
||||
def test_migration_264_no_psycopg_trap() -> None:
|
||||
sql = _MIGRATION_264.read_text("utf-8")
|
||||
def test_migration_266_no_psycopg_trap() -> None:
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert not re.search(r":\w+::", sql)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -126,13 +126,21 @@ def _run(db: _FakeDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[st
|
|||
|
||||
|
||||
def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ни одно из 127 доказанно ложных снятий не должно повториться.
|
||||
"""Ни одно из 127 доказанно ложных снятий (cian/yandex) не должно повториться.
|
||||
|
||||
Все они произошли на возрасте 29.9..30.3 суток. Эффективный TTL обязан быть
|
||||
строго выше этого возраста на КАЖДОМ прод-срезе — иначе следующий прогон
|
||||
снимет ту же строку снова.
|
||||
строго выше этого возраста на cian/yandex-срезах — иначе следующий прогон
|
||||
снимет ту же строку снова. avito из этого цикла исключён намеренно: 127
|
||||
доказанных ложных снятий (_FALSE_KILLS_BY_CITY) измерены только по cian/yandex,
|
||||
у avito другой сценарий и своя проверка ниже
|
||||
(test_avito_prod_floor_is_capped_by_calibrated_cap_mult) -- калибровка cap_mult=6
|
||||
для avito (миграция 264_deactivate_stale_avito_cap_mult.sql) пиннится ТАМ, а не
|
||||
здесь, чтобы не смешивать два разных замера под одним порогом
|
||||
_FALSE_KILL_AGE_MAX, который к avito не относится.
|
||||
"""
|
||||
for slice_name, (source, segments, ttl_days, floor) in _PROD_FLOORS.items():
|
||||
if source == "avito":
|
||||
continue
|
||||
db = _FakeDB(floor_days=floor)
|
||||
out = _run(
|
||||
db,
|
||||
|
|
@ -154,6 +162,108 @@ def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.Monkey
|
|||
), f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен"
|
||||
|
||||
|
||||
def _read_cap_mult_from_migration(filename: str, *, source: str) -> int:
|
||||
"""Читает cap_mult из UPDATE default_params миграции -- НЕ хардкодит дубль в тесте.
|
||||
|
||||
Найдено ревью круга 3 2026-08-15: раньше тест ниже принимал cap_mult=6 как
|
||||
аргумент напрямую, захардкоженный прямо в теле теста. Мутация значения в
|
||||
264_deactivate_stale_avito_cap_mult.sql (6 -> 2) НЕ трогала вход теста вовсе --
|
||||
набор оставался зелёным при любом реальном значении в миграции, то есть
|
||||
калибровка нигде не была пином, только упоминанием в комментарии. Здесь
|
||||
значение читается ИЗ ФАЙЛА миграции regex'ом, а ожидаемый результат
|
||||
(ttl_days_effective, ttl_floor_capped) остаётся зафиксированным числом в самом
|
||||
тесте -- так дрейф калибровки в миграции ломает тест, как и задумано.
|
||||
"""
|
||||
migration = Path(__file__).resolve().parents[1] / "data" / "sql" / filename
|
||||
src = migration.read_text("utf-8")
|
||||
# Порядок в файле -- jsonb_build_object('cap_mult', N) в SET, ЗАТЕМ WHERE source
|
||||
# = '<source>' ниже (см. 264/265_*.sql). DOTALL матчит перевод строки между ними;
|
||||
# source в regex -- страховка от чтения не того UPDATE, если файл когда-нибудь
|
||||
# станет мульти-source (сейчас в каждом файле ровно один UPDATE).
|
||||
match = re.search(
|
||||
r"jsonb_build_object\('cap_mult',\s*(\d+)\).*?WHERE\s+source\s*=\s*'"
|
||||
+ re.escape(source)
|
||||
+ r"'",
|
||||
src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match is not None, (
|
||||
f"{filename} сменил формат UPDATE default_params для source={source!r} -- "
|
||||
"обнови regex в _read_cap_mult_from_migration"
|
||||
)
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def test_avito_prod_floor_is_capped_by_calibrated_cap_mult(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Пиннит калибровку cap_mult=6 для avito (миграция
|
||||
264_deactivate_stale_avito_cap_mult.sql) на измеренном прод-поле _PROD_FLOORS
|
||||
("avito/все сегменты" = 69.7, замер 2026-08-09).
|
||||
|
||||
cap_mult -- ВХОД теста, читается ИЗ ФАЙЛА миграции (regex), не хардкодится
|
||||
здесь: дрейф калибровки в 264_*.sql (например 6 -> 2) меняет вход, но НЕ
|
||||
ожидаемый результат ниже (60/70) -- эти числа пинят калибровку саму по себе,
|
||||
поэтому дрейф ломает тест, как и задумано (см. _read_cap_mult_from_migration).
|
||||
|
||||
С дефолтным cap_mult=2 потолок avito (20 сут) РЕЖЕТ ниже собственного хвоста
|
||||
переобхода p99=42.1 (_REVISIT_TAIL) -- ровно тот false-kill, ради которого пол
|
||||
заведён. С калиброванным cap_mult=6 потолок 60 сут -- выше и p99=42.1, и живого
|
||||
прод-пика 52 (замер 08-10..08-12), и этого гипотетического замера 69.7 (капается
|
||||
ровно на 60, не пропускается как есть).
|
||||
"""
|
||||
calibrated_cap_mult = _read_cap_mult_from_migration(
|
||||
"264_deactivate_stale_avito_cap_mult.sql", source="deactivate_stale_avito"
|
||||
)
|
||||
source, segments, ttl_days, floor = _PROD_FLOORS["avito/все сегменты"]
|
||||
db = _FakeDB(floor_days=floor)
|
||||
out = _run(
|
||||
db,
|
||||
monkeypatch,
|
||||
listing_source=source,
|
||||
ttl_days=ttl_days,
|
||||
segments=segments,
|
||||
revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE,
|
||||
cap_mult=calibrated_cap_mult,
|
||||
)
|
||||
assert out["ttl_days_effective"] == 60, "cap_mult из миграции 264 обязан дать потолок 60"
|
||||
assert out["ttl_floor_capped"] == 1
|
||||
assert out["ttl_days_floor_raw"] == 70, "ceil(69.7) == 70 -- пол считается по real-числу"
|
||||
|
||||
|
||||
def test_yandex_prod_floor_is_not_capped_by_calibrated_cap_mult(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Пиннит калибровку cap_mult=3 для yandex (миграция
|
||||
265_deactivate_stale_yandex_cap_mult.sql, найдено ревью круга 3 2026-08-15) на
|
||||
измеренном прод-поле _PROD_FLOORS ("yandex/vtorichka" = 74.3).
|
||||
|
||||
cap_mult -- ВХОД теста, читается ИЗ ФАЙЛА миграции 265 (тот же приём, что и у
|
||||
avito выше): дрейф калибровки в 265_*.sql ломает тест.
|
||||
|
||||
С дефолтным cap_mult=2 потолок yandex (60 сут) РЕЖЕТ живой пол (75-79 сут,
|
||||
scrape_runs.counters 08-10..08-15 и live-замер 08-15) -- та же дыра, что у
|
||||
avito, найдена позже (первая версия 264 ошибочно считала yandex безопасным по
|
||||
устаревшему статическому p99=43.0). С калиброванным cap_mult=3 потолок 90 сут
|
||||
выше живого пика 79.2 -- пол 74.3 из этого теста НЕ капается, эффективный TTL
|
||||
равен сырому полу (75, ceil(74.3)).
|
||||
"""
|
||||
calibrated_cap_mult = _read_cap_mult_from_migration(
|
||||
"265_deactivate_stale_yandex_cap_mult.sql", source="deactivate_stale_yandex"
|
||||
)
|
||||
source, segments, ttl_days, floor = _PROD_FLOORS["yandex/vtorichka"]
|
||||
db = _FakeDB(floor_days=floor)
|
||||
out = _run(
|
||||
db,
|
||||
monkeypatch,
|
||||
listing_source=source,
|
||||
ttl_days=ttl_days,
|
||||
segments=segments,
|
||||
revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE,
|
||||
cap_mult=calibrated_cap_mult,
|
||||
)
|
||||
assert out["ttl_days_effective"] == 75, "ceil(74.3) == 75, потолок 90 не должен резать"
|
||||
assert "ttl_floor_capped" not in out, "потолок 90 выше живого пола 74.3 -- капать нечего"
|
||||
|
||||
|
||||
def test_false_kill_ages_sit_inside_the_old_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Замер согласован сам с собой: снимали ровно на границе TTL=30, не раньше."""
|
||||
assert _FALSE_KILL_AGE_MIN < 30.0 <= _FALSE_KILL_AGE_MAX
|
||||
|
|
|
|||
443
tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py
Normal file
443
tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
"""Потолок эффективного TTL деактивации (найдено на проде 2026-08-15).
|
||||
|
||||
Пол TTL по измеренному циклу переобхода (#2659, deactivate_stale_avito.py) поднимает
|
||||
эффективный TTL через max(ttl_days, пол) без верхней границы. На проде это оказалось
|
||||
петлёй с положительной обратной связью: медленный обход поднимает пол, высокий пол
|
||||
продлевает жизнь снятым лотам дольше, чем к ним успевает вернуться свежий обход, пул
|
||||
«активных» раздувается протухшими строками. У yandex ttl_days_effective держали
|
||||
75/75/75/39/52/54 шесть прогонов подряд при deactivated=0 -- это и есть разгон пола,
|
||||
ради которого потолок написан. Цифру «23 687 из 44 744» из исходного разбора сюда НЕ
|
||||
переносим: она про все источники сразу, две трети её -- новостройки вне выборки
|
||||
оценщика, а у самого avito просроченных строк ноль (уточнено 2026-08-15).
|
||||
|
||||
Этот файл проверяет CAP_MULT -- потолок, не пускающий эффективный TTL выше
|
||||
ttl_days * CAP_MULT, независимо от того, насколько высоко посчитанный пол.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.tasks import deactivate_stale_avito as task_mod
|
||||
|
||||
# ── Фейковая сессия (тот же контракт, что в test_deactivate_stale_revisit_floor.py) ──
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, rowcount: int = 0, scalar_value: Any = None) -> None:
|
||||
self.rowcount = rowcount
|
||||
self._scalar = scalar_value
|
||||
|
||||
def scalar(self) -> Any:
|
||||
return self._scalar
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Session-заглушка: percentile_disc -> пол, count(*) -> подтверждения, UPDATE -> rowcount."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
floor_days: float | None,
|
||||
confirmations: int = 10_000,
|
||||
rowcount: int = 137,
|
||||
) -> None:
|
||||
self._floor = floor_days
|
||||
self._confirmations = confirmations
|
||||
self._rowcount = rowcount
|
||||
self.executed: list[tuple[str, dict[str, Any] | None]] = []
|
||||
self.committed = False
|
||||
self.rolled_back = False
|
||||
|
||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||
sql = str(stmt.text)
|
||||
self.executed.append((sql, params))
|
||||
if "percentile_disc" in sql:
|
||||
return _FakeResult(scalar_value=self._floor)
|
||||
if "SELECT count(*)" in sql:
|
||||
return _FakeResult(scalar_value=self._confirmations)
|
||||
return _FakeResult(rowcount=self._rowcount)
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rolled_back = True
|
||||
|
||||
@property
|
||||
def update_query(self) -> tuple[str, dict[str, Any] | None]:
|
||||
return next((e for e in self.executed if "UPDATE listings" in e[0]), ("", None))
|
||||
|
||||
|
||||
def _run(db: _FakeDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[str, int]:
|
||||
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
|
||||
monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None)
|
||||
return task_mod.deactivate_stale_listings(
|
||||
db, # type: ignore[arg-type]
|
||||
1,
|
||||
listing_source=kwargs.pop("listing_source", "avito"),
|
||||
ttl_days=kwargs.pop("ttl_days", 10),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
# ── Контракт из задачи ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_high_floor_is_capped_at_double_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""revisit_floor=75, ttl_days=10 -> итог 20 (потолок 2x), НЕ 75."""
|
||||
db = _FakeDB(floor_days=75.0)
|
||||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 20
|
||||
assert out["ttl_floor_capped"] == 1
|
||||
assert out["ttl_days_floor_raw"] == 75
|
||||
_, update_params = db.update_query
|
||||
assert update_params is not None
|
||||
assert update_params["ttl_days"] == 20, "UPDATE обязан получить капнутый TTL, не сырой пол"
|
||||
|
||||
|
||||
def test_low_floor_leaves_ttl_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""revisit_floor=5, ttl_days=10 -> итог 10 (пол ниже заданного TTL, max() его не поднимает)."""
|
||||
db = _FakeDB(floor_days=5.0)
|
||||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 10
|
||||
assert "ttl_floor_capped" not in out
|
||||
assert "ttl_days_floor_raw" not in out
|
||||
_, update_params = db.update_query
|
||||
assert update_params is not None
|
||||
assert update_params["ttl_days"] == 10
|
||||
|
||||
|
||||
# ── Контракт потолка ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cap_mult_is_named_module_constant_equal_two() -> None:
|
||||
assert task_mod.CAP_MULT == 2
|
||||
|
||||
|
||||
def test_floor_between_ttl_and_cap_is_not_flagged_capped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Пол поднял TTL, но не дотянулся до потолка -- capped-флаг НЕ выставляется."""
|
||||
db = _FakeDB(floor_days=15.0)
|
||||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 15
|
||||
assert "ttl_floor_capped" not in out
|
||||
|
||||
|
||||
def test_floor_exactly_at_cap_boundary_is_not_flagged_capped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Пол ровно на потолке (2x ttl) -- это ещё "поднят до потолка", не "срезан выше него".
|
||||
|
||||
Формула -- min(raw, cap): при raw == cap срезания не происходит (raw > cap ложно),
|
||||
капнутый флаг предназначен сигналить именно "потолок реально что-то отрезал".
|
||||
"""
|
||||
db = _FakeDB(floor_days=20.0)
|
||||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 20
|
||||
assert "ttl_floor_capped" not in out
|
||||
|
||||
|
||||
def test_cap_logs_warning_containing_both_numbers(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""WARNING при срезании содержит и сырой пол, и капнутый результат -- не только counters."""
|
||||
db = _FakeDB(floor_days=75.0)
|
||||
with caplog.at_level("WARNING", logger=task_mod.logger.name):
|
||||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||||
messages = " ".join(r.getMessage() for r in caplog.records)
|
||||
assert "75" in messages, "лог обязан называть сырой пол"
|
||||
assert "20" in messages, "лог обязан называть итоговый (капнутый) TTL"
|
||||
|
||||
|
||||
def test_cap_never_lowers_ttl_below_configured_value(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Потолок -- верхняя граница, не альтернативный источник истины: заданный TTL
|
||||
(10) остаётся нижней границей независимо от того, насколько низко ушёл пол."""
|
||||
db = _FakeDB(floor_days=1.0)
|
||||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 10
|
||||
|
||||
|
||||
# ── avito self-descend (52 -> ... -> 10) не должен ломаться потолком ────────────
|
||||
|
||||
|
||||
def test_avito_high_transient_floor_is_capped_not_left_unbounded(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Наблюдённый на проде транзиентный пик avito (счётчики видели ttl_days_effective=52)
|
||||
теперь капается на 2x ttl=20, а не пропускается в UPDATE как есть."""
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 20
|
||||
assert out["ttl_floor_capped"] == 1
|
||||
assert out["ttl_days_floor_raw"] == 52
|
||||
|
||||
|
||||
def test_avito_recovered_low_floor_still_reaches_configured_ttl(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""После восстановления обхода (пол опустился ниже ttl_days=10, как на проде 52->10)
|
||||
потолок не мешает нормальному пути -- эффективный TTL просто равен заданному."""
|
||||
db = _FakeDB(floor_days=9.0)
|
||||
out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 10
|
||||
assert "ttl_floor_capped" not in out
|
||||
|
||||
|
||||
def test_avito_floor_above_ttl_but_under_cap_passes_through_uncapped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Промежуточная точка того же самопонижения (пол между ttl и потолком, например 18)
|
||||
поднимает TTL как раньше -- потолок не мешает нормальному постепенному пути."""
|
||||
db = _FakeDB(floor_days=18.0)
|
||||
out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 18
|
||||
assert "ttl_floor_capped" not in out
|
||||
|
||||
|
||||
# ── cap_mult конфигурируем per-source (найдено ревью 2026-08-15) ────────────────
|
||||
# Дефолтный CAP_MULT=2 даёт разный АБСОЛЮТНЫЙ потолок на разных источниках
|
||||
# (cian/yandex 60 сут, avito 20 сут), а хвост переобхода не пропорционален
|
||||
# ttl_days: avito p99=42.1 -- выше его же дефолтного потолка 20. cap_mult -- ручка
|
||||
# для конкретно такого источника, без изменения дефолта для остальных.
|
||||
|
||||
|
||||
def test_cap_mult_defaults_to_module_constant_when_not_overridden(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Без явного cap_mult поведение не меняется: потолок = ttl_days * CAP_MULT (2)."""
|
||||
db = _FakeDB(floor_days=75.0)
|
||||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 10 * task_mod.CAP_MULT
|
||||
|
||||
|
||||
def test_cap_mult_override_raises_the_ceiling_for_a_long_tailed_source(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""avito p99=42.1: cap_mult=6 (потолок 60) больше не режет пол ниже хвоста обхода,
|
||||
в отличие от дефолтного cap_mult=2 (потолок 20)."""
|
||||
db = _FakeDB(floor_days=45.0)
|
||||
out = _run(
|
||||
db,
|
||||
monkeypatch,
|
||||
listing_source="avito",
|
||||
ttl_days=10,
|
||||
revisit_floor_quantile=0.99,
|
||||
cap_mult=6,
|
||||
)
|
||||
assert out["ttl_days_effective"] == 45
|
||||
assert "ttl_floor_capped" not in out
|
||||
|
||||
|
||||
def test_cap_mult_override_still_caps_when_floor_exceeds_the_wider_ceiling(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""cap_mult поднимает потолок, но не убирает его -- пол выше 60 всё равно срезается."""
|
||||
db = _FakeDB(floor_days=90.0)
|
||||
out = _run(
|
||||
db,
|
||||
monkeypatch,
|
||||
listing_source="avito",
|
||||
ttl_days=10,
|
||||
revisit_floor_quantile=0.99,
|
||||
cap_mult=6,
|
||||
)
|
||||
assert out["ttl_days_effective"] == 60
|
||||
assert out["ttl_floor_capped"] == 1
|
||||
assert out["ttl_days_floor_raw"] == 90
|
||||
|
||||
|
||||
def test_cap_mult_is_threaded_into_update_params(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Капнутый по override'нутому потолку TTL реально уходит в UPDATE, не только считается."""
|
||||
db = _FakeDB(floor_days=90.0)
|
||||
_run(
|
||||
db,
|
||||
monkeypatch,
|
||||
listing_source="avito",
|
||||
ttl_days=10,
|
||||
revisit_floor_quantile=0.99,
|
||||
cap_mult=6,
|
||||
)
|
||||
_, update_params = db.update_query
|
||||
assert update_params is not None
|
||||
assert update_params["ttl_days"] == 60
|
||||
|
||||
|
||||
# ── ttl_days <= 0 (LOW из ревью 2026-08-15) ──────────────────────────────────────
|
||||
# До потолка max(ttl_days, floor) прикрывал ttl_days<=0, если пол посчитан и
|
||||
# положителен. С потолком min(raw, ttl_days * cap_mult) при ttl_days<=0 капнутый
|
||||
# потолок тоже <= 0 и побеждает в min() -- защита пола пропадает молча. Явный guard
|
||||
# ловит это ДО любого SQL, тем же путём, что и невалидный staleness_column.
|
||||
|
||||
|
||||
def test_ttl_days_zero_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = _FakeDB(floor_days=75.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=0, revisit_floor_quantile=0.99)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_ttl_days_negative_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = _FakeDB(floor_days=75.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=-5, revisit_floor_quantile=0.99)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_ttl_days_zero_fails_the_run_via_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Тот же контракт, что и невалидный staleness_column: run помечается failed,
|
||||
а не остаётся 'running'."""
|
||||
marked_failed: list[Any] = []
|
||||
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
task_mod.runs_mod,
|
||||
"mark_failed",
|
||||
lambda db, run_id, err, counters: marked_failed.append((run_id, err, counters)),
|
||||
)
|
||||
db = _FakeDB(floor_days=75.0)
|
||||
with pytest.raises(ValueError):
|
||||
task_mod.deactivate_stale_listings(
|
||||
db, # type: ignore[arg-type]
|
||||
7,
|
||||
listing_source="avito",
|
||||
ttl_days=0,
|
||||
)
|
||||
assert len(marked_failed) == 1
|
||||
assert marked_failed[0][0] == 7
|
||||
|
||||
|
||||
# ── cap_mult < 1 (HIGH из ревью круга 2, 2026-08-15) ─────────────────────────────
|
||||
# Тот же класс дыры, что и ttl_days<=0 выше, но со стороны потолка: cap_mult -- ЕДИНСТВЕННЫЙ
|
||||
# запланированный способ его задать -- руками вписать в jsonb default_params расписания
|
||||
# (см. миграцию для avito), т.е. именно там опечатка 0 / 0.5 вместо 6 доходит до прода.
|
||||
# cap_mult=0 -> capped=0 -> effective_ttl_days=0 -> UPDATE снимает весь активный пул
|
||||
# источника молча. cap_mult<1 (например 0.5) опускает потолок НИЖЕ заданного оператором
|
||||
# ttl_days -- прямое нарушение инварианта, который проверяет
|
||||
# test_cap_never_lowers_ttl_below_configured_value для пола, но не было проверено для
|
||||
# потолка при некорректном cap_mult.
|
||||
|
||||
|
||||
def test_cap_mult_zero_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=0)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_cap_mult_negative_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=-2)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_cap_mult_below_one_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""cap_mult=0.5 опустил бы потолок НИЖЕ заданного ttl_days -- та самая инверсия,
|
||||
которую тест test_cap_never_lowers_ttl_below_configured_value гарантирует для пола."""
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=0.5)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_cap_mult_non_numeric_fails_safe_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Опечатка в jsonb default_params (строка вместо числа) не должна молча пройти
|
||||
в SQL -- TypeError из сравнения `cap_mult < 1` ловится тем же except Exception,
|
||||
что и ValueError-гварды, и маршрутизируется через mark_failed. Никакого SQL не
|
||||
исполняется, ни один active-лот не тронут."""
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(TypeError):
|
||||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult="6")
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
# ── cap_mult / ttl_days -- bool (найдено ревью круга 3, 2026-08-15) ─────────────
|
||||
# bool -- подкласс int в Python: `True < 1` ложно, `True <= 0` ложно. Числовые
|
||||
# guard'ы выше (`cap_mult < 1`, `ttl_days <= 0`) поэтому НЕ ловят jsonb `true` в
|
||||
# default_params расписания -- ровно тот класс опечатки, ради которого guard'ы
|
||||
# вообще написаны. `cap_mult=True` даёт потолок == ttl_days (ttl_days * True ==
|
||||
# ttl_days) -- пол молча отключается без единого ValueError. `ttl_days=True` даёт
|
||||
# ttl_days == 1 -- TTL молча меняется на 1 сутки. Явная type-проверка ловит оба
|
||||
# ДО числового сравнения и ДО любого SQL.
|
||||
|
||||
|
||||
def test_cap_mult_true_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""cap_mult=True: `True < 1` ложно -- без явной type-проверки потолок = ttl_days
|
||||
(пол молча отключается) вместо ValueError. Воспроизведено на HEAD ветки."""
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=True)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_cap_mult_false_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""cap_mult=False уже ловится `cap_mult < 1` (False == 0), но type-guard идёт
|
||||
первым -- проверяем, что путь всё равно ValueError, а не иной exception."""
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=False)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_ttl_days_true_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ttl_days=True: `True <= 0` ложно -- без явной type-проверки TTL молча
|
||||
становится 1 сутки (True ведёт себя как int 1) вместо ValueError."""
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=True, revisit_floor_quantile=0.99)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_ttl_days_false_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ttl_days=False уже ловится `ttl_days <= 0` (False == 0), но type-guard идёт
|
||||
первым -- проверяем, что путь всё равно ValueError."""
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=False, revisit_floor_quantile=0.99)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_cap_mult_zero_fails_the_run_via_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Тот же контракт, что и ttl_days<=0: run помечается failed, а не остаётся 'running',
|
||||
и НИ ОДНА строка не деактивируется (в отличие от воспроизведённого на HEAD дефекта, где
|
||||
cap_mult=0 давало effective_ttl_days=0 и снимало весь активный пул источника)."""
|
||||
marked_failed: list[Any] = []
|
||||
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
task_mod.runs_mod,
|
||||
"mark_failed",
|
||||
lambda db, run_id, err, counters: marked_failed.append((run_id, err, counters)),
|
||||
)
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(ValueError):
|
||||
task_mod.deactivate_stale_listings(
|
||||
db, # type: ignore[arg-type]
|
||||
9,
|
||||
listing_source="avito",
|
||||
ttl_days=10,
|
||||
revisit_floor_quantile=0.99,
|
||||
cap_mult=0,
|
||||
)
|
||||
assert len(marked_failed) == 1
|
||||
assert marked_failed[0][0] == 9
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
# ── проводка cap_mult в product_handlers ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_handler_wires_cap_mult_from_schedule_params() -> None:
|
||||
"""Тот же приём, что test_handler_wires_revisit_floor_from_schedule_params:
|
||||
читаем исходник файлом (product_handlers тянет scraper_kit, которого в юнит-
|
||||
окружении может не быть) и проверяем именно проводку default_params -> вызов."""
|
||||
handlers = Path(__file__).resolve().parents[1] / "app" / "services" / "product_handlers.py"
|
||||
src = handlers.read_text("utf-8")
|
||||
job = src.split("async def _job_deactivate_stale")[1].split("\nasync def ")[0]
|
||||
flat = " ".join(job.split())
|
||||
assert 'params.get("cap_mult", CAP_MULT)' in flat
|
||||
assert "cap_mult=cap_mult" in job
|
||||
Loading…
Add table
Reference in a new issue