telegram: сервис поднимается без бота и работает одним входом
- Клиент бота собирается один раз и достаётся отправителю и транспорту; разрез прошёл по «ответил ли Telegram»: ответ «такого бота нет» роняет старт, недоступность даёт подъём без Telegram (ADR-2026-08-13). Ожидание при сборке ограничено сроком — иначе молчащий Telegram вешал подъём. - Недоставленный ответ не роняет шаг: пишется с job_id и считается метрикой, уровень по причине — WARN для неподнятого входа, ERROR для неназванного адресата. Заведены transcriber_intake_up и transcriber_undelivered_reply_count. - Закрыта утечка токена в журнал: отказ разбора адреса рождается раньше обращения к клиенту, то есть мимо чистки на его границе.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
## Context
|
||||
|
||||
Сегодня старт роняет отсутствующий токен бота: сборка отправителя ответов
|
||||
возвращает «токен не задан», и процесс заканчивается раньше, чем встаёт
|
||||
HTTP-сервер. Соседний путь того же старта — сборка транспорта бота — тот же отказ
|
||||
уже терпит и сервис не роняет. Два пути одного старта решают одно и то же
|
||||
по-разному, и побеждает тот, что стоит выше.
|
||||
|
||||
Ограничение, из-за которого это дорого: боевым токеном запускаться запрещено, а
|
||||
другого действующего токена у разработчика нет. Значит, живой прогон недоступен
|
||||
никому, и всякая задача проверяется одними тестами.
|
||||
|
||||
Отправитель ответов уходит в ядро расшифровки обязательной зависимостью, и ядро
|
||||
зовёт его без проверки. Убрать отправителя, ничего не решив, — значит уронить
|
||||
процесс на первой же задаче из Telegram, лежащей в базе с прошлого запуска.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- сервис поднимается без токена бота и работает оставшимся входом;
|
||||
- отсутствие бота видно в журнале, а не выводится читателем из тишины;
|
||||
- задача из Telegram, которой некому ответить, не роняет процесс и не теряется
|
||||
молча;
|
||||
- ошибка в токене остаётся заметной.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- приём из Telegram по существу — кто допущен, как забирается запись — не
|
||||
нормируется; оговорка спеки `intake` остаётся;
|
||||
- запрет запускаться боевым токеном не снимается и не смягчается;
|
||||
- второй вход не становится необязательным «вообще»: сервис без обоих входов
|
||||
бессмыслен, но проверять это изменение не берётся;
|
||||
- отправка отложенных ответов, когда бот появится позже, не заводится.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Решение 1: пустой токен — отказ от входа, негодный непустой — ошибка настройки
|
||||
|
||||
Разрез проходит по **пустому значению**, а не по отказу сборки бота.
|
||||
|
||||
Что человек увидит иначе: разработчик стирает токен в своём файле настроек и
|
||||
поднимает сервис; владелец сервиса, опечатавшийся в токене при ротации, получает
|
||||
отказ старта вместо сервиса, молча работающего без бота.
|
||||
|
||||
**Правка после ревью кода, решение владельца 2026-08-13.** Разрез перенесён с
|
||||
«пусто / непусто» на «ответил ли Telegram»: недоступность Telegram на подъём
|
||||
сервиса не влияет. Довод — тот же, что у паспорта: основной вход не Telegram, и
|
||||
класть его целиком из-за чужой аварии нельзя. Ровно этого и требовал прежний
|
||||
разрез: перезапуск в минуту аварии Bot API оставил бы без работы приём по HTTP,
|
||||
панель и конвейер, которому Telegram не нужен вовсе.
|
||||
|
||||
Отдельно снят довод, оказавшийся ложным. Дизайн утверждал, что «Telegram не
|
||||
признал бота» и «до Telegram не дошли» различать нечем. Различать есть чем:
|
||||
ответ Bot API приезжает своим типом с кодом, транспортный отказ — нашим после
|
||||
чистки, и одно от другого отделяется проверкой типа. Утверждение держалось на
|
||||
незнании библиотеки, а не на её устройстве.
|
||||
|
||||
Из решения следует второе, без которого оно невыполнимо: **ожидание при сборке
|
||||
ограничивается сроком**. Пока срока не было, недоступность не отличалась от
|
||||
подъёма — молчащий Telegram вешал старт бессрочно, без записи, без порта и без
|
||||
пробы здоровья. Срок стоит только на сборке; длинный опрос им не ограничен, и
|
||||
клиент подменяется сразу после.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **терпеть любой отказ сборки бота** — отвергнуто: опечатка в боевом токене
|
||||
дала бы работающий сервис без бота, и отправители перестали бы получать
|
||||
ответы. Ответ «такого бота нет» опознаётся точно, ждать по нему нечего, и он
|
||||
остаётся единственным отказом старта;
|
||||
- **ронять старт на любом отказе** — отвергнуто владельцем: авария третьей
|
||||
стороны не должна класть основной вход;
|
||||
- **отдельный ключ настройки «работать без Telegram»** — явное объявление
|
||||
намерения. Отвергнуто: имя ключа конфига объявлено необратимым, а пустое
|
||||
значение уже несёт ровно этот смысл. Второй способ сказать одно и то же
|
||||
разъезжается — останется решить, что делать с пустым токеном при выключенном
|
||||
ключе.
|
||||
|
||||
**Разрез стоит в одном месте, потому что клиент бота собирается один раз.**
|
||||
Сегодня его собирают дважды — под отправителя ответов и под транспорт бота, — и
|
||||
именно поэтому два пути разошлись. Вместо того чтобы согласовывать их вручную,
|
||||
изменение сводит сборку к одной: клиент заводится в сборке при старте и отдаётся
|
||||
обоим. Транспорт уже принимает готового клиента, так что менять надо только
|
||||
отправителя — он перестаёт принимать токен и начинает принимать клиента.
|
||||
|
||||
Что это даёт сверх опрятности: разрез «пусто / непусто» существует ровно один,
|
||||
запись о неподнятом боте по построению одна, обращение к Telegram при старте
|
||||
одно вместо двух, и подмена журнала библиотеки тоже одна. Проверять «согласованы
|
||||
ли два пути» больше не надо — второго пути нет.
|
||||
|
||||
**Цена решения:** сборка бота перестаёт терпеть негодный токен и начинает ронять
|
||||
старт. Наблюдаемо это почти ничего не меняет: сборка отправителя роняет старт на
|
||||
том же токене и сегодня, а стоит она раньше — до терпимости транспорта очередь
|
||||
попросту не доходит.
|
||||
|
||||
### Решение 2: заглушка отвечает «канала нет», а запись делает шаг
|
||||
|
||||
Когда токена нет, ядро получает отправителя-заглушку. Она ничего не отправляет и
|
||||
на всякий ответ возвращает **особое значение отказа — «канал доставки не
|
||||
поднят»**. Шаг конвейера узнаёт это значение, пишет недоставку в журнал с
|
||||
идентификатором задачи и завершается **без отказа**.
|
||||
|
||||
Что человек увидит иначе: владелец сервиса находит в журнале строку «ответ не
|
||||
доставлен» с идентификатором задачи и забирает расшифровку там же, где лежат
|
||||
остальные.
|
||||
|
||||
Почему запись делает шаг, а не сама заглушка: **идентификатора задачи у
|
||||
заглушки нет**. Контракт отправки несёт текст, чат и сообщение для ответа —
|
||||
задачу он не называет, и знать о ней отправителю незачем. Заглушка, пишущая
|
||||
`chat_id` вместо задачи, дала бы владельцу строку, по которой задачу не найти, а
|
||||
расширение контракта ради журнала потянуло бы правку и настоящего отправителя, и
|
||||
всех его вызовов.
|
||||
|
||||
Почему это не заводит в ядре ветки «а есть ли бот»: ядро ветвится не на
|
||||
устройстве сборки, а на **исходе доставки** — ровно так же, как оно уже ветвится
|
||||
на «работы нет» и «захват потерян». Особое значение отказа живёт там же, где эти
|
||||
два, и узнаётся тем же способом. Знания о том, как собран сервис, у ядра не
|
||||
появляется.
|
||||
|
||||
Источник задачи ядро при этом уже различает: ответ отправителю начинается с
|
||||
проверки источника и на задаче, пришедшей по HTTP, кончается раньше обращения к
|
||||
отправителю. Заглушка задач основного входа не увидит, и ложных строк о
|
||||
недоставке в журнале не будет.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **ронять задачу в `failed`** — отвергнуто: расшифровка к этому моменту уже
|
||||
получена и сохранена, а «не удалось» сообщить всё равно некому. Пометка отказа
|
||||
на удавшейся работе врёт и панели, и метрике;
|
||||
- **оставлять задачу пригодной к повтору** — отвергнуто: смысл повтора в том,
|
||||
чтобы работа однажды удалась, а недоставка сама не пройдёт — бот не появится
|
||||
оттого, что задачу подождали;
|
||||
- **немая заглушка, возвращающая успех** — отвергнуто находкой ревью дизайна:
|
||||
недоставку тогда некому записать, и норма «принятая запись не теряется молча»
|
||||
оказывается нарушена именно тем решением, которое её и обслуживало;
|
||||
- **отпустить отказ заглушки наверх, не разбирая** — отвергнуто: шаг объявил бы
|
||||
отказ там, где работа сделана. Задачу это в повтор не отправит — воркеры
|
||||
опрашивают только незавершённые состояния, — но воркеру засчитается сбой,
|
||||
которого не было, и владельцу уедет запись отказа. Соврала бы и метрика, и
|
||||
журнал.
|
||||
|
||||
### Решение 3: заглушка живёт рядом с настоящим отправителем
|
||||
|
||||
Место — тот же пакет, что и отправитель Telegram: заглушка знает ровно то же,
|
||||
что и он, и подставляется в сборке при старте, как и все прочие адаптеры.
|
||||
Направление зависимостей это не нарушает, и тесты-сканеры остаются зелёными.
|
||||
Само значение отказа живёт среди контрактов — там же, где «работы нет» и «захват
|
||||
потерян»: узнаёт его ядро, а порождает адаптер, и ни один из них не зависит от
|
||||
другого.
|
||||
|
||||
**Форма значения — сентинел, а не тип с полями.** Соседи по ряду несут поле
|
||||
(состояние, идентификатор задачи) и потому объявлены типами; этому нести нечего —
|
||||
заглушка не знает ни задачи, ни чата. Прецедент сентинела в проекте есть: им же
|
||||
объявлено «токен не задан».
|
||||
|
||||
**Заодно убирается третье представление того же факта.** Кроме пустой строки в
|
||||
настройках и значения «токен не задан» в пакете отправителя, в транспорте бота
|
||||
объявлен ещё один тип с тем же смыслом, не употребляемый нигде. Он снимается
|
||||
этой же задачей: объяснять четвёртое представление дороже, чем удалить мёртвое.
|
||||
|
||||
### Решение 4: живой прогон требует заполнить ещё две секции, и это говорится вслух
|
||||
|
||||
Пустого токена мало. Настройки входа проверяются на старте и роняют процесс,
|
||||
называя незаполненные ключи; конструкторы Yandex так же роняют его на пустых
|
||||
регионе, ключах Object Storage, ключе SpeechKit и папке. Ни один из них при
|
||||
старте наружу не ходит, поэтому **выдуманных непустых значений достаточно** —
|
||||
живой прогон получается, а денег не стоит.
|
||||
|
||||
Этой задачей разрез «пустое значение — отказ от возможности» на другие секции не
|
||||
переносится: распознавание без Yandex не работает по существу, и отказ от него —
|
||||
отдельное решение с отдельной ценой. Здесь только называется, что заполнить,
|
||||
чтобы сервис поднялся.
|
||||
|
||||
Отсюда же граница правки документов: строка «живой прогон недоступен» не
|
||||
снимается, а **сужается с остатком** — стал доступен подъём и осмотр, а прогон с
|
||||
по-настоящему пустыми ключами Yandex по-прежнему невозможен.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Сервис молча работает без бота, потому что токен забыли стереть или забыли
|
||||
вписать** → строка журнала при старте называет это прямо, а не оставляет
|
||||
читателю вывод из тишины. Дальше — дело того, кто выкладывает;
|
||||
- **Отказ старта на негодном токене останавливает выкладку, которая прежде
|
||||
проходила** → это и есть цель решения 1; чинится правкой настройки, и отказ
|
||||
называет, какой ключ виноват, не называя значения;
|
||||
- **Сборка бота ходит в Telegram, и без сети старт с непустым токеном упадёт** →
|
||||
поведение не новое и не ухудшается. Сегодня сборка отправителя зовётся первой и
|
||||
роняет старт на любом отказе, так что терпимость соседнего пути на негодном
|
||||
токене всё равно не срабатывает: до неё не доходит очередь. Изменение делает
|
||||
два пути согласованными и **снимает** сеть с законного пути — пустой токен не
|
||||
ходит наружу вовсе. Срока ожидания у обращения к Telegram при этом нет, и
|
||||
задача его не заводит: таймауты у трёх внешних собеседников — известный
|
||||
недостаток проекта и предмет отдельной работы;
|
||||
- **Недоставленный ответ пропадает навсегда** → отложенной доставки нет и не
|
||||
заводится: расшифровка лежит в хранилище и достаётся через панель и HTTP API;
|
||||
- **Остановка идёт по пути, которым прежде не ходили** → останов зеркален
|
||||
сборке: чего не собрали, того не закрывают и не ждут. Проверяется прогоном
|
||||
сигнала остановки на конфиге с пустым токеном.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Схемы хранилища изменение не трогает, миграции нет, откат — обычный откат образа.
|
||||
Выкладка с заполненным токеном ведёт себя ровно как прежде.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Нет.
|
||||
Reference in New Issue
Block a user