- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
698 lines
53 KiB
Markdown
698 lines
53 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** результат не записывается
|
||
|
||
#### 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 не учитывать владельца
|
||
при выборе очередной записи.
|
||
|
||
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
|
||
владельцем поставило бы записи одних людей в зависимость от того, кто первым
|
||
завёл учётную запись.
|
||
|
||
Оговорка про записи без владельца из требования ушла: заводить их стало нечем —
|
||
колонка владельца пустого значения не принимает, и норму держит capability
|
||
`storage`.
|
||
|
||
Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой
|
||
результат, владельца не трогает и не затирает.
|
||
|
||
#### 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 не выводить запись из выборки молча — запись,
|
||
переставшая браться в работу, обязана нести признак остановки с причиной.
|
||
|
||
Остановку MUST ставить тот, кто запись захватил. Перевод принадлежит одному
|
||
месту: условие отбора, молча пропускающее запись мимо выборки, оставило бы её
|
||
без следа.
|
||
|
||
Остановленная запись MUST не выдаваться захвату.
|
||
|
||
#### Scenario: Остановленная запись продолжает с места остановки
|
||
|
||
- **GIVEN** шаг остановил запись на рубеже приведения
|
||
- **WHEN** признак остановки снимают
|
||
- **THEN** следующим идёт отправка на распознавание, а не повторное приведение
|
||
|
||
#### Scenario: Остановленная запись не выдаётся захвату
|
||
|
||
- **GIVEN** у записи стоит признак остановки
|
||
- **WHEN** за её рубежом приходит захват
|
||
- **THEN** запись ему не выдаётся
|
||
|
||
#### Scenario: Снятие признака сбрасывает всех сторожей
|
||
|
||
- **GIVEN** запись остановлена с накопленными отказами и паузой
|
||
- **AND** остановленной она простояла дольше предела времени в рубеже
|
||
- **WHEN** признак остановки снимают
|
||
- **THEN** число отказов, пауза и время входа в рубеж сброшены
|
||
- **AND** ближайший захват выдаёт запись, а не останавливает её снова
|
||
|
||
#### Scenario: Возврат в работу освобождает захват
|
||
|
||
- **GIVEN** запись остановлена, и признак прошлого захвата на ней стоит
|
||
- **WHEN** признак остановки снимают
|
||
- **THEN** признака захвата и срока его протухания на записи нет
|
||
- **AND** ближайший захват выдаёт запись, не дожидаясь протухания прежнего срока
|
||
|
||
#### Scenario: Возврат в работу виден в журнале записи
|
||
|
||
- **GIVEN** запись остановлена
|
||
- **WHEN** её возвращают в работу
|
||
- **THEN** в журнале событий записи есть событие с происхождением «человек»
|
||
|
||
### 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 не отдаваться шагу в
|
||
работу. Остановка эта видна владельцу записи **карточкой записи** наравне с
|
||
прочими — норму держит capability `archive`.
|
||
|
||
Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое
|
||
записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни
|
||
с одной из двух обязанностей — опрос, вернувший «ещё в работе», обнуляет его, и
|
||
зависшая чужая операция опрашивается вечно, а не обнулял бы — убивал бы здоровую
|
||
запись.
|
||
|
||
#### Scenario: Запись отказывает на каждой попытке
|
||
|
||
- **GIVEN** шаг конвейера отказывает на каждой попытке
|
||
- **WHEN** запись проходит заданное число отказов
|
||
- **THEN** у неё появляется признак остановки
|
||
- **AND** следующий захват её не выдаёт
|
||
- **AND** карточка записи отдаёт владельцу признак остановки
|
||
|
||
#### Scenario: Шаг уносит процесс, не объявив отказа
|
||
|
||
- **GIVEN** шаг конвейера обрывается вместе с процессом на каждой попытке
|
||
- **WHEN** запись захватывается снова заданное число раз
|
||
- **THEN** у неё появляется признак остановки
|
||
|
||
#### Scenario: Остановка сервиса отказа не тратит
|
||
|
||
- **GIVEN** шаг работает над записью
|
||
- **WHEN** сервис останавливают, и шаг прерывается отменой
|
||
- **THEN** число отказов записи прежнее
|
||
- **AND** признака остановки у записи не появляется
|
||
|
||
#### Scenario: Прошедшая запись отказов не копит
|
||
|
||
- **GIVEN** запись прошла подряд несколько рубежей без единого отказа
|
||
- **WHEN** смотрят её число отказов
|
||
- **THEN** оно не приблизилось к пределу
|
||
|
||
### Requirement: Конвейер ответа отправителю не шлёт
|
||
|
||
Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться
|
||
к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход
|
||
своей записи владелец узнаёт **карточкой записи**; адрес карточки и содержимое
|
||
ответа нормирует capability `archive`.
|
||
|
||
Владелец сервиса узнаёт исход журналом и журналом событий записи. Панели, где он
|
||
видел бы то же строкой таблицы, у сервиса нет — она ушла вместе со встроенным
|
||
хранилищем, и второго канала наблюдения это не отняло: журнал событий пишется
|
||
по-прежнему, а читается запросом к базе, пока экрана нет.
|
||
|
||
Держатель нормы сменился вместе с убранным опросом готовности: прежде исход
|
||
отдавал адрес опроса, нормированный capability `intake`, и адреса этого больше
|
||
нет. Обязанность при этом не изменилась — изменилось только то, каким адресом
|
||
она исполняется.
|
||
|
||
Требование заведено взамен доставки в чат, убранной вместе с входом Telegram.
|
||
Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и
|
||
всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за
|
||
потерянную ветку.
|
||
|
||
Инвариант проекта «Принятая запись не теряется молча» держится теперь карточкой
|
||
записи — там остановка видна признаком и причиной — и журналом владельца, где у
|
||
неё стоит причина. Обязанность при этом сменила направление: прежде об отказе
|
||
сообщали, теперь отказ доступен спросившему. Отправитель, который не
|
||
спрашивает, об остановке не узнаёт.
|
||
|
||
Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец,
|
||
и карточка отдаёт ему её исход. Держится это обязательностью владельца в схеме —
|
||
норму держит capability `storage`.
|
||
|
||
#### Scenario: Готовый текст отправителю не уходит
|
||
|
||
- **GIVEN** запись дошла до конечного рубежа
|
||
- **WHEN** шаг конвейера её завершает
|
||
- **THEN** ни одного обращения наружу с текстом расшифровки не уходит
|
||
- **AND** текст достаётся отдельным адресом текста записи
|
||
|
||
#### Scenario: Остановка видна карточкой, а не сообщением
|
||
|
||
- **GIVEN** запись остановлена по исчерпании отказов
|
||
- **WHEN** владелец записи спрашивает её карточку
|
||
- **THEN** ответ несёт достигнутый рубеж, признак остановки и её причину
|
||
- **AND** в журнале владельца сервиса есть запись об остановке с причиной
|