Files
transcriber/openspec/specs/pipeline/spec.md
T
av 01cc31d45f хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась
  панель владельца, а gin, goqu, goose и требование CGO ушли
- захват задачи стал одним запросом с RETURNING; заведены число попыток,
  состояние dead и нарастающая пауза вместо признака is_error
- имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с
  идентификатором записи оно собирало бы ссылку на скачивание
2026-08-12 08:31:59 +03:00

20 KiB

pipeline Specification

Purpose

Конвейер расшифровки: как задача движется по состояниям, что делает воркер, когда работы нет, и что считается отказом шага.

Описан пока только пустой прогон воркера — тот, что нормируют проверки пакета internal/controller/worker и перевод признака в internal/service. Сознательно не описаны переходы состояний и цепочка 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 число попыток задачи не растёт