- CLAUDE.md, «Язык»: ссылаться можно на конкретную запись или на весь корпус разом, но не на их количество — число протухает молча, машина его не считает. Изъятие названо: неизменное число и историческое в записи о прошлом остаются. - Сняты счёты capability, прогонов ревью, типизированных ошибок, воркеров, сверок документов и правил линтера в docs/, спеке pipeline и CLAUDE.md. - Заодно исправлено то, что этот же счёт и скрывал: типизированных ошибок три, а не две — LostAcquisitionError был потерян из перечня.
328 lines
26 KiB
Markdown
328 lines
26 KiB
Markdown
# 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** шаг завершается без отказа и без записи о недоставке
|