fix(tradein/deactivate): потолок эффективного TTL — защита от разгона пола и опечатки в расписании #2907
5 changed files with 188 additions and 8 deletions
|
|
@ -7,6 +7,16 @@
|
|||
- Cian/Yandex не поддерживают full-coverage sweep -> паушальный TTL сломает живой
|
||||
инвентарь. DECISION: для yandex/cian деактивировать ТОЛЬКО listing_segment='vtorichka',
|
||||
TTL=30. novostroyki (9659 активных первичных строк) и NULL-сегмент не трогаем.
|
||||
ИЗВЕСТНЫЙ ПРОБЕЛ (ревью TTL-CAP круг 2, 2026-08-15): этот скоуп уже, чем множество
|
||||
реально протухших строк -- живой замер на проде даёт cian/novostroyki 9 483,
|
||||
cian/NULL-сегмент 211, yandex/NULL-сегмент 523 активных строки старше 60 суток, ни
|
||||
одна из них не деактивируется НИ ОДНОЙ джобой (внутри скоупа cian/vtorichka и
|
||||
yandex/vtorichka таких строк 0). Потолок cap_mult (см. CAP_MULT ниже) этот пробел
|
||||
не закрывает и закрыть не может -- он сжимает пул ВНУТРИ скоупа джобы, а не
|
||||
расширяет сам скоуп. Расширение скоупа -- отдельная задача (нужно сперва выяснить,
|
||||
поддерживают ли cian/yandex full-coverage sweep для novostroyki/NULL-сегмента
|
||||
СЕЙЧАС, иначе паушальный TTL повторит инцидент, ради которого этот DECISION и
|
||||
принят) и намеренно НЕ входит в TTL-CAP.
|
||||
- avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений.
|
||||
- Строки НЕ удаляются -- история нужна для бэктеста (#667).
|
||||
- #2674: деактивация в той же транзакции пишет снимок listings_snapshots со статусом
|
||||
|
|
@ -408,13 +418,31 @@ def deactivate_stale_listings(
|
|||
ttl_days<=0 в WHERE-условии last_seen_at < NOW() - INTERVAL 'N days'
|
||||
матчит практически весь активный пул -- без явного guard'а потолок
|
||||
(ttl_days * cap_mult <= 0) к тому же перебивал бы пол в формуле min(),
|
||||
снимая защиту, которую max(ttl_days, floor) давал раньше).
|
||||
снимая защиту, которую 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).
|
||||
"""
|
||||
counters: dict[str, int] = {"deactivated": 0}
|
||||
try:
|
||||
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 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),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
-- 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, пол) без верхней границы -- на проде
|
||||
-- это оказалось петлёй с положительной обратной связью: медленный обход поднимает
|
||||
-- пол, высокий пол продлевает жизнь снятым лотам дольше, чем к ним успевает
|
||||
-- вернуться свежий обход, пул «активных» раздувается протухшими строками -- 23 687
|
||||
-- из 44 744 avito-строк не подтверждались >7 суток, самая старая «активная» запись
|
||||
-- не видена 86 суток. Потолок cap_mult ограничивает пол сверху: эффективный TTL не
|
||||
-- может превысить ttl_days * cap_mult (код -- app/tasks/deactivate_stale_avito.py,
|
||||
-- CAP_MULT).
|
||||
--
|
||||
-- ПОЧЕМУ ИМЕННО 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 для них калиброван верно, миграция их не трогает.
|
||||
--
|
||||
-- ПОЧЕМУ 6. Потолок 60 = 10 * 6 -- тот же порядок, что у cian/yandex (60), с запасом
|
||||
-- выше и статического 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;
|
||||
|
|
@ -252,3 +252,4 @@
|
|||
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_deactivate_stale_avito_cap_mult.sql
|
||||
|
|
|
|||
|
|
@ -130,13 +130,13 @@ def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.Monkey
|
|||
|
||||
Все они произошли на возрасте 29.9..30.3 суток. Эффективный TTL обязан быть
|
||||
строго выше этого возраста на cian/yandex-срезах — иначе следующий прогон
|
||||
снимет ту же строку снова. avito пропущен намеренно: 127 доказанных ложных
|
||||
снятий (_FALSE_KILLS_BY_CITY) измерены только по cian/yandex, а гипотетический
|
||||
замер пола avito=69.7 при ttl=10 -- ровно тот случай, для которого заведён
|
||||
потолок CAP_MULT (#TTL-CAP, 2026-08-15): без потолка пол растёт без
|
||||
ограничения (петля с положительной обратной связью, найдена на проде),
|
||||
покрытие такого выброса потолком намеренно НЕ гарантируется -- см.
|
||||
test_deactivate_stale_ttl_cap.py.
|
||||
снимет ту же строку снова. 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":
|
||||
|
|
@ -162,6 +162,34 @@ def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.Monkey
|
|||
), f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен"
|
||||
|
||||
|
||||
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=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
|
||||
нигде не пиннится числом -- только упоминается в комментарии/миграции.
|
||||
"""
|
||||
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=6,
|
||||
)
|
||||
assert out["ttl_days_effective"] == 60, "cap_mult=6 * ttl_days=10 обязан дать потолок 60"
|
||||
assert out["ttl_floor_capped"] == 1
|
||||
assert out["ttl_days_floor_raw"] == 70, "ceil(69.7) == 70 -- пол считается по real-числу"
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -310,6 +310,77 @@ def test_ttl_days_zero_fails_the_run_via_mark_failed(monkeypatch: pytest.MonkeyP
|
|||
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 == []
|
||||
|
||||
|
||||
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 ─────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue