Files
transcriber/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/pipeline/spec.md
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

20 KiB

MODIFIED Requirements

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 нести время остановки, причину и машинный текст отказа.

Снятие признака 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 не обращаться к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход своей записи владелец узнаёт карточкой записи; адрес карточки и содержимое ответа нормирует capability archive.

Владелец сервиса узнаёт исход журналом и журналом событий записи. Панели, где он видел бы то же строкой таблицы, у сервиса нет — она ушла вместе со встроенным хранилищем, и второго канала наблюдения это не отняло: журнал событий пишется по-прежнему, а читается запросом к базе, пока экрана нет.

Держатель нормы сменился вместе с убранным опросом готовности: прежде исход отдавал адрес опроса, нормированный capability intake, и адреса этого больше нет. Обязанность при этом не изменилась — изменилось только то, каким адресом она исполняется.

Требование заведено взамен доставки в чат, убранной вместе с входом Telegram. Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за потерянную ветку.

Инвариант проекта «Принятая запись не теряется молча» держится теперь карточкой записи — там остановка видна признаком и причиной — и журналом владельца, где у неё стоит причина. Обязанность при этом сменила направление: прежде об отказе сообщали, теперь отказ доступен спросившему. Отправитель, который не спрашивает, об остановке не узнаёт.

Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец, и карточка отдаёт ему её исход. Держится это обязательностью владельца в схеме — норму держит capability storage.

Scenario: Готовый текст отправителю не уходит

  • GIVEN запись дошла до конечного рубежа
  • WHEN шаг конвейера её завершает
  • THEN ни одного обращения наружу с текстом расшифровки не уходит
  • AND текст достаётся отдельным адресом текста записи

Scenario: Остановка видна карточкой, а не сообщением

  • GIVEN запись остановлена по исчерпании отказов
  • WHEN владелец записи спрашивает её карточку
  • THEN ответ несёт достигнутый рубеж, признак остановки и её причину
  • AND в журнале владельца сервиса есть запись об остановке с причиной