Мажор ради GHSA-5xrq-8626-4rwp (CRITICAL, vitest ≤2.x). Сам CRITICAL к нам
неприменим — он про `--ui`, а `@vitest/ui` не установлен и скриптов с `--ui`
нет — но держать заведомо непатченный рантайм тестов ради этого рассуждения
не стоит: следующий, кто добавит `--ui`, про оговорку не узнает.
Побочно закрылось больше, чем планировалось. vitest тянет vite сам, и диапазон
у мажора другой: 2.1.9 → vite ^5.0.0 (резолв 5.4.21 → esbuild 0.21.5),
3.2.7 → vite ^5||^6||^7 (резолв 7.3.6 → esbuild 0.28.2). Так что одним мажором:
vitest 2.1.9 → 3.2.7 GHSA-5xrq-8626-4rwp
vite 5.4.21 → 7.3.6 GHSA-4w7w-66w2-5vf9, GHSA-fx2h-pf6j-xcff, GHSA-v6wh-96g9-6wx3
esbuild 0.21.5 → 0.28.2 GHSA-67mh-4wv8-2f99
OSV по локу: 4 → 1. Остаток — postcss 8.4.31, вендоренный ВНУТРИ
next/node_modules; нашим локом не управляется вообще.
@vitejs/plugin-react 4.3.4 → 5.2.0 — вынужденно и ровно поэтому: у 4.x peer
на vite ^4||^5||^6, семёрка в него не попадает. У 5.2.0 — ^4||^5||^6||^7.
В шестёрку не идём: там peer уже ^8.0.0.
vitest.config.ts править не пришлось: в нём нет ничего из того, что мажор
переименовал или убрал (ни environmentMatchGlobs, ни deps.inline, ни workspace) —
только environment/globals/setupFiles/include, которые в 3.x как были.
Приёмка (локально, node 26):
npm run test 66 passed, 2 failed
npm run type-check ok
Те же самые 2 падения (LoginPage, ветки 429/401) дают и vitest 2 на этом же
локе, и vitest 2 на локе ДО апдейта зависимостей — проверено прогоном в обеих
конфигурациях. Это локальный node 26 против node 20/24 в CI, к мажору
отношения не имеет. Гейт — CI.
`npm update --package-lock-only` в tradein-mvp/frontend: package.json не тронут,
все диапазоны прежние — обновился только резолв внутри них.
OSV по локу: 6 уязвимых версий → 4. Закрыто:
sharp 0.34.5 → 0.35.4 GHSA-f88m-g3jw-g9cj
nanoid 3.3.17 → 3.3.18 GHSA-2v37-7h3g-55p8
Заодно (без CVE, в тех же диапазонах): next/@next/* 15.5.23 → 15.5.24,
js-yaml 4.3.1 → 4.3.2, @tanstack/react-query 5.101.4 → 5.102.8,
@typescript-eslint/* 8.66.0 → 8.68.0, rollup 4.62.4 → 4.63.1,
@testing-library/react 16.3.2 → 16.3.3, ws 8.21.2 → 8.21.3.
Остаток (4) диапазонами не чинится, вынесен в отдельную задачу:
postcss 8.4.31 — вендорится ВНУТРИ next/node_modules, нашим локом не управляется;
vitest 2.1.9 → vite 5.4.21 → esbuild 0.21.5 — dev-цепочка, лечится мажором
vitest 2 → 3.2.6+. GHSA-5xrq-8626-4rwp (CRITICAL) при этом неприменим:
@vitest/ui не установлен, скриптов с --ui нет.
Приёмка (локально, node 26):
npm ci --legacy-peer-deps — 566 пакетов, ok
npm run build — ok, весь роут-набор собрался
npm run test — 66 passed, 2 failed (LoginPage 429/401)
Два падения НЕ от апдейта: те же 2 теста падают ровно так же на СТАРОМ локе
(проверено откатом лока + npm ci + прогоном того же файла). Локальный node 26
против node 20 в CI; гейт — CI.
Хойстинг в диффе выглядит как даунгрейд, но им не является: picomatch 2.3.2
переехал из micromatch/node_modules/ наверх, а 4.0.5 → 4.0.7 ушёл под
tinyglobby/ вместе с fdir 6.5.0. Реального понижения версий нет.
NB: npm 12 локально требует --allow-remote=all даже при --package-lock-only
(ничего не ставится, только резолв). В CI npm 10 — там ограничения нет.
Лок не пересобирали с 3 июля (у «Меры» — 7 августа), и весь разрыв по уязвимостям
между двумя фронтендами объясняется этим. Прогон OSV: было 19 уязвимых пакет-версий,
стало 4. package.json не тронут — все фиксы влезли в существующие каретки.
Главное — Next: у 15.5.15 висело 21 advisory, 10 из них HIGH (обходы middleware/
proxy, SSRF в Server Actions и rewrites, набор DoS). Максимальный fixed_in среди них
— 15.5.21, то есть ВСЕ закрываются внутри ветки 15.5.x. Переход на 16 для
безопасности не требуется, это отдельная миграция.
next / eslint-config-next 15.5.15 -> 15.5.24
sharp 0.34.5 -> 0.35.4 унаследованные дыры libvips
postcss 8.5.10 -> 8.5.26 чтение произвольного .map по
sourceMappingURL
nanoid 3.3.11 -> 3.3.18
js-yaml 4.1.1 -> 4.3.2 квадратичный CPU на merge-key
form-data 4.0.5 -> 4.0.6 CRLF-инъекция
brace-expansion ... -> 1.1.18 три DoS
@babel/core 7.29.0 -> 7.29.7
@opentelemetry/core 2.7.1 -> 2.10.0
echarts 6.0.0 -> 6.1.0
@sentry/nextjs 10.53.1 -> 10.72.0
tailwindcss + postcss-плагин 4.2.4 -> 4.3.3
react / react-dom 19.2.5 -> 19.2.8
Дерево схлопнулось с 1010 до 749 пакетов — это следствие двухмесячной давности
лока, а не косметика, поэтому приёмка шла через полный npm ci, а не по диффу.
Остаются 4 и не чинятся здесь: postcss 8.4.31 Next вендорит ВНУТРИ своего
node_modules, а vitest 2.1.9 -> vite -> esbuild требуют мажора vitest — отдельная
задача.
Проверено локально: npm ci с нуля и npm run build проходят, сборка Next 15.5.24 +
tailwind 4.3.3 + sentry 10.72 отдаёт все маршруты. npm run test локально даёт 13
падений в 4 файлах (localStorage undefined при определённом window) — это jsdom 25
под здешним node 26; версии тестового стека в диффе НЕ менялись (jsdom 25.0.1,
vitest 2.1.9, vite 5.4.21 те же), CI гоняет node 20 и он тут гейт.
NB для тех, кто повторит локально: npm 12 по умолчанию режет remote-загрузки
(allow-remote=none) и спотыкается об опциональную @tailwindcss/oxide-wasm32-wasi.
Обходится флагом --allow-remote=all на время команды; в CI npm 10, там этого нет.
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, единицы секунд без
приёма событий. Схема БД и миграции не затрагиваются.
Откат: убрать строку и передеплоить.
Прогон всех локов через OSV batch API (2026-08-29) показал: уязвимые версии есть
только в backend/uv.lock. Корневой лок «Меры» (tradein-mvp/uv.lock, из которого и
собирается её образ) чист полностью — ноль находок.
Было 8 пакетов / 53 advisory, стало 0. Проверено повторным прогоном OSV по
получившемуся локу.
pillow 12.2.0 -> 12.3.0 26 advisory, среди них heap OOB write в
Image.paste/crop и ImageFilter.RankFilter;
Image.open у нас на внешнем входе (загрузка фото)
starlette 1.0.0 -> 1.6.0 10 advisory, в т.ч. обход лимитов request.form()
cryptography 47.0.0 -> 50.0.1 7 advisory; транзитив pdfminer-six
urllib3 2.6.3 -> 2.7.0 4 advisory; обход защиты от decompression-bomb
weasyprint 68.1 -> 69.0 CSS injection via presentational hints
idna 3.13 -> 3.19 2 advisory
mako 1.3.11 -> 1.4.1 path traversal в TemplateLookup (Windows-only,
у нас Linux — берём попутно)
pydantic-settings 2.14.0 -> 2.15.0 1 advisory
Апгрейд ТОЧЕЧНЫЙ (--upgrade-package на 8 имён), не общий --upgrade: спеки в
pyproject все вида ">=" без верхней границы, и общий апгрейд затянул бы мажоры
вроде redis 7->8 и numpy заодно. Диффом подтверждено: из 138 пакетов изменились
ровно эти 8, прямые спеки в pyproject.toml не тронуты.
Проверка совместимости локально: fastapi 0.136.1 поднимается на starlette 1.6.0,
TestClient отвечает. weasyprint импортировать на Windows нельзя (нет GTK), его
гоняет CI на Linux.
NB: fastapi.testclient на starlette 1.6 предупреждает, что связка с httpx
устарела в пользу httpx2 — это ворнинг, не отказ; отдельная задача.
Замер на проде 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