- CLAUDE.md, «Язык»: ссылаться можно на конкретную запись или на весь корпус разом, но не на их количество — число протухает молча, машина его не считает. Изъятие названо: неизменное число и историческое в записи о прошлом остаются. - Сняты счёты capability, прогонов ревью, типизированных ошибок, воркеров, сверок документов и правил линтера в docs/, спеке pipeline и CLAUDE.md. - Заодно исправлено то, что этот же счёт и скрывал: типизированных ошибок три, а не две — LostAcquisitionError был потерян из перечня.
26 KiB
pipeline Specification
Purpose
Конвейер расшифровки: как задача движется по состояниям, что делает воркер, когда работы нет, что считается отказом шага и что бывает с ответом отправителю, когда доставить его некуда.
Описаны пустой прогон воркера, неделимость захвата и срок его протухания, число
попыток и состояние «мертва», нарастающая пауза перед повтором, условие записи
результата держателем захвата и недоставка ответа при неподнятом входе.
Сознательно не описаны: цепочка переходов created → converted → transcribe → done | failed, отмена контекста посреди шага и освобождение ресурсов внешних
клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а
требования на него не написаны, потому что требование без проверки —
предположение, а не норма. Первая задача, которая трогает любое из
перечисленного, дописывает его сюда.
Requirements
Requirement: Пустой прогон воркера — не отказ
Воркер SHALL отличать «работы в этом состоянии сейчас нет» от отказа шага. На пустом прогоне он MUST не считать прогон отказом: не увеличивать счётчик работы и не писать о нём на уровне владельца сервиса. Признак пустого прогона MUST узнаваться по смыслу значения, а не по его точной форме, и MUST переживать пояснения, добавленные к этому значению на любом промежуточном шаге пути.
Требование стоит на инварианте проекта «NoopJobError — не ошибка»: воркеры
опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт от
каждого запись отказа в секунду и столько же засчитанных сбоев, которых не было.
Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак в цепочке своей ошибки. Воркер узнаёт признак по смыслу на любой глубине, поэтому отказ, к которому признак примешался, тоже зачёл бы пустым прогоном: задача осталась бы в своём состоянии и переопрашивалась раз в секунду без единой записи — ровно то, что запрещает инвариант «Принятая запись не теряется молча».
Отказ шага, наоборот, MUST быть виден владельцу сервиса записью в журнале и MUST быть засчитан в счётчик работы с пометкой отказа.
Сколько раз он записывается и каким уровнем — это требование не нормирует, и
умолчанием тут считать нечего. Сегодня один отказ даёт две записи: пишет шаг
конвейера и следом воркер, — а уровень стоит ERROR там, где конвенция просит
WARN для повторяющегося сбоя фонового цикла. И то и другое записано долгом в
docs/conventions/logging.md, раздел «Ошибки», строкой «Расхождение, и оно
системное». Долгом оно и остаётся: требование, объявившее одиночную запись
нормой, сделало бы недостижимое обязательным, а требование, объявившее нормой
двойную, — закрыло бы долг контрактом. Задача, которая возьмётся за этот долг,
дописывает норму сюда.
Scenario: Работы в состоянии нет
- GIVEN ни одной задачи в опрашиваемом состоянии нет
- WHEN воркер делает свой прогон
- THEN на уровне владельца сервиса об этом прогоне не пишется ничего
- AND счётчик работы воркера не растёт
Scenario: Признак пустого прогона дошёл с пояснением
- GIVEN работы в опрашиваемом состоянии нет
- AND промежуточный шаг добавил к этому признаку своё пояснение
- WHEN воркер делает свой прогон
- THEN прогон по-прежнему считается пустым: счётчик не растёт, записи на уровне владельца нет
Scenario: Шаг отказал
- GIVEN шаг конвейера вернул отказ
- WHEN воркер завершает прогон
- THEN отказ виден владельцу сервиса записью в журнале
- AND счётчик работы воркера растёт с пометкой отказа
Scenario: Шаг сделал работу
- GIVEN шаг конвейера отработал задачу без отказа
- WHEN воркер завершает прогон
- THEN счётчик работы воркера растёт с пометкой успеха
- AND записи об отказе в журнале нет
Requirement: Захват задачи неделим
Захват задачи воркером SHALL быть одним неделимым шагом хранилища: выбор подходящей задачи и пометка её захваченной MUST происходить вместе, и захваченная задача MUST возвращаться тем же шагом.
Одна и та же задача MUST доставаться ровно одному захватившему. Двум вызывающим, пришедшим за одним состоянием одновременно, запись MUST достаться одному, а второй MUST получить признак «работы в этом состоянии нет».
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок обработки невоспроизводим, а проверка, опирающаяся на «следующую» задачу, зелена через раз.
Требование стоит на инварианте проекта «Принятая запись не теряется молча»: захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа одного из них теряется без следа.
Признак «работы нет» этим требованием не переопределяется — его нормирует требование «Пустой прогон воркера — не отказ».
Scenario: За задачей пришли трое разом
- GIVEN в опрашиваемом состоянии лежит ровно одна задача
- WHEN три захвата этого состояния идут одновременно
- THEN запись получает ровно один из них
- AND двое остальных получают признак «работы в этом состоянии нет»
Scenario: Захваченная задача не выдаётся второй раз
- GIVEN задача захвачена и срок захвата не истёк
- WHEN за тем же состоянием приходит следующий захват
- THEN эта задача ему не выдаётся
Requirement: Результат пишет только держатель захвата
Шаг конвейера SHALL записывать свой результат только тогда, когда захват задачи всё ещё принадлежит ему. Запись MUST быть условна по признаку захвата, а шаг, чей захват за время работы достался другому, MUST завершиться без записи результата и без ответа отправителю.
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока, теряет задачу, продолжая работать. Без этого условия два воркера пишут в одну задачу по очереди, счётчик попыток сбрасывает тот, кто уже не владелец, а отправитель получает два ответа на одну запись.
Шаг MUST записывать только те поля, которыми распоряжается сам. Задачу он держит снимком с момента захвата и до записи — это часы, — и безусловная запись снимка стёрла бы всё, что владелец правил в панели за это время: молча, без строки в журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы уверен, что правка на месте.
Scenario: Правка владельца пережила сохранение шага
- GIVEN шаг держит захваченную задачу
- AND владелец за это время изменил в панели поле, которого шаг не касается
- WHEN шаг записывает свой результат
- THEN результат шага записан
- AND правка владельца на месте
Scenario: Захват ушёл под работающим шагом
- GIVEN шаг работает над захваченной задачей
- AND за это время та же задача досталась другому захвату
- WHEN первый шаг доходит до записи результата
- THEN результат не записывается
- AND отправителю ничего не отправляется
Requirement: Брошенная задача возвращается в работу
Задача, захваченная и брошенная на середине, SHALL доставаться снова по истечении срока захвата. Срок MUST считаться от времени захвата, а истёкший захват MUST не мешать выдать задачу следующему.
Срок задаётся шагом конвейера и MUST быть не меньше того времени, которое этот шаг может занять на самом длинном допустимом входе. Срок короче делает протухание штатным событием живого шага, а не признаком беды.
Все значения времени, по которым идёт этот отбор, MUST записываться и сравниваться в одном виде — том же, в каком хранилище пишет собственные времена записи. Сравнение идёт побайтово, и вид, разошедшийся хоть разделителем, обращает условие в постоянную истину или постоянную ложь, причём молча.
Scenario: Захват протух
- GIVEN задача захвачена, а время захвата отстоит дальше срока
- WHEN за её состоянием приходит захват
- THEN задача выдаётся ему
Scenario: Срок сравнивается с временем, записанным хранилищем
- GIVEN задача захвачена, и время захвата записано в том же виде, в каком хранилище пишет время изменения записи
- WHEN за её состоянием приходит захват до истечения срока
- THEN задача ему не выдаётся
Requirement: Число попыток и состояние «мертва»
У задачи SHALL быть число попыток. Оно MUST расти при каждом захвате и MUST возвращаться к нулю, когда шаг завершился без отказа. Рост при захвате, а не при отказе, засчитывает попытку и задаче, брошенной на середине: шаг, уносящий с собой процесс, до объявления отказа не доходит никогда, и без этого такая задача крутилась бы вечно.
Задача, захваченная с числом попыток сверх заданного предела, MUST переводиться в состояние «мертва» тем, кто её захватил, и MUST не отдаваться шагу в работу. Перевод принадлежит одному месту: условие отбора, молча пропускающее задачу мимо выборки, оставило бы её без состояния и без следа.
Мёртвая задача MUST отбираться владельцем по своему состоянию и MUST возвращаться в работу правкой этого состояния — без запроса в консоли сервера.
Переход в «мертва» MUST сообщать отправителю о неудаче ровно так же, как сообщает о ней отказ шага. Иначе он становится третьим исходом там, где инвариант проекта «Принятая запись не теряется молча» допускает два: задача не пригодна к повтору и об отказе никто не сказал.
От состояния отказа «мертва» отличается тем, чей это приговор. В failed задачу
переводит шаг, рассудивший об этой записи окончательно: конвертация не удалась,
распознавание вернуло ошибку. В «мертва» задача уходит без такого суждения — мы
повторяли и перестали. Ни один шаг конвейера в «мертва» не переводит сам.
Прежний признак «задача с ошибкой», исключавший задачу из выборки навсегда и отдельный от перечня состояний, MUST не заводиться заново: два способа вывести задачу из выборки расходятся, и молчаливо теряется тот, который забыли проверить.
Scenario: Задача падает на каждой попытке
- GIVEN шаг конвейера отказывает на каждой попытке
- WHEN задача проходит заданное число попыток
- THEN она переходит в состояние «мертва»
- AND следующий захват её не выдаёт
- AND отправитель получает сообщение о неудаче
Scenario: Шаг уносит процесс, не объявив отказа
- GIVEN шаг конвейера обрывается вместе с процессом на каждой попытке
- WHEN задача захватывается снова заданное число раз
- THEN она переходит в состояние «мертва»
Scenario: Прошедшая задача попыток не копит
- GIVEN задача прошла подряд несколько состояний без единого отказа
- WHEN смотрят её число попыток
- THEN оно не приблизилось к пределу
Scenario: Мёртвая задача возвращена в работу
- GIVEN задача в состоянии «мертва»
- WHEN её состояние сменили на то, с которого она отказывала
- THEN следующий захват выдаёт её снова
Requirement: Пауза перед повтором нарастает
Перед повтором отказавшей задачи сервис SHALL выдерживать паузу, и пауза MUST расти с числом её попыток до объявленного потолка. Задача MUST не выдаваться захвату, пока пауза не кончилась.
Ожидание чужой операции этой паузой MUST не выражаться. Шаг, увидевший, что внешняя операция ещё идёт, отработал без отказа: он назначает свою задержку опроса, заданную числом, и попытки при этом не тратит. Пауза, выведенная из числа попыток, на таком шаге вырождается в наименьшее своё значение и учащает опрос внешнего сервиса во столько раз, во сколько задержка опроса длиннее секунды.
Scenario: Отказавшая задача ждёт
- GIVEN задача отказала на шаге конвейера
- WHEN захват приходит раньше конца её паузы
- THEN задача ему не выдаётся
Scenario: Вторая пауза длиннее первой
- GIVEN задача отказала дважды подряд
- WHEN сравнивают паузу после второго отказа с паузой после первого
- THEN вторая длиннее
Scenario: Ожидание операции не учащается и не тратит попыток
- GIVEN внешняя операция распознавания ещё идёт
- WHEN шаг проверки отрабатывает подряд несколько раз
- THEN задержка до следующей проверки каждый раз одна и та же
- AND число попыток задачи не растёт
Requirement: Недоставленный ответ не роняет шаг
Шаг конвейера SHALL доводить задачу до достигнутого состояния, когда ответ отправителю доставить не удалось, и MUST не считать недоставку отказом шага. Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор задачи, MUST называть причину и MUST считаться отдельной метрикой с причиной меткой.
Причин у недоставки две, и исход у них общий: вход отправителя не поднят — задача заведена прошлым запуском, а сервис поднялся без этого входа; и адресат у задачи не назван — источником значится Telegram, а чата в задаче нет.
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим, и его уровень «может стать проблемой». Неназванный адресат — симптом порчи записи: у задачи из Telegram чат есть всегда, и пропасть он может только от дефекта, самый коварный источник которого назван инвариантом проекта про колонки очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных записей о ненастроенном боте.
Общий исход — не упрощение, а следствие момента: ответ уходит после того, как
достигнутое состояние сохранено. Работа к этой минуте сделана, и объявленный
отказ засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть
соврал бы про исход дважды. Повтор делу не помогает: ни бот, ни адресат от
ожидания не появятся. Поэтому задача остаётся в достигнутом состоянии, в повтор
не уходит и в failed не переводится, а причина недоставки живёт в записи
журнала, а не в состоянии задачи.
Идентификатор задачи в записи обязателен: без него владелец видит, что ответ не ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя в эту запись MUST не попадать — приватность содержимого записи требование не ослабляет.
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не уходит и потом. Забрать расшифровку можно там же, где лежат остальные.
Scenario: Вход отправителя не поднят
- GIVEN задача принята входом Telegram прошлым запуском сервиса
- AND сервис поднялся без этого входа
- WHEN шаг конвейера доходит до ответа отправителю
- THEN шаг завершается без отказа, и воркер не считает прогон сбоем
- AND задача остаётся в достигнутом состоянии, в повтор не уходит и в
failedне переводится - AND в журнале есть запись уровня
WARNо недоставке с идентификатором задачи и причиной - AND счётчик недоставленных ответов вырос с этой причиной меткой
- AND ни текста расшифровки, ни сообщения отправителя в этой записи нет
Scenario: Адресат у задачи не назван
- GIVEN у задачи источником значится Telegram, а чат не назван
- WHEN шаг конвейера доходит до ответа отправителю
- THEN шаг завершается без отказа, и воркер не считает прогон сбоем
- AND задача остаётся в достигнутом состоянии
- AND в журнале есть запись уровня
ERRORо недоставке с идентификатором задачи и причиной: неназванный адресат — симптом порчи записи
Scenario: Отвечать некуда, потому что запись пришла не из Telegram
- GIVEN задача принята по HTTP
- WHEN шаг конвейера доходит до ответа отправителю
- THEN шаг завершается без отказа и без записи о недоставке