Пойман на проде 27.08 сразу после мержа #3136. На диске лежал новый конфиг —
`webhook_configs` на сервис кнопки подтверждения, — `amtool check-config` его
одобрил, деплой зелёный. А контейнер продолжал слать алерты напрямую:
на диске: webhook_configs: url http://alert-ack:8080/alertmanager
в контейнере: telegram_configs:
Конфиг подключён бинд-маунтом ФАЙЛА, а рендер делает `rm` и создаёт файл
заново — иначе не перезаписать: после chown он принадлежит 65534 с правами
600, а каталог принадлежит деплой-пользователю. `rm` + создание даёт НОВЫЙ
инод, тогда как открытый дескриптор внутри работающего контейнера продолжает
смотреть на прежний, уже удалённый. `up -d` контейнер не трогает: он
сравнивает описание сервиса, а содержимое бинд-маунта в сравнение не входит.
Отказ беззвучный — ни одного красного признака нигде. Значит и все прежние
правки маршрутизации применялись лишь тогда, когда контейнер пересоздавался
по совпадению.
Перезагрузка по SIGHUP/API не лечит: она перечитывает тот же открытый инод.
Лечит только пересоздание контейнера — его и добавляю, под флагом, который
выставляется ПОСЛЕ успешной проверки конфига. Порядок важен: при обратном
битый конфиг убивал бы работающий Alertmanager вместо того, чтобы оставить
прежний работать.
Прод уже приведён в соответствие вручную — контейнер пересоздан, маршрут
клиентских инцидентов теперь идёт через кнопку. Эта правка нужна, чтобы
следующая правка конфига доехала сама.
Три теста: пересоздание есть, оно закрыто проверкой флага (безусловное рвало
бы доставку на каждом деплое метрик), флаг выставляется после проверки.
Прогон: 78 ops-тестов зелёные, ruff чист.
Дрель восстановления стоит в cron с #3085, но её строка берёт только
`gendesign_*`. База tradein — клиентские оценки, листинги, дома, сделки —
автоматически на восстановимость не проверялась ни разу: дампы снимались,
уезжали в S3, и никто не знал, разворачиваются ли они.
Сам `ops/restore-drill.sh` менять не пришлось — он с самого начала умеет оба
проекта: находит globals по своей схеме имён (`tradein-globals-<ts>`),
подставляет свой список таблиц для сверки. Не хватало ровно строки в cron.
Прогнал на боевом дампе 27.08 перед тем, как добавлять (оповещение заглушено,
чтобы не слать ложную тревогу):
tradein-20260827-111102.sql.gz, 343 МБ → 58 c
GRANT-ы применены, материализованные представления обновлены
PostGIS 3.4.3, таблиц в public: 76
listings 109978 · listing_sources 106704 · deals 108623
houses 10400 · trade_in_estimates 1121
Сверка с боевой базой: houses, listings, deals, osm_poi сходятся точно;
trade_in_estimates и user_events больше на проде ровно на строки, созданные
ПОСЛЕ снятия дампа. То есть дамп верен.
Еженедельно, а не раз в месяц: две минуты работы против месяца, в течение
которого битый дамп остаётся незамеченным. Понедельник 03:00 — после дрели
gendesign (02:15 первого числа) и до ночных бэкапов в 03:30.
Замер на проде 27.08 (окно 1512×900): плашка стояла в левом нижнем углу
(`left:16; bottom:16`), а туда же приходит липкая кнопка «ОЦЕНИТЬ КВАРТИРУ»
из панели параметров. Прямоугольники: кнопка 59…484 по X и 859…904 по Y,
плашка 16…284 и 859…884 — перекрытие по всей высоте плашки.
Следствий два, и второе хуже первого. Надпись на кнопке читалась разорванной.
И `document.elementFromPoint` в центре кнопки возвращал плашку: клик по левой
трети главного действия продукта не доходил до кнопки вообще.
Плашка переезжает в правый нижний угол, НАД кнопку поддержки (44px от низа,
z-index 25). Там свободно: ниже только пассивная «Сводка объекта» без органов
управления — перекрыть её краем ничего не стоит.
Вдобавок плашка становится сквозной для указателя, а ссылка «История версий»
внутри — нет. Это страхует от повторения: что бы под плашкой ни оказалось в
будущем, клик достанется ему, а не ей.
Три теста закрепляют оба следствия разбора: якорь не левый, указатель
проходит насквозь, ссылка жива. Прогон: 68 тестов зелёные, tsc чист.
`SLF001` (обращение к приватному члену) в конфиге ruff не включён, поэтому
`# noqa: SLF001` — подавление того, что и так не проверяется. Ruff ловит это
правилом RUF100 и валит проверку.
Тест намеренно лезет в приватные `_tg`, `_PENDING`, `_send_alert`: подмена
единственного шва до сети — и есть смысл этих тестов. Пояснение, которое
стояло после директивы, сохранено обычным комментарием.
Оба дефекта найдены проходом клиентского пути на проде 27.08.
ПОДСКАЗКА АДРЕСА печатала одно и то же дважды. Строка списка состоит из
главной строки (`label`) и пояснительной (`full_address`), но подсказчик
для дома отдаёт их совпадающими буква в букву — проверено на проде:
{"label":"Свердловская область, г. Екатеринбург, ул. Малышева, д. 51",
"full_address":"Свердловская область, г. Екатеринбург, ул. Малышева, д. 51"}
Человек видел адрес продублированным в каждой строке выпадающего списка.
Показываем вторую строку только когда она отличается: замысел (короткое имя
сверху, полный адрес под ним) сохраняется, если подсказчик когда-нибудь
начнёт их различать.
ЧИСЛА В РЕЗУЛЬТАТЕ спорили друг с другом. Плитка сверху: «30 похожих
квартир продаётся в радиусе 1 км». Плитка под ней: «столько в среднем висит
объявление из этих 6». Слово «этих» указывает на 30, а стоит рядом 6 —
читается как ошибка в расчёте.
Шесть — это те объявления, у которых известна дата публикации; медиана
считается только по ним. Теперь доля названа явно («дата известна у 6 из
30»), а когда известна у всех — не упоминается вовсе, чтобы «30 из 30» не
шумело. Честное имя величины сохранено: это возраст активного объявления,
а не срок продажи (тест на это как был, так и остался).
Проверено: 27 тестов mera-public зелёные, tsc --noEmit чист.
A-записи www.meraocenka.ru / www.merahome.ru / www.meraotsenka.ru заведены и
указывают на прод, но site-блоков под них в Caddy не было. Caddy матчит строго
по имени хоста и на неизвестное имя сертификата не выпускает, поэтому клиент,
набравший привычное «www.», получал обрыв рукопожатия (TLS alert 80) — для
браузера это «сайт не открывается». В логах доступа при этом ни строки: до
HTTP-слоя запрос не доходил, так что молчали и метрики.
www.gendsgn.ru такой блок имел с самого начала и потому работал — здесь ровно
тот же приём, три отдельных блока с 301 на канонический meraocenka.ru.
Найдено сквозным аудитом периметра 27.08. Проверено: три www-имени резолвятся
в 188.124.37.140, curl до правки возвращал пустой код (000) на всех трёх,
www.gendsgn.ru — 301.
Смоук периметра дополнен проверкой 5b: отсутствие блока проявляется НЕ как
404, а как пустой код ответа, и обычная проверка «спутники отдают 301» такой
регресс не ловила.
Alertmanager инлайн-клавиатуру не поддерживает, а без кнопки нет обратной
связи «человек увидел и взял в работу»: 27.08 продукты лежали 10 часов, и
вопрос «а кто-нибудь это читает» было не к кому адресовать.
ГДЕ ЖИВЁТ. Рядом с Alertmanager, на инфраструктурной машине. У бота МЕРЫ
уже есть приём обновлений, и повесить обработку туда было бы дешевле, но
он работает на продуктовом хосте: при падении продукта кнопка оказалась бы
мёртвой ровно тогда, когда нужна.
ССЫЛКА, А НЕ CALLBACK. Callback требует читателя обновлений бота. Бот один,
и его обновления уже читает МЕРА — второй читатель получил бы 409 Conflict
и отобрал бы сообщения у поддержки.
БЕЗ ПАРОЛЯ НА /ack/*, ОСОЗНАННО. Кнопку жмут ночью с телефона, когда лежит
прод; требование пароля даст ноль нажатий. Защита — 128-битный токен под
конкретное сообщение, живущий сутки; максимум, чего добьётся угадавший, —
ложная отметка в чате, где сразу видно, что её поставил не человек.
ТОЛЬКО КЛИЕНТСКИЙ МАРШРУТ идёт через сервис. Прочие алерты сохраняют прямой
путь в Telegram: чем меньше звеньев, тем надёжнее. Если сервис лёг,
Alertmanager повторяет доставку и переуведомляет каждые 30 минут — алерт
задерживается, но не теряется. Дублировать вторым прямым каналом не стали:
шум в канале тревог опаснее задержки.
Девять тестов дёргают настоящие функции, подменяя один шов — вызов Bot API.
Важнейший: при отказе отправки с клавиатурой сообщение уходит БЕЗ неё —
алерт важнее кнопки.
Замер по метрикам за сутки:
продуктовый хост: 21 → 165 МиБ (стабильно) → после перезагрузки 305 →
359 МиБ из 384, то есть 93 % лимита
инфраструктурный: 105 МиБ стабильно → за два часа 228 → 291 МиБ
До OOM не дошло, но запас оставался 25 МиБ. Причина видна в собственном
логе cadvisor: «fs: disk usage and inodes count on following dirs took
1.3-1.8s» — обход overlay-слоёв docker на каждом цикле housekeeping.
Память растёт вместе с числом слоёв, а слои копятся от сборок образов,
то есть тем быстрее, чем активнее идёт разработка.
Отключаем disk/diskIO. Теряем использование диска ПО КОНТЕЙНЕРАМ —
проверено, что этих метрик не используют ни правила Prometheus, ни
дашборды: алерты по диску (DiskSpaceLow / DiskSpaceCritical /
DiskWillFillIn24h) построены на node_exporter, на уровне файловой системы
хоста. Это и есть нужное измерение — кончающееся место видно именно там,
а не в разбивке по контейнерам.
Остальные метрики контейнеров (CPU, память, сеть, рестарты) не тронуты:
на них держатся ContainerRestartLoop и ContainerNearMemoryLimit.
--web.external-url=…/alertmanager заставляет Alertmanager обслуживать ВСЁ
под этим префиксом, включая служебные ручки. Проверено на проде:
/-/healthy → 404 Not Found
/alertmanager/-/healthy → OK
Контейнер при этом работал и рассылал алерты, но docker держал его
unhealthy бессрочно. Само по себе безобидно — и ровно поэтому вредно:
статус, который всегда красный, приучают не смотреть. А любая автоматика
вида «перезапусти нездоровое» устроила бы из этого вечный перезапуск
канала оповещений — то есть выбивала бы именно то, что должно работать
в аварию.
Три правки, две причины — обе найдены прогоном по живому проду 27.08 под
аккаунтом админа.
## Карта поверх всего (два симптома, один корень)
Контейнер мини-карты в HeroBar имел position: relative БЕЗ z-index. Такой
блок контекст наложения не создаёт, поэтому внутренние слои Leaflet (тайлы
200, оверлеи 400, маркер 600, атрибуция 800) и карточка адреса (750)
конкурировали не между собой, а со всей страницей. Отсюда:
• меню аккаунта (UserMenu, z-index 200) открывалось ПОД картой;
• карточка «АДРЕС» всплывала поверх таблицы раздела «Продажи в доме».
Лечится изоляцией контейнера (isolation: isolate), а не гонкой чисел:
перебивать 800 у атрибуции пришлось бы в каждом новом элементе, и гонка
возвращалась бы. Меню шапки заодно поднято до 1000 — оно обязано быть
сверху по замыслу, а не по совпадению.
## Страница не помещалась в окно
Масштаб артборда 1536×1024 считался от window.innerWidth, который ВКЛЮЧАЕТ
вертикальную полосу прокрутки. На рабочей области 1425px артборд выходил
1440px — страница получала постоянный горизонтальный скролл. Воспроизводится
на любом десктопе: страница длинная, вертикальная полоса есть всегда.
Считаем от document.documentElement.clientWidth. Пороги isMobile/
isSmallViewport намеренно оставлены на innerWidth: это классификация
устройства, а не расчёт геометрии.
Критерий (размечен 21.08 ДО окна) выполнен, окно склеено через переезд:
замороженный Beget — 4 116 сканов против 217 712 у geog за всю историю
(прирост +164/4д — вырожденные IS NOT NULL-планы, паттерн #3020); живой
Poincare — 0 сканов за двое суток; EXPLAIN горячего пути — geog; свип кода —
ни одного пространственного оператора по голому listings.geom.
Экономия 115 МБ + минус одна индексная запись на каждый не-HOT апдейт
(их 20.86 млн). CONCURRENTLY по образцу 270, идемпотентно, откат — CREATE
INDEX CONCURRENTLY (в шапке миграции).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
27.08, пилот «Практика». У пользователя ВСЕ 75 оценок оказались
недоступны, включая позавчерашние: список показывал их со статусом
«устарел», а открытие отдавало «Ссылка устарела, отчёт удалён или у вас нет
к нему доступа». Выглядело как пропажа данных — на деле строки целы, истёк
expires_at = created_at + 24ч.
Совпадение двух вещей и создало впечатление аварии: сутки не работал вход
(упало право CONNECT на базу auth), и ровно за это время истекли последние
живые отчёты. Люди зашли и увидели, что не открывается ничего.
Обоснование «оценка живёт сессию клиента, не архив» писалось под анонимный
B2C. «Практика» — пилот-юрлицо: менеджер возвращается к оценке через
день-два, когда клиент перезванивает. Суточный срок для такого сценария
означает, что работа исчезает раньше, чем её успевают использовать.
Настройка одна на оба контура, и это осознанно НЕ маскируется: юридический
мотив 152-ФЗ относится к анонимному B2C, разделение сроков по контурам —
отдельная задача. Здесь поднят общий срок, а не сделан вид, что контуры уже
разведены.
27.08, инцидент с пилотом «Практика». После починки доступа к базе auth
сотрудники начали получать «Слишком много попыток» — при том, что многие
ещё вообще не пробовали войти.
Причина в форме лимита, а не в его величине. Пилот — ОФИС: все выходят
из-под одного NAT, и пять попыток за пять минут делились на всю компанию
сразу. Одного человека, перепутавшего пароль, хватало, чтобы запереть
остальных.
Защита от перебора не ослабевает. Настоящий предохранитель — счёт по
ЛОГИНУ (login_username_fail_threshold, 20 за час), он не тронут. Лимит по
адресу существует против всплеска с одной машины, а не против офиса, и
двадцать попыток за пять минут эту роль выполняют.
Канал включили — и алерты бэкапов посыпались в ОБЩУЮ тему форума. В форуме
Telegram адрес сообщения это пара «чат + тема»: без message_thread_id всё
попадает в General, причём без единой ошибки. sendMessage возвращает 200,
доставка «успешна», просто не туда.
Отказ того же класса, что и всё остальное сегодня: зелено везде, а человек,
которому адресован алерт, его не видит.
Оба отправителя (ops/lib-backup.sh и ops/uptime-healthcheck.sh) получили
условную подстановку ${TELEGRAM_TOPIC_ID:+-d "message_thread_id=..."}.
Условная намеренно: пустой message_thread_id= Telegram отвергает вместе со
всем сообщением, а молчащий алерт хуже алерта не в той теме. Нет переменной —
нет параметра, поведение прежнее бит в бит.
Тест ИСПОЛНЯЕТ настоящий notify(), извлечённый из файла построчно, подсовывая
подставной curl и проверяя, что реально ушло бы в сеть. Проверять подстроку в
файле бессмысленно: она может стоять в мёртвой ветке. Копировать функцию в
тест — тоже: копия разойдётся с оригиналом на первой правке. Сорсить файл
целиком нельзя: у uptime-healthcheck.sh нет guard'а по BASH_SOURCE, и сорсинг
запустил бы настоящие сетевые проверки.
Две вещи, вторая — исправление собственной ошибки из #3127.
## Дежурного зовут по имени
27.08 продукты лежали 10 часов, и в канале «диск занят на 86 %» и «клиенты
не могут открыть сайт» выглядели одинаково. Появился отдельный маршрут:
severity=critical И host=apps, то есть критично на ПРОДУКТОВОЙ машине —
значит людям недоступна МЕРА и Site Finder, а не «где-то в инфраструктуре
тесно».
У такого сообщения другой текст (🚨 КЛИЕНТЫ ЗАТРОНУТЫ), упоминание дежурного
и repeat_interval 30 минут против 3 часов у прочего критичного: пока
инцидент не погашен, напоминание должно быть неудобным.
Сужение по host=apps существенно. Без него дежурного звали бы на каждую
инфраструктурную мелочь, и тег перестал бы что-либо значить за неделю.
Сам аккаунт в репозиторий не попадает — берётся из METRICS_TELEGRAM_ONCALL.
Дежурный меняется, конфиг в git — нет. Пустая переменная = сообщение без
тега, поведение не ломается.
## Починка: комментарий внутри продолжения команды
В #3127 блок rm -f вместе с комментарием встал МЕЖДУ строками, каждая из
которых заканчивалась обратным слешем. Строки склеиваются, и весь вызов
envsubst уехал в комментарий — конфиг Alertmanager перестал бы рендериться
вовсе, при полностью зелёном деплое.
Синтаксически это корректный шелл, bash -n такое не ловит, а в диффе не
видно: строки выглядят как отдельные. Поэтому проверка структурная и
применяется ко ВСЕМ shell-блокам workflow, а не только к месту ожога.
Как только канал алертов реально включился (#3126), профиль alerts впервые
дошёл до проверки конфига — и деплой встал:
amtool: error: failed to validate 1 file(s)
Checking '/tmp/am.yml' FAILED: open /tmp/am.yml: permission denied
Отрендеренный конфиг пишется с правами 600 и принадлежит деплой-пользователю,
а и amtool, и сам Alertmanager в образе prom/alertmanager работают под nobody
(65534). Прочитать чужой файл 600 они не могут. Проверка падала не на
содержимом конфига, а на доступе к нему — и контейнер после подъёма упал бы
ровно там же.
Дефект не поймали раньше по понятной причине: без токена профиль alerts не
включался, и эта ветка не исполнялась НИ РАЗУ с момента появления гейта в
#3111. Проверка, которая никогда не запускалась, ничем не отличается от
отсутствующей — это ровно тот класс тихой поломки, ради которого весь стек и
заводится.
Права не ослабляем: в файле лежит токен бота. Вместо chmod 644 (который внёс
бы токен в список файлов, читаемых любым локальным пользователем машины)
отдаём файл во владение 65534 одноразовым контейнером от root — passwordless
sudo на хосте нет, а бинд-маунт правит host-инод напрямую. Доступ остаётся
ровно у того, кто конфиг читает.
Следствие, которое легко проглядеть: после смены владельца `>` в этот файл
на следующем деплое уже не запишет, поэтому добавлен rm перед рендером —
каталог принадлежит деплой-пользователю, пересоздать файл он может.
Стек наблюдаемости был готов ещё вчера, но Alertmanager не поднимался:
профиль alerts включается, только когда заданы METRICS_TELEGRAM_BOT_TOKEN и
METRICS_TELEGRAM_CHAT_ID, а читались они ИСКЛЮЧИТЕЛЬНО из файла окружения на
инфраструктурной машине. То есть включить алерты можно было только правкой
прод-файла руками по ssh — в обход репозитория, без следа в истории и без
возможности сделать это из CI.
Именно это и держало задачу открытой дольше нужного. Цена промедления
измерена: 27.08 продукты лежали 10 часов, и ни одно звено оповещения не
сработало (#3119); в тот же день сутки не доезжал деплой, и об этом тоже
никто не узнал (#3029).
Теперь три переменные форвардятся в ssh-шаг из секретов Actions. Порядок
разрешения сохранён осознанно: инжектированные значения ставятся ДО того,
как скрипт подхватит окружение машины, поэтому хост, если ключи заданы на
нём, переопределяет секреты — последнее слово остаётся за машиной, а
секреты работают как разумный дефолт.
Канал проверен вживую: бот MERAsupport_bot, форум «МЕРА», тема «алерты»
(message_thread_id=158) — пробное сообщение доставлено.
27.08 прод сутки жил на позавчерашнем коммите, и это нашлось только руками.
Замена IP сервера (#3110) осиротила секрет DEPLOY_HOST: deploy.yml падал на
`dial tcp ***:***: i/o timeout`, при этом CI оставался зелёным, PR
продолжали мержиться, а deploy-infra.yml исправно обновлял Beget и создавал
впечатление, что всё в порядке.
Проверок, которые спрашивают не «прошёл ли прогон», а «доехал ли код», не
было ни одной. Этот сторож — ровно такая: ежечасно читает HEAD с прод-хоста
и сверяет с tip main.
Три исхода вместо двух. «Хост не ответил» (2) отделён от «на хосте не тот
код» (1) — это разные аварии с разной первой командой в разборе, и сегодня
погорели именно на их смешении: недоступность выглядела как обычный красный
прогон. Льготный период 30 минут гасит ложную тревогу на деплое, который
ещё в полёте: ежечасный сторож неизбежно попадёт в окно между мержем и
концом выката, а сторож, которого научились игнорировать, хуже отсутствующего.
Логика вынесена в scripts/check-deploy-drift.sh и не ходит по сети — SSH
живёт в workflow, где секреты. Благодаря этому тест ИСПОЛНЯЕТ настоящий
скрипт, а не пересказывает его: ошибка в самом bash видна только при запуске.
Read-only: один ssh и git rev-parse, ничего не деплоит.
Первая живая приёмка #3122: деплоевский startup-reap успел снять зомби
раньше, boot-reap отработал с нулём — и не оставил НИКАКОГО следа
исполнения. Ноль — штатный исход, но «сторож, молчащий при нуле» — это
слепая зона по построению: оборванную проводку не отличить от чистого
прода. INFO-строка при нуле, warning при снятых — как было.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Прод-факт 27.08 (после ночного офлайна #3119): 4 прогона 'running' со
стартами до старта контейнера блокировали свои источники через
has_running_run до 6-часового порогового reap'а — до пяти часов слепоты
на источник ровно после простоя, когда догон нужнее всего.
Критерий — started_at < старт процесса планировщика (минус минута на
дрейф), пульс не участвует: ложные срабатывания класса #2702 (редкий
пульс длинных прогонов) невозможны по построению — живой прогон этого
процесса не может быть старше самого процесса.
Маркер counters.boot_reaped=true открывает boot-зомби подхват чекпоинта
(_resume_decision): у порогового zombie процесс может быть жив
(движущаяся точка — причина исключения 'zombie' из _RESUME_STATUSES),
у boot-зомби — гарантированно мёртв. Пороговый zombie без маркера
по-прежнему отвергается (закреплено тестом-инвариантом).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Миграция 272: колонка + бэкфилл ТОЛЬКО по bbox региона 66 (значения
байт-в-байт из реестра, синхронизацию держит тест). Карантин NULL:
620 домов без geom, 23 порченых (ЕКБ-адреса с чужими координатами —
«Вильгельма де Геннина» на Байкале, «Крауля» под Москвой, «Учителей» в
Таллине, с живыми ссылками листингов) — им регион не присваивается,
включая 3 дома с координатами в bbox Москвы (порча, не переезд). NOT NULL
из постановки — отдельной миграцией, когда карантин опустеет.
Запись: новый дом наследует регион РАЗВЁРТКИ (base.py → контракт →
адаптер → matching), только если координаты не противоречат; координаты
другого региона → NULL-карантин + warning. Вне-bbox координаты при живой
развёртке наследуют её регион (адрес и развёртка согласны, координатам
веры нет) — семантика закреплена тестом, чтобы смена была осознанной.
Матчинг-запросы НЕ тронуты — гард по региону это #3052.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Selectel заменил адрес сервера в ночь на 27.08 по нашей заявке: старый
188.246.224.93 фильтровался российскими операторами и не открывался ни с
домашнего интернета, ни с мобильного (#3110). Новый адрес фильтрации не имеет —
проверено с машины владельца после переключения DNS.
Замена уронила сервер на 10 часов: адрес на порте сменился, а в ОС остался
прежний (разбор и восстановление — #3119). Здесь только то, что осталось в
репозитории.
Единственное функциональное вхождение — дефолт HOST_IP в ops/selectel-ci-access.sh:
скрипт открывает раннеру доступ на прод-хост, и с прежним значением он молча
настроил бы доступ на адрес, которого у нас больше нет. Остальные семь — тексты
комментариев в Caddyfile-секциях, compose, bootstrap-скриптах и раннбуке крона;
они не исполняются, но именно по ним сверяются при переезде, и разошедшийся
адрес в них дороже, чем кажется.
Третий шард после yandex (#3098) и avito (#3112). Выбран по замеру за 60 дней:
65 прогонов, среднее 35 минут, максимум 72, две отмены деплоем. Пятиминутного
дренажа (#3029) на такие прогоны не хватает - убитый на 35-й минуте сбор
начинался заново с первого якоря.
Ключ чекпоинта - ИМЯ якоря, а не индекс: состав списка зависит от city_slug
(областные свипы идут по своим наборам), позиция между городами не устойчива.
По той же причине гарда по числу якорей не нужна - в отличие от combo-чекпоинта
яндекса, где ключ якоря не содержал.
Отличие от avito-шарда: там успех и неудача якоря сходились в одной строке и
потребовался отдельный флаг _anchor_ok. У циана граница уже проведена самим
потоком управления - все ветки отказа делают return или continue и до записи
чекпоинта не доходят. Добавлять флаг значило бы дублировать то, что уже
выражено структурой; достаточно писать чекпоинт в единственной точке успеха.
Тест сторожит эту границу отдельно, потому что рефакторинг, сливающий ветки,
сломал бы её незаметно.
Пропущенный якорь двигает anchors_done - чтобы счётчик продолжал означать
"докуда дошли по списку", а не "сколько собрал именно этот прогон".
Тесты (4) поведенческие, с подменой CianScraper и save_listings: якорь из
чекпоинта не опрашивается вовсе; пройденный дописывается поверх унаследованных;
без чекпоинта обходятся все; упавший в чекпоинт не попадает. Двойнику пришлось
добавить счётчики state_extraction_* - конвейер читает их после каждого якоря
(#2625), и без них падал бы сам двойник, а не проверяемая логика.
Фальсификация: на исходном коде краснеют все 4. Весь набор #3074 (yandex, avito,
cian, claim) - 14 passed.
CI поймал то, что мой локальный прогон пропустил: сьют в ПОДДИРЕКТОРИИ
tests/services/ звал удалённый _in_ekb_bbox. Граничные точки сохранены те же
(продукт-ядро 66 байт-в-байт), проверка дополнена кодом региона.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>