diff --git a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py index 0b55cfee..7b898079 100644 --- a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py +++ b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py @@ -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), diff --git a/tradein-mvp/backend/data/sql/264_deactivate_stale_avito_cap_mult.sql b/tradein-mvp/backend/data/sql/264_deactivate_stale_avito_cap_mult.sql new file mode 100644 index 00000000..2fed659b --- /dev/null +++ b/tradein-mvp/backend/data/sql/264_deactivate_stale_avito_cap_mult.sql @@ -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; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 483e25df..01075fff 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -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 diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py index da98c69d..d791e7e0 100644 --- a/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py +++ b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py b/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py index 3e1762d5..bacd78f6 100644 --- a/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py +++ b/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py @@ -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 ─────────────────────────────────────────