192/193 -> 229/230: main занял 192_tradein_users_auth.sql и
193_tradein_users_seed.sql за время простоя PR. 228 зарезервирован
открытым PR #2732 (228_payments.sql) - следующие реально свободные
229/230, порядок consent_proof -> retention сохранён.
Правки ссылок на старые имена/префиксы: docstring-заголовки самих
SQL-файлов, перекрёстная ссылка 229 -> 230 в комментарии-докстринге,
комментарии migration 192/193 в lead.py / config.py / schemas/trade_in.py
/ purge_expired_trade_in_data.py, переменные и имена тестов в
test_estimate_consent_gate.py / test_purge_expired_trade_in_data.py.
(Оставлены нетронутыми ссылки на migration 192/193 в auth_session.py и
test_team_api.py - это про другие, уже существующие на main миграции
192_tradein_users_auth.sql / 193_tradein_users_seed.sql, не про эту
пару.)
Правки по ревью PR #2689.
Признак панорамы был недостижим примерно для десятой части страниц. Вызов стоял
после раннего возврата по пустой истории размещений, поэтому идеально отрисованная
страница без единого объявления до записи не доходила: на проде 1519 оценок против
1360 домов с историей. Резолв дома и запись панорамы подняты выше возврата — гейт
честности не тронут. Цена: match_or_create_house теперь вызывается и для таких
страниц (может создать дом), но это тот же вызов с тем же адресом, который уже
отрабатывает на остальных 90%.
Числа в комментариях к схеме были оценками планировщика, а не точным счётом:
listings 142 569 против реальных 93 408 (раздув мёртвыми кортежами на 53%),
house_sources 46 813 против 49 502. На безопасность удаления это не влияло — нули
там точные, — но оценка уезжала в постоянный комментарий к схеме, в PR, тезис
которого «каждое утверждение несёт число с прода». Пересчитано точным count(*).
Окно расписания ДОМ.РФ 03:00-04:00 совпадало с refresh_search_matview — то есть
ровно с тем заданием, которое переносит year_built в поиск. Планировщик берёт
случайный момент внутри окна и гоняет источники параллельно, так что порядок был
подбрасыванием монеты. Перенесено на 01:00-02:00; в комментарии честно сказано, что
гарантии всё равно нет и при аномально долгом прогоне возможно отставание на цикл.
Тесты, адресовавшие вызовы по позиции (db.execute.call_args_list[0]), переведены на
фильтр по SQL — это и была причина, по которой добавление второго execute ломало
шесть чужих тестов разом. То же для side_effect в тесте отката батча: исключение
доставалось бы записи панорамы, которая свои ошибки глотает, и тест молча проверял
бы не тот путь. В test_save_history_items_inserts_each возвращено утверждение о
числе коммитов (было удалено вместо обновления).
Refs #2674
Восемь находок «написано, покрыто тестами, ни разу не сработало» разведены на три
разных диагноза. Две из восьми оказались не мёртвым кодом, а оборванной проводкой.
ПОДКЛЮЧЕНО
Загрузчик ДОМ.РФ. Loader и CLI существуют с #2013, а Handler'а в product_handlers
и строки в scrape_schedules не было — вызвать его было нечем. На проде 29 978 строк
staging с ОДНИМ loaded_at (2026-07-12), то есть ровно один ручной запуск, 24 дня
без обновления. Отсюда кормятся houses.year_built/material_walls/total_floors и
дальше listings.year_built — когортный фильтр эстиматора. Недельный такт, окно
03:00-04:00 UTC (до импорта ДКП и дневных агрегатов).
filters_hash. Парсер читал estimation.sale.data.filtersHash, а Циан кладёт ключ
уровнем выше — estimation.sale.filtersHash. Колонка пуста 0/1658, при том что в
сохранённых сырых ответах хеш есть у 139/139 и все значения различны. Путь исправлен,
139 строк восстановлены бэкфиллом из raw_payload.
has_panorama. Разбирался парсером, лежал в карте приоритетов, обещан публичным
контрактом market.v_houses — и не попадал в houses ни одной строкой кода (0 из 9366).
Пишется там, где yandex_valuation уже держит и house_id, и мету. Гейт честности:
парсер отдаёт bool, а не bool|None, поэтому false пишем только при подтверждённо
отрисованной странице (есть год или этажность) — иначе NULL, а не выдуманный false.
УДАЛЕНО
Дедуп-обёртки эстиматора _phys_dedup_key / _extract_street_token: 25 ссылок, все из
тестов. Хуже, чем просто мёртвые — _phys_dedup_key утверждала правило «ключ = кадастр
ИЛИ улица», которого в боевом дедупе нет (_union_find_phys_dedup держит оба композита
и сливает по любому совпадению). Тесты переведены на живые функции.
Тиерные коэффициенты выкупа asking_to_sold_ratios_tiered + asking_to_sold_tier_bounds:
ноль читателей и писателей, флага tier_aware_ratio_enabled не существует. Посчитаны
один раз при накатке 098 (computed_at 2026-06-27) — тогда как живая
asking_to_sold_ratios обновляется ежедневно (2026-08-05). Методика сохранена в 098.
Колонки без писателя: listings.merged_into (113 уже называла её мёртвой) и
house_sources.raw_payload вместе с GIN-индексом по всегда-NULL колонке.
v_data_quality.price_disagreements_count: у всех 89 699 объявлений ровно один
источник, показатель структурно не мог быть ненулевым, а ноль читался как
«расхождений нет».
ЗАДОКУМЕНТИРОВАНО
BROWSER_BLOCK_RESOURCES выставлен во всех трёх прод-контейнерах, а код перестал его
читать в #1812. Блокировка при этом не ослабла (image глушит camoufox block_images,
font/media — дефолт списка типов), мёртв только выключатель. Сервис теперь говорит
об этом на старте: молча игнорируемая ручка опаснее отсутствующей.
v_price_divergence / v_cross_source_health оставлены как задел, но в COMMENT написано,
почему они пусты структурно: боевой путь загрузки зовёт upsert_listing_source
напрямую и не зовёт match_or_create_listing.
house_sources.ext_url пуст 46 813/46 813, но входит в публичный контракт
market.v_house_sources — оставлен и подписан.
Refs #2674
Разбор ревью PR #2685. Выбор окна в 7 суток подтверждён непредвзятым замером по
одному прогону (run 2990, exhaustive 02.08, 2184 датированных строки): W=2 -> 147,
W=6 -> 446, W=7 -> 1275, W=12 -> 1278. Шестёрка теряет две трети семёрки, а 7..12 —
плато в +3 лота, то есть семёрка стоит на самой дешёвой его точке. Потеря окна 2
занижена мной в первом заходе: не 3.26x, а 8.7x.
Механизм объяснён неверно. Пик на возрасте ровно 7 — не недельный авто-подъём Авито,
а квантование нашего же парсера относительных дат: «неделю назад» -> ровно today-7,
«две недели назад» -> today-14, возрасты 8..13 по этому пути недостижимы. То самое
плато (3 лота из 2184) это и доказывает: при реальном подъёме полоса 8..13 была бы
заполнена. Вывод от этого только крепнет — шестёрка режет не по пику распределения,
а по границе квантования и теряет бакет «неделю назад» целиком, а внутри него
реальный возраст от 7 до 13 суток.
51% не воспроизводится: 1212 из 3714 датированных наблюдений — 32.6%. Пятьдесят один
получается только на знаменателе, урезанном возрастами 0-13.
Цена по запросам описана неверно и в опасную сторону. Рост не пропорционален лотам:
стоимость бакета — ceil(свежих/50) страниц с полом 1-2, при окне 7 на бакет выходит
~15-20 свежих (1275 на 77 бакетов), то есть меньше страницы. Большинство бакетов как
стояло на 1-2 страницах, так и останется. Верхняя граница честная и продом пережитая:
полный обход без отсечки — 6 ч 59 мин (run 295) и 2 ч 34 мин (run 2990).
Отсюда же переписан критерий приёмки: ждать «9-10 тысяч собранных лотов» нельзя, это
уведёт в ложный вывод. Прогон с окном 2 уже собирал 2804 лота, потому что первые
страницы всё равно полные — объём почти не сдвинется, сдвинется глубина. Считать надо
лоты с listing_date в полосе [D-7, D-3] и число страниц из лог-строки paginated=.
Впечатана мина на случай отката такта: ни миграция (GREATEST только расширяет), ни
планировщик (расширяет до такта, не сужает) окно не сузят, поэтому interval_days
7 -> 1 при окне 7 даст восьмикратный охват каждый день. Такт и окно менять вместе.
int(params.get("interval_days", 1)) падал на значении null в jsonb — соседний параметр
строкой выше обрабатывался через явную проверку на None, этот нет.
Ревью PR #2684 — четыре MINOR.
1. Починка фильтра открыла кнопку отмены на все 53 источника. Раньше таблица была
пуста на каждой вкладке, поэтому кнопка не рендерилась НИ РАЗУ и дыра не
проявлялась: ручки отмены source не проверяют вовсе. Оператор на вкладке Авито
мог бы «отменить» refresh_search_matview — задача продолжила бы работать под
статусом 'cancelled' (ещё один врущий статус ровно в тот день, когда их
вычищаем), а has_running_run перестал бы держать single-run guard, который
существует из-за инцидента с двойным свипом и баном (2026-05-31).
Гейт поставлен на общем узле всех пяти ручек — scrape_runs.honors_cancel +
отказ в mark_cancelled, — а не в UI: иначе ручной POST по-прежнему снимал бы
guard. Флаг cancellable отдаётся в строке, UI по нему прячет кнопку.
Состав набора выведен из call-site'ов runs.is_cancelled: city-sweep'ы (все
площадки и города), full-load'ы, avito_newbuilding_sweep, rosreestr_dkp_import.
Правило НЕ «любой *_sweep»: yandex_newbuilding_sweep отмену не опрашивает.
2. Комментарий пересозданного v_data_quality утверждал, что его обновляет
/api/v1/admin/data-quality. Читателей у view нет ни одного — живая ручка строит
свой запрос. PR с тезисом «ложный показатель хуже отсутствующего» не имеет права
переносить в прод ложное утверждение о читателе.
3. Лимит выдачи 20 → 50: первые 20 строк по started_at на три четверти —
сердцебиение proxy_healthcheck (1631 из 3245), часовой сбор мог не поместиться.
Привязка к вкладке НЕ возвращается.
4. Тест «действующее определение view» искал маркер подстрокой с OR REPLACE —
миграция с обычным CREATE VIEW или парой DROP+CREATE была бы невидима, и тест
проверял бы 214, пока показатель уже вернулся. Заменено регуляркой на обе формы.
Фальсификация трёх новых тестов патч-методом — все три красные. Полный прогон
3490 passed / 9 skipped, tsc --noEmit чистый.
Окно и такт разъехались. Каденс avito_full_load задаёт default_params.interval_days,
глубину обхода — incremental_days (since = today - N, ранняя остановка по listing_date).
Это два независимых литерала, обязанных совпадать: 129 поставил окно 2 при ежедневном
такте (перекрытие было), 206 перевёл такт на 7 суток и окно не тронул. Прогон видит
[D-2, D] = 3 суток из 7; дни D+1..D+4 не попадают ни в один прогон.
Числа с прода (tradein, read-only 2026-08-06). 2026-06-21 — единственный день, когда
оба обхода отработали: инкрементальный run 297 — 2804 unique, exhaustive run 295 —
9992 unique, то есть окно в 2 суток достаёт 28.1% инвентаря. Когорта run 295, не
виденная после 23.06 (listing_date заморожен): полоса [D-2, D] — 189 лотов, полоса
[D-7, D-3] — ещё 427, расширение окна берёт в 3.26 раза больше. Гистограмма
(last_seen_at::date - listing_date) за 20 суток: возраст 0-2 — 594, возраст ровно 7 —
1212 (51% датированных наблюдений) — у Avito недельный авто-подъём, sortTimeStamp
сдвигается кратно 7. Структурный минимум бездырочного покрытия — 6, но 6 режет ровно
по этому пику; 7 = такт, полосы соседних прогонов смыкаются с суточным перехлёстом
под дрейф расписания (замер: last_run 03.08 13:37 -> next_run_at 10.08 14:16).
Цена: страниц примерно втрое больше на прогон, но прогон недельный. До 206 система
платила ~150-370 страниц семь раз в неделю; после правки — ~500-1200 в неделю, всё
ещё примерно вдвое дешевле, чем до 206.
Чиню в двух местах: миграция 215 выводит окно из фактического interval_days строки
(GREATEST — не сужает окно шире такта), scheduler расширяет его на лету и пишет
warning, чтобы расхождение не вернулось следующей правкой каденса.
Полный обход: причина не в площадке. Все пять banned-прогонов
avito_full_load_exhaustive (05.07-02.08) несут один текст —
"browser-sidecar error: browser unavailable (proxy may be down)", то есть 503 от
своего же сайдкара, у которого не поднялся камуфокс. В те же дни avito_city_sweep
(20 done), avito_newbuilding_sweep (21) и avito_detail_backfill (63) работали.
Корень — мёртвый BROWSER_PROXY_AVITO (ard.mobileproxy.space) в env сайдкара, куда
полный обход проваливался, потому что строил свой BrowserFetcher без пула; починено
не здесь, а #2637 (02.08, пул для браузерного пути Авито) и #2616 шаг 2 (05.08,
снос мёртвых env). Прод подтверждает: 0 лотов 05/12/19/26.07, 2334 и 362 в двух
прогонах после 02.08.
Остаток, который чинится кодом, здесь: 503 сайдкара классифицировался как soft-ban и
уходил в бюджет IP-ротации, а ротация снята (#2616 шаг 2, max_rot=0) — условие
rot_done < max_rot ложно всегда, а бюджет коротких backoff-retry стоял в else и был
для soft-ban недостижим. Ни одного ретрая на самую частую ошибку: один блип сайдкара
стоил бакета, четыре подряд — всего прогона. Бюджет backoff теперь общий для обеих
причин; сайдкар на таком 503 сам поднимает фоновый retry launch'а, поэтому повтор
через пару секунд обычно проходит.
Статус banned на такой ошибке остаётся ложью (площадка не банила) — это же чтение
легло в основание 206. Здесь не трогаю: набор status ограничен CHECK-констрейнтом,
а mark_banned в отличие от mark_failed сохраняет чекпоинт done_buckets.
Четыре находки одного класса: админка показывает числа, которые никогда не
бывают ненулевыми, и подаёт это как результат. Ноль читается оператором как
«всё чисто», а не как «мы это не считаем» — такой показатель хуже отсутствующего.
1. «Помечено выбросов» (v_data_quality.outliers_flagged) — УБРАН вместе с
колонкой listings.is_outlier. Механизм не «не доделан»: «выброс» у эстиматора
вычисляется Tukey-фильтром по КОНКРЕТНОЙ подборке аналогов и живёт один
запрос — один и тот же лот выброс для одной оценки и нормальный аналог для
соседней. Persist-флаг на объявлении такое отношение выразить не может,
реализовать пометку нечем.
2. http_requests / http_errors / returning_count / disappeared_count — УБРАНЫ.
HTTP-запросы не считает ни один фетчер (заполнить нечем без сквозной
инструментации). Ошибки и «пропало/вернулось» уже считает тот, кто их знает,
и кладёт в counters jsonb: errors_count у pipeline, deactivated/revived у
deactivate_stale_*. Отдельные колонки были бы вторым определением того же.
3. run_type — УБРАН из API, из таблицы админки и из схемы. Ни одно место кода
его не задавало; DEFAULT из 051 подписывал 'city_sweep' даже proxy_healthcheck.
Колонка «Тип» в UI заменена на «Источник» — там осмысленное значение.
4. Фильтр источников — теперь из данных (GET /scrape/runs/sources, SELECT
DISTINCT source). Захардкоженная тройка не просто была неполной: сравнение
точное, а строк с source='avito'/'cian'/'yandex' в таблице нет вообще, то
есть каждый пункт фильтра давал пустую выдачу, и пустой выбор («Все») тоже —
он молча подставлял source вкладки. Новый источник появляется в списке сам.
Числа с прода (tradein-postgres, 2026-08-06): is_outlier=true у 0 из 93 408
listings (NULL у 0 — только DEFAULT); четыре счётчика = 0 во всех 3244 прогонах
с миграции 015; run_type — одно значение на 3244 строки; 53 реальных источника,
2466 прогонов (76%) вне трёх площадок, включая весь Домклик.
Миграция 214 идемпотентна; v_data_quality пересоздан тем же DDL минус
outliers_flagged (порядок DROP VIEW → DROP COLUMN → CREATE как в 095).
PR #2681 смержен, пока ветка была в работе, и принёс
212_sber_index_pull_weekly.sql. Номер 212 занят — беру 213 (свободен,
проверено git ls-tree по origin/main после fetch).
Почему локальный гейт молчал: test_new_files_do_not_reuse_prefix сравнивает
префиксы файлов В ОДНОМ ДЕРЕВЕ, а смерженный 212_sber в этой ветке
отсутствует. Проверено симуляцией (копия data/sql + stub 212_sber):
с моим 212 тест КРАСНЫЙ («212 уже у нового 212_sber»), с 213 — зелёный.
Кросс-ветковым реестром занятых номеров служит _manifest_applied.txt, но он
отстал на 27 имён (171, 187-188, 189-211, 213), поэтому префикс 212 нигде не
числился занятым. Про долг — отдельно, в этом PR манифест не трогаю.
Apply after в шапке обновлён на 212_sber_index_pull_weekly.sql.
Ревью PR #2682 нашло контрольную группу в наших же данных. Перепроверено
собственными запросами к проду — сходится, местами хуже заявленного.
1. delisted/relisted УБРАНЫ из писателя событий.
Покрытие обхода за 14-18.07: domklik 99.9-100%, yandex 34-43%, cian 21-27%,
avito 1.6-3.4%. Переходы за те же дни: domklik — снятий 1/2/0/2/4 в сутки и
возвратов РОВНО 0 все пять суток; yandex — снятий 343-433 в сутки. Тот же
обход, тот же день, разница только в покрытии: событие рождается тем, что
скрейпер снова дошёл, а не тем, что объявление вернулось. Подтверждения:
avito 13.07 (день остановки обхода) — 3023 «снятия» за сутки против
контрольной ставки 1-4 (точность ≈4%); 4705 возвратов из 5493 за 12 дней
(85.7%) — это 2-3.08, два дня после возобновления обхода.
Сужение окна свежести сделало бы хуже (больше флапаний). Журнал из догадок
хуже пустого журнала — не пишем. is_active убран из запроса целиком.
Гейт-тест ослаблен до трёх типов + новый гейт «невыводимые НЕ пишутся».
2. TTL-путь пишет 'stale', а не 'closed'.
Прогон по домклику 02.08 деактивировал 6131 объявление за раз (TTL 14 суток
против 12 суток простоя обхода) — под общим статусом это 6131 фальшивая
«дата продажи» одной датой. 'closed' остаётся только за 404: там ответила
площадка. CHECK на колонке нет, миграция 212 обновляет только COMMENT.
3. change_time усечён до суток (date_trunc). С now() UNIQUE(source, change_time,
type) работал только внутри прогона: второй прогон в те же сутки (2 августа
их было два) давал дубли. Теперь заявленная идемпотентность действительно
работает.
4. Комнатность в разборе заголовка стала необязательной: 1991 заголовок из
25 055 (7.9%) — «Квартира-студия, 34,2 м², 9/10 эт.», обязательная группа
роняла match и обнуляла все четыре поля. Чинит обоих писателей сразу
(house_suggestions + house_placement_history, там 8.8% без площади).
Студия → rooms=0 по конвенции kit'а, а не None.
Фальсификация: вернуть delisted — 1 красный; 'closed' на TTL-пути — 6;
обязательная комнатность — 2; now() вместо date_trunc — 1.
Ревью PR #2681 опровергло исходную посылку по СберИндексу, и это подтвердилось
на моих же числах (все 24 прогона монитора, read-only):
13-16.07 alert=1 age 73..76 latest=май
17.07 alert=0 age 46 latest=июнь ← день загрузки
18-31.07 alert=0 age 47..60
01-05.08 alert=1 age 61..65
Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст
считается от первого числа покрытого месяца → пол 46, потолок 74, порог 60
ВНУТРИ диапазона. Тревога срабатывала 14 суток из 28 без всякого застоя
источника: девять срабатываний были замером нашего собственного такта. Поднятие
до ERROR без этой правки завело бы ежедневное ложное событие две недели в месяц.
Миграция 212 переводит sber_index_pull на недельный такт (потолок ≈53 при пороге
60, запас 7 суток) вместо поднятия порога до 75 (запас 1 сутки — ломается от
любого сдвига окна). Цена: 9 запросов в неделю вместо 9 в 28 дней к публичному
sberindex.ru/api/sowa; прогон 4 секунды, 0 ошибок за всю историю.
Дополнительно по ревью:
- поллер Росреестра: ветка «файл найден в листинге, но HEAD не отдал zip» →
ERROR (ровно поведение старой Bitrix-заглушки) + вписана в таблицу уровней;
- тестовый харнесс закрывает клиент событий (фоновый поток на каждый тест).
Refs #2674
Пользовательская половина разбора #2574: витрины читают listings без сегмента
и без свежести, поэтому показывают числа, посчитанные не по тому пулу.
1. Миграция 211 — гард #1186 в window_listings у street_sales_vs_listings().
27.3% кандидатов на пару «ДКП ↔ объявление» были новостройками, и
девелоперский прайс (который не торгуется) формировал показываемый процент
торга. is_active здесь по-прежнему НЕ фильтруется — осознанно: функция
намеренно смотрит и снятые объявления, иначе к сделке нечего подставить.
Сигнатура не меняется, значит CREATE OR REPLACE — замена, а не вторая
перегрузка (грабли #2627 закрыты тестом-сравнением сигнатур с м.205).
2. location_index — предикат свежести + сегментный гард в обоих запросах
медианы, симметрично _COMMON_WHERE эстиматора. Витрина обязана смотреть на
тот же пул, на котором считается цена; окно свежести берётся импортом
LISTINGS_FRESH_DAYS, второго определения константы не заводим.
3. /scraper/data-quality и /cache-stats — «активно» не прячем, а разделяем:
рядом отдаётся «из них не виделись N дней» (+ сам порог N в ответе).
Именно слепой count(*) WHERE is_active заставлял #2574 месяц выглядеть
как «всё собирается».
Refs #2660
Правки по deep-review PR #2654.
MEDIUM. Внутренний EXISTS считал backup'ом любой enabled-узел affinity. До п.2 это
было эквивалентно «пригоден», потому что бан выключал узел глобально; теперь узел
бывает enabled и одновременно забанен СВОИМ же источником. Fallback мог увести
последний реально рабочий узел выделенной affinity (два domclick-узла, один забанен
domclick'ом → второй уходит под avito → domclick без прокси). Добавлено требование,
что backup не забанен своим источником — в acquire и зеркально в защите mark_banned.
MEDIUM. У оператора не осталось способа снять бан: в п.1 ложное срабатывание
лечилось PATCH enabled=true (он обнулял disabled_reason), теперь бан живёт в
отдельной таблице и истекает только по таймеру, до 72ч при эскалации. Добавлен
proxy_pool.clear_source_bans; зовётся из patch_proxy при ручном включении и после
УСПЕШНОЙ ротации exit-IP (бан привязан к proxy_id, а банился IP — после смены
адреса строка держала бы узел вне выдачи без причины).
LOW. Тест защиты дублировал логику вместо её проверки: ban-предикаты в фейксессии
теперь гейтятся по подстрокам боевого SQL (как в acquire-ветке) — проверено
мутацией, тесты краснеют при удалении NOT EXISTS из запроса.
LOW. Конверсия в миграции 210 матчила disabled_reason по LIKE 'banned:%' и могла
отменить ручное выключение оператора (формат подсказан комментарием 209-й) — сужено
до точного списка значений домена provider_affinity.
LOW. Docstring report_ban в browser_fetcher описывал старую модель (enabled=false);
формула в COMMENT ON COLUMN была на шаг мимо (срок ТЕКУЩЕГО бана, не следующего).
Расхождение с acquire по leased_by зафиксировано в докстринге как осознанное.
Refs #2600
Бан площадкой был глобальным: п.1 на распознанный бан выключал узел целиком
(enabled=false, disabled_reason='banned:<source>'). Реальность другая — Авито
банит IP, а Яндекс через тот же IP ходит чисто, поэтому один забаненный источник
выкидывал живой узел из пула для всех и худил пул быстрее, чем его пополняют
(#2638). Плюс такое состояние не самолечилось: ipify площадку не эмулирует, бан
не видит, а non-NULL disabled_reason блокирует авто-воскрешение (#2610) — нужен
был ручной PATCH.
Теперь бан — свойство ПАРЫ (proxy_id, source) в scrape_proxy_source_bans:
acquire(source) не выдаёт узел только этому источнику, для остальных узел
первосортный; снимается сам по времени. Срок эскалирует 6ч → 12 → 24 → 48 → 72
(потолок) на повторных банах той же пары; ban_count сбрасывается purge'ем
истёкших строк через 7 суток — поэтому purge намеренно отложенный, а не по
banned_until < now(). Защита последнего узла сохранена, но считается по
источнику: если после бана у acquire(source) не останется кандидатов — бан не
пишется, WARNING зовёт пополнять пул.
Миграция 210 конвертирует прод-остатки п.1 (enabled=false + disabled_reason
LIKE 'banned:%') в 6-часовые per-source баны и возвращает узлы в строй — иначе
они висели бы выключенными вечно.
Оператору активные баны видны в GET/PATCH /admin/proxies (source_bans) — без
этого «узел включён, но не выдаётся» необъяснимо.
Refs #2600
Root cause: event-diff CTE джойнил "today" (снимок за CURRENT_DATE) с "prior"
(DISTINCT ON по всей listing_source_snapshots, ~2.6-2.8M строк) обычным JOIN.
Планировщик оценивал today в 1 строку (свежевставленные в той же транзакции
строки ANALYZE ещё не видел) → Nested Loop без Materialize пересчитывал
DISTINCT ON по всей таблице заново на каждую из ~80-140k реальных строк today
(EXPLAIN на проде: cost≈300k на этом шаге) — прогон не укладывался ни в 6h
zombie-порог, ни в сутки, каждую ночь минимум с 19 июля.
Переписано на JOIN LATERAL (per-row indexed point-lookup через idx_lss_source_date,
cost упал до ~4.4/строку). Плюс budget_sec → SET LOCAL statement_timeout как
defense-in-depth (по образцу geocode_missing_listings) — задача теперь честно
падает в mark_failed вместо того чтобы висеть сутками, если план когда-нибудь
разрегрессирует снова.
Зомби-детектор (reap_zombies) не тронут — он только помечает scrape_runs.status,
не убивает backend (нет pid/application_name в схеме run'а); pg_terminate_backend
для этого — отдельный follow-up, не в этом PR.
DELETE 4 мёртвых mobileproxy-узлов (id 2/3/4/5) из scrape_proxies. Подписка закрыта, узлы мертвы с 4-9 июля; у трёх в rotate_url лежал чужой API-ключ открытым текстом (источник блокера PR #2611). scrape_proxy_rotations пуста, единственный FK не мешает. Условие по домену (url LIKE mobileproxy.space), не по id.
Миграция 200 (номер 199 занят параллельным PR #2611, не смержен в main).
UPDATE listings SET region_code=NULL WHERE source='avito' и slug города в
source_url не входит в наши шесть (ekaterinburg/nizhniy_tagil/
kamensk-uralskiy/pervouralsk/verhnyaya_pyshma/serov). Строки — наследие
массового заброса 18 июня до появления гео-фильтра карточек (f0264237,
20 июня), канал закрыт, все 16930 строк is_active=false.
NULL вместо настоящего региона: колонку не читает ни одна живая выборка,
восстанавливать регион по тексту не будем. Idempotent (region_code IS NOT
NULL guard). Только UPDATE, без DDL.
Скрапер знает город в момент сбора (city_slug из CITY_LOCATIONS/CITY_ANCHORS,
scraper_kit.orchestration.pipeline), но раньше нигде его не записывал. Провайдеры
(avito/cian) часто отдают адрес БЕЗ города в тексте ("ул. Победы, 30" вместо
"Нижний Тагил, ул. Победы, 30" — cian даже явно вырезает location-часть перед
записью, providers/cian/serp.py _format_address skip_types={"location",...}).
Без города такой адрес при геокодинге считался "город не назван" и коллизировал
с одноимённой екатеринбургской улицей (Ленина/Победы/Тенистая — сотни совпадений
в ЕКБ-реестрах) → объявление получало координаты Екатеринбурга.
Fix: отдельная колонка listings.city (196_listings_city.sql), проставляется из
sweep-контекста через save_listings(..., city=...) — НЕ парсингом/дописыванием
в address. Раздельная колонка не портит исходный текст адреса: downstream
text-парсеры (geocoder._parse_street_house/_names_non_ekb_city, estimator
house-matching) продолжают работать на исходном сыром тексте неизменёнными —
дописывание города в address ломало бы bare-form адреса без street-маркера
("Дружинина, 33" без "ул.") в этих же парсерах.
Симметрия: EKB-варианты city-sweep функций (city_slug=None) тоже получают
city="Екатеринбург" — resolve_city_name(None) даёт тот же ЕКБ-дефолт, что и
get_city_location/get_city_anchors. Проставлено во всех продовых write-путях:
run_avito_city_sweep/run_yandex_city_sweep/run_cian_city_sweep (city_slug-aware),
run_avito_newbuilding_sweep/run_cian_full_load/run_yandex_full_load/
run_avito_full_load (подтверждённо EKB-only по докстрингам), run_domclick_city_sweep
(EKB city_id, oblast B2 ещё не wired — честный None для неизвестного city_id).
Scope: только write-path для НОВЫХ листингов. Бэкфилл накопленных строк и
консультация city в geocode_missing_listings/backfill_coords_from_geoportal
(gate там пока text-only, _names_non_ekb_city) — geocoder.py намеренно не
тронут (#2582/#2580) — отдельные follow-up задачи.
backfill_coords_from_geoportal брал все listings с lat IS NULL, парсил street+house
и матчил напрямую против EKB-only ekb_geoportal_buildings, минуя geocoder.geocode()
и его городской гейт (_names_non_ekb_city). Улица+дом могут буквально совпасть между
Екатеринбургом и другим городом области ("проспект Ленина 1" есть и в ЕКБ, и в Нижнем
Тагиле) — такие адреса получали екатеринбургские координаты и портили радиусные
выборки аналогов на этой улице в ЕКБ, а сами исчезали из выборки своего города.
Фикс: _names_non_ekb_city(address) перед вызовом _geoportal_house_match — тот же гейт,
что уже используется в geocode(). Прямой вызов geoportal-матчера (а не полноценный
geocode()) сохранён намеренно — pure local-DB операция без внешнего HTTP, полноценный
geocode() добавил бы Nominatim/Yandex вызов на каждый non-EKB адрес backlog'а (лишняя
нагрузка на ограниченный Nominatim, Yandex сейчас 403 — #2585).
geo_precision оставлен NULL для house-level матчей этого тира — по конвенции
089_listings_geo_precision.sql/geocode_missing.py NULL означает "не coarse", то же
значение что geo_precision=None для precise-адресов в geocode_missing_listings;
исключать из radius-аналогов нужно только 'city'-fallback.
Порядок окон (05:00 geoportal → 06:00 geocode_missing_listings) не менялся: гонка была
безвредна для корректно заматченных EKB-адресов, вредна только из-за отсутствия гейта —
теперь non-EKB адреса здесь не матчатся вообще и просто ждут oblast-aware провайдеров
в следующем окне.
Поправлен ложный комментарий в migration 171 ("не-ЕКБ адреса не матчатся — корректно").
Ущерб на проде (SELECT-only, без изменений): 2040 листингов с координатами внутри
EKB-bbox (56.65-56.95, 60.40-60.85) при адресе, называющем другой город области
(1941 после исключения мкр/р-н/жк-омонимов вроде ЖК "Заречный" внутри ЕКБ). Только
~31 из них совпадают по координатам с ekb_geoportal_buildings/gendesign_cad_buildings —
основной массив, вероятно, из других источников координат (не только этот таск).
Чистка — отдельный шаг.
Миграция 178 засеяла deal_city_price_bands разово (N>=30 сделок), без scheduler'а
на refresh. Город без строки падал на глобальный DEAL_MIN_PPM2=50000 (ЕКБ-калибровка)
— для дешёвых городов области это не anti-outlier guard, а cut-off легитимного рынка
(Североуральск median ~21.7k). Замер по прод-данным: 289 из 369 не-ЕКБ городов
(1265 сделок) не имели строки и падали на ЕКБ-порог.
- 194_deal_city_price_bands_tiers.sql — трёхуровневая схема (tier колонка):
full (N>=30, own p1/p99, unchanged) / rough (N 10-29, own p1 floor + фикс.
ceiling 800000) / region_fallback (N 1-9, pooled областной p1=15263 вместо
ЕКБ-порога). Екатеринбург по-прежнему не в таблице — estimator fallback
byte-identical.
- 195_scrape_schedules_seed_deal_city_price_bands_refresh.sql — scrape_schedules
row, окно 07:00-08:00 UTC (после rosreestr_dkp_import + asking_to_sold_ratio_refresh).
- app/tasks/deal_city_price_bands_refresh.py — периодический re-derive (kit-scheduler,
byte-identical 194 derivation), без DELETE (множество городов монотонно растёт).
- app/services/product_handlers.py — регистрация Handler для нового source.
Валидация: scratch-БД (syntax_check) в прод-контейнере, synthetic данные на
границах тиров (N=9/10/29/30) + Екатеринбург/non-rosreestr/NULL exclusion, оба
файла применены дважды (идемпотентность подтверждена), scratch-БД удалена.
Deep-review #2564: manager_id синкался из EXCLUDED безусловно — повторный прогон сида
тихо обнулял связь сотрудник->менеджер, назначенную через team-API (#2563), сотрудник
выпадал из _LIST_EMPLOYEES_BY_MANAGER_SQL. Защищён COALESCE, как остальные UI-managed
поля.
is_active убран из ON CONFLICT DO UPDATE SET вовсе (не COALESCE — колонка NOT NULL
DEFAULT true делала бы COALESCE-ветку недостижимой, мёртвый код вводил в заблуждение
симметрией с реально работающими COALESCE-полями). Open/close доступа — решение
владельца продукта через UI (#2556), не повторный прогон seed-файла.
Migration 193: переносит org-карту (admin/kopylov/praktika/user1-10), утверждённую
владельцем продукта, из auth/roles.yaml в tradein_users (Foundation — миграция 192).
password_hash=NULL для всех — пароли админ проставит вручную через team-UI (#2556).
ASCII-CHECK на username (deep-review #2561): rbac кодирует session-username через
encode("latin-1","replace"), кириллические логины одинаковой длины схлопываются
в общий downstream-identity (IDOR) — constraint запрещает это fail-closed.
ON CONFLICT DO UPDATE защищает password_hash/is_active/display_name/org_name/email
через COALESCE — повторный прогон (recovery / staging без _schema_migrations
tracking) не затирает то, что менеджер поменял через UI.