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