amtool check-config падал на "unsupported scheme \"\" for URL": шаблон
заводил url: "${METRICS_WATCHDOG_PING_URL}" напрямую, а envsubst в
deploy-metrics.yml вызывается с явным allow-list переменных, в который
эту новую переменную не добавили — литерал плейсхолдера (или пустая
строка, если бы добавили без блока) доезжал до amtool и валил проверку
целиком, то есть ронял ВЕСЬ Alertmanager, а не только Watchdog.
Тот же приём, что уже применён для темы форума: подставляется ЦЕЛЫЙ
БЛОК (webhook_configs целиком), а не голое значение url. Собирается в
deploy-metrics.yml через METRICS_WATCHDOG_PING_BLOCK — если секрет задан,
блок с url и send_resolved; если пуст, пустая строка и ::warning в лог.
Пустой блок оставляет receiver watchdog-ping без единого *_configs — это
валидный конфиг Alertmanager (приёмник молча ничего не шлёт), а не
деградация всего алертинга. METRICS_WATCHDOG_PING_URL добавлен в env/envs
appleboy/ssh-action и в allow-list envsubst.
_render() в test_3078_alert_topic.py переписан так, чтобы подставлять
РОВНО тот набор переменных, что реально в allow-list деплоя (вытащен
регексом из самого воркфлоу), а не свой отдельный список — так и
воспроизвёлся бы этот регресс автоматически. Добавлен явный тест
test_every_template_placeholder_is_in_envsubst_allowlist на этот инвариант
и два теста на обе ветки watchdog-секрета (пуст / задан), проверенные
amtool check-config на prom/alertmanager:v0.28.0 вручную для обоих
рендеров.
Watchdog горел всегда по построению (10080/10080 интервалов за 7 суток) и
раньше слался в Telegram раз в 12ч — 14 сообщений в неделю ни о чём. Это
приучило пролистывать инфра-тему, и 17.09 настоящий DiskWillFillIn24h утонул
между Watchdog и вечно горящим NoActiveCeleryWorkers: диск дошёл до 84%
незамеченным.
Watchdog переведён на внешний deadman-приёмник (webhook на
METRICS_WATCHDOG_PING_URL, healthchecks.io-подобный сервис) вместо Telegram:
человек не получает ни одного штатного сообщения, а живость канала
по-прежнему подтверждается регулярным HTTP-пингом. Секрет на хосте пока не
заведён — деплой не трогаем, добавление аккаунта и значения переменной на
проде остаётся ручным шагом.
Новое правило Prometheus AlertFiringTooLong эскалирует любую тревогу,
непрерывно firing дольше 6 часов (кроме Watchdog и себя самой — оба
исключения обязательны, иначе получаем второй вечный сигнал). Маршрутизирована
в клиентскую тему (алертменеджер матчит по alertname, не по host — исходная
тревога может быть про любой хост).
Затронуты также два backend-теста ops/ (test_3078_alert_topic.py,
test_3078_oncall_route.py), проверявших структуру шаблона Alertmanager —
обновлены под новую форму (один прямой Telegram-получатель вместо двух,
маркер watchdog-ping вместо telegram-heartbeat).
После мержа #3577 (снапшоты «строка на изменение») прогон без БД — тот, что
гоняет deploy-tradein.yml перед сборкой, — завершается rc=1:
НЕУЧТЁННЫЙ ПРОПУСК (3): tests/test_2993_snapshot_change_only.py::...
В CI PR эти тесты шли против postgres-сервиса и проходили, поэтому
расхождение видно только на деплой-лэйне, где они законно пропускаются.
Объявлены с причиной, как соседние живые тесты (#3385, #3191).
Деплой-лэйн: 6465 passed, 68 skipped, rc=0, неучтённых пропусков 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
По ревью PR #3582. Три мутации тесты не ловили (5 passed): нормализация
только пробелов, односторонний LIKE, снятый EXISTS #968. Плюс латентный
дефект: UNIQUE(source, source_id) не запрещает complex иметь два
objective-проекта, DISTINCT ON брал любой, сверка его отвергала, и верный
проект терялся (воспроизведено: complex с «Клён» и «Сосны», брался «Клён»).
- nearest_cx в обоих SQL: нормализованные ключи в LATERAL, сверка name_ok
считается там же и стоит в ORDER BY после расстояния (name_ok DESC,
source_id) — при двух проектах берётся сверенный, выбор детерминирован.
Фильтр по-прежнему ПОСЛЕ DISTINCT ON.
- тесты: «Квартал "Татлин"» = «Квартал Татлин» (кавычки посреди имени),
«Парковый» ⊂ «Парковый квартал» (обратная сторона LIKE), ближайший
complex без лотов не съедает матч (#968), complex с двумя проектами.
Старый тест пунктуации проверял пробел — переименован честно.
Прод 17.09 (только чтение): выбор nearest_cx у всех 185 gap-fill объектов
в обоих SQL совпал с головой PR (0 расхождений, 176 принято, 9 отвергнуто);
время на центре ЕКБ 1 км: конкуренты 70 → 69 мс, цена 103 → 103 мс.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
На голове b74b02ea CI / backend-tests был красным (run 11737): 5125 passed, но
все 5 тестов test_2962_competitors_gapfill_bridge.py пропустились с причиной
«нет расширения postgis», а в skip_allowlist.txt их не было — гейт вернул rc=1.
В CI ПТИЦЫ plain postgres:16, а SQL конкурентов без PostGIS не исполнить
(ST_DWithin по geography).
- ci.yml: образ postgis/postgis:16-3.4 (тот же, что в ci-tradein.yml); образ
сам создаёт расширение в POSTGRES_DB.
- тест: при CI/GITHUB_ACTIONS skipif выключен — если PostGIS из CI пропадёт,
тест упадёт с настоящей причиной, а не пропустится молча.
- skip_allowlist.txt: пропуск объявлен только для машины без базы.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
В шести тестах test_cadastre_bulk.py комментарий к моку с xmin=None
говорил, что пропускаются «grid-walk + territorial_zones фазы». Фазы
territorial_zones в harvest_quarter после предыдущего коммита нет;
при bbox=None пропускается только grid-walk (bulk_harvest.py:176, :335).
Правка только в комментариях, поведение тестов не меняется.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Что было. Для конкурентов вне objective_complex_mapping мост шёл
«ближайший complex → objective_lots.complex_id → все project_name».
complex_id проставлен один раз миграцией 76 на загрузке 10.05, а
еженедельный 70_parse_objective_raw.py UPSERT'ом по objective_lot_id
переписывает project_name и не трогает complex_id. Прод 17.09: из
303 677 строк с complex_id у 236 354 проект чужой (все вставлены 10.05,
переписаны 17.05–15.09). У 185 gap-fill конкурентов своих лотов 23 %,
скорость в медиане завышена в 39 раз (сумма 65 798 против 1 609 сделок/мес).
Что сделано. В _COMPETITORS_SQL и _OBJECTIVE_PRICE_FALLBACK_SQL complex
связывается с проектом через complex_sources (source='objective', 1:1),
лоты и сделки берутся по project_name. Связь в complex_sources почти вся
fuzzy и не проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит» —
'Гранит'), поэтому имя проекта дополнительно сверяется с именем объекта
ДОМ.РФ без регистра и пунктуации; сверка стоит после DISTINCT ON, внутри
join планировщик гонял regexp по 383k пар (3 с).
Замер на проде (все 1556 объектов, окно 3 мес): явный маппинг 308 и
остальные 1063 — изменилось 0; gap-fill 185 — изменились все, у 9 связь
отвергнута (7 чужих проектов + 2 «Традиции»/«Традиция»), доля своих лотов
23 % → 96 %. На 500 участках: 4317 прежних пар участок-конкурент — 0
изменений, 598 gap-fill пар — изменились все. Время запроса конкурентов
393 → 65 мс, ценового fallback 23 → 54 мс.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Инстанс camoufox поднимался на поставщика и не закрывался никогда: пять
поставщиков давали пять вечных браузеров в контейнере с mem_limit 2560m. Прод
15.09-17.09: 5 живых простояли 2.4 ч прямо перед резким рестартом 16.09 19:08;
медиана working_set 1494 МиБ при 3 живых, 1956 при 4, 2108 при 5.
Потолок BROWSER_MAX_INSTANCES (по умолчанию 3) проверяется в _launch_browser,
через который идут все запуски, включая фоновый retry. На потолке закрывается
простаивающий инстанс другого поставщика: без context'а первым, по давности
использования внутри класса. Занятый лок не трогается, если закрыть некого,
запуск идёт сверх потолка с предупреждением. Слот резервируется на время
запуска. _close_browser снимает инстанс со словарей до первого await и больше
не сбрасывает пейсинг: это делают только собственный relaunch и смена прокси,
как раньше. Четыре дефекта ревью ветки fix/3412-cian-instance-relaunch
закрыты тестами, каждый краснеет при снятии своей части фикса.
init: true у сервиса browser: PID 1 был python server.py и не собирал
осиротевших детей закрытого браузера; прод 17.09 — 2296 процессов в Z за 14 ч.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
harvest_quarter после основного обхода делал отдельный grid-walk слоя 875838
(49 запросов к НСПД на квартал) и писал результат в cad_territorial_zones.
Эту таблицу никто не читает: на проде 1 строка за два полных прохода по ЕКБ,
zone_code/zone_name пустые (маппинг свойств не совпадает с ответом НСПД).
ПЗЗ до отчёта доходят другой трубой: nspd_sync -> nspd_quarter_dumps.features_json.
Владелец 17.09 выбрал удаление фазы.
Удалено: блок Phase 2.5 в harvest_quarter, писатель _save_territorial_zones,
обёртка NSPDBulkClient.get_territorial_zones_in_bbox (других вызовов нет),
тесты писателя и мок в test_cadastre_bulk. Таблица и её данные не тронуты,
миграций нет. Поправлены комментарии, ссылавшиеся на фазу.
Новый тест: квартал без overflow с валидным bbox стоит ровно один запрос
к НСПД (search_by_quarter), фазы прогресса без territorial_zones_started.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
После удаления golden-parity (#2419) разбор avito.houses, cian.serp и yandex.serp
не проверял ни один тест: правка извлечения поля в kit проходила бы зелёной.
- avito.houses: обе осиротевшие карточки дома (572538, 115188) — характеристики,
рейтинг из reviews.filterEntries (#1789), отзывы, история размещений, мини-выдача
с продавцом, рекомендации; карточка ЖК 12879380 — ветка developmentPage.
- avito.serp: цена, комнаты, площадь, этаж/этажность и дата из sortTimeStamp
на avito_serp_sample.html.
- cian.serp: живая выдача 2026-09-17, урезанная до 4 offers и читаемых ключей
(без телефонов и данных пользователя): вторичка, новостройка с домом, отделка
и балкон, новостройка без улицы — номер дома не выдумывается (#1773).
- yandex.serp: живой gate-API 2026-09-17: канонизация партнёрского url (#2235),
собственник/агентство, студия без roomsTotal, новостройка без оценки.
- guard: ни один модуль scraper_kit не импортирует app.* (AST, включая ленивые
импорты) — замена удалённого test_kit_avito_has_no_app_imports.
Тесты лежат в tradein-mvp/backend/tests/scrapers, а не в packages/scraper-kit/tests:
CI гоняет pytest только из backend, каталог в пакете не собирался бы никем.
Мутационный прогон: 9 порч kit (рейтинг #1789, developmentPage, цена истории,
этаж Авито, год сдачи и адрес Циана, url и студия Яндекса, import app) — каждая
роняет хотя бы один тест.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
listing_source_snapshot каждую ночь копировал listing_sources целиком: прод
14-17.09 — 290-293 тыс. строк в сутки, из них отличались от предыдущего
снимка 7.7-11.4 тыс. (3-4 %). Решение владельца 2026-08-23 — строка на
изменение; читатель (пол переобхода, PR-A #3056) и рельсы объёма (PR-B
#3066) уже смержены.
Строка пишется, если снимка ещё нет или (price_rub, is_active, last_seen_at,
payload_hash) отличается от последнего снимка источника, включая сегодняшний
(повторный прогон в те же сутки перезаписывает строку, только если значение
снова сдвинулось). Состояние на дату D = последняя строка с snapshot_date <= D
— для всех четырёх колонок то же, что дала бы суточная копия, поэтому пол
переобхода и события не меняются. Старые суточные снимки не трогаются.
Замер чтением на проде 17.09: отбор с условием — 1.0 с на 293 602 источника
(бюджет 900 с). Миграция 325 — только COMMENT на таблицу и view: прежние
«ежедневный снимок»/«на дату снимка» стали бы неправдой.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Семантический конфликт двух PR, смерженных 17.09 подряд. Текстового конфликта не
было, CI каждого PR был зелёным на своей голове, а объединённое дерево падало:
test_3466_corridor_tier_a.py (2), test_estimator_radius_floor.py (2)
AttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_slack'
TypeError: _run_estimate() got an unexpected keyword argument 'radius_floor_factor'
#3554 (Tier A и advisory_only) писал тесты против settings.estimate_corridor_clamp_*,
а #3556 (#2380) убрал эти поля из Settings в константы CORRIDOR_CLAMP_SLACK
(estimator) и CORRIDOR_CLAMP_MIN_N (app.core.config) и снял параметр
radius_floor_factor у хелпера. Продуктовый код не затронут: в app/ старые имена
остались только в комментарии, на проде AttributeError не было. Но красный test
блокировал деплой МЕРЫ: на проде до сих пор образ до второй пачки мержей.
Тесты переведены на константы, значения прежние (0.40, 10, 0.8).
pytest tests/ — 6374 passed, 44 skipped, rc=0; ruff check/format — rc=0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Было: est_days_on_market — одно число, медиана days_on_market активных
аналогов (возраст висящего объявления, цензурированная выборка), в
trade_in_estimates не сохранялось, GET по ссылке отдавал null.
Стало:
- эстиматор берёт exposure_days из house_placement_history: те же комнаты,
площадь ±15%, дома в радиусе подбора аналогов, снятые за 24 мес.;
квартили как percentile_cont; при выборке < 30 окна нет;
- миграция 324: est_days_p25/p50/p75/n в trade_in_estimates, пишутся в
INSERT и при ревайвле, поднимаются на GET (/estimate/{id} и /r/{token});
- API: exposure_window {p25_days, p50_days, p75_days, n}; старое поле
est_days_on_market оставлено и равно p50 окна, у старых строк null;
- B2B hero: «До снятия объявления N–M дн.» вместо «Срок продажи»;
- /docs: убран «прогноз срока» из описания полного отчёта.
Порог 30 — замер 17.09 на 51 прод-когорте: ошибка края окна при 20 лотах
24%, при 30 — 18%. Окно есть у 94 из 217 недавних оценок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/house-analytics при <8 архивных лотах добирает до 30 соседних домов в
радиусе 300 м и возвращает radius_m. Карточка истории цен всё равно писала
«История цен в этом доме · только этот дом», а рядом на том же экране стояло
«в радиусе 300 м».
Заголовок и подпись теперь зависят от radius_m: при расширении — «дом и
соседние в радиусе N м». Поправлены обе версии экрана (v2 и старый
PriceHistoryChart).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Фильтры матча стояли только на стороне ГАР, UPDATE houses шёл по канону
улица+дом по всем домам всех регионов. После #3523 (матч для 77 и 50) это
стало порчей: прод 2026-09-17 — 578 домов региона 66 с guid региона 50,
340 — с guid 77, 1371 дом 50 с guid 77, 497 домов 77 с guid 50. По чужому
guid ЖКХ/ФРТ/капремонт-загрузчики тянут данные другого дома.
Матч сверяет регион дома с регионом ГАР-строки и ранжирует канон внутри
региона. Перед матчем снимаются уже проставленные canon_addr-guid чужого
региона (дом без региона не трогаем) — иначе дом без пары в своём регионе
так и остался бы с чужим. Эффект на проде — после ops-прогона --match-only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Резолвер города знал 24 имени региона 66, а развёртки идут ещё по 23 городам
(Реж, Карпинск, Кушва, Лесной, Тавда…). Город не распознан — коридор ДКП
собирался по одной улице во всей области, где «Ленина» в основном
екатеринбургская, и Реж получал цену Екатеринбурга.
Резолвер теперь знает и города развёрток (кроме городов других регионов).
Общий словарь региона не трогали: на нём гейты геокодера и токены ключа дома.
Замер прода: по всем 23 городам сделки лежат под теми же е-формами, в сделках
ЕКБ ни одного адреса с этими именами отдельным словом.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
С 13.09 21:14 по 16.09 18:52 пять объявлений (10776456, 10775964, 10775945,
10775747, 10775594), стоящих в очереди подряд, в каждом прогоне приходили пустой
SPA-оболочкой ~381 КБ без блока контактов. Счётчика попыток на объявлении не было,
они возвращались в голову каждого снапшота и пятью недогрузами подряд выбивали
брейкер: 16 прогонов закончились ровно attempted=5 incomplete=5.
Миграция 321: listings.detail_incomplete_count / detail_incomplete_at.
yandex_detail_backfill пишет недогруз на объявление; снапшот не берёт карточку
сутки и не берёт совсем после трёх недогрузов (счётчик incomplete_given_up);
повторный недогруз уже известной карточки не двигает брейкер, первый — двигает,
защита от системного недогруза сохранена.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Конфликт только в tests/skip_allowlist.txt: обе стороны дописали блок в конец —
live-тесты пула прокси (#3299/#3310/#3404) и миграции 310 (#3385). Оставлены оба.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>