Compare commits

...

9 commits

Author SHA1 Message Date
bot-backend
e00dcac177 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
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.
2026-08-15 21:13:41 +03:00
5843d63d22 Merge pull request 'fix(tradein/deactivate): потолок эффективного TTL — защита от разгона пола и опечатки в расписании' (#2907) from fix/tradein-ttl-effective-cap into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m41s
Deploy Trade-In / build-backend (push) Successful in 1m30s
Deploy Trade-In / deploy (push) Successful in 1m23s
Deploy Trade-In / deploy-status (push) Successful in 1s
2026-08-15 18:06:55 +00:00
bot-backend
08cff706ec docs(tradein/deactivate): убрать неверное число из обоснования потолка
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 4m33s
В шапке модуля, в комментарии миграции 264 и в докстринге теста стояло «23 687 из
44 744 avito-объявлений не подтверждались >7 суток» под заголовком «ЗАМЕР НА ПРОДЕ».

Число реальное, но приписано не тому. Перепроверено запросом 2026-08-15:
это ВСЕ источники вместе, и две трети — новостройки, которых оценщик не берёт
(он фильтрует listing_segment IS NULL OR = 'vtorichka'). У самого avito
просроченных строк ноль: 8 663 активных, максимальный возраст 10 суток.

Оставлять это в коде нельзя: следующий человек прочитает «avito раздут вдвое»,
проверит и не найдёт — а заодно потеряет доверие к остальным числам в том же
абзаце, которые верны и сверены с scrape_runs.counters.

Заодно явно записано, чего потолок НЕ делает: он не сжимает пул (0 деактиваций
замерено на всех четырёх джобах), а защищает от разгона пола и от опечатки в
расписании. Настоящий раздутый срез — строки с пустым сегментом, они чинятся
отдельной джобой.
2026-08-15 21:00:42 +03:00
bot-backend
19c9da8119 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 чист на изменённых
файлах.
2026-08-15 20:42:00 +03:00
bot-backend
8fff174337 Merge remote-tracking branch 'forgejo/main' into fix/tradein-ttl-effective-cap 2026-08-15 20:26:40 +03:00
bot-backend
772ae116b5 fix(tradein/deactivate): validate cap_mult, calibrate avito, document scope gap
Round-2 review (MAJOR) left three items open:

1. cap_mult was threaded through as a jsonb default_params parameter but never
   validated, reproducing the exact ttl_days<=0 hole the earlier guard closed.
   Verified live: cap_mult=0 -> effective_ttl=0 -> whole active pool of the
   source would deactivate; cap_mult=0.5 pushes the ceiling BELOW the operator-
   configured ttl_days. Added `if cap_mult < 1: raise ValueError` next to the
   ttl_days guard (same fail-fast contract, before any SQL). Non-numeric values
   (e.g. a stringly-typed "6" from a typo in default_params) already fail safe
   via TypeError on the comparison, caught by the same except-block -> mark_failed.
   Covered with 5 new tests (zero/negative/<1/non-numeric/mark_failed routing).

2. The mechanical part of cap_mult (parameter + wiring) was merged but never
   calibrated for avito on prod -- no migration shipped, so prod default_params
   for deactivate_stale_avito still lacked "cap_mult" and ran with the module
   default (CAP_MULT=2, ceiling=20d), which is BELOW avito's own p99 revisit gap
   (42.1d) and below the observed prod peak (floor=52, three runs 08-10..08-12).
   Added data/sql/264_deactivate_stale_avito_cap_mult.sql (idempotent, same
   pattern as 219) setting cap_mult=6 for deactivate_stale_avito only (ceiling
   60d, matching the order of magnitude already used for cian/yandex). cian/
   yandex/domklik keep the CAP_MULT=2 default -- their p99 gaps (26.6/43.0/3.1)
   sit comfortably under their default ceilings (60/60/28), no override needed.
   Pinned the calibration with a dedicated test
   (test_avito_prod_floor_is_capped_by_calibrated_cap_mult) instead of leaving
   the avito slice skipped in the false-kill coverage test.

3. Confirmed (SSH read-only, prod counts): active rows aged >60d that this PR
   cannot touch regardless of cap_mult -- cian/novostroyki 9483, cian/NULL
   211, yandex/NULL 523 (0 inside the jobs' actual scope: cian/vtorichka,
   yandex/vtorichka). deactivate_stale_cian/_yandex are scoped to
   segments=['vtorichka'] by a deliberate, documented DECISION (blanket TTL on
   novostroyki risks killing live inventory cian/yandex don't fully sweep).
   Widening that scope is a separate, riskier investigation and is out of
   scope here -- documented the gap directly in the module docstring next to
   the existing DECISION so it isn't lost.

Verification (SSH read-only against prod, 2026-08-15): recomputed the exact
per-source formula the next scheduled run will use. In-scope next-run
deactivation is currently 0 for all four sources -- the active pool has
already self-corrected to be consistent with each source's own recent
effective TTL (yesterday's yandex run used effective=54, so no active row is
older than that yet). This matches the round-2 reviewer's own conclusion: the
cap is a preventative guardrail, not a retroactive cleanup, and isn't expected
to fire on the exact day it's calibrated. It is not idle, though -- live
recompute of yandex/vtorichka's raw (uncapped) floor right now is 78.2d,
already above its 60d ceiling; the trailing 6-day counters show the identical
loop (floor=75, deactivated=0, three days straight) already recurred twice
without this cap in place. The mechanism will bind the moment the pool ages
past the ceiling, which is exactly the recurrence it exists to stop.

Tests: 106 passed (test_deactivate_stale_ttl_cap.py,
test_deactivate_stale_revisit_floor.py, test_deactivate_stale_health_gate.py,
test_deactivate_stale_listings.py, test_migrations_manifest.py). ruff clean.
scripts/check-migration-lock-timeout.py: pass (UPDATE-only migration, no
blocking DDL, no SET LOCAL needed).
2026-08-15 19:58:19 +03:00
bot-backend
bef2a05f60 Merge remote-tracking branch 'forgejo/main' into local/fix-ttl-effective-cap-review2 2026-08-15 19:43:03 +03:00
bot-backend
cb79c67bfc fix(tradein/deactivate): make TTL-cap multiplier configurable per source
Review of 3a1e29a7 found CAP_MULT=2 is uniform across sources with wildly
different ttl_days, so it produces a different ABSOLUTE ceiling per source:
cian/yandex (ttl=30) -> 60d, avito (ttl=10) -> 20d, domklik (ttl=14) -> 28d.
That breaks exactly where the crawl's revisit tail doesn't scale with
ttl_days: avito's measured p99 revisit gap is 42.1d (_REVISIT_TAIL) --
above its own default cap of 20d -- so a legitimately slow-but-alive avito
crawl cycle would get its floor cut below the very tail the floor exists
to protect (the false-kill scenario #2659 was filed for). cian/yandex/
domklik aren't affected: their default ceilings (60/60/28) already sit
comfortably above their own measured tails (26.6/43.0/3.1).

Fix: cap_mult is now a function parameter (same pattern as
revisit_floor_quantile/min_confirmations) with the module constant CAP_MULT
as its default, wired through product_handlers via default_params["cap_mult"]
so a schedule can override it without touching the shared default. Also
closes the ttl_days<=0 edge case flagged in the same review: before the cap,
max(ttl_days, floor) tolerated a misconfigured ttl_days<=0 as long as the
floor was positive; with the cap, min(floor, ttl_days*cap_mult<=0) would
silently defeat that protection and match nearly the whole active pool.
ttl_days<=0 now raises ValueError before any SQL, same contract as the
existing staleness_column whitelist check.

Also verified (read-only, postgres-tradein) the review's core "no-op"
claim: false. scrape_runs.counters for the 6 days since the revisit-floor
went live (08-10..08-15) show the cap DID bind on 3 of 6 runs for avito
(floor 52 vs cap 20) and 3 of 6 for yandex (floor 75 vs cap 60) -- the
reviewer's "no source hits the cap" read a single-day trough right after
a natural recovery, not the whole observation window. See PR discussion
for the full counter history and refutation detail.

Refs #2659
2026-08-15 18:47:49 +03:00
bot-backend
3a1e29a7da fix(tradein/deactivate): cap effective TTL floor at 2x configured value
Revisit-floor (#2659) raises effective TTL via max(ttl_days, floor) with no
upper bound -- a positive feedback loop confirmed on prod: slow crawl raises
the floor, a high floor keeps stale listings marked active longer than a
fresh sweep needs to return, the "active" pool bloats with rot, and the next
floor measurement on that bloated pool comes out even higher. Yandex counters
sat at ttl_days_effective=75/75/75/39/52/54 for six runs straight with
deactivated=0; 23,687/44,744 "active" avito listings hadn't been confirmed in
>7 days, cian 10,572/19,514 and yandex 7,178/15,790 were >30 days stale, the
oldest "active" row hadn't been seen in 86 days.

CAP_MULT=2 caps the floor's upward push without disabling it -- the floor
still protects against premature deactivation during genuinely slow (but
alive) crawl cycles, it just can no longer grow unbounded. Beyond 2x, a
persistently low crawl rate is better handled by the existing health gate
(min_confirmations), which disables deactivation outright instead of
stretching TTL forever.

When the cap binds, counters gain ttl_floor_capped=1 + ttl_days_floor_raw
(the uncapped value) so it's visible in the run-history dashboard, not just
logs -- counters are stored as-is in scrape_runs.counters.

Single fix point: all four sources (avito/yandex/cian/domklik) route through
this one deactivate_stale_listings() via the product_handlers wildcard
"deactivate_stale_*" handler, so no other task file needed the change.
2026-08-15 17:56:12 +03:00
9 changed files with 898 additions and 37 deletions

View file

@ -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,
),
)

View file

@ -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 сут "

View file

@ -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;

View file

@ -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;

View file

@ -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):

View file

@ -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

View file

@ -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)

View file

@ -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

View 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