fix(tradein/avito): окно ретроспективы под недельный такт + диагноз полного обхода (#2674) #2685

Merged
bot-backend merged 2 commits from fix/2674-avito-full-load-coverage into main 2026-08-05 23:13:40 +00:00
3 changed files with 43 additions and 24 deletions
Showing only changes of commit 6d76328168 - Show all commits

View file

@ -17,30 +17,41 @@
-- (57%) структурно вне поля зрения источника.
--
-- ЧИСЛА, обосновывающие новое значение (прод, tradein):
-- * 2026-06-21 — единственный день, когда оба Avito-обхода отработали успешно:
-- инкрементальный (окно 2) run 297 — 2804 unique, exhaustive (без отсечки)
-- run 295 — 9992 unique. Окно в 2 суток достаёт 28.1% того, что достаёт
-- полный обход; 7188 лотов (71.9%) лежат ниже отсечки.
-- * Когорта run 295, не виденная после 2026-06-23 (listing_date заморожен):
-- в полосе [D-2, D] — 189 лотов, в полосе [D-7, D-3] — ещё 427. Расширение
-- окна 2 -> 7 берёт в 3.26 раза больше лотов.
-- * Гистограмма (last_seen_at::date - listing_date) по avito за последние 20
-- суток: возраст 0-2 — 594 лота, возраст ровно 7 — 1212 лотов (51% всех
-- датированных наблюдений). У Avito недельный авто-подъём: sortTimeStamp
-- сдвигается кратно 7 суткам, поэтому на возрасте ровно 7 стоит пик.
-- Структурный минимум бездырочного покрытия — 6 (при 6 полосы соседних
-- прогонов смыкаются), но 6 режет ровно по этому пику. 7 = такт: полосы
-- [D-7, D] и [D, D+7] смыкаются с однодневным перехлёстом, который
-- покрывает дрейф расписания (замер: last_run 2026-08-03 13:37 ->
-- next_run_at 2026-08-10 14:16 = +7 суток 39 минут за цикл).
-- * Замер по ОДНОМУ прогону (run 2990, exhaustive 2026-08-02, 2317 строк
-- снапшота / 2184 с датой) — сколько лотов достаёт окно шириной W суток:
-- W=2 -> 147 W=6 -> 446 W=7 -> 1275 W=12 -> 1278
-- Три вывода. Окно 2 теряет в 8.7 раза (147 против 1275). Шестёрка теряет
-- две трети семёрки (446 против 1275) — это обрыв, а не экономия. И 7..12 —
-- ПЛАТО: +3 лота на пять дополнительных суток окна, то есть семёрка стоит
-- на самой дешёвой точке плато, а не является компромиссом.
-- * Обрыв на 7 и плато за ним — свойство НЕ Avito, а КВАНТОВАНИЯ нашего же
-- парсера относительных дат (_parse_relative_date, providers/avito/serp.py):
-- «неделю назад» -> ровно today-7, «две недели назад» -> ровно today-14.
-- Возрасты 8..13 по этому пути недостижимы — на проде их 3 лота из 2184
-- (это и есть плато). Значит бакет «возраст 7» — не «поднятые ровно неделю
-- назад», а ВСЁ, чему реально от 7 до 13 суток. W=6 режет не по пику
-- распределения, а по ГРАНИЦЕ КВАНТОВАНИЯ и теряет бакет целиком.
-- * Тот же бакет в общей выборке: (last_seen_at::date - listing_date) по
-- avito за 20 суток — возраст 0-2 = 594, возраст ровно 7 = 1212 из 3714
-- датированных наблюдений (32.6%).
-- * Полосы соседних прогонов [D-7, D] и [D, D+7] смыкаются с суточным
-- перехлёстом, который покрывает дрейф расписания (замер: last_run
-- 2026-08-03 13:37 -> next_run_at 2026-08-10 14:16 = +7 суток 39 минут).
--
-- ЦЕНА ПО ЗАПРОСАМ. Пагинация останавливается по глубине окна, поэтому число
-- страниц растёт примерно как число лотов в окне: ~3.3x к прогону. Прогоны с
-- окном 2 при delay=1.0: 2804 lots / 40 мин (run 297) .. 3989 lots / 99 мин
-- (run 620) ≈ 150-370 страниц. Окно 7 -> ≈ 500-1200 страниц НА ПРОГОН, но
-- прогон теперь недельный, а не ежедневный: до 206 система платила те же
-- 150-370 страниц СЕМЬ раз в неделю (~1050-2600). После этой правки —
-- ~500-1200 в неделю, то есть по-прежнему примерно вдвое дешевле, чем до 206.
-- ЦЕНА ПО ЗАПРОСАМ растёт НЕ пропорционально лотам. Стоимость бакета —
-- ceil(свежих / 50) страниц с полом в 1-2 страницы; при окне 7 на бакет
-- приходится ~15-20 свежих (1275 лотов на 77 бакетов = 7 комнатностей x 11
-- ценовых seed-брекетов) — МЕНЬШЕ одной страницы. Большинство бакетов как
-- стояло на 1-2 страницах, так и останется, глубже пойдут только плотные.
-- Верхняя граница честная и продом уже пережитая: полный обход без отсечки
-- вообще — 6 ч 59 мин (run 295) и 2 ч 34 мин (run 2990); окно 13-15 UTC
-- ограничивает только СТАРТ прогона, не длительность.
--
-- ВОЗВРАЩАЕТЕ ЕЖЕДНЕВНЫЙ ТАКТ — ВЕРНИТЕ И ОКНО. Ни эта миграция (GREATEST
-- только расширяет), ни планировщик (расширяет до такта, не сужает) окно НЕ
-- сузят. interval_days 7 -> 1 при incremental_days = 7 даст ежедневный прогон
-- с семисуточной глубиной, то есть восьмикратный охват КАЖДЫЙ день. Такт и
-- окно менять одной правкой.
--
-- Значение НЕ хардкодим числом 7, а выводим из фактического interval_days строки:
-- если такт когда-нибудь поменяют снова, повторный прогон файла (или ручной

View file

@ -87,6 +87,11 @@ async def test_daily_cadence_keeps_window() -> None:
assert await _incremental_days_passed({"incremental_days": 2}) == 2
async def test_null_interval_days_does_not_crash() -> None:
"""`"interval_days": null` в jsonb приезжает сюда как None, а int(None) — TypeError."""
assert await _incremental_days_passed({"interval_days": None, "incremental_days": 2}) == 2
async def test_no_window_stays_exhaustive() -> None:
"""Строка без incremental_days = полный обход; такт не должен её «инкрементализировать»."""
assert await _incremental_days_passed({"interval_days": 7}) is None

View file

@ -526,7 +526,10 @@ async def _job_avito_full_load(
# incremental_days=2 из миграции 129 → 3 календарных дня из 7 в поле зрения.
# Два независимых литерала, которые обязаны совпадать, однажды уже разъехались —
# поэтому расхождение чиним здесь, а не только данными.
interval_days = max(1, int(params.get("interval_days", 1)))
# None-safe так же, как incremental_days выше: `"interval_days": null` в jsonb
# приезжает сюда как None, а int(None) — TypeError.
_interval_days = params.get("interval_days")
interval_days = max(1, int(_interval_days)) if _interval_days is not None else 1
if incremental_days is not None and incremental_days < interval_days:
logger.warning(
"avito_full_load: окно ретроспективы incremental_days=%d уже такта "