14 октября на dev-стенде в логах API нашлась строка, которая повторялась примерно раз в сорок секунд:
Watchdog ack failed: relation "message_watchdog" does not exist
Таблицы message_watchdog не было ни на dev, ни на проде. Код, который в неё пишет, лежал в репозитории с 15 апреля. Писал его я.
Зачем он был нужен
Я делаю Octery, бота, который отвечает лидам в чатах amoCRM и передаёт сделку менеджеру. Сообщение клиента приходит вебхуком в пайплайн обработки, тот сходит в наш API за настройками и базой знаний, позовёт модель и отправит ответ обратно в чат. Если по дороге что-то упадёт молча, клиент останется без ответа, и никто об этом не узнает.
В апреле я сделал против этого схему ping/ack. В начале обработки пайплайн зовёт POST /watchdog/ping, API записывает строку «сообщение от такого-то контакта принято». После отправки ответа он зовёт /watchdog/ack, и строка помечается отвеченной. Раз в несколько минут отдельная задача по расписанию спрашивает /watchdog/stale: есть ли сообщения старше пяти минут без ack? Если есть, в Telegram уходит алерт.
Всё это держалось на одной таблице, которую создавала миграция V10__message_watchdog.sql.
Как я потерял таблицу
Номер 10 был занят с марта. В папке уже лежал V10__update_bot_config_trigger_with_company_id.sql, и я его не заметил. На двух одинаковых версиях Flyway падает сразу, так что деплой встал. Через шесть дней я переименовал свой файл в V10b__message_watchdog.sql. Деплой позеленел. Коммит назывался «fix: rename duplicate V10 migration to V10b to resolve Flyway version conflict», и тогда мне казалось, что это и правда fix.
Версия миграции во Flyway состоит из чисел через точку или подчёркивание. 10b не разбирается. За такие имена отвечает параметр validateMigrationNaming, и по умолчанию он выключен. В этом режиме Flyway файл с кривым именем просто не считает миграцией и молча идёт дальше. Я поменял ошибку, которая останавливала деплой, на ошибку, которую не видно вообще.
Проверка занимает два запроса:
SELECT version, description FROM flyway_schema_history WHERE version LIKE '10%'; -- 10 | update bot config trigger with company id SELECT to_regclass('message_watchdog'); -- NULL
Десятая версия в истории есть, от соседней миграции. Никакой 10b там нет, таблицы тоже.
Код, который не умел жаловаться
Без таблицы каждый вызов падал на SQL. Падал внутри try, который я написал так:
internal.post('/watchdog/ping', async (c) => { try { // INSERT INTO message_watchdog ... ON CONFLICT DO UPDATE return c.json({ success: true }); } catch (error) { logger.error('Watchdog ping failed', { error }); // Never crash - this is monitoring, not business logic return c.json({ success: true, warning: 'ping registration failed' }); } });
Мысль за комментарием верная: сломался мониторинг, а клиент всё равно должен получить ответ. Только применил я её не там. Бота от падения мониторинга уже защищал сам пайплайн: шаг с вызовом ping был настроен продолжать работу при ошибке, и честный 500 максимум оставил бы красную отметку в журнале выполнений. API подстраховал то же место второй раз и заодно убрал эту отметку.
Решал, слать ли алерт, эндпоинт stale. Его catch:
} catch (error) { logger.error('Watchdog stale check failed', { error }); return c.json({ success: true, data: { staleCount: 0, messages: [], error: 'query failed' }, }); }
staleCount: 0 значит «потерянных сообщений нет». Cron смотрел на счётчик, поле error он не читал. Полгода проверка, которая не могла выполнить ни одного запроса, отвечала тем же, чем ответила бы в хороший день.
Три состояния вместо двух
Пока я это разбирал, стало понятно, что дело шире одного catch. Я думал о проверке как о вопросе с ответом «да или нет»: есть потерянные сообщения или нет. У проверки есть третий ответ, «не смог посмотреть», и он должен звучать громче, чем «всё хорошо». В моём коде третьего ответа не существовало, и он сливался с хорошим.
На практике это выглядит просто. stale при ошибке отдаёт 500 или хотя бы staleCount: null, а cron на null шлёт отдельный алерт «watchdog не работает». Ping и ack тоже отдают ошибку, а тихо продолжать работу остаётся заботой вызывающей стороны, у нас это уже было настроено.
Чем закончилось
Переписывать watchdog мы не стали, а 14 октября удалили. Падения пайплайна к тому времени ловил отдельный обработчик ошибок с алертами, а неотвеченные сообщения ловит тестер бота, который пишет в Telegram как живой клиент и ждёт ответ. Таблицу удалили миграцией V26__drop_message_watchdog.sql с DROP TABLE IF EXISTS. На обоих стендах она ничего не делает, но теперь схема в git совпадает с тем, что лежит в базе. V10b остался в папке, историю миграций мы не переписываем.
Если у вас миграции когда-нибудь переименовывали руками, сравните список файлов в папке с flyway_schema_history. Разница там означает файлы, которые Flyway не видит. И включите validateMigrationNaming=true: с ним такой файл уронит деплой в тот же день, а у меня на это ушло полгода.
