fix(tradein/deactivate): bool guard hole + unpinned test + yandex cap_mult gap (TTL-CAP round 3)
Три остатка ревью TTL-CAP: 1. cap_mult < 1 пропускал bool: jsonb true -> True < 1 ложно -> потолок = ttl_days*True = ttl_days -> пол молча отключается без ValueError. Тот же класс дыры возможен и через ttl_days=true (TTL молча = 1). Оба параметра теперь явно отклоняют bool ДО числового сравнения; воспроизведено на HEAD и закрыто тестами (True/False на обоих параметрах). 2. test_avito_prod_floor_is_capped_by_calibrated_cap_mult хардкодил cap_mult=6 как вход -- мутация миграции 264 (6 -> 2) оставляла набор зелёным. Тест теперь читает cap_mult ИЗ ФАЙЛА миграции regex'ом, ожидаемый результат (потолок 60) остаётся зафиксированным числом -- дрейф калибровки в SQL теперь ломает тест. 3. Текст миграции 264 утверждал "yandex 43.0 -> потолок 60, запас есть" по статическому p99. Живые полы из scrape_runs.counters (08-10..08-15: 75/75/75/39/52/54) и live-замер сегодня (79.2, n=1961) выше потолка 60 -- тот же false-kill класс, что у avito. Откалибровал yandex отдельной миграцией 265 (cap_mult=3 -> потолок 90, тот же запас ~14%, что у avito), поправил таблицу в 264 на живые числа и пиннящий тест по образцу avito. Численный эффект (live-замер 2026-08-15, до и после): next-run deactivated=0 на всех четырёх джобах что до, что после -- ветка по-прежнему НЕ сжимает пул (avito/cian: живой пол уже ниже потолка, cap не участвует; yandex: 0 активных строк старше 39 суток вообще, калибровка убирает будущий риск, не текущее число; domklik: блокирован гейтом здоровья, confirmations 94 < 200). Ветка остаётся тем, чем и была: защита от опечатки в расписании + калибровка, не сжатие пула. 4508 backend-тестов зелёные (uv run pytest tests/), ruff чист на изменённых файлах.
This commit is contained in:
parent
8fff174337
commit
19c9da8119
6 changed files with 257 additions and 25 deletions
|
|
@ -231,16 +231,28 @@ _REVISIT_FLOOR_SEGMENT_FILTER = "\n AND l.listing_segment = ANY(CAST(:s
|
|||
# ttl_days. Замер (_REVISIT_TAIL, 40 суток): avito p99 = 42.1 сут -- ВЫШЕ его же
|
||||
# потолка 20. То есть для avito дефолтный CAP_MULT=2 может резать ttl ниже
|
||||
# собственного хвоста обхода -- ровно тот false-kill, ради которого пол вообще
|
||||
# заведён (см. комментарий выше). У cian/yandex (потолок 60) и domklik (потолок 28
|
||||
# при хвосте 3.1) такого разрыва нет -- множитель 2 для них калиброван верно.
|
||||
# заведён (см. комментарий выше). 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 достаточно, но
|
||||
# расписание может переопределить через default_params (JSON-колонка scrape_schedules,
|
||||
# ключ "cap_mult") для источника с непропорционально длинным хвостом -- см. миграцию
|
||||
# для avito, поднимающую cap_mult до 6 (потолок 60 суток, тот же порядок, что у
|
||||
# cian/yandex, и с запасом выше и статического p99=42.1, и живого прод-пика 52,
|
||||
# замеренного 2026-08-10..12).
|
||||
# не голая константа: 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
|
||||
|
||||
|
||||
|
|
@ -426,10 +438,23 @@ def deactivate_stale_listings(
|
|||
активный пул источника; cap_mult<1 (например 0.5) опускает потолок НИЖЕ
|
||||
заданного оператором ttl_days -- прямое нарушение инварианта «потолок не
|
||||
может понизить TTL ниже настроенного», который проверяет
|
||||
test_cap_never_lowers_ttl_below_configured_value).
|
||||
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-проверкой ДО числового сравнения для обоих параметров).
|
||||
"""
|
||||
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}")
|
||||
|
||||
|
|
@ -440,6 +465,8 @@ def deactivate_stale_listings(
|
|||
# запланированный способ задать 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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -13,19 +13,46 @@
|
|||
--
|
||||
-- ПОЧЕМУ ИМЕННО AVITO. Дефолт CAP_MULT=2 даёт разный АБСОЛЮТНЫЙ потолок на разных
|
||||
-- источниках (множитель от ttl_days), и ломается там, где хвост переобхода
|
||||
-- источника НЕ пропорционален его ttl_days:
|
||||
-- источник/сегмент p99 хвоста ttl_days потолок при cap_mult=2
|
||||
-- domklik vtorichka 3.1 14 28 (запас есть)
|
||||
-- cian vtorichka 26.6 30 60 (запас есть)
|
||||
-- yandex vtorichka 43.0 30 60 (запас есть)
|
||||
-- avito все сегменты 42.1 10 20 (ХВОСТ ВЫШЕ ПОТОЛКА)
|
||||
-- У avito p99=42.1 суток ВЫШЕ его же дефолтного потолка 20 -- дефолтный cap_mult=2
|
||||
-- может резать пол ниже собственного хвоста обхода, то есть ровно тот false-kill,
|
||||
-- ради которого пол вообще заведён. cian/yandex/domklik разрыва не имеют, дефолт
|
||||
-- cap_mult=2 для них калиброван верно, миграция их не трогает.
|
||||
-- источника НЕ пропорционален его 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, ради которого пол вообще заведён.
|
||||
--
|
||||
-- ПОЧЕМУ 6. Потолок 60 = 10 * 6 -- тот же порядок, что у cian/yandex (60), с запасом
|
||||
-- выше и статического p99=42.1 (_REVISIT_TAIL, tests/test_deactivate_stale_revisit_floor.py),
|
||||
-- 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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -253,3 +253,4 @@
|
|||
262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql
|
||||
263_scrape_schedules_wave2_cian_newbuilding_only_false.sql
|
||||
264_deactivate_stale_avito_cap_mult.sql
|
||||
265_deactivate_stale_yandex_cap_mult.sql
|
||||
|
|
|
|||
|
|
@ -162,18 +162,57 @@ 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, не пропускается как есть). Без этого теста калибровка cap_mult=6
|
||||
нигде не пиннится числом -- только упоминается в комментарии/миграции.
|
||||
ровно на 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(
|
||||
|
|
@ -183,13 +222,48 @@ def test_avito_prod_floor_is_capped_by_calibrated_cap_mult(monkeypatch: pytest.M
|
|||
ttl_days=ttl_days,
|
||||
segments=segments,
|
||||
revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE,
|
||||
cap_mult=6,
|
||||
cap_mult=calibrated_cap_mult,
|
||||
)
|
||||
assert out["ttl_days_effective"] == 60, "cap_mult=6 * ttl_days=10 обязан дать потолок 60"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -355,6 +355,52 @@ def test_cap_mult_non_numeric_fails_safe_before_any_sql(monkeypatch: pytest.Monk
|
|||
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 дефекта, где
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue