Ревью (⚠️ minor) на #3403: «Ошибка - Циан» может быть транзиентной 5xx-страницей,
отданной с кодом 200, а не отказом конкретному узлу. Цена ошибки несимметрична —
mark_banned эскалирует TTL до часов, поэтому 20-минутный сбой площадки выбил бы из
выдачи весь пул. Один список маркеров этого различить не мог: и капча, и страница
ошибки шли одним путём в BanPageDetectedError.
Проба прода 06.09.2026 09:25 UTC (одна карточка по узлам через сайдкар):
* узел 14, час назад отдававший «Captcha - база объявлений ЦИАН», вернул НАСТОЯЩУЮ
карточку — капча снимается за 1-2 часа, то есть TTL бана по назначению;
* узел 1 отдал ТРЕТИЙ вариант отказа — `<title>Вы не робот?`, 16 КБ (час назад —
«Ошибка - Циан», 374 КБ). Прежние маркеры его не знали вовсе: отказ уезжал наверх
как валидный HTML ровно так же, как до #3402.
Маркеры разделены на два класса, одинаково в обоих слоях (образы backend и browser
деплоятся раздельно и расходятся на часы):
* КАПЧА — «captcha - база объявлений циан» + «вы не робот?»: безусловный отказ
площадки, прежний путь (сайдкар → BanPageDetectedError → 403 + ban_page; kit →
report_platform_ban + CianBlockedError). За ней нет контента, и узел, которому её
показали, будет получать её дальше;
* «ошибка - циан» — ТОЛЬКО ЛОГ: сайдкар отдаёт HTML клиенту как есть и пишет WARNING
«страница ошибки Циана (title=…, upstream=…) — не бан, только лог (#3402)», kit при
провале extract_state пишет WARNING и возвращает прежний None. Ни бана, ни рапорта,
ни исключения — решение принимаем по частоте в логах за цикл наблюдения, а не по
догадке о природе страницы.
Нормализация заголовка прежняя (регистр/пробелы/тире). `_is_cian_refusal` →
`_is_cian_captcha` + `_log_cian_error_page`; `_refusal_title` → `_page_title` и два
кортежа маркеров рядом.
Фальсификация: «вы не робот?» убран из маркеров обоих слоёв → kit 1 failed
(«DID NOT RAISE CianBlockedError»), сайдкар 3 failed («DID NOT RAISE
BanPageDetectedError», `_is_cian_captcha` → assert False is True). Маркер возвращён,
обе сьюты зелёные: backend 5599 passed / 35 skipped, browser 246 passed.
Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`, 44 КБ) и страницу
ошибки (`<title>Ошибка - Циан`, 374 КБ) с кодом 200. Детектор сайдкара их не знал
(_REFUSAL_STATUSES {403,429} + маркеры Авито/Домклика), HTML уезжал клиенту как
успех, extract_state возвращал None и провайдер печатал «defaultState extraction
failed» — отказ ПЛОЩАДКИ читался как дрейф НАШЕЙ разметки. Аренда при этом не
менялась: fetch() уже отрапортовал mark_health(ok=True), fail-streak обнулялся, и
один капча-узел сжигал батч целиком (6200: 0/210; 6123/6091/6052/6032/6010/5981:
0/400 — против 161/162 через здоровый узел на прогоне 13).
Два слоя, потому что образы backend и browser деплоятся раздельно и расходятся
на часы:
* сайдкар (browser/server.py) — детект по <title> на обоих путях (navigate и
подзапрос) → BanPageDetectedError → прежний путь #3288/#3379: 403 + ban_page +
ЧЕСТНЫЙ upstream-статус 200;
* kit (providers/cian/detail.py) — при провале extract_state те же маркеры →
CianBlockedError вместо тихого None, плюс report_platform_ban по живому lease.
Там же ветка SidecarBanPageError: отказ, опознанный сайдкаром, больше не
гасится общим `except` в «не смогли разобрать».
Слово `captcha` признаком быть не может: в нормальной карточке оно встречается 11
раз, на капче 17. Детект по <title> с нормализацией тире.
`_report_platform_ban` → `report_platform_ban` (публичный): тем же путём обязан
идти отказ, распознанный не сайдкаром, а провайдером. report_ban один только
пишет бан пары «узел×источник» — сменить сожжённую аренду ВНУТРИ батча позволяет
только fail-streak (_LEASE_ROTATE_AFTER_FAILS).
Две живые копии одного модуля с противоположной семантикой counters: app
`mark_done`/`mark_failed`/`mark_banned`/`update_heartbeat` ЗАМЕНЯЛИ
(`counters = CAST(:counters AS jsonb)`), kit — МЕРЖИЛИ
(`COALESCE(counters,'{}') || …`). Расхождение дважды за сутки дало ложные
выводы на ревью (#3388 «отдать только флаг, остальное домержится» — на
replace это стёрло бы измеренное; #3355). Разошлись и другие места: гейт
статуса, `honors_cancel` у mark_cancelled (был только в app), `mark_skipped`
(только в kit), `mark_backfill_finished`/`distinct_sources` (только в app).
Реализация теперь одна — `scraper_kit.orchestration.runs`; в неё перенесены
app-only функции. `app.services.scrape_runs` — алиас kit-модуля через
sys.modules, а не реэкспорт имён: реэкспорт разводит патч-цели
(`patch("app.services.scrape_runs.sentry_sdk")`, `patch.object(runs_mod,
"mark_done")` правили бы глобаль модуля-обёртки, а тело функции читает
глобаль kit'а) — тест остался бы зелёным, не подменив ничего. С алиасом оба
имени ведут в единственную реализацию, и ни один из ~40 вызывающих и ~30
патч-сайтов в тестах не правится.
Победила семантика мержа: у строки прогона несколько писателей (пульс,
финализатор, дрейн), каждый знает лишь свои ключи, и замена теряла чужие —
чекпоинт done_buckets (#930), метку interrupted (#3391), замер из пульса
(#3384). Обратной зависимости («вызывающий рассчитывает, что финализатор
УДАЛИТ ключ заменой») нет: строка создаётся пустой в create_run, резюм читает
counters ПРЕДЫДУЩЕГО прогона по его id.
Тесты по значению на обоих путях импорта (двойник сессии читает SQL: `||`
против CAST, WHERE-гейт из текста): пульс {a:5} + mark_failed {b:1} → {a,b};
пульс/финализатор по финализированной строке — no-op; mark_cancelled
отказывает источнику, который отмену не опрашивает. На main эти тесты
красные для app-пути.
Комментарии в app/services/scheduler.py и kit/pipeline.py, утверждавшие про
живого «перезаписывающего двойника», приведены в соответствие.
`run_cian_city_sweep` звал `fetch_newbuilding(zhk_url, config=config)` без
`proxy_provider`, хотя провайдер лежит в аргументах самого свипа и соседние
фазы (SERP через CianScraper, detail через cian_fetch_detail) его передают.
Внутри это давало `build_browser_fetcher(config, "cian", proxy_provider=None)`
→ `use_pool` эффективно False → POST /fetch без "proxy" → сайдкар брал свой
env-узел SCRAPER_PROXY_URL, на проде выключенный (407 → camoufox InvalidIP →
/fetch 503, факт #3386). Фаза houses давала 0/30 с 02.09 (run 6179: 24 ×
`houses failed ... 503 Service Unavailable`), строк `override=True` в логах
сайдкара по ней не было ни одной. Остальные вызывающие `fetch_newbuilding` /
`resolve_cian_zhk_url_via_search` провайдер уже передают (#2767/#2830/#3382),
этот вызов был последним мимо пула.
Второе: пустой пул поднимается ДО запроса, следующий дом упрётся ровно в то
же самое — общий `except Exception` на дом превращал это в 30 одинаковых
houses_failed и прогон уходил в 'done'. Теперь NoProxyAvailableError рвёт
фазу и свип: `no_proxy_stop=1` в counters, mark_banned с ban_kind='infra' и
сохранённым done_buckets (образец — #3389 yandex-nb-sweep, #3382). Ветка
стоит ДО generic-except, иначе наш отказ инфраструктуры читался бы как
«IP likely blocked» — бан площадки.
После #3392 SIGTERM-дрейн финализирует in-flight прогоны штатными
mark_done/mark_failed с counters.interrupted=1 — раньше они оставались
'running' → 'zombie' и сторожей не касались. В популяции стриков эти строки
судят частичные счётчики:
- detail-бэкфилл, убитый на 20% отказов, получал 'failed' с диагнозом
«сбор деградировал» (_failed_ratio_too_high) — диагноз про площадку,
которого никто не измерял, — и входил в стрик неудач;
- cian-бэкфилл (без attempted) получал 'done' и ОБНУЛЯЛ стрик банов —
ровно вред, задокументированный в orchestration/scheduler.py:99
(«5 банов подряд обнулил один 'cancelled' 09.08»).
Прогон с меткой interrupted теперь считается так, будто его не было: он
стрик ни продлевает, ни обнуляет (обе лестницы, обе копии модуля), а три
honest-status-гейта в mark_done пропускаются — статус остаётся 'done' с
меткой interrupted (конвенция #3319/#3333/#3355), причина в логе.
'cancelled' оставлен как был: там прогон прервал человек.
SELECT сторожа неудач дополнен колонкой counters — по ней и идёт отбор.
Пять замечаний deep-ревью к PR #3392, ровно они.
1. Пульс стирал метку дрейна. `update_heartbeat` обеих копий бил `WHERE id = :run_id`
без гейта по статусу, а app-копия counters ЗАМЕНЯЕТ (#3390): задача, помеченная
`interrupted`, но ещё живая (ветка таймаута drain_inflight отдаёт её внешнему
hard-cancel'у — несколько итераций спустя, пульс на каждый батч —
app/services/scheduler.py:141), следующим же ударом стирала метку, и оборванный
прогон снова читался как полный проход. Гейт — `IN ('running', 'cancelled')`, а не
`= 'running'`: 'cancelled' финализирует строку, но задача встаёт лишь на ближайшей
границе якоря, и её последний пульс — ЕДИНСТВЕННЫЙ писатель чекпоинта в этот момент
(pipeline.py:1308/2368/2986/4488, mark_done там уже no-op по своему гейту), а
'cancelled' входит в _RESUME_STATUSES — сужение до 'running' молча съело бы точку
возобновления у каждой отмены. Возвращаемое значение update_heartbeat не читает
никто (обе копии -> None, ни одного присваивания на 130 сайтах вызова), так что
«0 строк обновлено» ломать нечего; no-op логируется WARNING'ом, как у mark_done.
2. Отмена вне drain_inflight. Hard-cancel приходит по расписанию grace'а
scheduler_main, а не по нашему, и может застать ТЕЛО тика (reap / stale-digest /
`_dispatch` с сетевым pre_claim). `except Exception` тика CancelledError не ловит,
до `await ctx.drain_inflight()` дело не доходит — строки оставались 'running'.
Тело вынесено в `_tick_loop`, `scheduler_loop` ловит CancelledError, помечает
in-flight и пробрасывает отмену.
3. rollback в except пометки: отказавший statement оставляет сессию в aborted-tx, и
первый же непроходимый run_id утаскивал все следующие (образец — defensive rollback
в mark_failed/mark_banned).
4. session_factory()/db.close() втянуты в try: исключение оттуда ЗАМЕНИЛО бы собой
CancelledError, а suppress(CancelledError) в scheduler_main его не глушит — процесс
уходил бы с трейсбеком вместо чистого drain-выхода.
5. WARNING перечисляет marked_ids, а не весь run_ids (там были и пропущенные по
статусу). В докстринге назван потолок: SELECT синхронный, у движка нет ни connect-,
ни statement-таймаута (app/core/db.py:8-19) — недоступная БД блокирует луп до
SIGKILL'а через 20 с docker-grace; данные при этом не хуже прежних (строки остаются
'running' → boot-reap).
Тесты — по значению, не по факту вызова; на исходниках 30e3bacc краснеют все пять:
'listings_processed' дописан в финализированную строку (kit), KeyError: 'interrupted'
(app), assert 'running' == 'done' (отмена в теле тика), assert 0 == 1 (второй run_id
не помечен после отказа первого), RuntimeError наружу (недоступная БД).
Прод 07.09 02:36 UTC, первый настоящий drain после #3363: hard-cancel из
scheduler_main оборвал дрейн, и два бэкфилла (cian_detail_backfill 6167,
cian_history_backfill 6173) остались в scrape_runs со статусом 'running' —
boot-reap следующего контейнера сделал их 'zombie' (boot_reaped=true), метки
interrupted не было. interrupted=1 при дрейне писали только kit-пайплайны и
DKP-импорт: у задач, чьё тело живёт в app, ставить её было некому.
Метка ставится в единственной точке, через которую проходит любая detached
run-задача — SchedulerContext.drain_inflight: и по истечении
_CHILD_DRAIN_TIMEOUT_S, и в обработчике CancelledError (тот самый прод-путь).
run_id берётся из нового реестра {task: run_id}, который заполняет _dispatch
сразу после claim'а; claim-логика не тронута. Статус строки перечитывается
перед записью, поэтому успевший финализироваться сам прогон не
перезаписывается, а counters читаются из строки и дописываются — app-копия
mark_done их ЗАМЕНЯЕТ (#3390), голая {"interrupted": 1} стёрла бы чекпоинт.
scheduler_main: _await_scheduler возвращает признак hard-cancel'а, и строка
«scheduler drained cleanly (SIGTERM)» больше не печатается сразу за WARNING'ом
о превышении grace — на проде эти две строки стояли подряд и противоречили
друг другу.
Запас времени на запись: docker stop_grace_period 120s − _DRAIN_TIMEOUT_S 100s
= 20 с после hard-cancel'а, запись синхронная (несколько statement'ов).
`YandexNewbuildingScraper.fetch_jk` и `resolve_yandex_jk_slug` строили
`BrowserFetcher(source="yandex", endpoint=...)` без proxy_provider/use_pool/
environment — сайдкар брал env-узел SCRAPER_PROXY_URL, на проде выключенный
(407 → camoufox InvalidIP → /fetch 503, факт #3386), то есть путь шёл мимо пула
целиком, а прод-отказ «пул пуст» (#2616) был мёртв: он смотрит на environment,
который до конструктора не доезжал. Обе точки собраны через
build_browser_fetcher(config, "yandex", proxy_provider=...), как yandex/serp.py.
Второе: общий `except Exception` в обеих функциях глотал NoProxyAvailableError и
возвращал None — прогон, не ходивший к площадке, перебирал все ЖК и уходил в
'done'. Теперь «пул пуст» пробрасывается наружу (caused_by_no_proxy), sweep
обрывается на первом доме с no_proxy_stop, а прогон финализируется как failed
(образец дефекта — #3382, cian/detail.py).
Ревью нашло, что миграция и код ловили РАЗНОЕ. Миграция брала базой предыдущую
СЫРУЮ строку (lag), гейт — предыдущую ОСТАВЛЕННУЮ. На 1M→10M→1M→10M (цена 1M)
lag-версия удаляла честную точку, на 1M→10M→1.05M→9.9M — не была идемпотентной
(второй прогон доедал 9.9M). Теперь кандидаты выбирает PL/pgSQL-цикл, пошагово
повторяющий drop_decimal_slips, а правило первой точки — отдельным INSERT..SELECT
уже по ОСТАВШИМСЯ строкам.
Правило первой точки — из прод-разбора: 12 из 20 остатков domklik это серии вида
330 000 → 3 300 000 (текущая цена 3 300 000) и 420 000 → 4 200 000 → 4 500 000,
где дефектная точка ПЕРВАЯ и базы слева у неё нет. Свидетелей по-прежнему два:
×10 ко второй точке И подтверждение второй третьей-или-текущей-ценой. Решение по
первой точке принимается по kept-серии, а не по сырой, — иначе гейт теряет
идемпотентность (перебор ловит 1122 таких прогона).
Идемпотентность доказана НА ГЕЙТЕ: property-тест gate(gate(s)) == gate(s) по всем
сериям длины 2-6 (19 525 серий × 3 текущие цены). Фальсифицирован обеими
поломками — сырая база даёт 136 красных прогонов, сырые соседи первой точки 1122.
Раз SQL зеркалит гейт, свойство переносится на миграцию.
Ещё в 286: третий свидетель ПРОТИВ удаления (цена подтверждена триггерной строкой
того же объявления — значит она реально наблюдалась в listings.price_rub) и
финальный шаг |diff_percent| > 100 → NULL по всем источникам, то же правило, что
validate_diff_percent на записи. Ожидаемое число удалений в шапке — 35 + ~12 из
двухсвидетельского предзамера, а не 76 (то была односвидетельская цифра).
Прогон обеих фаз дважды с ROLLBACK — tradein-mvp/scripts/sql/286_dryrun.sql.
yandex: проводка гейта снята как мёртвая. На том пути серия из двух точек, а
свидетель последней — текущая цена лота, то есть она же сама: ветка по построению
не могла выбросить ничего. Оставлен честный комментарий-потолок и ссылка на
follow-up (отлов требует DELETE на следующем наблюдении).
cian: после выброса точки соседу пересчитывается diff_percent (было только у
domclick). domclick: цена листинга берётся RETURNING'ом у UPDATE вместо отдельного
SELECT по PK, пересчёт вынесен в общий recompute_diff_percent с гейтом на пустую
цену (ручной ingest кладёт price_changes из JSONL без валидации).
В priceHistory источников встречаются точки ровно ×10/÷10 к соседям с
возвратом к базе следующей же точкой — это потерянный разряд у источника,
а не рынок. Прод-замер 06.09.2026: 76 таких строк у 55 объявлений
(domklik 48, cian 21, yandex 6; единственная avito-строка — триггерная).
Читатели колонки — медианный торг лендинга (#3223), админка, /scrapers.
drop_decimal_slips живёт рядом с validate_diff_percent — на той же единой
границе записи, что и гейт #3225, и подключён ко ВСЕМ писателям истории
(domclick/detail.py, cian/detail.py, yandex_price_history.py). Критерий
требует двух свидетелей: скачок ×10 к предыдущей точке И возврат к базе у
следующей; у последней точки серии свидетель — текущая цена объявления,
нет и её → точку не трогаем (без второго свидетеля ×10 может быть честной
сменой цены). У domklik после выброса пересчитывается diff_percent
соседа: парсер считал его от базы, которой больше нет.
Миграция 286 чистит уже собранные строки тем же критерием и только у
загрузчика (change_time <> recorded_at): триггерные строки — это живые
смены listings.price_rub, у них другая база отсчёта (см. 285). Падает,
если кандидатов больше 200.
BrowserFetcher(source="cian") в cian_history_backfill конструировался без
proxy_provider/use_pool/environment — трёх аргументов, которые кладут "proxy" в тело
POST /fetch. Сайдкар брал свой env-прокси (SCRAPER_PROXY_URL): пул из 4 узлов, его
баны и ротация проходили мимо, а прод-отказ «пул пуст → не ходить на env/direct»
(#2616) на этом пути был мёртв, потому что смотрит на environment. Проводка теперь
как у соседей — domclick_detail_backfill и house_imv_backfill.
Ожившему отказу нужен обработчик: NoProxyAvailableError ловился общим except на
объявление, и батч крутил впустую весь список (пул пуст с первого — значит пуст и на
1000-м). Распознаём по цепочке причин, обрываем прогон, counters.no_proxy_stop=1 и
mark_failed вместо mark_banned — отказ нашей стороны не должен записываться как бан
Циана. Дома и оценки после стопа пропускаем: они идут через тот же пул.
caused_by_no_proxy вынесен в scraper_kit.proxy_errors (у avito #3288 и domclick #3283
живут приватные копии — их схлопывание отдельной правкой).
Единственный код 500 означал и «площадка забанила», и «сайдкар упал»:
разбор каждого инцидента начинался с ложного следа — в лог провайдера и в
houses.imv_error_reason уезжала httpx-преамбула «Server error '500 Internal
Server Error' for url 'http://tradein-browser:3000/fetch'», то есть текст
ошибки называл гонца, а не виновника.
- browser/server.py: BanPageDetectedError → 403 (доступ ограничен площадкой);
451 — про юридическую блокировку, это не она. Своих 403 сайдкар не отдаёт
(400/422/503), код однозначен. classify_browser_probe не задета: у неё любой
status >= 400 → "sidecar". Тело не меняется — ban_page/status на месте.
- scraper_kit/browser_fetcher.py: текст SidecarBanPageError теперь свой —
«площадка отдала бан-страницу (upstream 403, ответ сайдкара 403): …».
Распознавание остаётся по ТЕЛУ и code-agnostic: tradein-browser — отдельный
образ со своим деплоем, версии штатно расходятся на часы, и гейт по коду в
этот час уводил бы отказ площадки в инфра-ветку.
- Тесты: 403 → SidecarBanPageError; старый 500 + ban_page → он же; чистый 500
без ban_page → прежний инфра-диагноз; текст ошибки без «500»/«Server error».
Refs #3288 п.4
`_leaf`/`_degraded` звали on_bucket(bucket_key, len(seen), complete) — int
уезжал в run_yandex_full_load._on_bucket и дальше в save_listings
(`for lot in lots`) → TypeError, ручной full-load Яндекса не сохранял ничего.
Контракт выровнен по cian/avito: провайдер копит лоты бакета (новые в seen)
и отдаёт список. Parity-фикстура подменяла скрапер целиком и слала list по
построению — добавлен прогон run_yandex_full_load через НАСТОЯЩИЙ
YandexRealtyScraper (замокан только gate-JSON транспорт).
Closes#3375
Ревью #3373, два minor:
1. `_leaf` считал полноту только по потолку страниц — упавшая страница
(`payload=None` → `[]`) молча уходила в чекпоинт как собранная. Теперь
паритет с cian по-настоящему: `complete = not capped and dropped_pages == 0`.
2. `counters.capped_buckets` присваивался ПОСЛЕ await — cancel/shutdown
(RuntimeError из `_on_bucket`) уносил управление мимо строки. Перенесено в
`finally`, счётчик виден в финальном payload дрейна.
Нит: `ceil(total / 20)` → `_GATE_PAGE_SIZE`.
Тесты по значению: бакет с 1 выпавшей страницей из 3 — не в done-леджере
(+ контроль: 3 успешных страницы по-прежнему complete); дрейн после обрезанного
бакета → `capped_buckets == 1` в финальных counters. Фейк `_DrainAtFirstBucket`
(#3355) теперь объявляет `capped_buckets` явно: его catch-all `__getattr__`
отдавал корутину на любое имя и ронял сериализацию counters.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Правка ревью к PR #3372: комментарии и текст коммита обещали восстановление
потери, которой нет. Факт: pipeline.py импортирует scraper_kit.orchestration.runs,
и все четыре его писателя МЕРЖАТ jsonb (`counters = COALESCE(counters,'{}') ||
CAST(:counters AS jsonb)`, runs.py:708/776/817/880), а _pick_resume наследует
done_buckets при claim (scheduler.py:741-748, #3074) — чекпоинт domclick в БД не
терялся. Перезаписывающий двойник app/services/scrape_runs.py этой функцией не
вызывается.
Поэтому done_buckets в каждом payload — единообразие и защита от смены писателя
(если запись пойдёт через app-копию или kit-писатель станет перезаписывающим), а
не спасение данных. Комментарии переписаны под этот факт.
Дрейн-ветка и NoProxyAvailableError строили dict вручную — переведены на
{**_payload(), "interrupted": 1} и _payload(), чтобы «один _payload() на функцию»
было правдой.
Тест: к cancel добавлены ассерты на финальный heartbeat и mark_done — оба
payload'а несут унаследованное ∪ пройденное этим прогоном.
Refs #3355, PR #3363. Closes#3369
После #3362 degraded-ветка отмечается complete=False, а `_leaf` писал бакет как
полный, даже когда его пагинация упиралась в max_pages_per_bucket/_GATE_MAX_PAGES_CAP.
С containment-гейтом (#3358/#3359) такой ключ покрывает свой интервал целиком, и
резюм больше не заходит в полосу, чей хвост не читали ни разу.
Флаг полноты — как у cian: complete = pages_needed <= max_pages, передаётся в
on_bucket на обоих выходах leaf'а (_mark_bucket кладёт в done только complete).
Переполненный leaf возможен только там, где бисекции дробить нечем (размах <
min_bracket, открытый верхний брекет, потолок глубины) — это честный исход
«бакет неполон по построению», поэтому он ещё и считается отдельно
(scraper.capped_buckets → counters.capped_buckets): лечится не повтором прогона,
а порогами бисекции.
Closes#3368
cancel-ветка run_domclick_city_sweep писала голый counters.to_dict(); писатель —
app-level scrape_runs с полной перезаписью (CAST(:counters AS jsonb)), а
'cancelled' входит в _RESUME_STATUSES → отменённый прогон закрывался с пустым
чекпоинтом и резюм пересобирал все корзины. Те же потери были у финального
heartbeat и у mark_banned/mark_failed/mark_done (banned/failed тоже
резюмируемы): они затирали чекпоинт, записанный после SERP-фазы.
Один _payload() на функцию — done_buckets едет в каждой записи counters.
Refs #3355, PR #3363. Closes#3369
offer_price_history.diff_percent у domklik содержал РУБЛИ: загрузчик карточки
клал поле источника priceHistory.diff как есть, а clamp_diff_percent только
зажимал его в ±999999.99 — заведомо неправдоподобное значение проходило молча.
Прод 29.08.2026: 8900 из 11 075 непустых значений с |diff| > 50, p50 = -50 010.
- парсер Домклика считает процент сам из соседних price_rub (сортировка по
change_time); у самой ранней записи предыдущей цены нет -> NULL, не 0;
- clamp_diff_percent -> validate_diff_percent: |x| > 100 не зажимается, а
отвергается (NULL + warning с listing_id и сырым значением). Гейт стоит в
общем хелпере, поэтому закрывает и cian-путь;
- миграция 285 пересчитывает уже собранные domklik-строки оконной lag() по
(listing_id, change_time); идемпотентна (UPDATE только IS DISTINCT FROM).
Closes#3225
Ревью PR #3363:
1. domclick city sweep: комментарий обещал, что унаследованный при claim
чекпоинт переживёт дрейн «jsonb-мержем» — мержа нет. domclick пишет
counters через app-level scrape_runs (update_heartbeat/mark_done делают
`counters = CAST(:counters AS jsonb)`, полная перезапись), а scheduler
при claim done_buckets не наследует. Дрейн закрывал прогон с ПУСТЫМ
чекпоинтом, резюм пересобирал все шесть корзин. Чтение чекпоинта поднято
выше ранних выходов, дрейн-payload несёт `done_buckets` явно — как уже
делает ban-ветка (except NoProxyAvailableError) той же функции.
2. cian full-load, detail-фаза: второй ранний выход по shutdown_requested()
делал `break` и уходил в обычный mark_done — SERP целый, обогащение
обрезано, а прогон выглядел полным. Теперь тот же sentinel
RuntimeError("shutdown"), что и в _on_bucket: interrupted=1 + done_buckets.
3. yandex/avito full-load проверены: по одному shutdown-сайту в _on_bucket,
detail-фазы нет вовсе — аналогичного дефекта нет.
Тесты: чекпоинт дрейна domclick сверяется ПО ЗНАЧЕНИЮ в обеих записях
(heartbeat + финализатор); дрейн cian в detail-фазе даёт interrupted=1.
Ревью #3364. Требуя именно encryptedPhones, отбраковывали бы вечно
необмеренный класс карточек «без телефона / только чат»: в очередь они
возвращаются, а ключ не появится. redirectPhones измерен тем же замером
#3192 и присутствует в обеих ветках (с куками и без).
Текст ABORT: consecutive_none смешанный (фетч-ошибка + parse-None +
недогруз) — «N подряд без обогащения», а не «недогруженных».
Ревью #3362: в _degraded (probe провалился → пагинация до пустоты, обрезаемая
max_pages_per_bucket) yandex звал on_bucket БЕЗ признака полноты, и ключ падал
в done-леджер наравне с честно добранным листом. До containment-гейта это был
точечный skip одного ключа; теперь ключ покрывает ИНТЕРВАЛ и сливается со
смежными — недобранная после отказа полоса больше никогда не переобходится.
Тот же путь, что у cian: третий позиционный аргумент complete, в чекпоинт
пишет только _mark_bucket(..., True); partial_buckets вынесен в счётчики
прогона (виден в heartbeat), лоты и cancel/shutdown-проверки не трогаем.
Плюс комментарий смежности в bisection.py приводил полуоткрытый пример, споря
с «hi ВКЛЮЧИТЕЛЬНА» в том же докстринге.
`_STALE_SOURCES_SQL` считала свежесть возрастом последнего прогона со
статусом 'done'. Прогон с блоком честно финализируется как 'banned'
(#2657) и при этом вставляет строки: у domclick_city_sweep 886 строк
25.08 и 128 строк 23.08 — оба 'banned'. Источник, регулярно ловящий блок
и столь же регулярно приносящий данные, числился мёртвым навсегда,
отсюда ложный P1 #3118 «домклик не собирается с 5 августа».
Запрос отдаёт завершённые прогоны, решение «прогон дал данные»
принимает `run_brought_data` ТЕМ ЖЕ результатным словарём, которым уже
судит сторож нулевого результата (runs._RESULT_COUNTER_KEYS, #2703) —
одна мера на оба механизма. Не 'lots_inserted': это новизна, а не
наличие данных (здоровый дедуплицированный sweep вставляет ноль).
Результат не измерен (28 источников без результатного ключа) → судим
прежней мерой, статусом: «не измерено» ≠ «ноль». never_ok считается той
же мерой, иначе соврал бы в другую сторону.
Closes#3172
После #3330/#3346 резюм берёт 'done' только с counters.interrupted=1, но четыре
функции с живым чекпоинтом done_buckets финализировали дрейн чистым 'done':
cian/yandex/avito full-load (ветка RuntimeError("shutdown")) и domclick city
sweep (дрейн до SERP). Оборванный обход был неотличим от полного, а собранные
бакеты никто не подхватывал — у avito это ещё и бан-бюджет (#3315).
done_buckets в domclick-payload не добавляем: чекпоинт унаследован при claim
(#3074), jsonb-мерж его сохраняет.
Closes#3355
Страница на 1,8 МБ без блока контактов приходит с HTTP 200 и валидным HTML:
window.INITIAL_STATE на месте, parse отрабатывает — и частичная карточка уезжала
в БД с detail_enriched_at, выбывая из очереди навсегда. Единственная проверка
размера (newbuilding.py, len(html) < 500) отвечала на вопрос «пришло ли хоть
что-то»: 1,8 МБ проходит её в 3600 раз.
Признак полноты структурный + размерный, любой из двух даёт отказ:
encryptedPhones (65 вхождений у полных карточек, 0 у недогруза; отдаётся и
анонимной сессии — см. yandex_session.py) и settings.yandex_detail_min_html_bytes
(1 МБ). Наблюдавшийся недогруз ловит именно структурный: 1,8 МБ порог проходит.
В backfill проверка стоит ДО parse: исход incomplete ⊆ failed, save не
вызывается, значит detail_enriched_at не проставляется и следующий снапшот
(detail_enriched_at IS NULL) возьмёт объявление снова. Серия недогрузов двигает
consecutive_none — тот же брейкер, что у parse→None, поэтому вечно недогружаемая
карточка обрывает прогон, а не молотится (per-listing счётчика попыток в схеме
нет).
Фейковые ответы в тестах-соседях (#3196/#3338) теперь при HTTP 200 выглядят
полной страницей — иначе они молча стали бы кейсами про полноту.
Гейт should_skip живёт в общем движке (walk_price_range, #3315), но предикат
из done-леджера строил и передавал только avito. У cian и yandex та же
бисекция и тот же чекпоинт — на резюме дерево деления спускалось ВНУТРЬ
зачтённых полос живыми probe-запросами: ключи чекпоинта суть границы
ДИНАМИЧЕСКОЙ бисекции, при сдвиге рынка новый лист старому не равен даже
внутри собранной территории, поэтому сравнение строк ничего не ловит.
Формат ключей у провайдеров разный, и ключи не трогаем (иначе протухнут
живые чекпоинты): cian пишет room_label:lo:hi / :open — как avito, парсер
подходит без изменений; yandex пишет _combo_label «rooms:lo-hi» с «None»
вместо открытого потолка, поэтому в done_range_skipper параметризованы
range_sep и open_token (дефолты = прежнее поведение avito/cian).
Один леджер на два режима: у yandex incremental-ключи несут префикс
сегмента (secondary/2:…) и отсев по label их не пропускает, а смешение
режимов блокирует _pick_resume (params IS NOT DISTINCT FROM); у cian
incremental-режима нет вовсе. Записано в докстринге предиката.
Тесты по значению на обоих провайдерах: счётчик стоит на горлышке фетча
(_fetch_page_html / _fetch_page_json), резюм готовой комнатности = 0
запросов, частично покрытая полоса по-прежнему пробивается.
Closes#3359
Дедуп report_ban по _banned_lease_id не достигал цели при ротации. Узел 13 ловит
бан-страницу → фетчер репортит бан 13 и по fail-streak меняет lease на 14 →
провайдерский report_ban в providers/avito/detail.py видит уже сброшенный
_banned_lease_id и банит СВЕЖИЙ узел 14, который к площадке не ходил. При трёх узлах
в пуле одна бан-страница выбивала две трети выдачи на 6 часов с эскалацией ban_count.
Убран провайдерский report_ban на ветках SidecarBanPageError в avito/detail.py и
domclick/detail.py: фетчер репортит сам, раньше и по правильному lease. Детекты не от
сайдкара (firewall / 0 карточек в serp.py, QRATOR-маркеры parse_detail_html) фетчеру
не видны — там report_ban остаётся.
Плюс два смежных: fetch()-ретрай ловил httpx.HTTPError, подклассом которого является
SidecarBanPageError, — каждая бан-страница стоила 2 POST'а и +2 к fail-streak (ротация
вдвое раньше задуманного); и NoProxyAvailableError из ротационного _acquire_lease внутри
_report_platform_ban вылетала ВМЕСТО SidecarBanPageError, подменяя диагноз platform на
infra — теперь ротация там best-effort.
Тесты: рабочий пул теперь РОТИРУЮЩИЙ (13→14) — на неподвижном пуле дефект физически не
проявляется. Два теста, пинившие прежний контракт (провайдер репортит), инвертированы:
у них MagicMock-фетчер, который настоящего рапорта не делает.
Refs #3288
houses.material_walls уже заполнена словарём ДОМ.РФ (капремонт КР1.2, #2013):
кирпич 2662, железобетонная панель 2107, иное 1850, монолит 754. Ветка писала
туда сырую фразу карточки (Монолитный 2656, Кирпичный 2241, Панельный 1671,
Монолитно-кирпичный 773) — колонка стала бы двухсловарной, и `WHERE
material_walls = 'монолит'` перестал бы видеть весь Домклик. Ровно та болезнь,
которую sale_type уже пережил в #2674.
canon_wall_type / canon_floor_type стоят на границе записи в houses (как
canon_sale_type — на границе записи в listings): Кирпичный→кирпич,
Панельный→железобетонная панель, Монолитный/Монолитно-кирпичный→монолит,
Блочный/Деревянный→иное, Железобетонный→Железобетонные (форма, уже лежащая в
колонке). Незнакомое → None + warning раз на процесс: сырьё в колонку не
попадает никогда, а новое значение словаря видно в логах. В raw_payload сырая
фраза площадки остаётся как была.
Миграция 284 получила тот же CASE lower(...) — иначе backfill залил бы задним
числом ровно то, что код перестал писать. CASE без ELSE: незнакомое → NULL.
Резюм exhaustive-обхода пере-пробивал уже зачтённую территорию: skip
проверялся в листе, ПОСЛЕ probe, поэтому дерево бисекции спускалось в
поддиапазоны done-корзин живыми запросами (прогон 5718: 21 минута внутри
room_studii:4000000:4999999, ноль новых корзин). На пуле из 1-2 нод это
сжигает весь бан-бюджет до первой НОВОЙ работы.
Ключи чекпоинта — границы ДИНАМИЧЕСКОЙ бисекции: при сдвиге рынка новый
лист ключом не равен старому даже внутри покрытого диапазона, поэтому
сравнение строк бесполезно. done_range_skipper парсит ключи room:lo:hi
(hi=open → бесконечность) в отрезки, сливает пересекающиеся и смежные и
отдаёт предикат покрытия; walk_price_range проверяет его на входе в узел,
ДО probe, и обрезает готовые поддеревья без единого запроса.
Closes#3315