Первая живая приёмка #3122: деплоевский startup-reap успел снять зомби
раньше, boot-reap отработал с нулём — и не оставил НИКАКОГО следа
исполнения. Ноль — штатный исход, но «сторож, молчащий при нуле» — это
слепая зона по построению: оборванную проводку не отличить от чистого
прода. INFO-строка при нуле, warning при снятых — как было.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Прод-факт 27.08 (после ночного офлайна #3119): 4 прогона 'running' со
стартами до старта контейнера блокировали свои источники через
has_running_run до 6-часового порогового reap'а — до пяти часов слепоты
на источник ровно после простоя, когда догон нужнее всего.
Критерий — started_at < старт процесса планировщика (минус минута на
дрейф), пульс не участвует: ложные срабатывания класса #2702 (редкий
пульс длинных прогонов) невозможны по построению — живой прогон этого
процесса не может быть старше самого процесса.
Маркер counters.boot_reaped=true открывает boot-зомби подхват чекпоинта
(_resume_decision): у порогового zombie процесс может быть жив
(движущаяся точка — причина исключения 'zombie' из _RESUME_STATUSES),
у boot-зомби — гарантированно мёртв. Пороговый zombie без маркера
по-прежнему отвергается (закреплено тестом-инвариантом).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Миграция 272: колонка + бэкфилл ТОЛЬКО по bbox региона 66 (значения
байт-в-байт из реестра, синхронизацию держит тест). Карантин NULL:
620 домов без geom, 23 порченых (ЕКБ-адреса с чужими координатами —
«Вильгельма де Геннина» на Байкале, «Крауля» под Москвой, «Учителей» в
Таллине, с живыми ссылками листингов) — им регион не присваивается,
включая 3 дома с координатами в bbox Москвы (порча, не переезд). NOT NULL
из постановки — отдельной миграцией, когда карантин опустеет.
Запись: новый дом наследует регион РАЗВЁРТКИ (base.py → контракт →
адаптер → matching), только если координаты не противоречат; координаты
другого региона → NULL-карантин + warning. Вне-bbox координаты при живой
развёртке наследуют её регион (адрес и развёртка согласны, координатам
веры нет) — семантика закреплена тестом, чтобы смена была осознанной.
Матчинг-запросы НЕ тронуты — гард по региону это #3052.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Третий шард после yandex (#3098) и avito (#3112). Выбран по замеру за 60 дней:
65 прогонов, среднее 35 минут, максимум 72, две отмены деплоем. Пятиминутного
дренажа (#3029) на такие прогоны не хватает - убитый на 35-й минуте сбор
начинался заново с первого якоря.
Ключ чекпоинта - ИМЯ якоря, а не индекс: состав списка зависит от city_slug
(областные свипы идут по своим наборам), позиция между городами не устойчива.
По той же причине гарда по числу якорей не нужна - в отличие от combo-чекпоинта
яндекса, где ключ якоря не содержал.
Отличие от avito-шарда: там успех и неудача якоря сходились в одной строке и
потребовался отдельный флаг _anchor_ok. У циана граница уже проведена самим
потоком управления - все ветки отказа делают return или continue и до записи
чекпоинта не доходят. Добавлять флаг значило бы дублировать то, что уже
выражено структурой; достаточно писать чекпоинт в единственной точке успеха.
Тест сторожит эту границу отдельно, потому что рефакторинг, сливающий ветки,
сломал бы её незаметно.
Пропущенный якорь двигает anchors_done - чтобы счётчик продолжал означать
"докуда дошли по списку", а не "сколько собрал именно этот прогон".
Тесты (4) поведенческие, с подменой CianScraper и save_listings: якорь из
чекпоинта не опрашивается вовсе; пройденный дописывается поверх унаследованных;
без чекпоинта обходятся все; упавший в чекпоинт не попадает. Двойнику пришлось
добавить счётчики state_extraction_* - конвейер читает их после каждого якоря
(#2625), и без них падал бы сам двойник, а не проверяемая логика.
Фальсификация: на исходном коде краснеют все 4. Весь набор #3074 (yandex, avito,
cian, claim) - 14 passed.
CI поймал то, что мой локальный прогон пропустил: сьют в ПОДДИРЕКТОРИИ
tests/services/ звал удалённый _in_ekb_bbox. Граничные точки сохранены те же
(продукт-ядро 66 байт-в-байт), проверка дополнена кодом региона.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Границы покрытия лежали литералами в трёх файлах (location_index / geocoder /
matching.normalize), и каждая молча отвергла бы Москву. Новый модуль
app.services.regions — лист дерева импортов — держит per-регион bbox'ы
(tight/wide/region/product_core), города, city_token и набор доступных тиров
обогащения; потребители держат прежние имена как алиасы на объекты реестра
(identity закреплена тестом — копии, разъезжающиеся при правке, невозможны).
Регион 66 — байт-в-байт прежние литералы (закреплено тестом: этот PR только
переносит границы, менять их = отдельное решение). Регион 77 (Москва): МКАД-
ядро + генеральный bbox с Новой Москвой и Зеленоградом; тиров обогащения НЕТ
ни одного — и это явный факт реестра с готовой формулировкой
(unsupported_tier_reason), а не молчаливое «посчитаем без источника».
Приёмка #3051: точка 55.75/37.62 больше не out_of_coverage — location_index
узнаёт регион 77 и считает в его ядре (сегодня листингов Москвы нет → честный
insufficient_data). Область 50 отложена по решению в #2996.
Не здесь (следующие шарды): city_fias_id сквозняком (п.2), doc_type в deals
(п.3), region_code у houses (п.4), депромоут описаний (п.5), параметры
загрузчиков (п.6). Гейт ЕКБ-тиров геокодера (#2582) уже деградирует правильно
для Москвы — fail-closed открывает их только при подтверждённом ЕКБ.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getattr вместо прямого импорта LISTINGS_FRESH_DAYS из config: на origin/main
константы там ещё нет, и тест умирал ImportError'ом на сборке модуля —
«возможности нет» вместо «значение неверно». Теперь на main: 5 красных
ассертами (предикат отсутствует/окно None/протухшие комплы в пуле), 3 зелёных
(сброс anchor_tier уже влит отдельно).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Продолжение после yandex-свипа. Выбор источника — по замеру, а не «для
полноты»: за 60 дней avito_city_sweep дал 68 прогонов, 25 банов и 3 отмены
деплоем при среднем времени 15 мин и максимуме 97.
Прод-факт, который решает дело. Типичный итог свипа:
{"anchors_done": 1, "anchors_total": 5, ...,
"enrichment_abort_note": "detail enrichment aborted (Avito detail
firewall/soft-block ...)"}
Прогон срывается блокировкой на ПЕРВОМ из пяти якорей. Без чекпоинта
следующий прогон снова идёт в первый якорь, упирается в ту же стену, и якоря
2-5 не собираются никогда.
Ключ чекпоинта — ИМЯ якоря, а не индекс: состав списка зависит от city_slug,
позиция в нём между городами не устойчива. По той же причине здесь не нужна
гарда по числу якорей, которая есть у combo-чекпоинта яндекса: там ключ
якоря не содержал, здесь якорь и есть ключ.
Инвариант, ради которого отдельный флаг _anchor_ok: в чекпоинт попадает
только якорь, пройденный до конца. Ветка блокировки делает return и до записи
не доходит, а generic-except доходит — якорь упал, но цикл продолжается.
Записать такой якорь пройденным значило бы, что следующий прогон пропустит
его навсегда, причём молча: прогон завершится штатно, просто часть города не
соберётся. Флаг сбрасывается на каждой итерации, иначе один упавший якорь
заразил бы все последующие.
Пропущенный якорь двигает anchors_done — чтобы счётчик продолжал означать
«докуда дошли по списку», а не «сколько собрал именно этот прогон».
domclick_city_sweep намеренно НЕ трогаю: 54 прогона, ноль отмен деплоем,
среднее время 3 минуты — чекпоинт там не окупается. cian_city_sweep (среднее
35 мин, 2 отмены) — следующий шард.
Тесты (4) поведенческие: якорь из чекпоинта не опрашивается вовсе; пройденный
дописывается поверх унаследованных; без чекпоинта обходятся все; упавший в
чекпоинт НЕ попадает. Оговорка: на исходном коде они падают по сигнатуре
(unexpected keyword argument), то есть доказывают отсутствие параметра, а не
поведение — поведенческую часть держат сами проверки. Весь набор #3074 —
10 passed.
Третья часть #3078 и единственная, трогающая прод-код.
До неё числовых рядов у приложений не было вовсе: только логи и исключения в
GlitchTip. Класс отказов «отвечает, но медленно» и «отдаёт 401 потоком» в такой
картине невидим — исключения нет, строка в логе выглядит обычной, а продукт
при этом не работает.
Метка route — ШАБЛОН маршрута, а не путь запроса. Это несущее решение, а не
деталь: кадастровый номер или идентификатор заявки в метке даёт новый временной
ряд на каждую сущность, а ряд у Prometheus стоит памяти постоянно, а не в момент
запроса. Самый известный способ уронить мониторинг тем самым мониторингом.
Незаматченные пути (404, сканеры) сведены в одну метку, иначе тот же взрыв
устроит любой бот, перебирающий адреса. Оба свойства сторожатся тестами, а не
комментарием: тест бьёт тремя разными идентификаторами и требует ОДИН ряд.
Слой регистрируется последним и потому оказывается самым внешним. Изнутри
RBAC-гварда не видно ни отказов авторизации, ни времени, которое он тратит на
резолв сессии в БД auth, — а именно этот путь уже давал инцидент с блокирующим
I/O в middleware (#1202). Упавший исключением запрос считается как 500 в
finally: без этого он просто отсутствовал бы в счётчике, то есть ровно тогда,
когда метрики нужнее всего.
Путь публичен ВНУТРИ и закрыт СНАРУЖИ — это два разных периметра. Скрейп идёт
из docker-сети, где заголовка X-Authenticated-User нет ни у кого, поэтому
/metrics внесён в _PUBLIC_PATHS обоих бэкендов; иначе агент получал бы 401 и
метрик не было бы вовсе. Наружу путь не открывается ни через gendsgn.ru, ни
через meraocenka.ru, и вдобавок закрыт явным respond 404 в обоих site-блоках —
чтобы закрытость осталась решением, а не следствием текущего порядка директив.
Ограничитель частоты и аудит «Меры» не трогались: оба смотрят только на пути
под /api/, скрейп под них не попадает. Проверено тестом, а не чтением.
Прод-поведение не меняется ничем, кроме нового публичного пути: ни один
существующий обработчик, гвард или маршрут не тронут.
Refs #3078
Запрос оценки один и блокирующий (POST /estimate, mutation.isPending) —
промежуточных событий по источникам не существует, а карточка рисовала
пофайловые «сбор...» с полосками 60%, счётчик 0/5 и подпись про Celery
group с таймаутом. Пользователь не мог отличить «думает» от «завис», а
серверная механика текла в UI-текст.
Вариант A из задачи (только фронт):
- на pending строки нейтральны («ожидает ответ»), раскраска — только по
факту estimate.sources_used после ответа;
- общая полоса на pending — честная неопределённая анимация вместо
выдуманного процента (Math.min(95, ...) убран);
- счётчик N/M на pending заменён на «опрашиваем…» (0/5 при идущей работе
читался как отказ);
- подпись без Celery/таймаута, тон legacy-заглушки «Считаем оценку…»;
- недостижимые ветки error/loading у строк удалены (их ничто не выставляло).
Вариант B (реальный статус с бэкенда) отклонён сознательно: /estimate
остаётся синхронным по решениям #3082/#3083, городить async+task_id ради
прогресс-бара — против них.
Preview-страница /ui-preview/estimate теперь рендерит ОБА состояния
карточки. Проверено скриншотом на dev (pending + done).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Detail-обогащение извлекало agency_name, но признак «продаёт профи» не
выводило — 4 469 активных листингов с известным агентством стояли с
is_pro_seller=NULL (признак эрозирован SERP-затиранием до PR #3067, а
заново не появлялся). Признак идёт в оценщик trade-in.
- save_detail_enrichment: is_pro_seller=TRUE при известном agency_name,
fill-only COALESCE; отсутствие блока НЕ доказывает «частник» (п.3 задачи —
отдельное решение), в ту сторону ничего не пишем;
- миграция 271: одноразовый бэкфилл уже существующих строк всех источников
(правило источник-независимо), идемпотентна.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Из таблицы убитых деплоем: yandex_city_sweep 15.08 прожил 65 мин, 12.08 —
2ч33м; оба потеряны целиком — у свипа не было чекпоинтов вовсе.
Единица чекпоинта — combo (сегмент × комнатность × ценовой диапазон), ровно
то, чем цикл обхода уже итерируется. Три слоя:
- провайдер: skip_combos (ни одного HTTP по собранным) + on_combo для каждого
ПРОЙДЕННОГО combo, включая пустые — иначе пустой combo не попадал бы в
чекпоинт и перечитывался бы вечно; оборванный отказом combo (gate failure)
on_combo по-прежнему не вызывает;
- пайплайн: done_combos → heartbeat с done_buckets (мерж jsonb, финализаторы
не затирают); подхват гейтится единственным якорем — combo_label не содержит
якоря, multi-anchor подхват пропускал бы чужие якоря;
- планировщик: resume_run_id=_pick_resume(...) в диспатче (generic-механизм
#2845 — params-идентичность, свежесть точки, потолок цепочки — бесплатно).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Прод-факт: прогон 4707 (23.08) подхватил 42 корзины у 4117, был убит деплоем
на 26-й минуте до завершения первой НОВОЙ корзины — и не успел ни разу
написать heartbeat с done_buckets. Его собственный чекпоинт пуст: следующий
кандидат увидел бы no_checkpoint, цепочка оборвалась бы с потерей 42 корзин.
_resume_decision при вердикте 'ok' теперь кладёт done_buckets предшественника
в counters-заготовку нового прогона — она персистится при claim, до старта
пайплайна. Heartbeat мержит jsonb: первый настоящий bucket-heartbeat
перезапишет ключ надмножеством, двойной записи нет. Отказные вердикты
чекпоинт не наследуют (прогон с нуля не должен врать о собранном).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Рейт-лимит меряет частоту (300/60с вправе стартовать в одну секунду), квота —
счётная и помесячная: параллелизм /estimate не ограничивал никто. Оценка
0.8–2.4с держит соединение общего пула SQLAlchemy (5+10) и внешние тиры —
пила одновременных оценок выедала пул и тормозила весь /api/v1/*.
Семафор по образцу public/mera.py::_suggest_slots: acquire после дешёвых
отказов (рейт-лимит, квота) с ожиданием 5с ≈ две длительности оценки,
timeout → 429 с Retry-After; release в finally сразу после дорогой части.
4+4 слота (estimate+suggest) = 8 удерживаемых соединений из 15 пула.
Семафор в памяти процесса — при переходе на несколько воркеров (#3083)
лимит умножится на их число; задачи согласовывать (о чём комментарий на месте).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Прод-факт: замер по avito_detail_backfill прочитал «обогащено 0 за 7 дней»
при реальных 801 — джобы пишут результат только ключом 'enriched', которого
_column_counts не знал, и колонка total_seen оставалась 0 у всех прогонов.
'enriched'-фолбэк добавлен именно в _column_counts (витринная колонка), а НЕ в
_RESULT_COUNTER_KEYS: тот список кормит zero-result-стрик, где догнавший
очередь бэкфилл стал бы непрерываемым измеренным нулём — ловушка, за которую
ревью уже выкинуло из списка 'rows_inserted' (#2703). Контракт стрик-сторожа
закреплён инвариант-тестом.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Замер 2026-08-23 с Poincare: у api.telegram.org семь публикуемых адресов,
отвечает РОВНО ОДИН — 149.154.167.220 (302 за 0.11 с). Резолвер при этом
отдаёт 149.154.166.110, который мёртв. Скан всего 149.154.167.0/24
подтвердил: живой он один во всём блоке.
Это не блокировка Selectel и не наш файрвол: с Beget отвечают три адреса
из тех же семи, включая тот, что отдаёт DNS там. Telegram частично
фильтруется у ОБОИХ провайдеров, просто Beget попадает на живой адрес.
Через прокси ASocks Telegram не проходит ни с одного из четырёх узлов
(все 000) — узлы российские, путь закрыт.
Без закрепления tradein-tgbot на long-polling резолвит мёртвый адрес,
виснет и умирает МОЛЧА: restart поднимает его заново, он снова виснет,
ни краша, ни строки в логе.
Закреплены все три потребителя Telegram, а не только бот:
- tgbot и backend — extra_hosts в новом docker-compose.selectel.yml.
backend тоже ходит в Telegram: пересылка алертов GlitchTip
(api/v1/glitchtip.py) и support-чат (api/v1/support.py).
- хостовые скрипты ops/lib-backup.sh и ops/uptime-healthcheck.sh —
шаг 11 в selectel-bootstrap.sh кладёт запись в /etc/hosts.
Им compose не помогает, они идут из cron, не из контейнера.
Отдельный override-файл, а не правка docker-compose.prod.yml: на Beget
закрепление не нужно, и менять поведение действующего прода ради
будущего хоста нельзя. Файл просто не передаётся в -f.
Адрес вынесен в TELEGRAM_API_IP с текущим дефолтом — если он умрёт,
правка в одну переменную окружения без релиза. Шаг bootstrap проверяет
не факт записи, а что Bot API отвечает 401 на фиктивный токен, и при
другом коде печатает команду поиска нового живого адреса.
Проверено: docker compose config даёт ровно две вставки extra_hosts
(tradein-backend, tradein-tgbot) и ничего больше; TELEGRAM_API_IP
подставляется в обе.
Refs #3059, #3057
Репетиция полного перелива на Poincare (12 потоков / 62 ГиБ / NVMe RAID1)
2026-08-23 вскрыла две мины окна 30.08, которых нет ни в одном issue.
1. mem_limit: 3g зашит жёстко. На 62 ГиБ целевое 44g, и лимит КОНТЕЙНЕРА
обязан подниматься РАНЬШЕ shared_buffers — он срабатывает раньше
postgresql.conf, поэтому shared_buffers=12GB при mem_limit=3g убивает
контейнер OOM-kill'ом на старте. Предупреждение об этом в файле было,
правки не было.
2. Оба compose монтируют каталог миграций в /docker-entrypoint-initdb.d.
На СВЕЖЕМ томе цепочка из 249 миграций отработает ДО восстановления
дампа и засеет данные (scrape_schedules 157 строк, tradein_users 13,
deals 80), а дамп идёт с --clean --if-exists.
Параметризованы только те значения, что зависят от размера хоста:
TRADEIN_PG_MEM_LIMIT (одна переменная на mem_limit и memswap_limit —
инвариант «без свапа» обязан держаться и после правки), TRADEIN_PG_SHM_SIZE,
shared_buffers, effective_cache_size, work_mem, maintenance_work_mem,
max_wal_size, плюс TRADEIN_PG_INITDB_DIR / GENDESIGN_PG_INITDB_DIR.
checkpoint_timeout, wal_compression, random_page_cost, pg_stat_statements
от размера хоста не зависят — не тронуты.
Все дефолты равны текущим прод-значениям на Beget. Проверено docker compose
config дважды на обоих файлах: без переменных резолвится в 3g / 512m /
768MB / 6GB / 16MB / 256MB / 4GB и штатные пути initdb — бит-в-бит как
в main; с переменными — в 44g / 12GB / 36GB / 64MB / 2GB / 16GB.
Лимиты остальных сервисов не изменились.
Postgres Site Finder (корневой compose) намеренно получил только переменную
initdb: mem_limit и тюнинга у него сейчас нет вовсе, добавление изменило бы
поведение текущего прода. Он до сих пор на стоковом конфиге —
shared_buffers 128 МБ на базу 15 ГБ, wal_compression off — отдельная задача.
Refs #2989, #3057
Правка меняет две вещи разом, обе намеренно.
1. Равенство по дате -> «последняя строка не позже якоря».
listing_source_snapshots переходит на модель «строка на изменение».
В ней equality-join теряет 95.6% пар (на 2026-08-20 изменениями являются
4458 строк из 101795): выборка для percentile_disc схлопывается до n=3-4,
квантиль вырождается в максимум из трёх чисел, TTL обваливается —
domklik 28->14 (под снятие сразу 128 активных строк), yandex 44->30,
avito 13->10.
Дыры в суточной истории ломали equality-join и до перехода: на проде
03-14.06 (12 суток подряд), 03-04.07, 12.07, 26.07, 30-31.07, 01.08 —
там n_pairs=0, floor_days=NULL и TTL молча оставался как задан.
2. Глобальный max(snapshot_date) -> максимум внутри источника.
Старый подзапрос брал максимум по ВСЕЙ таблице, не скоупленный по
listing_source_id: источник, чья история короче общей, выпадал из
выборки целиком. LATERAL ищет предшественника по строке.
Замер на живом проде 2026-08-23 (health_window_days=3): обе формы дают
побитово одинаковый результат на всех четырёх источниках —
avito n=765 пол=49.7, cian n=1226 пол=81.6, domklik n=565 пол=35.7,
yandex n=3856 пол=87.3. Сегодня суточная джоба пишет строку для каждого
источника каждый день, поэтому глобальный максимум совпадает с максимумом
каждого источника, и пункт 2 — no-op на текущих данных. Расхождение
проявится только на дырах и после перехода на change-only.
Плюс floor_n_pairs в counters — наблюдательность для будущего гейта
деградации пола, сейчас ничего не блокирует.
FROM..WHERE вынесен в _revisit_floor_from_where_sql, чтобы count(*) и
percentile_disc гарантированно шли по одному срезу.
Замер на проде 2026-08-22, разбивка одного расчёта по логам:
10.659 старт
10.827 дом найден 0.17 с
11.565 аналоги, 49 кандидатов 0.74 с
11.579 ДКП-коридор 0.01 с
17.576 yandex_valuation ← 6.0 с
19.159 cian_valuation ← 1.5 с
19.252 готово итого 8.6 с
Семь с половиной секунд из восьми с половиной — ожидание чужих HTTP. Наша база
и сам расчёт укладываются в секунду. По уже виденному адресу (кэш 24 ч) — 0.4-0.8 с,
по новому — 8-12.4 с. Геокодинг ни при чём: с готовыми координатами те же 7.5-10.8.
Публикация в РБК 30.08 приведёт аудиторию на НОВЫЕ адреса, то есть мимо кэша.
Масштабирование контейнеров тут не помогает: время уходит на ожидание чужого
ответа, а не на наши вычисления.
Что сделано: у обоих источников появился режим «только кэш» (fetch_on_miss).
При включённом ESTIMATE_EXTERNAL_SOURCES_BACKGROUND запрос делает лишь чтение
кэша (локальный запрос на миллисекунды), а свежая загрузка уходит в фон и
наполняет кэш к следующему обращению по тому же адресу.
Почему это безопасно: деградация источника в None — НЕ новое состояние ответа.
Ровно так же ведёт себя таймаут estimate_*_valuation_timeout_s, и этот путь
работает в проде сегодня. Контракт API не меняется.
Фоновая задача берёт СВОЮ сессию: сессия запроса закрывается вместе с ответом,
а обе функции источников делают внутри себя db.commit() — переиспользование
чужой сессии зафиксировало бы её незавершённую работу. По той же причине
отвергнут наивный asyncio.gather двух источников на одной сессии.
Очередь догрузки ограничена восемью задачами. Без потолка всплеск по новым
адресам — ровно тот случай, ради которого режим и сделан — породил бы сотни
параллельных задач с сессиями и HTTP-клиентами при max_connections 100 и
mem_limit 768m у backend, то есть отказ вместо ускорения.
Дефолт в коде False: поведение других окружений не меняется. На проде режим
включён через docker-compose.prod.yml у сервиса backend.
Отдельно НЕ сделано, хотя предлагалось: снижение таймаутов до 4 с. Замер
показал, что свежий запрос к Яндексу занимает 6 с — таймаут 4 обрывал бы его
почти всегда, кэш бы не наполнялся, и источник оказался бы тихо отключён.
Таймаут здесь страховка от патологии, а не регулятор задержки.
Тесты: 7 штук на режим «только кэш», собственную сессию, гашение ошибок,
удержание ссылки на задачу и потолок очереди. Фальсифицированы — на неизменённом
коде падают 5 из 7 (проходит только сторож неизменности дефолта). Смежные
тесты оценщика (29 штук: бюджет ЦИАН, клиентские координаты, аудит) зелёные.
Замер 2026-08-22, уже после починки транспорта (#3049): обогащение работает,
но очередь не разбирается. За сутки 238 карточек при 9 951 активном объявлении.
Причина в планировщике, а не в скрапинге. `compute_next_run_at` имеет суточную
гранулярность по построению — `interval_days = max(1, int(...))`, целевая дата
`now + interval_days`. Меньше суток не выражается. Бэкфилл при этом умирает по
бану через 17-83 минуты, то есть работал около часа в сутки, а остальное время
расписание ждало следующего дня.
Механизм sub-hourly каденса уже был написан — `reschedule_after_minutes`
(#2162, сделан для proxy_healthcheck). Его просто не подключили к бэкфиллу.
Хук ставит next_run_at = now() + interval_minutes сразу после claim и сам себя
тормозит: пока прогон идёт, has_running_run в _claim_run возвращает None.
180 минут — осознанно консервативная отправная точка, НЕ найденный оптимум.
Данных для подбора нет, и имеющиеся два прогона противоречат наивному
ожиданию: 4562 дал 175 карточек за 83 минуты, а 4586 через 4.3 часа — когда
пул прокси был давно чист — умер за 17 минут с 42 карточками. Значит память
Авито длиннее часов, и учащение может ухудшить выход.
Отдельно держать в голове при подборе: те же 4 прокси обслуживают SERP-свипы,
то есть первичный сбор. Сжечь их на обогащении хуже, чем медленно обогащать.
Двигать интервал вниз только по замеру нескольких суток, глядя и на свипы.
Подбор — через default_params расписания, правка кода для этого не нужна.
Тесты закрепляют подключённость хука и коридор дефолта, а не конкретное
значение: 180 будет двигаться, а вот утрата хука вернёт суточный простой
молча. Проверено фальсификацией — на неизменённом коде все три падают.
Замер на проде 2026-08-21: Авито за QRATOR отдаёт JavaScript proof-of-work
челлендж (startPow → кука pow_solved → self-reload через window.location).
curl_cffi не исполняет JS и пройти его не может в принципе.
Что это давало в цифрах (counters прогонов avito_detail_backfill):
run 4508: enriched 3 / attempted 46
run 4394: enriched 2 / attempted 47
run 4348: enriched 4 / attempted 53
~6% успеха. Браузерный путь на том же проде — ~74% (17/23 в инлайн-обогащении
свипа, 2/3 в точечной проверке после #3048). То есть задание работало почти
вхолостую, а поля дома, которые чинил #3048, до карточек просто не доходили.
Исходное обоснование AVITO_DETAIL_BACKFILL_USE_CURL=true (browser открывает
десятки коннектов и превышает cap прокси-аккаунта auv) больше не действует:
браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect
SCRAPER_PROXY_URL, что и curl-путь.
Переключение сделано в docker-compose.prod.yml (environment перекрывает
env_file), а не правкой рантайм-env на хосте — чтобы изменение осталось в
репозитории и пережило deploy без дрейфа конфигурации. Дефолт в config.py
оставлен True: другие окружения не трогаем вслепую, но устаревшее обоснование
там помечено.
Воркер сослался на #3086, которого не существует — тест с таким именем
остался бы загадкой для следующего читателя. Заведён настоящий issue #3047
с замерами покрытия и разбором дефекта; все ссылки и имя файла приведены
к нему.
Refs #3047
Разобрались по эталонной разметке (2 живые detail-карточки, 2026-08-21), что реально
парсится и что нет:
- sale_type: item-view/item-params — Avito отдаёт "О квартире" и "О доме" как ДВА
ОТДЕЛЬНЫХ <div> с ОДНИМ И ТЕМ ЖЕ маркером, а не один div с двумя <ul>, как
предполагал старый код. css_first брал только ПЕРВЫЙ div — sale_type это не
задевало (он в первом блоке), но house_type/total_floors_house/лифты из второго
блока терялись молча через мёртвую ветку `len(ul_els) >= 2`. Теперь читаем <ul>
из ВСЕХ блоков с этим маркером — устойчиво к порядку блоков.
- metro_stations: раньше только эвристика по тексту описания (28 строк из 54 855).
Основной источник теперь — структурная разметка (#item-view-address, иконка
"Пешком до метро"), покрывает станции без ограничения на суффикс имени
(METRO_RE ловил только "-ская"/"-инская"). Описание — фолбэк.
- is_homeowner: не парсился вовсе. [data-marker='seller-info/label'] — "Частное
лицо" -> True, "Агентство" -> False. Подтверждено разными значениями на двух
эталонах.
- days_on_market: на странице явно нет, но есть publish_date, из которого честно
выводится. Заодно чинит сам publish_date — искали дату ВНУТРИ item-id-блока, а
она в СОСЕДНЕМ [data-marker='item-view/item-date'] ("сегодня в HH:MM").
- cadastral_number: подтверждено отсутствие на странице (не парсим, не выдумываем).
Тесты на реальной разметке (backend/tests/test_avito_detail_fields_3086.py) на
обеих эталонных фикстурах (большие embedded JS-блобы вырезаны из фикстур —
не используются DOM-based парсером, экономят место). estimator.py не тронут —
ни одно поле не влияет на цену.
Найдено при ревью ветки. `_wait_out_pow_challenge` опрашивал `page.content()`
без защиты, а челлендж перезагружает страницу САМ (`window.location =
location.href`). Вызов content(), попавший в момент этой перезагрузки, кидает
«Execution context was destroyed, most likely because of a navigation» — то
есть цикл ронял фетч ровно тогда, когда проверка успешно пройдена и мы
дождались того, ради чего ждали.
На моках дефект не воспроизводился: поддельная page навигацию не рвёт.
Добавлено:
- `_content_during_navigation()` — content(), возвращающий None вместо
исключения, если текст ошибки указывает на гонку с навигацией. Различаем
по тексту, а не по типу: сервис не импортирует playwright, page приходит
готовым. Всё прочее (закрытая страница, упавший браузер) пробрасывается.
- Цикл трактует None как «ещё не устоялось, опроси снова».
- Финальная догидрация тоже защищена: один короткий добор, затем внятная
ошибка вместо падения на гонке.
- Поддельная page в тестах умеет поднимать исключение из content();
три теста на гонку — прохождение, вечная навигация, посторонняя ошибка.
Фальсификация: без правки два новых теста падают именно с
`Execution context was destroyed`. Полный сьют сервиса — 123 passed.
Refs #3045
Живой замер 2026-08-21 (#3045): фиксированной паузы BROWSER_WAIT_MS (6с) не
хватает на цепочку «PoW-расчёт в JS → таймер 3с → self-reload → гидрация» —
4 из 6 карточек с органической навигацией отдавали 7891-байтную challenge-
страницу вместо контента (не бан, "проверка безопасности"). caller считал её
валидным HTML — парсер либо падал, либо молча ничего не находил.
_fetch_once теперь опрашивает page.content() (шаг ~1с, бюджет
BROWSER_CHALLENGE_WAIT_MS=30000) пока маркеры челленджа (startPow / "проверка
безопасности") не исчезнут, затем догидрируется тем же BROWSER_WAIT_MS. По
истечении бюджета — ChallengeTimeoutError вместо заглушки. wait_for_url не
годится: страница перезагружает саму себя, URL не меняется.
Бан-страница ("проблема с IP") распознаётся отдельно и падает сразу
(BanPageDetectedError), без траты бюджета ожидания — это не то же самое, что
челлендж, и ждать там нечего.
Провайдер-агностично по форме: включается только по факту маркеров в HTML,
cian/yandex/generic их никогда не отдают.
Замер живьём 2026-08-21 через tradein-browser (camoufox, JS исполняется):
59-60 уникальных `data-item-id` на странице выдачи. «Лишние» сверх 50 —
обычные объявления с платным продвижением (`vas-icon_type-promoted`), они
лежат в том же списке под `page-title/count`, а не отдельным рекламным
блоком, и собираются наравне с остальными.
Направление эффекта важно понимать правильно. Константа участвует ТОЛЬКО
в `ceil(total / PAGE)`, поэтому занижение размера страницы ЗАВЫШАЛО
расчётное число страниц, а не занижало:
- запрашивали примерно на 17 % страниц больше, чем нужно; при доле банов
37-83 % по avito-заданиям лишние запросы — основная цена ошибки;
- `tail_loss` считался как `total - cap * 50` и завышал потерю;
- флаг `complete` в пагинации листа чаще ложно показывал «неполно».
Тихой потери данных НЕ было: условия «страница вернула меньше PAGE
карточек, значит последняя» в коде нет, пагинация ограничена только
`max_pages`. Тест закрепляет и значение, и направление арифметики, чтобы
неверная трактовка не вернулась при следующем рефакторинге.
Найдено при разборе Авито сверкой живого браузера со скраппером; полный
разбор — в волте `research/Avito_Live_Browser_Recon_0821.md`.
Refs #3033
DEFAULT_IMPERSONATE переведён на chrome146, но оба pyproject продолжали
объявлять `curl-cffi>=0.7.0`. В 0.7.0 такого профиля нет: установка по
нижней границе — и curl_cffi роняет КАЖДЫЙ запрос скраппера на невалидном
impersonate, причём одинаково для avito, cian и yandex.
На проде стоит 0.15.0 (проверено в контейнере tradein-scraper: профили
chrome99 … chrome146 плюс алиас chrome), поэтому текущий деплой бы не
пострадал — но любая пересборка окружения с резолвом к нижней границе
воспроизвела бы отказ, и диагностировался бы он как «скрапперы легли»,
а не как несогласованная зависимость.
Refs #3034