Редизайн результата /estimate по макету отчёта: шапка объекта (адрес + чипы
параметров + «Изменить данные»), две большие бесплатные карточки, тизер
платной части с shimmer-строками, честные состояния «мало данных» и «город
не покрыт» в стилистике «ЧЕСТНЫЙ ОТВЕТ».
Сознательные расхождения с макетом (задокументированы в шапке компонента):
- «✓ расчёт готов и ждёт вас» не воспроизводится — платного расчёта для
анонима не существует (анонимный /estimate закрыт, платёжного контура
нет, #2896); тизер честно называется «что будет в полном отчёте», вместо
кнопки оплаты — прежняя формулировка «приём оплаты подключается»;
- бейджи МИР/СБП/VISA — за тем же TODO платёжного контура, что и в FooterV3;
- email-подписка «сообщим когда добавим» — нет хранения и согласия (#2895),
вместо неё живой канал поддержки;
- «Расширить радиус до 3 км» — /coverage радиус не параметризует;
- подписи бесплатных плиток — запиненные честные из coverage-copy.
Возврат 150 ₽ — строкой из макета со ссылкой на /refund. Старый
CoverageResult удалён (один рендер результата, не два). Фикстурное превью
всех трёх тонов — /mera-public/ui-preview/report.
Проверено: tsc чисто, lint чисто, vitest 27 passed, скрин трёх состояний.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Макет «B2C модуль для МЕРА» (Макс, 29.08) — пять секций, которых превью
/mera-public/v3 не имело (его шапка их и перечисляла как отсутствующие):
- DealsTickerV3 — бегущая строка «ПРОГНОЗ → ФАКТ»; данные — ТЕ ЖЕ строки,
что таблица сверок (PROOF_ROWS_PLACEHOLDER): один будущий контур — один
набор реквизита, второй не заводится;
- GuessGameV3 — игра «Угадай цену», три раунда со слайдером, сверка
«ваш ответ / МЕРА / факт», итог с медианной ошибкой игрока; раунды —
GAME_ROUNDS_PLACEHOLDER под гейтом #2904;
- TwoPathsV3 — «сами / сделаем за вас»; заявка пути 2 ведёт в Telegram
поддержки (формы и договора «под ключ» не существует);
- ObjectionsV3 — FAQ «перед оплатой» на нативных details; ответы из
выверенной честной копии (content.ts/oferta/refund), макетный ответ про
«отметки продавцов» заменён тем, что в продукте есть (Росреестр + снятие
с публикации);
- ArticlesV3 — тизер «Разборы на данных»; карточки БЕЗ ссылок до появления
раздела статей (следующий шард).
Решение «модалка → /estimate» (StickyCtaV3) сохранено для всех CTA макета.
Гейт noindex↔плейсхолдеры дополнен новыми файлами и GAME_ROUNDS_PLACEHOLDER.
Проверено: tsc чисто, next lint чисто, vitest 27 passed, живой прогон игры
на dev (раунд → сверка → следующая), полностраничный скрин 1:1 с макетом.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GLITCHTIP_ENABLE_MCP по умолчанию False (settings.py:291), поэтому
https://errors.gendsgn.ru/mcp сегодня отдаёт 404. Флаг читается в
asgi.py:181 (обёртка MCPDjangoDispatcher) и api/api.py:189 — нужен
только web-контейнеру, worker HTTP не отдаёт.
Заменяет неофициальный mcp-glitchtip (coffebar 0.1.2, 2 инструмента)
на вендорский с 17, включая detect_n_plus_one. Аутентификация —
статический APIToken в Authorization: Bearer, полный OAuth не нужен:
фолбэк в apps/oauth/provider.py:281-291 проверен чтением образа 6.1.6
(апстрим-issue #473 описывает более раннее состояние кода).
Blast radius: пересоздание только glitchtip-web, единицы секунд без
приёма событий. Схема БД и миграции не затрагиваются.
Откат: убрать строку и передеплоить.
Замер на проде 2026-08-29. Три независимых запуска camoufox через прод-прокси,
идём по карточкам до первого отказа — каждый раз одно и то же: карточка #1
даёт 200 и ~1 МБ с SSR-стейтом, карточка #2 даёт 401 и страницу отказа на
26 625 байт. A/B на восьми карточках: свой браузер на каждую — 4 из 4 успеха,
один браузер на четыре — 1 из 4. Прямой выход с сервера и выход через прокси
неразличимы (1 из 8 в обоих условиях), то есть дело не в IP.
Свежего КОНТЕКСТА не хватает: в контрольном замере каждая карточка бралась
через browser.new_page(), в новом изолированном контексте, — и всё равно отказ
со второй. Признак живёт на уровне процесса, camoufox генерирует отпечаток при
запуске, а не при создании контекста. Отсюда: reset_context (#3118) эту задачу
не решает в принципе. Цена перезапуска — 0.6 с (3.5 с только первый, холодный).
- PROVIDERS: добавлен "domclick" (+ host-detect). Раньше он проваливался в
generic и делил браузер со счётчиком страниц с прочим трафиком — при пороге
перезапуска 1 это было бы неверно.
- BROWSER_RECYCLE_PAGES стал поставщик-зависимым (_resolve_recycle_pages +
BROWSER_RECYCLE_PAGES_{PROVIDER}), по образцу BROWSER_BLOCK_IMAGES_{PROVIDER}
из #3185. Код-дефолт domclick=1, остальным прежние 15 — у Авито и Циана узор
другой и своего замера под него нет.
Отдельно починены ~24 холостых охранника в тестах. Они делали
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000), чтобы запретить
перезапуск браузера; после перехода на словарь этот патч перестал на что-либо
влиять, и набор оставался зелёным лишь потому, что ни один тест не делает 15
страниц подряд. Теперь патчится _RECYCLE_PAGES_BY_PROVIDER, а сама глобальная
константа убрана, чтобы её не патчили снова. Проверено мутацией: при пороге 1
для всех провайдеров падают ровно три теста, которые этот дефолт и проверяют,
остальные 155 удерживаются — значит охранники работают.
Замер на проде 2026-08-29: страница отказа ДомКлика при HTTP 401 — РОВНО
26 624 байта, байт в байт та же, что снималась 28.08 под кодом 403. Ни PoW,
ни QRATOR, ни капчи. Приходит одинаково и с валидной сохранённой сессией
(16 куков из domclick_session), и полностью анонимно — значит это не
«сессия отвергнута», а WAF-отказ с подменённым кодом ответа.
В таблице классификатора 401 не было (403/429 → platform, 5xx → infra),
поэтому все три пробы подряд дали ban_kind='unknown' — ровно то, что #3196
и должен был убрать. Механика диагноза при этом рабочая: статус доезжает от
page.goto до исключения целым, шов blocked.status = status подтверждён живым
401 на проде.
Правка доменная — в _ban_kind_of_block домкликового таска, а не в общей
scraper_kit.browser_fetcher.ban_kind_from_status: у других поставщиков 401
обычно значит «наша сессия протухла», это наша сторона, и метка 'platform'
там зря запустила бы ротацию IP (#2611).
Правила ссылались на механику удалённых ботов. Главное — не косметика:
секция "Polling loop / Foreground fallback" безусловно предписывала парсить
маркер <!-- gendesign-review-bot: ... -->, который писал auto-code-reviewer.
Агента нет, маркера нет — соло-сессия опрашивала PR до Cap 30 iter × 60s
в ожидании вердикта, которого не будет, и через полчаса пинговала человека.
git-pr.md:
- Polling loop: шаг 3 вместо маркера бота теперь merge по зелёному CI,
добавлены ветки "checks красные" и "человеческий review с правками"
- Refs #N -> Closes #N: обходной путь был нужен только потому, что issue
закрывал qa-бот на status/done. Бота нет, иначе issue не закроется никогда
- Auto-merge policy: убрано упоминание reviewer-окна и approve+SHA gate
delegation.md:
- снята ветка "bot-pipeline: label status/needs-analysis, снять claim"
Что НЕ менялось: сам self-extending guard, пороги эвристик, Cap 30 iter.
Сайдкар вообще не читал код ответа page.goto: страница классифицировалась
только по маркерам, снятым с Авито. Домклик отдаёт статическую `403 | Домклик`
на 26 624 байта, где нет ни одного такого маркера (замер прода 28.08.2026) —
она уезжала наверх как валидный HTML, парсер не находил состояние, и прогон
получал блок неизвестной природы. За 14 дней все 14 прогонов домклика легли с
ban_kind='unknown'; у Яндекса счётчика blocked не было вовсе, поэтому ветка
перевода прогона в 'banned' была недостижима по построению — ноль банов.
- browser/server.py: статус целевой навигации сохраняется per-provider и
доезжает в тело /fetch аддитивным ключом "status" (ключ "html" не тронут);
403/429 с маркерами челленджа больше не ждут PoW — ждать нечего, статическая
страница сама себя не перезагрузит. Наверх идёт BanPageDetectedError, а не
заглушка: вернув её контентом, воскресили бы #3045.
- scraper_kit/browser_fetcher.py: BrowserFetcher.last_response_status +
ban_kind_from_status (403/429 → platform, 5xx → infra, прочее → None).
Поток управления не менялся: fetch() по-прежнему отдаёт str.
- domclick: DomClickBlockedError несёт .status — один тип исключения на
маркер-детект и на сбой фетча разводится без размножения типов; прогон
передаёт перепись диагнозов в mark_backfill_finished.
- yandex: появился счётчик blocked, оживляющий ветку бана. Серии блоков и
промахов парсера считаются РАЗДЕЛЬНО: иначе четыре промаха плюс один 403
пятым давали 'banned' с переписью {platform: 1}.
- cian: ban_kinds наполняется только диагностируемым статусом. HTTP 200 с
пустым разбором — дрейф разметки на нашей стороне, а не отказ площадки;
записав его блоком, мы бы штамповали фиктивные баны у здорового источника
(13 done против 1 banned за 14 дней).
Инвариант: непустой ban_kinds ⟺ виден ответ 403/429/5xx. Значения остаются в
пределах CHECK scrape_runs.ban_kind.
Известный пробел: шов providers/domclick/detail.py `blocked.status = status`
тестами не покрыт — существующие домкликовые тесты подают исключение готовым
моком и боевой fetch_detail не исполняют.
Прод-прогон 5210 оборвался по ratio-критерию — 14 блоков из 20, ровно порог 0.7 —
и отчитался строкой «ABORT -- 1 consecutive blocks». Число верное: последняя серия
в тот момент действительно равнялась единице (19-я попытка успех, 20-я блок).
Величина не та. Читатель лога видит цифру, по которой обрыва быть не могло, и идёт
искать несуществующий баг в брейкере.
Причина: #3184 заменил критерий обрыва на долю в скользящем окне, а текст лога
остался от прежнего критерия «N подряд» — то есть ровно та же болезнь, которую
#3178 лечил у соседней строки (литерал «IP rate-limited» вместо измеренной причины).
- BlockRatioBreaker.abort_reason() возвращает "ratio" / "safety_net" / None;
should_abort() выражен через него, поведение не меняется.
- abort_explanation() даёт текст с той величиной, по которой обрыв и произошёл:
доля печатает «доля блоков 14/20 в окне (порог 70%)», safety-net — «5 блоков
подряд без единого успеха (снапшот 5 короче окна 20)».
- counters["abort_reason"] — чтобы причина обрыва читалась SQL-запросом по
scrape_runs, а не грепом контейнера. Ключа нет, если прогон не обрывался.
Тесты (проверено мутацией источника — на прежнем сообщении оба падают):
- ratio-обрыв на раскладке прогона 5210 (серия на обрыве = 1) требует «14/20»
в логе и отсутствия слова consecutive;
- safety-net требует «5 блоков подряд» и отсутствия «доля блоков» — без этого
зеркала первый тест проходил бы и у сообщения, всегда печатающего долю;
- прогон без обрыва (13/20) не пишет abort_reason в counters.
Refs #3184, #3178
#3195 замержен с утверждением, что авторизованная сессия раскрывает контакты
продавца. Утверждение неверно, а лежит оно в двух местах, которые читают в первую
очередь: докстринг app/services/yandex_session.py и шапка миграции 274.
Повторный замер (#3192, 2026-08-28) сделан на ПРОД-транспорте — curl_cffi + прокси
из пула, тот же путь, что у yandex_detail_backfill, — а не на сайдкаре, как первый:
- offerCard.card.author у целевой карточки не несёт phones/phoneNumbers ни в одном
из 12 случайных объявлений (6 AGENCY, 6 DEVELOPER), одинаково с куками и без;
только encryptedPhones (1 токен) и redirectPhones;
- phoneNumbers во всём INITIAL_STATE встречается только под
offerCard.visitedOffers[*].author — истории просмотров НАШЕЙ учётки; анонимно
список пуст, с куками в нём 9-10 записей;
- первый замер («0 → 3,4,5,6») считал рост именно этой истории: +1 на каждый фетч;
- authorStats.phones (коммутатор застройщика) отдаётся анонимно — тот же номер
в обеих ветках.
Правка только текстовая: ни схема, ни поведение не меняются. Шапку применённой
миграции правлю сознательно — файл повторно не выполняется (учёт по имени в
_schema_migrations), а неверное описание пережило бы любой следующий разбор.
Таблицу не трогаю: она пуста, но DROP без явного решения владельца делать нельзя.
Судьба #3195 — на владельце, #3192 помечен needs-human.
Refs #3192, #3195
#3185 выключил блокировку картинок для avito по гипотезе, что она сама по себе
сигналит QRATOR'у. Гипотеза шла от camoufox'ского LeakWarning, а не от замера.
Что показали замеры после выкатки:
- прямой A/B на сайдкаре — 6/8 успехов с блокировкой против 7/8 без, разница в
пределах шума;
- прод стал хуже: прогон 5200 (картинки блокировались) — 43/63 карточки при 32%
блоков; прогоны 5206 и 5207 (не блокировались) — 2/22 и 2/13 при 91% и 77%.
Причинность НЕ доказана: между прогонами через тот же пул прокси прошло ~40 моих
диагностических запросов, репутация пула могла просесть от них. Но выгоды правка
не показала ни разу, поэтому дефолт возвращается к прежнему поведению.
Ручка из #3185 остаётся целиком: BROWSER_BLOCK_IMAGES и
BROWSER_BLOCK_IMAGES_{PROVIDER} работают как работали, меняется только код-дефолт
(_BLOCK_IMAGES_DEFAULT_BY_PROVIDER теперь пуст). Эффект картинок надо мерить
отдельно и на чистом пуле.
Тест per-provider-override развёрнут в направление, которое ОТЛИЧАЕТСЯ от дефолта
(env=false снимает блокировку): со всеми дефолтами True прежний тест с env=true
проходил бы и у функции, всегда возвращающей True.
Refs #3185
Сайдкар на каждый /fetch делал browser.new_page() поверх AsyncCamoufox — это новый
изолированный контекст, cookie-jar умирал сразу после ответа. Туда вливался
замороженный снимок кук из domclick_session_cookies, где qrator_jsid2 живёт ~2.5
часа, а хранится 30 дней. Первый запрос проходил с ещё живым токеном, все следующие
показывали QRATOR один и тот же протухший — он отдавал челлендж-страницу без
__SSR_STATE__. Отсюда двухнедельное attempted=4, enriched=1, blocked=3.
Замер на проде 28.08: через сайдкар 26 запросов подряд — 100% блоков, включая
свежеротированный exit-IP; те же карточки в тёплом контексте — 5 из 5 успешно,
~2 сек каждая.
Сайдкар получил переиспользуемый per-provider контекст: куки вливаются один раз при
создании, дальше jar живёт сам; страница закрывается после ответа, контекст остаётся.
Закрытие контекста подшито к _close_browser, так что relaunch и shutdown его не
теряют. Флаг opt-in: при reuse_context=False payload /fetch не получает новых ключей
вовсе, поведение остальных поставщиков не меняется.
Сброс сожжённого контекста — ровно один на обнаруженный блок, через отложенный флаг
BrowserFetcher.request_context_reset(): fetch_detail() в kit не прокидывает
reset_context, и менять этот промежуточный слой ради одного поставщика не хотелось.
NB для деплоя: правка работает только если tradein-browser пересобран вместе с
бэкендом. Старый сайдкар новые поля молча проигнорирует — не упадёт, но и не починит.
Refs #3190, #3118
last_id жил только в памяти процесса (import_rosreestr_dkp, scheduler.py):
heartbeat писал его в scrape_runs.counters каждый батч (комментарий рядом
прямо называл это чекпоинтом), но при старте last_id всегда инициализировался
литералом 0 — обрыв (деплой/OOM/рестарт хоста) откатывал прогресс и заставлял
пере-сканировать источник с начала.
Разведка: из пяти backfill-циклов issue (avito_detail_backfill,
house_imv_backfill, cian_history_backfill, yandex_detail_backfill,
geocode_missing_listings) ни один не имеет этого дефекта — все устроены как
WHERE ... IS NULL/NOT EXISTS ... LIMIT, естественно резюмируемы без курсора.
Единственный код, буквально описанный в issue (строки/SQL/комментарий),
это шестой, не входящий в таблицу backfill — rosreestr_dkp_import.
Фикс — _resume_dkp_cursor(db, run_id):
- кандидат — последний прогон source='rosreestr_dkp_import';
- резюмится только незавершённый штатно прогон: status running/zombie,
либо done с counters.interrupted=1 (SIGTERM-drain — эта ветка раньше
считала последующий full rescan штатным поведением, теперь помечает
себя как прерванную и резюмится наравне с zombie);
- потолок возраста чекпоинта — 24ч, старше — 'checkpoint_stale', старт с 0;
- чистый 'done' (полный проход) не резюмится — иначе ON CONFLICT DO UPDATE
перестанет ловить правки уже импортированных сделок при следующем проходе.
Вердикт и per-batch чекпоинт пишутся через kit_runs.update_heartbeat (merge
`counters || :counters`) вместо локального runs_mod.update_heartbeat (полная
замена) — иначе resume-вердикт стирался первым же heartbeat'ом батча.
Тесты: tests/test_3168_backfill_cursor_resume.py — резюм с сохранённого
last_id, резюм после SIGTERM-drain, отказ резюмить чистый done, отказ
резюмить протухший (>24ч) чекпоинт, merge не стирает посторонние ключи.
Обратимость проверена вручную (временный откат _resume_dkp_cursor красил
6 из 8 тестов).
NoProxyAvailableError поднимается из BrowserFetcher.__aenter__ (_acquire_lease)
ДО первого HTTP-запроса, когда пул прокси пуст — это НАША инфраструктура, не
блокировка площадкой. У run_avito_full_load/run_cian_full_load/run_yandex_full_load
уже есть выделенный except NoProxyAvailableError -> mark_banned(ban_kind='infra'),
у run_domclick_city_sweep его не было: исключение проваливалось в общий except
Exception внутри SERP-фазы, _scraper_ref оставался пустым, и честный статус ниже
видел "0 лотов + errors>0" -> mark_failed("fetch errors — 0 listings") с
ban_kind=NULL. Прод-факт: run 5023 (27.08) умер за 51 мс, errors_count=1,
ban_kind=NULL — неотличимо от честного отказа сбора площадкой.
Добавлен except NoProxyAvailableError перед generic except Exception (порядок
важен: класс — подкласс RuntimeError). Обработчик зеркалит avito/cian/yandex:
mark_banned + ban_kind_of_exception(exc) (даёт BAN_KIND_INFRA), и сохраняет
унаследованный чекпоинт (skip_buckets) вместо потери его на нашем же отказе.
Тест test_3118_domclick_no_proxy.py проверен на обратимость: без обработчика
падает (mark_failed вместо mark_banned), с обработчиком — проходит.
_on_combo в run_yandex_city_sweep делал done_combos.add(combo_label) ДО
вызова save_listings. Отказ save_listings перехватывается (осознанно —
одна упавшая единица не должна ронять весь sweep) и логируется, но combo
уже был отмечен пройденным и уходил в heartbeat done_buckets. Следующий
resume брал skip_combos из done_buckets (членство в множестве — само по
себе корректно, не трогал) и пропускал этот combo навсегда: молча, прогон
завершался штатно, просто сегмент выдачи не собирался никогда.
Тот же инвариант "отмечаем пройденным только после успешного save", что
уже есть у страницы в run_avito_newbuilding_sweep (_saved_ok), якоря в
run_avito_city_sweep (_anchor_ok) и бакета в run_cian_full_load
(_mark_bucket) — применил к combo. Heartbeat пишется в любом случае
(и при отказе save тоже), иначе reap_zombies посчитает живой прогон
мёртвым.
Тесты: test_3170_yandex_combo_checkpoint.py — combo с упавшим save не
попадает в done_buckets, успешный (включая пустую выдачу) — попадает.
Обратимость проверена: с возвращённым дефектом (git stash) первый тест
красный, со снятым — зелёный вместе с существующим test_3074_yandex_
sweep_checkpoint.py (6/6).
Последний длинный свип без возобновления: при обрыве прогон начинался с
первой страницы, а собранное терялось целиком — save_listings вызывался
один раз на весь sweep.
Единица возобновления — страница выдачи, по образцу якорей в city sweep.
`_paginate_sweep`/`fetch_newbuildings` получили `start_page` (уже собранные
страницы не запрашиваются) и колбэк `on_page`, который вызывается только
после того, как страница пройдена до конца. Сохранение стало постраничным,
номера пройденных страниц копятся в `scrape_runs.counters.done_buckets`
мержем через `update_heartbeat`.
Два инварианта, без которых фича вредна:
1. В чекпоинт попадает только страница, чьи лоты СОХРАНЕНЫ. Отказ
save_listings перехвачен и прогон продолжается, но отметить такую
страницу пройденной значило бы, что следующий прогон её пропустит и
объявления оттуда не соберутся никогда — молча, потому что прогон
завершится штатно.
2. Подхват начинается с ПЕРВОЙ несобранной страницы, а не с max+1. Дыра в
чекпоинте возможна ровно из-за п.1, и max+1 перепрыгнул бы её навсегда.
Страницы после дыры перечитаются — это дешевле потери и безопасно,
повторная запись схлопывается по dedup_hash.
Оба инварианта закрыты тестами, которые падают при их нарушении.
Разделение тем из #3163 зависело от шага «завести секрет руками». Шаг отказал
сразу же: 27.08 два прогона деплоя подряд отработали зелёными, напечатали
строку про откат — и тема «метрики» осталась пустой, а весь инфраструктурный
поток продолжил идти в тему клиентских инцидентов.
Номер темы форума секретом не является: в репозитории уже лежат домены, пути
на хостах, имена контейнеров и внешние адреса. Ставим 245 значением по
умолчанию прямо в деплое; переменная окружения по-прежнему перекрывает — переезд
темы или другой чат решается ею, без правки кода.
Откат на METRICS_TELEGRAM_TOPIC_ID убран намеренно и запрещён тестом. Он
возвращал ровно то состояние, ради ухода от которого всё затевалось, и
сообщал об этом строкой в логе прогона, которую никто не читает. Молчаливое
«почти правильно» хуже явной поломки.
Прогнано в обе стороны: с переменной — тема из окружения, без неё — 245.
backend/tests/ops — 86 passed.
Замер на проде 27.08: `getUpdates` падает 23 раза в сутки, 14 из них за один
час. Ретрай почти всегда чинит с первой попытки, поэтому сообщений не теряется
— теряется возможность понять, что происходит:
network error (попытка 1/3): — retry через 2s
После двоеточия пусто. У httpx.ReadError и httpx.ConnectError `str(exc)` пуст,
а тип исключения в строку не попадал. По такому логу не отличить таймаут от
обрыва соединения от сброса TLS, то есть 23 события в сутки не дают ни одной
зацепки. Сеть при этом цела: сырой TLS до Telegram проходит 6 из 6 попыток
за ~0.16s.
Тип добавляется к тексту, а не вместо него: на исключениях с внятным
сообщением диагностика не должна стать беднее прежней. Оба конца закреплены
тестами — с пустым текстом и с непустым.
Closes#3156
Форумная группа имеет три темы, но тема «метрики» была пуста: оба прямых
получателя Alertmanager — telegram и telegram-heartbeat — брали топик из той
же переменной METRICS_TELEGRAM_TOPIC_ID, что и сервис alert-ack. Развести их
было нечем, и heartbeat вместе со всем инфраструктурным шумом падал в ленту
клиентских инцидентов.
Смешанные в одной теме, инфраструктура и клиентский инцидент не равны по
срочности и приучают пролистывать обе.
Вводится METRICS_TELEGRAM_INFRA_TOPIC_ID для прямых получателей Alertmanager.
alert-ack и вебхук GlitchTip остаются на прежней переменной, тема поддержки
не тронута. Пока новая переменная не задана, берётся старая — до этого момента
поведение ровно прежнее, а не сломанное.
Клиентская METRICS_TELEGRAM_TOPIC_LINE убрана целиком: после переезда обоих
получателей на инфраструктурную строку шаблон её не содержит, а деплой
продолжал бы её собирать и объявлять в envsubst. Тест, закрепляющий сборку
такой строки, зеленел бы вечно и мешал бы её убрать.
Проверено рендером, а не чтением: при заданной теме telegram и
telegram-heartbeat дают 245, telegram-clients уходит вебхуком без темы; при
незаданной — поля message_thread_id нет вовсе (пустое значение уронило бы
Alertmanager целиком). Логика отката прогнана во всех трёх состояниях
переменных. backend/tests/ops — 86 passed.
Closes#3163
Приёмочная проверка #3155 показала второй отказ на том же пути. Prometheus
нашёл приёмник (`activeAlertmanagers` непуст), отправил уведомление — и
получил 404: `alertmanager_alerts_received_total 0` при
`prometheus_notifications_errors_total 1` и `dropped_total 1`.
`--web.external-url=…/alertmanager` без `--web.route-prefix=/` заставляет
Alertmanager обслуживать ВСЁ под этим префиксом. Проверено прямой пробой из
контейнера Prometheus: `/api/v2/status` → 404, `/alertmanager/api/v2/status`
→ 200. Снаружи префикс при этом никому не нужен: маршрута `/alertmanager`
в Caddy нет вовсе, внешний адрес используется только для ссылок в сообщениях.
Симптом этого же префикса уже ловили 27.08 на healthcheck — и вылечили на
стороне проверки, прописав ей путь с префиксом. Из-за этого причина осталась
жива и продолжила ломать то, что чинить куда важнее. Возвращаю обычный путь
healthcheck вместе с флагом.
Заодно оживает датасорс Alertmanager в Grafana
(`ops/metrics/grafana/provisioning/datasources/datasources.yml:37`) — он
настроен на корень и до сих пор упирался в тот же 404.
Closes#3161
Профиль alerts включили, а файл целей `alertmanager_targets.yml` остался
плейсхолдером `[]`. Снаружи всё зелёное: alertmanager и alert-ack Up/healthy,
деплой зелёный, в логах ни одной ошибки — при этом `activeAlertmanagers: []`
и 1568 уведомлений в `prometheus_notifications_dropped_total`. Горящий с 26.08
`Watchdog` не доехал никуда, как и HostAgentDown с RemoteWriteStalled.
Причина в том, что включатель профиля и цель для Prometheus лежали в разных
местах: профиль поднимает deploy-metrics.yml по наличию токена и чата, а файл
целей правился руками. Разъезд не ловится ничем — `[]` штатен при выключенном
профиле, поэтому ни валидация, ни healthcheck, ни лог на него не реагируют.
Файл становится производным (`alertmanager_targets.gen.yml`, в .gitignore) и
рендерится деплоем тем же условием, что включает профиль: цель при включённых
алертах, `[]` при выключенных. Рендер идёт до `up` и пишет усечением на месте,
поэтому инод сохраняется и работающий Prometheus подхватывает цель сам — та же
ловушка одиночного бинд-маунта, что уже описана в этом workflow у Alertmanager.
Пустой список пишется явно, а не удалением файла: несуществующий путь docker
подменяет каталогом, и Prometheus не стартует вовсе.
Closes#3155
Follow-up #2451 (эпик #2445, B2/B3): тот же класс — object-literal lookup по
значению, пришедшему из API, без runtime-guard. Значение вне закрытого union'а
(schema drift / частичный деплой) даёт `undefined`, и дальше по-разному плохо:
- `BestLayoutsBlock` (DataQualityCard) — обращение `.fg`/`.bg` к undefined
РОНЯЕТ рендер всей карточки качества данных;
- `ForecastChart.confidenceByHorizon` — подпись уверенности для горизонта
пропадает МОЛЧА, при том что прогноз для него есть;
- `compare/CompareTable` и `ptica/compare/PticaCompare` — рядом с процентом
остаётся «73% · » и обрыв: число выглядит недосказанным, а не неизвестным.
Везде нейтральный fallback по образцу VelocityBlock/ParcelDrawer: серый + «—»,
намеренно НЕ семантический цвет — неизвестное качество не должно читаться ни
как хорошее, ни как плохое.
Фон бейджа взят из существующего `--border-soft`: список токенов закрыт
(ui-tokens.md — «НЕ выдумывать новые»), нейтральной заливки в нём нет.
`DataQualityCard`, `confidenceByHorizon` и `METRIC_ROWS` (ptica) экспортированы
ради тестов — тем же приёмом, что уже применён к `isDeficitDegenerate`.
Тесты: 12 новых в 4 файлах. Проверены на слом — снял все четыре fallback'а,
упало ровно 5 проверок out-of-union, остальные 7 (известные значения,
отсутствующие данные) остались зелёными.
`vitest run src/components/site-finder` — 145 passed, `tsc --noEmit` чисто.
Closes#2452
Refs #2445, #2451
`run_geocode_missing_listings` рекламирует `budget_sec` как максимальное время
прогона, но проверка стояла после возврата из батча. Внутри батча цикл шёл по
всем 200 адресам и часов не смотрел, то есть фактический потолок был
`budget_sec + один полный батч`.
Замер: прогон 5017 (27.08, `budget_sec=1800`) шёл 3150 с — 175 % бюджета, и
вышел не по бюджету, а по дренажу: бюджетная ветка за 52 минуты не выполнилась
ни разу.
Пока адрес стоил ~1.9 с это терялось в шуме. После общего ограничителя темпа
Nominatim (#2953) средняя цена 4.5 с, а на трудном хвосте (tier-1 + до 4
typo-вариантов под паузой 1 с, плюс retry×3) — до 33 с. Полный батч из таких
адресов уезжает на ~110 минут поверх бюджета, при окне расписания 06:00–09:00.
Дедлайн теперь передаётся В батч и проверяется на каждом адресе. Оборванный
батч — штатный исход: `geocode_tried_at` проставлен только у обработанных пар,
остальные попадут в выборку следующего прогона.
Отдельный флаг `budget_exhausted` нужен потому, что `addresses_total` на
оборванном батче равен размеру ВЫБОРКИ (== batch_size) — ветка дренажа
`addresses_total < batch_size` не сработала бы, и обёртка крутила бы цикл
дальше. Тест на это падает без флага (проверено снятием ветки).
Тесты: 5 новых, все три несущие проверки падают без соответствующей правки.
37 passed локально.
Closes#3151
Оборванный CREATE INDEX CONCURRENTLY оставляет индекс с indisvalid=false:
планировщик им не пользуется, ошибки нет, а re-run миграции с IF NOT EXISTS
видит его как существующий и молча пропускает. До сих пор это ловил только
шаг 3b деплоя — то есть после раскатки на прод, ценой красного деплоя и
ручного DROP INDEX CONCURRENTLY в окне.
Тот же запрос теперь стоит в CI сразу после сборки схемы из 252 миграций.
Проверка в деплое ОСТАЁТСЯ: CI собирает схему с нуля и по построению не
может воспроизвести оборванный CIC на живой базе — это разные детекторы,
не дубликаты. CI ловит миграцию, которая рождает невалидный индекс из
чистой схемы; деплой ловит след аварии на проде.
Оба пути проверены на реальной базе (prod tradein-postgres): штатный
предикат → invalid=0, гейт зелёный; инвертированный → 358, ветка падения
срабатывает и печатает список idx/tbl.
Refs #2990