- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
713 lines
54 KiB
Markdown
713 lines
54 KiB
Markdown
# pipeline Specification
|
||
|
||
## Purpose
|
||
|
||
Конвейер расшифровки: как аудиозапись движется по рубежам, что делает воркер,
|
||
когда работы нет, что считается отказом шага и что бывает с ответом отправителю,
|
||
когда доставить его некуда.
|
||
|
||
Описаны цепочка рубежей и смысл рубежа, остановка признаком и её причины, оба
|
||
сторожа — число отказов и время в рубеже, — откладывание работы отдельно от
|
||
перехода, неделимость захвата и срок его протухания, условие записи результата
|
||
держателем захвата, нарастающая пауза перед повтором, число воркеров настройкой,
|
||
журнал событий записи и недоставка ответа при неподнятом входе.
|
||
|
||
Сознательно не описаны: освобождение ресурсов внешних клиентов и **какие отказы
|
||
считаются приговором записи, а какие поводом к повтору**. Второе — не пробел
|
||
формулировки, а неразобранный вопрос: сегодня отказ приведения останавливает
|
||
запись с первой попытки, и предел отказов на нём не работает никогда. Это не
|
||
значит, что поведения нет: оно живёт в коде, а требования на него не написаны,
|
||
потому что требование без проверки — предположение, а не норма. Первая задача,
|
||
которая трогает любое из перечисленного, дописывает его сюда.
|
||
## 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 ехать с рубежом записи, а не с воркером: воркер не
|
||
привязан к шагу и не знает заранее, что вытянет. Срок MUST записываться числом
|
||
при самом захвате.
|
||
|
||
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному
|
||
значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок
|
||
обработки невоспроизводим, а проверка, опирающаяся на «следующую» запись, зелена
|
||
через раз.
|
||
|
||
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
|
||
захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа
|
||
одного из них теряется без следа.
|
||
|
||
Признак «работы нет» этим требованием не переопределяется — его нормирует
|
||
требование «Пустой прогон воркера — не отказ».
|
||
|
||
#### Scenario: За работой пришли трое разом
|
||
|
||
- **GIVEN** к работе пригодна ровно одна запись
|
||
- **WHEN** три захвата идут одновременно
|
||
- **THEN** запись получает ровно один из них
|
||
- **AND** двое остальных получают признак «работы сейчас нет»
|
||
|
||
#### Scenario: Захваченная запись не выдаётся второй раз
|
||
|
||
- **GIVEN** запись захвачена и срок захвата не истёк
|
||
- **WHEN** приходит следующий захват
|
||
- **THEN** эта запись ему не выдаётся
|
||
|
||
#### Scenario: Захват отдаёт идентификатор и свой признак
|
||
|
||
- **GIVEN** к работе пригодна запись
|
||
- **WHEN** воркер её захватывает
|
||
- **THEN** захват возвращает идентификатор записи и признак этого захвата
|
||
- **AND** колонки записи шаг читает отдельным чтением
|
||
|
||
#### Scenario: Признак перевыданного захвата отличается от прежнего
|
||
|
||
- **GIVEN** запись захвачена, и признак первого захвата известен
|
||
- **WHEN** человек снимает признак остановки, и запись захватывает другой воркер
|
||
- **THEN** признак нового захвата отличается от признака первого
|
||
|
||
### Requirement: Результат пишет только держатель захвата
|
||
|
||
Шаг конвейера SHALL записывать свой результат только тогда, когда захват записи
|
||
всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата** —
|
||
значению, уникальному для каждого захвата, — а не по занятости записи вообще.
|
||
Шаг, чей захват за время работы достался другому, MUST завершиться без записи
|
||
результата и без ответа отправителю.
|
||
|
||
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
|
||
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
|
||
теряет запись, продолжая работать. Снять захват может и человек, вернувший
|
||
остановленную запись в работу. Без условия по уникальному признаку два воркера
|
||
пишут в одну запись по очереди, счётчик отказов сбрасывает тот, кто уже не
|
||
владелец, а отправитель получает два ответа на одну запись.
|
||
|
||
Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит
|
||
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
|
||
стёрла бы всё, что владелец правил в панели за это время: молча, без строки в
|
||
журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы
|
||
уверен, что правка на месте. Владелец записи, заголовок, краткое описание и темы
|
||
конвейер MUST не трогать.
|
||
|
||
#### Scenario: Правка владельца пережила сохранение шага
|
||
|
||
- **GIVEN** шаг держит захваченную запись
|
||
- **AND** владелец за это время изменил в панели поле, которого шаг не касается
|
||
- **WHEN** шаг записывает свой результат
|
||
- **THEN** результат шага записан
|
||
- **AND** правка владельца на месте
|
||
|
||
#### Scenario: Захват ушёл под работающим шагом
|
||
|
||
- **GIVEN** шаг работает над захваченной записью
|
||
- **AND** за это время та же запись досталась другому захвату
|
||
- **WHEN** первый шаг доходит до записи результата
|
||
- **THEN** результат не записывается
|
||
- **AND** отправителю ничего не отправляется
|
||
|
||
#### Scenario: Человек снял остановку под работающим шагом
|
||
|
||
- **GIVEN** шаг работает над захваченной записью
|
||
- **AND** человек за это время снял с неё признак остановки, освободив захват
|
||
- **AND** запись досталась другому воркеру
|
||
- **WHEN** первый шаг доходит до записи результата
|
||
- **THEN** результат не записывается
|
||
|
||
### Requirement: Брошенная задача возвращается в работу
|
||
|
||
Запись, захваченная и брошенная на середине, SHALL доставаться снова по
|
||
истечении срока захвата. Срок MUST считаться от времени захвата, а истёкший
|
||
захват MUST не мешать выдать запись следующему.
|
||
|
||
Срок задаётся рубежом, с которого запись взята, и MUST быть не меньше того
|
||
времени, которое шаг этого рубежа может занять на самом длинном допустимом
|
||
входе. Срок короче делает протухание штатным событием живого шага, а не
|
||
признаком беды. Срок MUST записываться в саму запись при захвате: воркер шага не
|
||
знает и вывести срок из себя не может.
|
||
|
||
Все значения времени, по которым идёт этот отбор, MUST записываться и
|
||
сравниваться в одном виде — том же, в каком хранилище пишет собственные времена
|
||
записи. Сравнение идёт побайтово, и вид, разошедшийся хоть разделителем,
|
||
обращает условие в постоянную истину или постоянную ложь, причём молча.
|
||
|
||
#### 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 чат есть всегда, и пропасть он может только от
|
||
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
|
||
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
|
||
записей о ненастроенном боте.
|
||
|
||
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
|
||
достигнутый рубеж сохранён. Работа к этой минуте сделана, и объявленный отказ
|
||
засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть соврал бы
|
||
про исход дважды. Повтор делу не помогает: ни бот, ни адресат от ожидания не
|
||
появятся. Поэтому запись остаётся на достигнутом рубеже, в повтор не уходит и
|
||
**признака остановки не получает**, а причина недоставки живёт в записи журнала,
|
||
а не в рубеже записи.
|
||
|
||
То же MUST относиться к недоставке сообщения об **остановке**: остановка уже
|
||
сохранена, и недоставка её MUST не отменять.
|
||
|
||
Идентификатор записи в этой строке обязателен: без него владелец видит, что
|
||
ответ не ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя
|
||
в эту запись MUST не попадать — приватность содержимого записи требование не
|
||
ослабляет.
|
||
|
||
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
|
||
уходит и потом. Забрать расшифровку можно там же, где лежат остальные.
|
||
|
||
#### Scenario: Вход отправителя не поднят
|
||
|
||
- **GIVEN** запись принята входом Telegram прошлым запуском сервиса
|
||
- **AND** сервис поднялся без этого входа
|
||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
|
||
- **AND** запись остаётся на достигнутом рубеже, в повтор не уходит и признака
|
||
остановки не получает
|
||
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
|
||
записи и причиной
|
||
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
|
||
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
|
||
|
||
#### Scenario: Адресат у записи не назван
|
||
|
||
- **GIVEN** у записи источником значится Telegram, а чат не назван
|
||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
|
||
- **AND** запись остаётся на достигнутом рубеже
|
||
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
|
||
записи и причиной: неназванный адресат — симптом порчи записи
|
||
|
||
#### Scenario: Не доехало сообщение об остановке
|
||
|
||
- **GIVEN** запись остановлена признаком
|
||
- **AND** вход отправителя не поднят
|
||
- **WHEN** шаг доходит до ответа отправителю
|
||
- **THEN** признак остановки у записи остаётся
|
||
- **AND** в журнале есть запись о недоставке с идентификатором записи и причиной
|
||
|
||
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
|
||
|
||
- **GIVEN** запись принята по HTTP
|
||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||
- **THEN** шаг завершается без отказа и без записи о недоставке
|
||
|
||
### Requirement: Выборка воркера владельцем не сужается
|
||
|
||
Воркер SHALL брать записи всех владельцев подряд и MUST не учитывать владельца
|
||
при выборе очередной записи. Запись без владельца — принятая ботом — MUST
|
||
обрабатываться наравне с прочими.
|
||
|
||
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
|
||
владельцем остановило бы расшифровку записей бота вовсе, а записи остальных
|
||
поставило бы в зависимость от того, кто первым завёл учётную запись.
|
||
|
||
Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой
|
||
результат, владельца не трогает и не затирает.
|
||
|
||
#### Scenario: Записи двух владельцев проходят одним воркером
|
||
|
||
- **GIVEN** заведены записи двух разных владельцев на одном рубеже
|
||
- **WHEN** воркер забирает работу
|
||
- **THEN** ему достаются обе, в порядке заведения
|
||
|
||
#### Scenario: Запись без владельца обрабатывается
|
||
|
||
- **GIVEN** заведена запись, принятая ботом, — без владельца
|
||
- **WHEN** воркер забирает работу
|
||
- **THEN** она достаётся ему наравне с прочими
|
||
|
||
#### Scenario: Шаг конвейера владельца не затирает
|
||
|
||
- **GIVEN** запись с владельцем прошла шаг конвейера
|
||
- **WHEN** шаг сохраняет свой результат
|
||
- **THEN** владелец записи остаётся прежним
|
||
|
||
### Requirement: Рубеж записи называет достигнутое
|
||
|
||
Аудиозапись SHALL нести рубеж — состояние, называющее **достигнутое**, а не
|
||
предстоящее. Цепочка рубежей: `uploaded`, `normalized`, `submitted`,
|
||
`transcribed`, `done`. Какой шаг делать дальше, сервис MUST выбирать по рубежу
|
||
одним общим местом, а не тем, какой воркер пришёл за записью.
|
||
|
||
Прежние состояния называли предстоящую работу (`created`, `converted`,
|
||
`transcribe`), и потому по состоянию нельзя было сказать, что с записью уже
|
||
сделано: продолжить с места остановки было не с чего.
|
||
|
||
Конечный рубеж MUST зваться `done`. Доставка ответа отправителю в конвейер не
|
||
входит, и слово описывает пройденный конвейер, а не полученный человеком текст.
|
||
|
||
Перечень рубежей, из которых запись берётся в работу, MUST выводиться из одного
|
||
объявления рубежа, а не перечисляться отдельно каждым потребителем. Рубеж,
|
||
забытый в отборе захвата, не выдаётся ни одному воркеру никогда, а пустой прогон
|
||
по инварианту проекта не пишется в журнал и не считается в метрику: запись
|
||
встала бы без единого следа. Потребителей у перечня больше двух — выбор шага,
|
||
отбор захвата, срок захвата, предел времени, закрытый перечень значений в схеме,
|
||
— и человеческая сверка между ними не механизируема.
|
||
|
||
Записи на конечном рубеже MUST не браться в работу и MUST не подпадать под
|
||
предел времени в рубеже: `done` не ждёт работы, и стоять в нём запись будет
|
||
вечно по построению.
|
||
|
||
#### Scenario: Рубеж называет сделанное
|
||
|
||
- **GIVEN** запись прошла приведение к рабочему формату
|
||
- **WHEN** смотрят её рубеж
|
||
- **THEN** он называет приведение сделанным, а не предстоящим
|
||
|
||
#### Scenario: Следующий шаг выбирается по рубежу
|
||
|
||
- **GIVEN** запись стоит на рубеже приведения
|
||
- **WHEN** её берёт воркер
|
||
- **THEN** идёт отправка на распознавание, а не повторное приведение
|
||
|
||
#### Scenario: Запись на конечном рубеже не берут и не останавливают
|
||
|
||
- **GIVEN** запись стоит на конечном рубеже дольше любого предела
|
||
- **WHEN** приходит захват
|
||
- **THEN** запись ему не выдаётся
|
||
- **AND** признака остановки у неё не появляется
|
||
|
||
### Requirement: Остановка записи — признак, а не рубеж
|
||
|
||
Сервис SHALL останавливать запись отдельным признаком с причиной и MUST не
|
||
стирать при этом достигнутый рубеж. Признак MUST нести время остановки, причину
|
||
и машинный текст отказа.
|
||
|
||
Снятие признака SHALL возвращать запись в работу **с того рубежа, где она
|
||
стояла**, и MUST сбрасывать число отказов, паузу **и время входа в рубеж**.
|
||
Время входа сбрасывается по той же причине, что и остальные сторожа: запись,
|
||
простоявшая остановленной дольше предела, иначе останавливалась бы снова первым
|
||
же захватом, и перезапуск не работал бы вовсе.
|
||
|
||
Прежние состояния отказа и смерти MUST не заводиться заново: обе причины
|
||
восстанавливаются одинаково — снятием признака, — и различие между ними
|
||
перестаёт быть структурным, оставаясь причиной остановки. Состояние, называющее
|
||
отказ, стирает достигнутый рубеж, и продолжение с места остановки становится
|
||
невозможным.
|
||
|
||
**Способ вывести запись из выборки MUST быть один — этот признак.** Второго
|
||
признака, исключающего запись из работы помимо рубежа и паузы, MUST не
|
||
заводиться: два способа расходятся, и молчаливо теряется тот, который забыли
|
||
проверить. Условие отбора MUST не выводить запись из выборки молча — запись,
|
||
переставшая браться в работу, обязана нести признак остановки с причиной.
|
||
|
||
Остановку MUST ставить тот, кто запись захватил. Перевод принадлежит одному
|
||
месту: условие отбора, молча пропускающее запись мимо выборки, оставило бы её
|
||
без следа.
|
||
|
||
Остановленная запись MUST не выдаваться захвату.
|
||
|
||
#### Scenario: Остановленная запись продолжает с места остановки
|
||
|
||
- **GIVEN** шаг остановил запись на рубеже приведения
|
||
- **WHEN** признак остановки снимают
|
||
- **THEN** следующим идёт отправка на распознавание, а не повторное приведение
|
||
|
||
#### Scenario: Остановленная запись не выдаётся захвату
|
||
|
||
- **GIVEN** у записи стоит признак остановки
|
||
- **WHEN** за её рубежом приходит захват
|
||
- **THEN** запись ему не выдаётся
|
||
|
||
#### Scenario: Снятие признака сбрасывает всех сторожей
|
||
|
||
- **GIVEN** запись остановлена с накопленными отказами и паузой
|
||
- **AND** остановленной она простояла дольше предела времени в рубеже
|
||
- **WHEN** признак остановки снимают
|
||
- **THEN** число отказов, пауза и время входа в рубеж сброшены
|
||
- **AND** ближайший захват выдаёт запись, а не останавливает её снова
|
||
|
||
### Requirement: Всякая остановка сообщает отправителю
|
||
|
||
Остановка записи по любой причине SHALL сообщать отправителю о неудаче ровно
|
||
так же, как сообщает о ней отказ шага, и MUST быть видна владельцу сервиса
|
||
записью в журнале.
|
||
|
||
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
|
||
инвариант допускает два исхода — запись пригодна к повтору либо об отказе
|
||
сказано, — а остановленная запись захвату не выдаётся, значит первый исход
|
||
исключён.
|
||
|
||
Причин остановки больше одной, и обязанность общая для всех: исчерпанные
|
||
отказы, застревание в рубеже, приговор шага. Обязанность, записанная у одной
|
||
причины, у остальных читалась бы как снятая.
|
||
|
||
Ответ уходит **после** того, как признак остановки сохранён, и недоставка этого
|
||
ответа MUST не отменять остановку: её нормирует требование «Недоставленный ответ
|
||
не роняет шаг».
|
||
|
||
#### Scenario: Остановка по отказам сообщает отправителю
|
||
|
||
- **GIVEN** запись остановлена по исчерпании отказов
|
||
- **WHEN** шаг доходит до ответа отправителю
|
||
- **THEN** отправитель получает сообщение о неудаче
|
||
|
||
#### Scenario: Остановка по времени сообщает отправителю
|
||
|
||
- **GIVEN** запись остановлена по пределу времени в рубеже
|
||
- **WHEN** шаг доходит до ответа отправителю
|
||
- **THEN** отправитель получает сообщение о неудаче
|
||
- **AND** в журнале владельца есть запись об остановке с причиной
|
||
|
||
### Requirement: Время в рубеже ограничено
|
||
|
||
У аудиозаписи SHALL быть время входа в рубеж, и оно MUST ставиться только при
|
||
смене рубежа и при возврате записи в работу. Запись, простоявшая в рубеже дольше
|
||
предела, MUST останавливаться признаком с причиной «застряла».
|
||
|
||
Пределов MUST быть два, и граница проходит по тому, **чью работу ждём**: своя
|
||
работа — час, ожидание чужой операции — сутки. Одно общее число пришлось бы
|
||
мерить по самому долгому, и застрявшее приведение стояло бы сутки; число на
|
||
каждый рубеж назвало бы разными вещи, различающиеся только исполнителем. Сколько
|
||
идёт распознавание долгой записи, сервис не мерил, поэтому у чужой работы ошибка
|
||
идёт в сторону долгого: ложная остановка хуже поздней. Оба числа MUST лежать в
|
||
настройках.
|
||
|
||
Сторож этот ловит **зависание**, а не долгую работу, и час у своей работы меньше
|
||
времени, которое многочасовая запись занимает на приведении. Цена решения
|
||
названа прямо: длинная запись, отказавшая один раз и ждущая повтора дольше часа,
|
||
будет остановлена как застрявшая. Цена ограничена тем, что остановка обратима —
|
||
снятие признака возвращает запись на её рубеж, — и тем, что живой шаг проверяется
|
||
по самому процессу. Решение владельца 2026-08-14.
|
||
|
||
Откладывание опроса MUST не двигать время входа в рубеж и MUST не сдвигать этот
|
||
предел. Иначе запись, чью чужую операцию опрашивают раз в несколько секунд,
|
||
никогда не достигнет предела, и застревание останется незамеченным.
|
||
|
||
Остановка по этому пределу MUST ничего не терять: идентификатор чужой операции
|
||
остаётся в строке попытки распознавания, и снятие признака возобновляет опрос
|
||
той же операции, а не заводит вторую.
|
||
|
||
#### Scenario: Сотня откладываний не двигает отсчёт
|
||
|
||
- **GIVEN** запись стоит на рубеже отправки, и чужая операция ещё идёт
|
||
- **WHEN** опрос откладывается сотню раз подряд
|
||
- **THEN** время входа в рубеж не изменилось
|
||
- **AND** отсчёт до предела не обнулился
|
||
|
||
#### Scenario: Предел достигнут
|
||
|
||
- **GIVEN** запись простояла в рубеже дольше своего предела
|
||
- **WHEN** за ней приходит захват
|
||
- **THEN** запись получает признак остановки с причиной «застряла»
|
||
|
||
#### Scenario: Возобновление опроса не заводит вторую операцию
|
||
|
||
- **GIVEN** запись остановлена по пределу на рубеже отправки
|
||
- **WHEN** признак остановки снимают
|
||
- **THEN** опрос идёт по прежнему идентификатору операции
|
||
- **AND** новая операция у провайдера не заводится
|
||
|
||
### Requirement: Откладывание не является переходом
|
||
|
||
Сервис SHALL различать переход на новый рубеж и откладывание работы над
|
||
записью. Откладывание MUST ставить паузу и снимать захват, MUST не трогать ни
|
||
рубеж, ни время входа в него, и MUST не считаться отказом: ожидание чужой
|
||
операции отказом не является, поэтому число отказов оно MUST обнулять.
|
||
|
||
Сегодня шаг опроса зовёт переход с **тем же** состоянием, и мнимость этого
|
||
перехода обнуляет счётчик. Без разделения время входа в рубеж сбрасывалось бы на
|
||
каждом опросе и повторило бы ровно тот промах, ради которого заводится.
|
||
|
||
#### Scenario: Откладывание не двигает рубеж
|
||
|
||
- **GIVEN** шаг опроса увидел, что чужая операция ещё идёт
|
||
- **WHEN** он откладывает работу
|
||
- **THEN** рубеж записи прежний, и время входа в него прежнее
|
||
- **AND** захват с записи снят, а пауза поставлена
|
||
|
||
### Requirement: Шаг с внешней оплатой проверяет сделанное
|
||
|
||
Шаг, чьё повторение оплачивается наружу, SHALL проверять, не сделана ли работа
|
||
уже, и MUST не делать её второй раз. Проверка MUST идти по наблюдаемому признаку
|
||
присутствия результата, а не по сверке содержимого хешем: у составного объекта
|
||
во внешнем хранилище признак целостности не равен отпечатку содержимого.
|
||
|
||
Признак MUST записываться прежде, чем оплачиваемое обращение считается
|
||
состоявшимся: строка попытки распознавания заводится до обращения к провайдеру,
|
||
и повторный шаг начинает с проверки, не заведена ли операция.
|
||
|
||
Полной защиты от обрыва процесса между ответом провайдера и записью признака
|
||
требование не даёт и дать не может: жёсткая остановка контейнера не оставляет
|
||
места ни одной записи. Это остаточный риск, названный в дизайне, а не норма:
|
||
норма, обязывающая к недостижимому, не пишется.
|
||
|
||
#### Scenario: Работа уже сделана
|
||
|
||
- **GIVEN** результат оплачиваемого шага уже на месте, и признак его записан
|
||
- **WHEN** шаг повторяется
|
||
- **THEN** внешнее обращение не повторяется
|
||
|
||
### Requirement: Число воркеров задаётся настройкой
|
||
|
||
Сервис SHALL брать число рабочих потоков конвейера из настроек, а сами потоки
|
||
MUST не быть привязаны к отдельному шагу: каждый берёт любую подходящую запись и
|
||
выбирает шаг по её рубежу. Поведение записи MUST не зависеть от числа потоков.
|
||
|
||
Ноль MUST быть законным значением: сервис поднимается, записи принимаются и не
|
||
двигаются. Это режим, а не поломка.
|
||
|
||
Счётчик работы воркера MUST различать шаги: метка счётчика MUST нести рубеж, с
|
||
которого запись взята, а не имя или номер потока. У одинаковых потоков имя
|
||
перестаёт что-либо значить, а счётчик отказов — единственный сигнал, по которому
|
||
владелец сервиса замечает поломку; без разреза по шагу «падает приведение» и
|
||
«падает распознавание» становятся неразличимы.
|
||
|
||
Опрос чужой операции MUST оставаться работой очереди, а не отдельного
|
||
смотрителя: очередь даёт ему неделимость захвата и возврат брошенного даром, а
|
||
единственный смотритель умирает молча и уносит с собой целый класс записей.
|
||
|
||
#### Scenario: Запись доходит при одном потоке и при нескольких
|
||
|
||
- **GIVEN** число потоков конвейера равно одному
|
||
- **WHEN** запись проходит конвейер
|
||
- **THEN** она доходит до конечного рубежа
|
||
- **AND** при числе потоков больше одного исход тот же
|
||
|
||
#### Scenario: Потоков нет вовсе
|
||
|
||
- **GIVEN** число потоков конвейера равно нулю
|
||
- **WHEN** запись принимают
|
||
- **THEN** сервис принимает её и не теряет
|
||
- **AND** запись остаётся на первом рубеже
|
||
|
||
#### Scenario: Отказ виден с разрезом по шагу
|
||
|
||
- **GIVEN** шаг приведения отказал
|
||
- **WHEN** наблюдатель читает счётчик работы воркера
|
||
- **THEN** отказ засчитан с меткой рубежа приведения
|
||
|
||
### Requirement: Журнал событий записи пишется на смену рубежа
|
||
|
||
Сервис SHALL вести журнал событий аудиозаписи и MUST писать в него строку на
|
||
смену рубежа, на остановку и на снятие остановки. Строка MUST называть источник
|
||
события — шаг конвейера или человека, — сам шаг, исход и длительность.
|
||
|
||
Журнал MUST не писаться на каждое откладывание опроса: часовая запись дала бы
|
||
сотни строк ни о чём.
|
||
|
||
Ни один шаг конвейера MUST не читать этот журнал, чтобы решить, что делать
|
||
дальше: решение принимается по рубежу записи, и второй источник решения
|
||
разошёлся бы с первым молча.
|
||
|
||
Содержимое записи в журнал событий MUST не попадать — инвариант приватности
|
||
действует здесь наравне с журналом сервиса.
|
||
|
||
#### Scenario: Смена рубежа записана
|
||
|
||
- **GIVEN** шаг конвейера довёл запись до нового рубежа
|
||
- **WHEN** смотрят журнал событий этой записи
|
||
- **THEN** в нём есть строка с шагом, исходом и длительностью
|
||
|
||
#### Scenario: Откладывание строки не пишет
|
||
|
||
- **GIVEN** опрос чужой операции откладывается многократно
|
||
- **WHEN** смотрят журнал событий записи
|
||
- **THEN** строк об откладываниях в нём нет
|
||
|
||
#### Scenario: Перезапуск человеком виден в журнале
|
||
|
||
- **GIVEN** запись остановлена признаком
|
||
- **WHEN** человек снимает признак
|
||
- **THEN** в журнале событий есть строка с указанием, что это сделал человек
|
||
|
||
### Requirement: Число отказов ограничивает повторы шага
|
||
|
||
У аудиозаписи SHALL быть число отказов. Оно MUST расти при каждом захвате и MUST
|
||
возвращаться к нулю, когда шаг завершился без отказа либо отложил работу. Рост
|
||
при захвате, а не при отказе, засчитывает попытку и записи, брошенной на
|
||
середине: шаг, уносящий с собой процесс, до объявления отказа не доходит
|
||
никогда.
|
||
|
||
**Остановка сервиса отказом не считается.** Шаг, прерванный отменой по
|
||
собственной остановке сервиса, MUST возвращать число отказов назад и MUST не
|
||
выносить записи приговора: запись не виновата в том, что нас перезапустили, и
|
||
несколько выкладок подряд иначе останавливают здоровую многочасовую запись с
|
||
приговором «отказы исчерпаны». Всякая другая причина, по которой шаг не дошёл до
|
||
объявления исхода, отказ тратит.
|
||
|
||
Запись, захваченная с числом отказов сверх заданного предела, MUST
|
||
останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в
|
||
работу. Об этой остановке отправителю сообщается наравне с прочими — норму
|
||
держит требование «Всякая остановка сообщает отправителю».
|
||
|
||
Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое
|
||
записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни
|
||
с одной из двух обязанностей — опрос, вернувший «ещё в работе», обнуляет его, и
|
||
зависшая чужая операция опрашивается вечно, а не обнулял бы — убивал бы здоровую
|
||
запись.
|
||
|
||
#### Scenario: Запись отказывает на каждой попытке
|
||
|
||
- **GIVEN** шаг конвейера отказывает на каждой попытке
|
||
- **WHEN** запись проходит заданное число отказов
|
||
- **THEN** у неё появляется признак остановки
|
||
- **AND** следующий захват её не выдаёт
|
||
- **AND** отправитель получает сообщение о неудаче
|
||
|
||
#### Scenario: Шаг уносит процесс, не объявив отказа
|
||
|
||
- **GIVEN** шаг конвейера обрывается вместе с процессом на каждой попытке
|
||
- **WHEN** запись захватывается снова заданное число раз
|
||
- **THEN** у неё появляется признак остановки
|
||
|
||
#### Scenario: Остановка сервиса отказа не тратит
|
||
|
||
- **GIVEN** шаг работает над записью
|
||
- **WHEN** сервис останавливают, и шаг прерывается отменой
|
||
- **THEN** число отказов записи прежнее
|
||
- **AND** признака остановки у записи не появляется
|
||
|
||
#### Scenario: Прошедшая запись отказов не копит
|
||
|
||
- **GIVEN** запись прошла подряд несколько рубежей без единого отказа
|
||
- **WHEN** смотрят её число отказов
|
||
- **THEN** оно не приблизилось к пределу
|
||
|