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>
Правка меняет две вещи разом, обе намеренно.
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 не тронут —
ни одно поле не влияет на цену.
Замер живьём 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
Живой замер браузера 2026-08-21 разошёлся с тем, что шлёт прогретая сессия:
- impersonate="chrome120" был захардкожен в 9+ местах (avito/{serp,detail,imv,
houses}.py, pipeline.py x4, cian/valuation.py, yandex/valuation.py) вместо
единой DEFAULT_IMPERSONATE (providers/_base.py). Обновление до chrome146
(макс. доступный профиль curl_cffi 0.15.0; alias "chrome" НЕ используется -
едет сам при апгрейде библиотеки без ревью) теперь меняется в одном месте.
Guard-тест backend/tests/test_impersonate_single_source.py грепает всё
дерево scraper_kit на литерал "chrome120".
- DOCUMENT_HEADERS ставил Sec-Fetch-Site="none" на уровне сессии, а Referer
добавлялся per-request (curl_cffi мёржит per-request headers поверх
session-level) - живой Referer с "none" рядом не бывает у настоящего
Chrome. Добавлен referer_headers() (Sec-Fetch-Site="cross-site") для
caller'ов с чужедоменным Referer; avito warm-up (yandex/ya.ru -> avito)
теперь его использует. Внутренний avito-search -> avito-detail Referer
(fetch_detail, same-origin) НЕ тронут - отдельный явный комментарий почему.
- Referer прогрева заменён с yandex.ru на ya.ru (живой переход из выдачи
даёт короткий домен). ysclid сознательно не добавлен - значение выпускает
Яндекс, подделка хуже отсутствия.
- warm_up_session/research_in_session считали прогрев успешным по HTTP-
статусу и отсутствию firewall-маркеров, не проверяя антибот-cookies
(__zzatw-*/cfidsw-*, сняты с живого браузера) - detail-батч на такой
"прогретой" сессии сжигал прокси на обречённых 403. Теперь поднимают
AvitoWarmupCookiesMissingError (наследник AvitoBlockedError - существующие
except-блоки/ban_kind/proxy-ротация в pipeline и backfill ловят без
изменений).
Полный backend pytest suite зелёный (4672 passed), ruff check чист.
Refs #3034
Deep-review BLOCK на PR #3011: обе правки чинили заявленный симптом только
частично.
077: гард считал pending-строки по source='rosreestr' AND dedup_hash ~
md5-паттерн и пропускал backfill, только если таких строк 0. На чистой БД
они есть — 003_seed_deals.sql сеет синтетические сделки с тем же паттерном,
значит pending > 0 уже на пустом томе, и миграция всё равно падала на
"user mapping not found" (воспроизведено в CI run 8257). Первичный гард
теперь проверяет напрямую наличие USER MAPPING для gendesign_remote
(идиома из app/core/fdw.py:57-62), счётчик pending оставлен вторым —
экономит обращение к FDW, когда мигрировать уже нечего.
deploy-tradein.yml: цикл ожидания готовности postgres ходил по unix-сокету
(pg_isready без -h). На пустом томе временный init-сервер отвечает на
сокете, пока docker-entrypoint-initdb.d ещё прогоняет цепочку миграций —
проба зеленела посреди initdb. Добавлен -h 127.0.0.1 (тот же приём уже
есть в ci-tradein.yml:157) — TCP открывается только после полного
завершения initdb.d.
Отдельно ужесточён sentinel baseline-детекции: раньше «схема уже накачена»
проверялась одной таблицей listings (миграция 002, почти голова цепочки).
Если бы гонка готовности когда-нибудь вернулась, listings был бы уже
создан, а хвост цепочки — ещё нет, и baseline тихо пометил бы недостающие
миграции применёнными без прогона. Теперь проверяются оба конца — listings
(голова) и houses_geog_gist_idx, индекс из миграции 270 (хвост); при
несовпадении (ровно один конец на месте) деплой падает громко с explicit
ошибкой вместо угадывания.
Вне транзакции SET LOCAL молча ничего не делает — гейт это ловит, и он
поймал меня второй раз подряд, теперь локально, до CI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Гейт CI «блокирующий DDL без lock_timeout» покраснел по делу: ALTER TABLE
берёт ACCESS EXCLUSIVE на houses и при живом писателе ждал бы бесконечно,
копя очередь. 5с — миграция падает честно и деплой перезапускается.
Предыдущий пустой коммит «перезапуск — флуктуация раннера» был ошибкой
диагноза: я не дочитал лог changes до ::error и приписал падение
checkout'у. Единственный источник истины — строка ::error в логе самого
changes; «Job 'changes' failed» у зависимых джобов и «'runs-on' key not
defined» в act_runner v6.3.1 — шум.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
В SQL-формуле relevance_score кандидат без year_built получает штраф 0 —
столько же, сколько точное попадание в год, и лучше, чем кандидат с
известным годом, отличающимся на 24 (2.0). То же с house_type. Отсутствие
данных выигрывает у знания и возвышает источник с худшей полнотой.
Флаг estimate_unknown_attr_penalty_enabled (default OFF): кандидат с NULL
получает МЕДИАННЫЙ по пулу штраф того же признака среди тех, у кого он
известен — не наказание и не награда; считается по тем же термам, что
SQL (abs(Δyear)/12.0, 1.5 за несовпадение типа). Лежит в Python-слое
после SQL, рядом с kitchen/ceiling (#2012), по тому же контракту:
включать — только по бэктесту.
Без чего флаг был бы мёртв (и был в первой редакции): _ANALOG_SELECT_COLS
не выбирал year_built/house_type — SQL считал по ним CASE, но в словарь
кандидата колонки не попадали, Python-слой видел None у ВСЕХ и не штрафовал
никого по построению. Probe-лог в прод-оверлее: pool=30 null_year=30.
Добавлены в _ANALOG_SELECT_COLS, во внешние SELECT тиров H/W и во
внутренний base Tier W (он строится явным списком). Контроль: флаг OFF с
колонками и без — метрики бэктеста идентичны до сотых. На этот инвариант
стоит тест по исходнику запросов.
Живой A/B (бэктест в прод-контейнере, оверлей /tmp/ab, 300 сделок ЕКБ
Q2 2026, одна и та же выборка в обоих прогонах — проверено по deal_id):
состав топ-50: сменилось 96 слотов из 5 915 (1.6 %), 27 сделок из 300
источники: avito 55.7→55.3 %, cian 24.5→24.8 %, yandex 12.7→12.6 %
цена: MAPE 16.70→16.70, bias −4.52→−4.52, coverage 84.46→84.46 —
идентично до сотых по всем срезам (сегменты, комнатность).
Нижний Тагил (300 сделок, пулы 21/p90 40): 0 из 5 666 слотов сменилось.
Почему эффект в разы меньше замера задачи (×0.20 avito): тот замер шёл
по SQL тира H без стратификации. В боевом пути 54.9 % слотов топ-50 —
гарантированная квота MIN_ANALOGS_PER_SOURCE=5, раздаётся ДО сортировки
остатка; и у avito в топ-50 NULL-год лишь у 25.7 % (задача мерила 56 %
по всем активным объявлениям). Обе величины измерены по фикстурам A/B.
Что это значит: артефакт в формуле есть, флаг его корректно лечит, но на
итоговую цену он не влияет измеримо. Включать по умолчанию оснований
нет — и это и есть ответ, ради которого флаг заводился вместо правки.
pytest tradein-mvp/backend: test_2936 7 passed; гейт фикстуры и
roundtrip 8 passed; -k "estimat or analog" 735 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Блокер переезда (#2990). Чистый старт на пустом томе падал: 077 читает foreign
table gendesign_rosreestr_deals, а USER MAPPING создаёт бэкенд при старте
(app/core/fdw.py), то есть ПОСЛЕ docker-entrypoint-initdb.d. Контейнер не
поднимался вообще. Путь «пустой том» на реальном железе не исполнялся ни разу,
а CI этот файл явно пропускал — гейт, который должен был поймать, был ослаблен.
Проверено по всем 14 миграциям, упоминающим FDW-таблицы: читает ровно одна —
077. Остальные только CREATE/DROP FOREIGN TABLE и COMMENT, им ни USER MAPPING,
ни связь с чужой БД не нужны.
077 не удалена, а сделана самозащитной: гард считает строки в md5-форме и
выходит раньше обращения к FDW, если мигрировать нечего. На чистой БД таких
строк нет по определению. Удаление файла было бы неверным — прод помнит
миграции по bare-filename в _schema_migrations, и
test_applied_migration_is_not_renamed_or_deleted падает на удалении.
Исключение в ci-tradein.yml снято: теперь цепочка применяется целиком, то есть
CI сам стал репетицией чистого старта.
Отдельно закрыт тихий отказ в deploy-tradein.yml. Ветка baseline срабатывала по
одному лишь отсутствию _schema_migrations, а это состояние неоднозначно: так
выглядит и наполненный прод до внедрения tracking, и пустая БД нового сервера.
Во втором случае baseline пометил бы все миграции применёнными, ни одной не
прогнав, и деплой уехал бы зелёным на пустой схеме. Добавлен sentinel по
listings: пусто → baseline пропускается, цепочка применяется с нуля.
Refs #2990, #2989
Находка 3 задачи («23 дома лежат вне области 66, гарда на приёме нет»)
подтвердилась, и к ней добавились масштаб и причина.
Масштаб: к этим домам привязано 591 объявление. Адреса екатеринбургские
(«Ул. 8 Марта», «Крауля», «Амундсена»), координаты — Варшава, Белград,
Таллин, Владивосток, Ижевск.
Причина: 22 из 23 несут в raw_payload след разового бэкфилла
`full_backfill_2026-05-27`, который звал Yandex-геокодер напрямую, без
резолва города. Живой путь при этом ЧИСТ — 0 записей вне региона в
geocode_cache из 10 448 и 2 из 88 544 у listings; скрипта в репозитории
нет. Это исторический осадок, а не текущая утечка, и гард нужен не
столько живому пути, сколько следующему разовому скрипту.
17 из 22 помечены геокодером `precision: "exact"`. Точность отвечает на
«нашёлся ли номер дома», а не «в том ли городе», и критерием приёмки быть
не может — поэтому проверяется ПРИНАДЛЕЖНОСТЬ.
Инвариант нарочно не географический: «дом рядом со своими объявлениями»,
а не «дом внутри рамки области». Рамка сломалась бы при выходе в Москву —
ровно то, ради чего заведена #2996. Он же строго сильнее: ловит 25 домов
против 23 у рамки, и оба лишних проверены («Ул. Белинского/Фурманова» за
207 км, 34 объявления).
Порог 100 км не подобран на глаз. Замер по 9 052 домам: ближе 1 км —
8 914 (98.5 %), 25-100 км — 9 настоящих пригородов (Сарапулка, Кедровка,
Чусовское Озеро, Ревда, Первоуральск; самый дальний 46.7 км), дальше
100 км — 25 (ближайший 119.2, максимум 5 079). Между 46.7 и 119.2 км нет
НИ ОДНОГО дома: порог лежит в середине пустого промежутка.
Первая редакция клала счётчик полем в /scraper/data-quality. Замер это
остановил: запрос стоит ~445 мс на тёплом кэше, а обе существующие
выборки той ручки вместе — 27 мс, при опросе фронтом каждые 120 с. То
есть 17-кратное удорожание ради числа, которое меняется раз в месяцы.
Проверка вынесена в отдельную ручку по требованию, и на возврат в горячий
путь поставлен контроль-тест.
Ручка отдаёт не только счётчик, но и масштаб (список домов + сколько
объявлений привязано) — по одному числу «25» решение об очистке не
принять.
Двусторонне: против origin/main три теста красные, краснота везде по
значению — ни одного ImportError/AttributeError. Контроль
test_check_stays_out_of_the_polled_endpoint зелёный с обеих сторон.
pytest tradein-mvp/backend — 4642 passed, 23 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`run_cian_full_load` передавал `secondary_only=True` жёстко, поэтому
включить новостройки в полный обход можно было только деплоем. Теперь это
параметр с ТЕМ ЖЕ дефолтом `True` — поведение прода не меняется ни на
строку, но решение становится правкой одной ячейки
`scrape_schedules.default_params`, а не выкаткой кода. Откат — тем же
движением.
Почему это важно именно здесь. Новостройки НЕ пропускаются при запросе:
они скачиваются, разбираются и выбрасываются последним шагом
(`cian/serp.py`), потому что SERP-параметр `object_type=1` у Cian
ненадёжен (~5 % выдачи) и фильтруют по authoritative `listing_segment`
после парсинга. Проба бакета берёт `totalOffers` из Redux-состояния SERP
и считает `pages_needed = ceil(totalOffers / offers_per_page)`, а
`totalOffers` включает ОБЕ категории — то есть страницы с новостройками
уже скачаны, лимит страниц и антибан-бюджет за них уже заплачены.
Включение стоит ноль дополнительных запросов.
Заодно `dropped_novostroyki` сохраняется в counters прогона. Счётчик
логировался (`dropped_nb=`), но не персистился, и ответить «сколько
инвентаря выбрасывает полный обход» задним числом было нечем: логи за
17.08 уже ротировались — `docker logs --since 120h` не находит ни строки
«cian:» ни в одном контейнере. Тот же довод, по которому рядом заведён
`partial_buckets`. Копится в атрибуте инстанса, а не аргументом
`on_bucket`: у колбэка есть внешние реализации, менять его сигнатуру
ради счётчика нельзя. Сброс на каждый прогон — инстанс переиспользуется.
Замер, ради которого это делается (прод 21.08): месячный охват свипа
cian/novostroyki — 11.7 % против 100 % у cian/vtorichka и
avito/novostroyki; 11 993 активные строки, медианный возраст 81 сутки,
10 585 старше 30 суток. Подробности и оговорки — в #1781 и #2994.
Двусторонне: против origin/main пять тестов красные, и краснота везде по
значению, а не по отсутствию символа — ни одного KeyError. Сообщения
перечисляют фактическое состояние («параметра нет в сигнатуре; параметры:
[...]», «поля нет в запросе; поля: [...]»).
Контроли зелёные с обеих сторон: дефолт остаётся True (иначе правка тихо
включила бы сбор новостроек на проде — это отдельное решение с замером);
фильтр при `secondary_only=True` остаётся на месте и по-прежнему зависит
от флага.
pytest tradein-mvp/backend — 4644 passed, 23 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
listings.tsv (GENERATED ALWAYS ... STORED) пересчитывался на КАЖДОМ UPDATE
listings независимо от того, менялись ли description/address, и заново
перетостивался — ~5 ГБ TOAST-оборота за 91 день.
Разведка: /api/v1/search (search_query.py) читает listings_search_mv, не
listings напрямую, а витрина сама считает to_tsvector из сырых
description/address/developer_name при каждом REFRESH — l.tsv она никогда не
читала. DROP EXPRESSION безопасен для поиска и, по ATExecDropExpression
(PG16), не вызывает table rewrite — catalog-only операция.
listings_tsv_idx (GIN, 116 MB) снесён отдельно: 0 idx_scan за всю историю БД,
не обслуживает ни один constraint. Колонка tsv остаётся (заморожена, без
читателей) — DROP COLUMN вне рамок этой миграции.
Refs #2992, #2989