хранилище, файлы записей и очередь переведены на встроенную PocketBase

- записи, метаданные и файлы съехались под один каталог данных; появилась
  панель владельца, а gin, goqu, goose и требование CGO ушли
- захват задачи стал одним запросом с RETURNING; заведены число попыток,
  состояние dead и нарастающая пауза вместо признака is_error
- имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с
  идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
av
2026-08-12 08:31:59 +03:00
parent 09cedc4e61
commit 01cc31d45f
55 changed files with 5238 additions and 1235 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11
@@ -0,0 +1,454 @@
## Context
Способ решения выбран до этой задачи, и переписывать его здесь незачем:
хранилищем становится PocketBase вместе с файлами и панелью
([ADR-2026-08-11-pocketbase-storage-with-admin-panel](../../../docs/adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md),
замер — [research/pocketbase.md](../../../docs/research/pocketbase.md)), очередь
остаётся своей таблицей, но коллекцией той же базы
([ADR-2026-08-11-queue-as-pocketbase-collection](../../../docs/adr/ADR-2026-08-11-queue-as-pocketbase-collection.md),
сравнение кандидатов — [research/job-queue.md](../../../docs/research/job-queue.md)).
Ниже — только то, что этими решениями не закрыто.
Сегодня состояние лежит в SQLite через `mattn/go-sqlite3`, запросы строит goqu,
схему двигает goose, файлы лежат плоским каталогом с именами по идентификатору,
а HTTP отдаёт gin. Версия PocketBase — та же, что мерила разведка: 0.39.10.
## Goals / Non-Goals
**Goals:**
- Запись, её метаданные и её файл лежат в одном хранилище и видны владельцу
панелью.
- Захват задачи неделим, у задачи есть предел попыток и состояние «мертва».
- Сборка перестаёт требовать CGO.
- Сервис поднимается на чистом каталоге сам.
**Non-Goals:**
- **Перенос прежних данных.** Не переносим и не пытаемся прочитать: решение
задачи, а не следствие отказа.
- **Вход пользователей.** Провайдер OIDC у коллекции пользователей — задача
`oidc-login`.
- **Закрытие панели снаружи.** Это работа выкладки: адрес панели закрывает
Authelia на обратном прокси.
- **Отказ от холостого опроса.** Три воркера по-прежнему опрашивают базу раз в
секунду; открытый вопрос архитектуры остаётся открытым.
- **Удаление файлов и объектов.** Хранение бессрочно, задача `delete-record`
своя.
- **Своё резервное копирование.** Берём мы встроенное или нет — вопрос не
решён и здесь не решается.
## Decisions
### Приложение поднимает PocketBase само, а не отдаёт ему командную строку
Библиотека умеет запускаться двумя способами: `Start()` отдаёт процесс её
собственному набору команд, а `Bootstrap()` плюс `apis.Serve()` оставляет
управление нам.
Берём второй. Первый забирает разбор флагов себе, и ключ `-c config.toml`,
объявленный в командах проекта, пришлось бы либо ломать, либо подпирать. Мягкая
остановка по сигналу с двумя таймаутами из конфигурации — тоже наша, и отдавать
её чужой команде не за что.
Цена названа: набора команд PocketBase у нас не появляется, а вместе с ним нет и
команды заведения владельца панели. Чем это закрыто — ниже.
*Отвергнуто:* `Start()` с подстройкой флагов — экономит десяток строк и ломает
объявленный контракт запуска.
### Наши два обработчика переезжают на роутер PocketBase, gin уходит
Панель отдаётся тем же портом, что и приложение, — так решено ADR. Значит порт
слушает сервер PocketBase, и второму серверу на том же порту взяться неоткуда.
Наши маршруты — `POST /api/audio`, `GET /api/status/:id`, `GET /health`,
`GET /metrics` — переезжают в его роутер обработчиком на `OnServe`. Столкновения
имён нет: PocketBase занимает `/api/collections`, `/api/files`, `/api/settings`,
`/api/logs`, `/api/backups`, `/api/crons`, `/api/realtime`, `/api/batch` и
`/api/health`, а `/api/audio` и `/api/status/:id` свободны. Публичный контракт
API от этого не меняется — меняется то, кто его обслуживает.
Вместе с gin уходит `samber/slog-gin`, и запросы начинает писать журнал
PocketBase. Проверки приёма по HTTP переписываются под новый обработчик; их
предмет — коды, поля ответа и запрет имени отправителя в журнале — сохраняется
дословно.
*Отвергнуто:* **два сервера на разных портах** — наружу опубликован один порт, и
панель осталась бы недоступной либо потребовала бы второй маршрут на прокси;
ADR решил иначе. **gin общим обработчиком под роутером PocketBase** — маршруты
разбирались бы дважды, а совпадение с чужим путём проявилось бы как молчаливый
перехват.
### Схему заводят миграции PocketBase, каталог `migrations/` уходит
Коллекции `files` и `transcribe_jobs` заводит зарегистрированная миграция на Go;
`apis.Serve` применяет непринятые перед стартом сервера. Инвариант проекта
«миграция, уехавшая на сервер, не переписывается» переносится дословно: файл
шага не правится, изменение — только новым файлом.
Goose, goqu и `mattn/go-sqlite3` уходят из зависимостей вместе с каталогом
`migrations/*.sql` и вшиванием его в бинарник.
### Состав коллекций
`files` — по записи на физическую копию, как и сегодня:
| Поле | Что |
| --- | --- |
| `file` | сам файл; пусто у копии в Object Storage |
| `location` | `local` или `s3` |
| `object_key` | ключ объекта; пусто у местной копии |
| `size` | размер в байтах |
Поле названо `location`, а не `storage`, как сегодня, потому что после перевода
слово `storage` занято дважды: так зовётся capability и так зовут само хранилище.
Третий смысл в поле записи развёл бы `storage.FileRepository` и `entity.StorageS3`
по разным вещам под одним словом, и увидеть это в коде было бы нечем.
`transcribe_jobs` — задача и она же очередь. Поля сегодняшней таблицы переезжают
один в один, кроме трёх мест:
- `is_error` **уходит**. Задача выбывает из выборки состоянием, и способ этот
один: два способа разошлись бы, и молчаливо потерялся бы тот, который забыли
проверить;
- `state` получает значение `dead`;
- прибавляется `attempts` — число попыток.
Идентификаторы записей выдаёт PocketBase — 15 знаков собственного алфавита. Наши
UUID уходят: два источника идентификатора в одной таблице дают два формата
ссылки на одну сущность. Ответ `POST /api/audio` при этом продолжает нести
`job_id` строкой — контракт говорит о поле, а не о длине значения.
**Инвариант «новая колонка правится в четырёх местах» остаётся, но переезжает.**
Мест по-прежнему четыре — отображение задачи в запись и обратно, перечень колонок
захвата и структура, в которую он читает, — и все четыре лежат в одном пакете, а
не в четырёх запросах разных слоёв. Условие, ради которого инвариант писался, при
этом не снято: компилятор видит два места из четырёх, и колонка, забытая в паре
«перечень — структура», приезжает из захвата нулевой, а первый же `Save` пишет
этот ноль поверх сохранённого. Теряется поле **только у задачи, попавшей к
воркеру**, — то есть тише, чем прежде.
### Захват — один запрос с `RETURNING` мимо записей коллекции
```
UPDATE transcribe_jobs
SET acquisition_id = ?, acquire_time = ?, attempts = attempts + 1, updated = ?
WHERE id = (SELECT id FROM transcribe_jobs
WHERE state = ? AND (delay_time IS NULL OR delay_time < ?)
AND (acquisition_id IS NULL OR acquire_time < ?)
ORDER BY created LIMIT 1)
RETURNING <колонки>
```
`RETURNING` в движке за `modernc.org/sqlite` есть, и замер разведки показал: на
трёх горутинах разом запись получает ровно одна. Запрос идёт через `app.DB()`,
который всё, кроме выборок, направляет в пул с единственным соединением, — то
есть захваты выстраиваются в очередь, а не соревнуются за файл.
`ORDER BY` идёт по времени заведения **и по ключу записи**: время неуникально, и
без ключа порядок обработки невоспроизводим, а проверка, опирающаяся на
«следующую» задачу, зелена через раз.
**Время во всех колонках очереди — то же, каким хранилище пишет свои
`created`/`updated`:** строка `2006-01-02 15:04:05.000Z` в UTC (`types.DateTime`
библиотеки). Наш запрос кладёт и сравнивает `acquire_time`, `delay_time` и
`updated` только через это же значение. Причина не в аккуратности: сравнение
строк в SQLite побайтовое, и вид, разошедшийся на разделителе или на дробной
части, обращает `acquire_time < ?` в постоянную истину — тогда любая захваченная
задача немедленно достаётся второму воркеру — или в постоянную ложь — тогда
брошенная задача не возвращается никогда. Оба исхода тихие, и тест, который сам
же кладёт время своим кодом, зелен в обоих.
Хуки коллекции на сыром запросе не срабатывают — цена названа в ADR; поле
времени изменения проставляет тот же запрос.
*Отвергнуто:* **захват записями коллекции** — это снова два шага без транзакции,
ровно то, от чего уходим. **Захват в транзакции PocketBase** — даёт то же
свойство дороже: транзакция на каждый холостой опрос, которых 259 200 в сутки.
### Результат пишет только держатель захвата
Неделимость захвата не закрывает всего: захват протухает не только у мёртвого
воркера, но и у живого. Конвертация шестичасовой записи идёт дольше часа по
построению, а срок захвата на конвертацию сегодня — час.
Отсюда две правки. Первая: **сроки захвата привязываются к потолку своего шага**,
и в таблице настроек стоят рядом с ним. Вторая, и она важнее: **запись результата
условна по признаку захвата** — шаг, чей захват за время работы достался другому,
завершается без записи и без ответа отправителю.
Без второго два воркера пишут в одну задачу по очереди: результат первого
затирает результат второго, файл второго остаётся сиротой, распознавание уходит в
Yandex дважды за наши деньги, а отправитель получает два ответа на одну запись.
### Мёртвая задача, попытки и пауза
Три вещи, которые легко свести в одну и нельзя: **счётчик попыток**, **пауза
повтора** и **задержка опроса чужой операции**.
**Счётчик** растёт при каждом захвате и обнуляется, когда шаг завершился без
отказа. Рост при захвате, а не при отказе, — единственное, что засчитывает
попытку задаче, уносящей с собой процесс: до объявления отказа такая задача не
доходит никогда, и по счётчику отказов крутилась бы вечно. Обнуление при успехе
делает то же самое с другой стороны: задача, прошедшая конвейер, попыток не
копит и до предела не добирается.
**Кто переводит в «мертва».** Тот, кто захватил задачу с превышенным счётчиком:
захват её выдаёт, вызывающий видит перебор, ставит `dead`, сообщает отправителю и
возвращает «работы нет». Условие `attempts < предел` прямо в отборе не годится —
задача исчезла бы из выборки, не получив состояния, то есть выбыла бы молча.
**Отправителю сообщается.** Переход в «мертва» идёт тем же путём, что отказ шага:
инвариант «Принятая запись не теряется молча» допускает два исхода — либо задача
пригодна к повтору, либо о неудаче сказано, — и молчаливая смерть не подходит ни
под один.
**`dead` и `failed` — разные приговоры, а не два имени одного.** В `failed`
задачу переводит шаг, рассудивший об этой записи окончательно: файл не
конвертируется, распознавание вернуло ошибку. В `dead` задача уходит без такого
суждения: мы повторяли и перестали. Ни один шаг конвейера в `dead` не переводит
сам, и выбирать между двумя ему не приходится.
**Пауза повтора** — функция счётчика, ставится в момент отказа. **Задержка опроса
чужой операции** — число, ставится шагом проверки, и попытку он не тратит, потому
что отработал без отказа. Свести их было бы ошибкой ровно потому, что счётчик на
ожидании обнулён: пауза выродилась бы в своё наименьшее значение, и опрос
SpeechKit участился бы с пяти секунд до одной — вчетверо больше обращений к
платному сервису, а отказ по его лимиту тратит попытки уже по-настоящему.
Числа — в таблицу настроек `docs/database.md`:
| Настройка | Значение | Откуда |
| --- | --- | --- |
| Предел попыток | 5 | — |
| Пауза перед повтором | `2^(попытка−1)` секунд, потолок 5 минут | — |
| Срок захвата, конвертация | 8 часов | потолок записи 6 часов плюс запас |
| Срок захвата, проверка операции | 1 час | опрос идёт секунды |
| Задержка перед первой проверкой операции | 10 секунд | как сегодня |
| Задержка между проверками операции | 5 секунд | как сегодня |
Из вариантов эти числа не выбирались, и это честнее назвать, чем оправдать:
предел 5 и удвоение паузы — обычное умолчание, а не вывод из замера. Позволительно
потому, что числа обратимы — они живут в одной таблице и правятся строкой, в
отличие от имени ключа конфигурации и раскладки файлов.
### Имя файла в хранилище задаём мы, а не хранилище
Умолчание PocketBase строит имя из имени, данного отправителем: `sample.ogg`
превращается в `sample_uztrv6wvz3.ogg` — так это замерила разведка, и так это
предсказали ADR и модель угроз.
**Умолчание не берём.** Спека `intake` уже нормирует обратное: имя отправителя в
хранилище не попадает, потому что имя файла кончается в журнале, а имя
отправителя в журнал не пишется по инварианту приватности. Имя файла у библиотеки
— обычное поле, и мы ставим в него своё: идентификатор с расширением, как
сегодня.
**Суффикса при этом не появляется**, и это выяснилось прогоном: десять случайных
знаков дописывает не укладка, а тот самый конструктор имени, который мы обходим.
Значит имя в хранилище равно заданному, и защищает ссылку не суффикс, а то, что
имени в журнале нет вовсе.
Изъятие из инварианта — расширение, хвост после последней точки — остаётся ровно
таким, каким объявлено, и не расширяется до полного имени.
### Файл кладётся потоком, а рабочая копия заводится одним способом
Расчётный потолок записи — шесть часов, и в память такая запись не помещается.
Библиотека умеет строить файл из пути на диске и читает его потоком. Значит приём
пишет тело во временный файл, отдаёт его хранилищу и убирает за собой; каталог
временных файлов — общесистемный, не `data/`.
Обратная сторона — та же и упускается легче. **Конвертер и чтение метаданных
принимают путь**, потому что отдают файл внешней программе: `ffmpeg` и `ffprobe`
получают имя аргументом. Хранилище пути наружу не даёт, значит между ними нужна
рабочая копия — и вот её-то и надо завести **одним местом**, а не по месту в
каждом шаге.
Место это — сам репозиторий файлов: он выдаёт рабочую копию и единственный
способ её убрать, а зовёт уборку шаг. Полностью замкнуть уборку на репозиторий —
вызовом шага изнутри — мешает конвертация: ей нужны две копии разом, исходник и
результат, и вложенные вызовы читались бы хуже, чем два `defer` подряд. Цена
названа: норма держится проверкой, а не построением, и проверки на уборку есть у
приёма и у шага конвертации.
*Отвергнуто:* **построение файла из байтов в памяти** — проще на строку и роняет
процесс на первой же длинной записи. **Путь внутрь раскладки хранилища, отданный
`ffmpeg` напрямую** — раскладка библиотеки становится нашим контрактом, а
требование «файл адресуется записью» не выполняется с первого дня и молча.
**Перевод конвертера и `ffprobe` на потоки** — дороже всего и упирается в то, что
длительность из потока `ffprobe` отдаёт не всегда.
### Панель — вход в задачу, а не окно просмотра
Ради правки задачи панель и покупалась: мёртвая задача оживляется сменой
состояния, а не запросом в консоли сервера. Но правка полем в панели идёт мимо
кода, который сегодня чистит служебные поля прошлого состояния, — и владелец,
«вернувший задачу в работу», получил бы задачу с прежним признаком захвата
(захвату она не выдастся до конца срока) и с числом попыток на пределе (умрёт от
первого отказа). Он бы об этом не узнал.
Поэтому переход, сделанный в панели, проходит те же правила, что переход из кода:
на правку записи задачи вешается хук, который при смене состояния чистит признак
захвата, время захвата, паузу и число попыток. Единая точка перехода остаётся
одна, и панель ходит через неё.
Схема при этом держит то, что сегодня держит компилятор: ссылка на файл
обязательна, перечень состояний закрыт, число попыток неотрицательно. Задача,
заведённая в панели руками, не должна ронять процесс на разыменовании пустой
ссылки — а сегодня уронила бы, и вместе с воркером ушли бы бот и приём по HTTP.
*Отвергнуто:* **панель только для чтения по этой коллекции** — отнимает ровно то,
ради чего перевод затевался. **Оставить как есть** — перекладывает на владельца
знание о четырёх служебных полях, и первая же ошибка тихо ломает задачу.
### Потолок размера назван числом, потому что чужие умолчания малы
Прогон показал то, что чтением не видно: нулевой потолок у поля файла библиотека
читает не как «без предела», а как своё умолчание в **5 МиБ**, а роутер
хранилища отсекает тело запроса на **32 МиБ** раньше нашего обработчика. Оба
умолчания на два-три порядка меньше расчётной записи в шесть часов: приём
отказывал бы на всём длиннее примерно пяти минут, а уже принятая запись
исчерпывала бы попытки на шаге конвертации — результат в ogg переваливает 5 МиБ
примерно на пятой минуте.
Поэтому потолок задан числом и одним: `entity.MaxRecordSize`, 8 ГиБ, выведено из
шести часов с запасом на видео. Тем же числом ограничено тело запроса приёма.
Заодно снят таймаут чтения — умолчание в пять минут не переживает заливку
шестичасовой записи по медленному каналу, а стойкость к целенаправленной
нагрузке объявлена вне модели угроз.
### Правила панели стоят на правке запросом, а не на всяком сохранении
Первая редакция вешала их модельным событием, и это оказалось дефектом: событие
не различает, кто пишет, и срабатывало на каждом переходе конвейера. Задержка,
поставленная шагом вместе со сменой состояния, стиралась тем же сохранением —
опрос платного распознавания уходил через секунду вместо десяти, — а число
попыток мёртвой задачи, которое переход хранит намеренно, приходило владельцу
нулём.
Событие правки **запросом** различает источник по построению: конвейер пишет
мимо HTTP-слоя и под него не попадает.
### Поле файла не помечаем защищённым, но ссылка не уезжает в журнал
Защищённое поле требует отдельного файлового токена. Не помечаем: сегодня право
прочитать задачу даёт знание её идентификатора, и файл встаёт вровень с
`GET /api/status/:id`, а не ниже. Правила доступа коллекций при этом остаются
пустыми — то есть перечислить записи может только владелец панели, и подобрать
идентификатор снаружи неоткуда.
**Отсюда следствие, которого не было при плоском каталоге, и оно меняет смысл
изъятия из инварианта приватности.** Изъятие выписано под путь на диске:
`data/files/<uuid>.ogg` читателю журнала бесполезен. После перевода имя файла в
хранилище — это последняя часть ссылки `/api/files/...`, по которой запись
скачивает кто угодно; строка журнала стала бы бессрочным ключом к чужому аудио.
Поэтому **в журнал идёт расширение собственным полем**, а имя файла — ни в каком
виде. Прослеживаемость от этого не страдает: требование `intake` просит
идентификатор, расширение и размер, и все три остаются. Изъятие остаётся ровно
таким, каким объявлено: расширение, и только оно.
**Отказы обрываются там же.** Отказ чтения из хранилища несёт ключ файла целиком,
отказ выгрузки в Object Storage — полный адрес объекта; обе цепочки `%w` уехали
бы в журнал и собрали бы ссылку не хуже успешного пути. Поэтому наружу идёт свой
текст с идентификатором записи, а чужой не оборачивается.
Цена названа: ссылка на файл, единожды утёкшая, работает без ограничения по
времени. Разграничение доступа целиком — задачи `oidc-login` и
`record-ownership`, и до них периметр таков, каким его описывает модель угроз.
### Ссылку на файл строит панель, а не наш контракт
Потребителя у ссылки внутри сервиса нет: ответ опроса готовности её не несёт,
распознавание берёт содержимое, а панель строит ссылку сама. Поэтому метода
«построй ссылку» в договоре ядра с хранилищем **не заводим** — иначе форма
HTTP-пути протекла бы в доменный контракт, а знать о протоколе хранилищу незачем.
Требование «файл отдаётся ссылкой» при этом остаётся: оно нормирует свойство
хранилища, а проверяется прогоном — запросом за файлом и сверкой длины. Первым
потребителем ссылки станет приложение, и заведёт её себе оно.
### Владелец панели заводится ссылкой при первом запуске
Команды заведения владельца у нас нет — её забрал отказ от чужой командной
строки. Библиотека закрывает это сама: пока владелец не заведён, при старте
сервера она печатает ссылку установки, по которой владелец задаёт себе почту и
пароль. Ссылка идёт в журнал контейнера, а журнал читает владелец сервиса.
Ссылка равносильна паролю от панели, поэтому у неё два ограничения, и оба у
библиотеки уже есть: **тридцать минут жизни** (`NewStaticAuthToken(30*time.Minute)`)
и печать **только пока владельца нет**. Проверено чтением её кода;
подтвердить прогоном — шаг приёмки. Бессрочная ссылка в журнале отдала бы панель
всякому читателю логов навсегда — при инварианте «строки уже уехали в журнал
контейнера» это необратимо.
Ключа конфигурации под пароль не появляется, и это осознанно: секрет, которого в
конфигурации нет, не утекает вместе с ней. Хранилище держит только отпечаток.
*Отвергнуто:* **пароль ключом конфигурации** — заводит в конфигурации самый
чувствительный секрет проекта и ставит его в один ряд с токеном бота, тогда как
хранилище умеет обойтись отпечатком.
### Ключи конфигурации: два пути заменяются одним каталогом
`[database] path` и `[storage] path` уходят: база и файлы съезжаются под один
каталог, и по-другому хранилище не умеет.
**Имя ключа конфигурации проект объявил необратимым**, поэтому решение принял
человек 2026-08-11: **`[storage] data_dir` со значением `data`**. Варианты и цена
каждого:
- `[storage] data_dir`**выбрано**. Ключ назван по назначению, как названы и
сегодняшние; смена библиотеки через год имени не тронет. Слово `storage` при
этом уже занято capability, но в конфигурации оно значит ровно то же — где
лежат данные;
- `[pocketbase] data_dir` — прямее всего читается тем, кто знает библиотеку, и
вписывает имя поставщика в необратимый ключ. Смена библиотеки потребует второго
необратимого переименования;
- `[data] dir` — короче и нейтральнее всех, но `data` в проекте уже значит
каталог на диске, и секция с таким именем читается как «настройки каталога», а
не «настройки хранилища».
## Risks / Trade-offs
- **Правила доступа коллекций оставлены пустыми, а сама база публикует
`/api/collections/...` и служебные разделы наружу** → пустое правило значит
«только владелец панели», то есть анонимный запрос к записям получает отказ.
Проверяется прогоном на живом сервисе, а не рассуждением, и прогон этот —
отдельный шаг приёмки.
- **Панель висит на публичном порту** → закрывает её Authelia на обратном прокси;
это работа выкладки, и до неё панель открыта всякому, кто знает адрес. Записано
моделью угроз, задачи в беклоге нет намеренно.
- **Захват идёт сырым запросом мимо записей коллекции** → правка состава колонок
очереди перестаёт быть видной компилятору в этом одном месте. Держится тестом
захвата, который читает захваченную задачу целиком.
- **Число попыток растёт при захвате** → задача, которую бросают по независящей от
неё причине (перезапуск сервиса), тратит попытки. Смягчение: счётчик обнуляется
на каждом шаге, завершившемся без отказа, поэтому пять перезапусков подряд
должны прийтись на одну и ту же задачу, чтобы её убить.
- **Задача умирает молча, если сообщение отправителю не дошло** → переход в
«мертва» отвечает тем же путём, что и отказ, и отказ отправки логируется так же.
Гарантии доставки у нас нет ни там, ни там, и этой задачей она не заводится.
- **Проверки приёма по HTTP переписываются целиком** → предмет проверок при этом
не меняется, и расхождение поймает сравнение с прежним списком сценариев спеки
`intake`.
- **Идентификаторы задач меняют формат** → внешняя программа, хранящая прежние
идентификаторы, их не найдёт. Прежних данных нет по решению задачи, поэтому
цена нулевая — но названа, потому что при переносе данных была бы не нулевой.
## Migration Plan
Переноса нет. Сервис поднимается на чистом каталоге данных; момент перехода на
сервере назначает человек, и до него прежний каталог остаётся нетронутым.
Откат — возврат прежнего образа и прежнего каталога `data/`: новый каталог
данных заводится рядом, старого не трогает.
## Open Questions
- **Своё резервное копирование PocketBase** — берём или оставляем серверу;
открытый вопрос архитектуры, этой задачей не закрывается.
- **Отказ от холостого опроса** — 259 200 запросов в сутки посчитаны, цена не
измерена; вопрос остаётся открытым.
@@ -0,0 +1,60 @@
## Why
Записи, их метаданные и сами файлы лежат порознь, и владелец сервиса не видит их
ничем, кроме консоли на сервере: чтобы посмотреть задачу или послушать запись,
он идёт руками в базу и в каталог на диске. Заодно принятая запись держится на
захвате из двух шагов подряд, между которыми задачу может перехватить соседний
воркер, а задача, падающая на каждой попытке, падает вечно и никем не считается.
## What Changes
- Записи, их метаданные и файлы съезжаются в одно хранилище, и владелец получает
панель, где видит задачу строкой, правит её и слушает саму запись.
- **BREAKING** Раскладка файлов на диске меняется: плоского каталога с именами по
идентификатору не остаётся, файл ложится в раскладку хранилища. Момент перехода
назначает человек.
- **BREAKING** Прежние данные не переносятся. Сервис начинает с чистого каталога
и заводит свою схему сам.
- Файл перестаёт отдаваться чтением с диска и отдаётся ссылкой, которую хранилище
строит по записи.
- Захват задачи воркером становится одним неделимым шагом: две задачи одному
состоянию больше не достаются.
- У задачи появляется число попыток. Задача, исчерпавшая их, переходит в
состояние «мертва»: из выборки исчезает, но остаётся видна владельцу и
возвращается в работу снятием состояния.
- Пауза перед повтором нарастает с номером попытки.
- Сборка перестаёт требовать CGO.
- Появляется секрет, которого не было: пароль владельца от панели. В
конфигурации он не лежит.
## Capabilities
### New Capabilities
- `storage`: где живут запись, её метаданные и её файл; как файл попадает в
хранилище и как отдаётся обратно; что владелец видит и правит в панели; с
каким состоянием сервис поднимается на чистом каталоге.
### Modified Capabilities
- `pipeline`: захват задачи становится неделимым; появляются число попыток,
нарастающая пауза и состояние «мертва» вместо признака ошибки, исключающего
задачу навсегда; описывается срок протухания захвата.
- `intake`: принятая запись уезжает в хранилище, а не в плоский каталог;
требование «имя отправителя в хранилище не попадает» остаётся в силе и в новой
раскладке.
## Impact
- Хранилище задач и файлов целиком: прежний слой запросов, построитель запросов и
механизм миграций уходят вместе с каталогом `migrations/`.
- Договор между ядром и хранилищем: интерфейсы репозиториев задач и файлов.
- Состав полей задачи: прибавляется число попыток, признак ошибки уступает место
состоянию в перечне состояний.
- Ключи конфигурации: путь к базе и путь к каталогу файлов заменяются одним
каталогом данных.
- Приём по HTTP и приём из Telegram — в части того, куда кладётся принятая
запись.
- Сборка образа: набор зависимостей меняется, требование CGO уходит.
- Документы: схема хранилища, инварианты и запреты с путями, модель угроз в части
того, из чего строятся пути.
@@ -0,0 +1,215 @@
# Отчёт ревью — change `pocketbase-storage`
Метка `large`, режим «по графу». Составил проход `review-triage`; файл записал
оркестратор — среда запрещает подагенту писать отчётные `.md`. Раздел «Исход по
находкам» дописан оркестратором после отработки и помечен как таковой.
## Сводка
- **Размер:** крупное. **Сложность:** незнакомое. **Метка:** `large` — максимум
по обеим осям. Триггеры `docs/review.md`: «замена хранилища или переход на
PocketBase — любой её кусок», «смена модели очереди», «изменение, трогающее оба
входа сразу».
- **Гейт:** зелёный, проверен триажем — `task gate`, exit 0, восемь шагов.
- **Особенность прогона:** часть находок первой волны оркестратор починил до
запуска `ops`. Каждую пометку «ПОЧИНЕНО» триаж проверял в коде.
### Сигнал о заниженной метке
Не пришёл. `review-code` возражений не заявил; `review-basics` на этой метке не
запускался. Это одно молчание и одно отсутствие, а не подтверждение двумя
источниками.
### План разметки задачи с исходом по каждой теме
| тема | дом | глубина | кто закрывает | исход |
|---|---|---|---|---|
| requirements | `openspec/specs/` + дельты | разбор | `specs` | **закрыта**, 6 находок |
| autotests | `CLAUDE.md`, «Гейт», «Инварианты» | — | `autotests` | **закрыта**, 3 находки |
| conventions | `docs/conventions/` | разбор | `code` | **закрыта**, 9 находок + 3 за срезом потолка |
| architecture | `docs/architecture.md` + `passport.md` | доказательство | `architecture` | **закрыта**, 7 находок |
| security | `docs/security.md` | доказательство | `adversary` | **закрыта**, 5 находок |
| operations | `docs/architecture.md` «Эксплуатация» + `database.md` | доказательство | `ops` | **закрыта**, 3 находки + 3 замера |
**Темы без отчёта нет.** `basics` не запускался по условию состава при метке
`large` — темы он не уносил.
Отдельно: результатов **ревью дизайна** на вход триажа не подавали. Сошлись ли
ответы автора о трёх формах решения с построенным — на этом прогоне не проверено
никем.
### Счёт находок
36 пунктов от шести проходов → 27 причин после дедупликации → 12 починено до
`ops` (одна частично), 1 снята как неверная, 14 осталось плюс 1 новая, вскрытая
проверкой починок. В отчёте: 3 блокирующих, 3 «исправить сейчас», 6 гипотез,
3 promote.
### Проверка починок на месте
| находка | чем проверено | вердикт |
|---|---|---|
| `MaxSize: 0` = 5 МиБ (specs-1 = code-1 = adversary-2) | `entity.MaxRecordSize = 8 << 30` у поля файла и у тела приёма; тест судит границу `5<<20 + 1` и способен упасть | полна |
| хук панели правил записи конвейера (specs-2 = code-2 = adversary-3 = architecture-1) | `OnRecordUpdateRequest`; два теста, включая обратный | полна в объявленном объёме; остаток — блокер 2 |
| 413 на 32 МиБ и `ReadTimeout` 5 минут | `apis.BodyLimit(entity.MaxRecordSize)` на маршруте, `ReadTimeout = 0` | полна |
| гонка `srv` | буферизованный канал, чтение `select`/`default` | полна |
| `file` без `Required` | `Required: true`, шаг схемы на сервер не уезжал | полна |
| спека утверждала про суффикс имени | прогон: имя в хранилище — ровно заданное, суффикса нет | полна |
| рабочая копия без расширения | `Stage(ext string, …)`, приём передаёт расширение | полна |
| инвариант «колонка в N местах» | возвращён в `CLAUDE.md` с перечнем мест | полна |
| две записи ERROR на один отказ приёма | лог из обработчика снят | полна |
| расхождения с `conventions/database.md` | обе строки на месте | полна |
| **вечная ссылка на чужое аудио через журнал** | прогон: `Open` и `Localize` отдают текст с идентификатором записи, имени файла в нём нет | путь А закрыт; путь Б закрыт наполовину — см. «Стоит исправить», п. 2 |
| пустой держатель в `Save` | комментарий исправлен, ветка оставлена | частично, и этого достаточно |
**Снято как неверное:** замечание `architecture` про ключ объекта в Object
Storage. Оно стояло на посылке о суффиксе имени, которой нет: имя в хранилище и
есть `<uuid><расширение>`, значит утверждение `architecture.md` верно.
## Блокирует мердж
### 1. Образ не собирается: сборщик `golang:1.24`, а модуль требует Go 1.25
- Файл: `Dockerfile:2`, `go.mod:3`
- Severity: major, Confidence: high
- Оракул: сборка в контейнере `golang:1.24-alpine` → `go.mod requires go >= 1.25.0
(running go 1.24.13; GOTOOLCHAIN=local)`; на `golang:1.25-alpine` → успех.
Директива не наша: `pocketbase@v0.39.10/go.mod` объявляет `go 1.25.0`.
- Последствие: `task image` падает — выкладки change нет вовсе. Гейт этого не
показывает: образ он не собирает, а `go build` идёт на хостовом Go.
- Найдено: **никем** — вскрылось проверкой починок (триаж).
- Действие: инлайн
### 2. Правка задачи в панели пропадает при ближайшем сохранении шага
- Файл: `transcript_job_repo.go`, `job_mapping.go`, `panel.go`
- Severity: major, Confidence: high
- Оракул: временный тест — задача с `tg_chat_id = 111` захвачена шагом, правится
через `PATCH /api/collections/transcribe_jobs/records/<id>`, затем шаг
сохраняет снимок. Итог: `expected 999999 / actual 111`.
- Последствие: `Save` сторожил только `acquisition_id`, а запись клала **все**
поля снимком с момента захвата. Окно — до восьми часов. Ни строки в журнале,
ни отказа в панели. Панель ради правки и покупалась.
- Найдено: `ops`.
- Действие: развилка
### 3. Два из трёх шагов конвейера переписаны и не выполнены ни одним тестом
- Файл: `internal/service/transcribe.go` — `transcribeJob`, `checkTranscribeJob`,
`completeJob`
- Severity: major, Confidence: high
- Оракул: покрытие с `-coverpkg=./...` — 0.0% у всех трёх и у обоих
`FindAndRun*`; `git diff --stat` по файлу — 268 вставок, 192 удаления.
- Последствие: путь «отдать запись на распознавание → дождаться операции →
ответить пользователю» прошёл замену хранилища без единого выполнения.
- Найдено: `autotests`.
- Действие: инлайн
## Стоит исправить сейчас
### 1. Документы обещают, что рабочую копию убирает хранилище; убирает вызывающий
- Severity: minor, Confidence: high. Найдено: `architecture`. Действие: развилка.
- `architecture.md`, `design.md` и дельта-спека `storage` требуют, чтобы уборку
делало хранилище. В коде копию закрывает вызывающий тремя `defer`. Дефекта в
коде нет — расхождение в записи, которая уедет в архив.
### 2. Отказ заливки в Object Storage больше не отличим от любого другого
- Severity: minor, Confidence: high. Найдено: **никем** — вскрылось проверкой
полноты починки. Действие: инлайн.
- Починка приватности оборвала цепочку `%w` целиком: отозванные ключи,
исчезнувший бакет и отказ сети дают одну строку.
### 3. Входящие HTTP-запросы исчезли из журнала процесса
- Severity: minor, Confidence: high. Найдено: `code`. Действие: развилка.
- `sloggin` ушёл вместе с gin; `activityLogger` PocketBase пишет в свою таблицу.
Строка «*Расхождение:*» в `logging.md` указывает на удалённую библиотеку.
## Гипотезы без доказательства
1. Ответ SpeechKit об отказе операции может нести S3-URI и уехать в журнал и в
`error_text`. Оракул требует настоящего отказа Yandex — запрещено `CLAUDE.md`.
2. Имя файла в хранилище принимает почти любой хвост имени отправителя. Выхода за
каталог **нет** (15 враждебных имён). Станет находкой с появлением экрана
списка записей.
3. `entity.File.FileName` несёт два смысла — имя в хранилище и ключ объекта.
Сегодня они совпадают дословно.
4. Отмена контекста структурно невозможна: `RunInTransaction`/`FindRecordById`
контекста не принимают. Замер: под внешней блокировкой захват держится 9.53 с
при `busy_timeout=10000`. Уточнение уже объявленного долга.
5. Таймаутов у Telegram, S3 и SpeechKit по-прежнему нет. Задача заведена.
6. Приём пишет тело на диск дважды. Не замерено; нагрузка объявлена вне модели.
Ниже потолка названы и не потеряны: `LostAcquisitionError` типом там, где
конвенция просит sentinel; форма обёрток `"failed to <действие>: %w"`; уровень
`ERROR` с инкрементом метрики на потерянном захвате против `WARN` конвенции;
панель как третий вход в таблицу задач.
## Promote candidates
1. **`govulncheck ./...` шагом гейта.** Сегодня его нет ни в гейте, ни в списке
«чего в гейте намеренно нет». Достижимых уязвимостей две, унаследованных:
`GO-2026-6061` (grpc), `GO-2026-5764` (aws eventstream, s3).
2. **Сверка версии Go в `Dockerfile` с директивой `go` в `go.mod`.** Блокер 1
прошёл весь конвейер молча именно потому, что образ никто не собирает.
3. **Покрытие изменённых строк.** Блокер 3 — второй случай подряд, когда его
ловят руками.
## Границы покрытия
- Запускались: `specs`, `code`, `architecture`, `adversary`, `autotests` — по
коду **до** починок первой волны; `ops` — по коду **после**. Находки пяти
первых перепроверены триажем поимённо.
- `basics` не запускался: при метке `large` темы разобраны именными проходами,
своих тем проекта нет.
- Независимой реализации для сравнения не строил никто — прохода нет в конвейере.
- Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом.
Для change, который впервые в проекте пишет на PocketBase, это самый дорогой
пробел.
- **Блоки `Coverage of this pass` до триажа дошли не от всех проходов.** О своих
потолках сообщил только `code` (конвенционная половина, 4, за срезом три
находки — перенесены). Остальные не сообщили; это находка о прогоне.
- Решения проекта (`docs/adr/`) и записанные наблюдения (`docs/research/`) прогон
не открывает — процессные документы. Расхождение change с записанным решением
ловит не ревью, а сверка документации (`av-dev-docs:healthcheck`).
- Не проверит ни один проход: поведение SpeechKit и Object Storage под нагрузкой
и на границах; реальный профиль нагрузки; стойкость `ffmpeg` к вредоносному
входу.
- Перестали проверять сознательно: разбор вывода настоящего `ffprobe` — решение и
цена в `ADR-2026-08-11-stub-adapters-in-tests.md`.
- Каких документов не хватило: `docs/conventions/web-ui.md` описывает будущую
SPA, а не панель — судить панель против конвенции было нечем; `docs/research/`
по весу шестичасовой записи пуст, оценки читались как оценки.
## Исход по находкам
*Раздел дописан оркестратором после отработки урожая; в отчёте триажа его нет.*
**Починено:**
- блокер 1 — `Dockerfile` переведён на `golang:1.25-alpine`, строки про Go 1.24 в
`CLAUDE.md` и `README.md` исправлены; сборка в контейнере проверена;
- блокер 2 — запись шага разделена: `applyOwnedByPipeline` кладёт только поля
конвейера, `applyToRecord` целиком остаётся заведению. Заведён тест
`TestSave_KeepsOwnerEditMadeWhileStepHeldTheJob`; норма дописана в дельта-спеку
`pipeline` со сценарием;
- блокер 3 — заведён управляемый двойник распознавателя и восемь проверок
(`internal/service/recognition_test.go`). Покрытие: `transcribeJob` 0 → 67.6%,
`checkTranscribeJob` 0 → 72.4%, `completeJob` 0 → 100%, оба `FindAndRun*` → 100%;
- «стоит исправить» 2 — класс отказа SDK сохраняется через `smithy.APIError`,
адрес объекта — нет;
- «стоит исправить» 3 — журнал входящих запросов вернулся своим слоем в `main.go`
с пятью полями конвенции, `/health` и `/metrics` на `DEBUG`; расхождение в
`logging.md` снято.
**Решено развилкой, вариант назван человеку на чекпоинте:**
- «стоит исправить» 1 — норму привели к коду: хранилище даёт единственный способ
убрать копию, зовёт его шаг. Замкнуть уборку на репозиторий мешает конвертация:
ей нужны две копии разом. Цена названа — норма держится проверками, и проверки
заведены на приём и на шаг конвертации.
**Оставлено, передано урожаем:** все шесть гипотез и четыре находки ниже потолка;
три кандидата в promote.
@@ -0,0 +1,166 @@
## MODIFIED Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio`. Принятая запись MUST быть сохранена
и получить заведённую под неё задачу расшифровки в состоянии `created`; ответ
MUST нести идентификатор задачи полем `job_id` и её состояние полем `status`.
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
и переименование поля ломает внешнюю программу молча.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `created`
- **AND** содержимое записи целиком лежит в хранилище одним файлом
#### Scenario: Поля с записью нет
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни задачи не заводится
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
### Requirement: Имя файла в хранилище
Сервис SHALL сохранять принятую запись под собственным именем — идентификатором,
к которому приписано расширение из имени файла отправителя. Имя, данное
отправителем, MUST не попадать в хранилище: оно приходит извне и содержимым
своим приёму не подконтрольно.
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
файла в хранилище расширение было всегда.
Требование переживает смену раскладки. Умолчание хранилища, строящее имя из
имени отправителя, MUST не применяться: имя отправителя в журнал не пишется по
инварианту приватности, а изъятие из него кончается расширением — хвостом после
последней точки.
#### Scenario: Расширение взято из имени отправителя
- **WHEN** программа шлёт запись с именем `test.mp3`
- **THEN** имя файла в хранилище оканчивается на `.mp3`
#### Scenario: Имени без расширения назначено своё
- **WHEN** программа шлёт запись с именем `test` без расширения
- **THEN** имя файла в хранилище оканчивается на `.audio`
#### Scenario: Имя отправителя в хранилище не попало
- **WHEN** программа шлёт запись с именем `секретное-слово.mp3`
- **THEN** имя файла в хранилище не содержит `секретное-слово`
- **AND** путь к этому файлу не содержит его тоже
### Requirement: Имя файла, данное отправителем, не попадает в журнал
Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную
запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать
текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
откуда строку не убрать.
Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему
прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости,
нормирует требование ниже; наружу расширение выходит только приведённым к
известному виду — этому отдано отдельное требование.
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не
имя человека. Правка при этом ложится на общий шаг заведения задачи, через
который идут оба входа, поэтому своей нормы приём из Telegram здесь не получает —
её напишет задача, которая тронет его поведение.
#### Scenario: Имя записи не видно в журнале принятой записи
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку при обычном расширении `.mp3`
- **THEN** ни одна журнальная запись приёма этой строки не содержит
- **AND** расширение `.mp3` в журнале допустимо
#### Scenario: Имя записи не видно в журнале при отказе приёма
- **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
строки не содержит
### Requirement: Журнал приёма прослеживает запись
Приём SHALL писать в журнал идентификатор заведённого файла, расширение принятой
записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и
удаление имени отправителя прослеживаемости не отнимает.
Расширение засчитывается собственным полем журнальной строки. Имя, под которым
файл лёг в хранилище, приём MUST в журнал не писать: это имя — последняя часть
ссылки на скачивание, и записанное вместе с идентификатором записи оно собирает
ссылку целиком. Норму держит capability `storage`.
#### Scenario: Идентификатор, расширение и размер на месте
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью
- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение
принятой записи и её размер в байтах
#### Scenario: Имени файла в хранилище в журнале нет
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью
- **THEN** имени, под которым файл лёг в хранилище, в журнале приёма нет
### Requirement: Метка метрики несёт только известное расширение
Сервис SHALL приводить расширение принятой записи к известному виду прежде, чем
употребить его меткой метрики: расширение приводится к нижнему регистру и
сверяется с закрытым перечнем; совпавшее идёт приведённым, всякое другое MUST
заменяться единым значением `other`. Перечень — `mp3`, `wav`, `ogg`, `oga`,
`opus`, `flac`, `m4a`, `aac`, `wma`, `mp4`, `mkv`, `mov`, `avi`, `webm`, плюс
`audio`: последнее не формат, а собственное умолчание сервиса на случай имени
без расширения, и различать его от чужого хвоста метка обязана.
Страница метрик отдаётся без проверки отправителя, поэтому метка — поверхность
пошире журнала: её читает кто угодно. Тем же ограничением снимается и рост числа
временных рядов, которым иначе распоряжается анонимный отправитель.
Требование намеренно шире приёма: под него подпадает и метка шага конвертации.
Когда конвертацию нормируют своей capability, обязанность переезжает туда вместе
с ней.
Имя файла в хранилище это требование не трогает: там расширение остаётся тем,
каким пришло, — это уже нормировано требованием «Имя файла в хранилище».
Настоящий формат записи, попавшей в `other`, остаётся видимым в журнале: значение
`other` в метке означает «расширение не из перечня», а само оно стоит полем
журнальной строки приёма и полем формата строки конвертации.
#### Scenario: Незнакомое расширение наружу не выходит
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
принадлежит перечню known-форматов
- **THEN** метка метрики принимает значение `other`
- **AND** имя файла в хранилище сохраняет пришедшее расширение
#### Scenario: Известное расширение идёт как есть
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем `sample.MP3`
- **THEN** метка метрики принимает значение `mp3`
@@ -0,0 +1,200 @@
## Purpose
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
когда работы нет, и что считается отказом шага.
Описаны пустой прогон воркера, захват задачи и срок его протухания, число
попыток и выбывание задачи из очереди, пауза перед повтором. Сознательно не
описаны переходы состояний и сама цепочка `created → converted → transcribe →
done | failed`, отмена контекста посреди шага, освобождение ресурсов внешних
клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а
требования на него не написаны, потому что требование без проверки —
предположение, а не норма. Первая задача, которая трогает любое из
перечисленного, дописывает его сюда.
## ADDED Requirements
### 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** число попыток задачи не растёт
@@ -0,0 +1,240 @@
## Purpose
Где живут запись, её метаданные и её файл; как файл попадает в хранилище и как
отдаётся обратно; что видит и правит владелец сервиса; с каким состоянием сервис
поднимается на чистом каталоге.
Сознательно не описаны удаление записей и файлов, срок их хранения, резервное
копирование и вход пользователей: первое и второе решены хранить бессрочно,
третье принадлежит серверу, четвёртое — задаче про вход. Первая задача, которая
тронет любое из перечисленного, дописывает его сюда.
## ADDED Requirements
### Requirement: Сервис поднимается на чистом каталоге данных
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
MUST завести свою схему и принимать записи обоими входами без единого ручного
шага до первого запуска.
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
читаться и не считаться источником: сервис начинает с чистого листа, и это
решение задачи, а не следствие отказа.
Схема MUST заводиться версионированными шагами, а применённый шаг MUST не
переписываться — только новым шагом. Иначе повторный запуск на уже заведённом
каталоге разошёлся бы с первым молча.
Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним
вместе, и второго пути к ним не заводится.
#### Scenario: Первый запуск на пустом каталоге
- **GIVEN** каталог данных пуст
- **WHEN** сервис запускается
- **THEN** он заводит своё хранилище и продолжает работу
- **AND** принятая следом запись доходит до состояния `done`
#### Scenario: Повторный запуск на заведённом каталоге
- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище
- **WHEN** он запускается снова
- **THEN** он не заводит схему второй раз и не теряет прежние записи
### Requirement: Файл записи живёт в хранилище
Сервис SHALL держать файл записи в хранилище, а не отдельным каталогом рядом с
ним. Файл MUST попадать туда вместе с записью, которой принадлежит, и MUST
адресоваться этой записью, а не путём на диске.
Раскладку файлов на диске выбирает хранилище. Собственного плоского каталога
записей у сервиса MUST не оставаться: файл, лежащий мимо хранилища, не попадёт
ни в панель владельца, ни в резервную копию, а ради этих двух вещей перевод и
делается.
Содержимое записи MUST не читаться в память целиком ни при укладке в хранилище,
ни при чтении из него: расчётный потолок записи — шесть часов, и такая запись в
память не помещается.
**Потолок размера записи MUST быть задан числом, выведенным из этого расчётного
потолка**, и задан он MUST быть везде, где иначе действует чужое умолчание: и у
поля файла в хранилище, и у тела запроса приёма. Умолчания здесь не «без
предела», а величины на два-три порядка меньше нужного, и оставленные как есть
они отвергают штатную запись сервиса — приём отказывает, а уже принятая запись
исчерпывает попытки на шаге конвертации.
Отказ по этому потолку MUST быть виден отправителю ответом, а не молчанием.
Шаги, которым нужен файл именем на диске — конвертация и чтение метаданных
отдают его внешней программе, — MUST получать рабочую копию **одним общим
способом**, и у этого способа MUST быть единственный способ её убрать. Уборку
зовёт шаг, и звать её он MUST на любом исходе, включая отказ. Заводить копию по
месту шагам MUST не приходиться: иначе обязанность прибрать переписывается
столько раз, сколько шагов, а забытая копия — это шестичасовая запись,
оставшаяся во временном каталоге, и узнать о ней неоткуда.
#### Scenario: Принятая запись легла в хранилище
- **WHEN** запись принята любым входом
- **THEN** её файл лежит в хранилище и связан со своей записью
- **AND** отдельного каталога записей рядом с хранилищем не появляется
#### Scenario: Запись длиннее чужого умолчания принимается
- **WHEN** в хранилище кладут запись длиннее умолчания, действующего у поля файла
- **THEN** она ложится в хранилище, а не отвергается
#### Scenario: Шаг конвейера берёт файл по записи
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** шаг конвейера берётся за эту запись
- **THEN** он получает файл по самой записи, а не по пути на диске
#### Scenario: Рабочая копия убрана после отказа шага
- **GIVEN** шагу выдана рабочая копия файла
- **WHEN** шаг завершается отказом
- **THEN** рабочей копии во временном каталоге не остаётся
### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
записи. Отданный файл MUST совпадать с принятым по длине.
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
бессрочно.
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
другое кончается в журнале и собирает ссылку не хуже успешного пути.
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
`intake`.
#### Scenario: Файл забирают по ссылке
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Ссылка ведёт в никуда
- **WHEN** запрашивают ссылку на запись, которой нет
- **THEN** приходит отказ, а не пустой ответ
#### Scenario: По журналу ссылку не собрать
- **GIVEN** запись принята и прошла конвейер
- **WHEN** читают журнал сервиса целиком
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
#### Scenario: Отказ чтения файла не называет его ключ
- **GIVEN** файл записи не читается из хранилища
- **WHEN** шаг конвейера берётся за эту запись и отказывает
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
### Requirement: Наружу хранилище отдаёт только то, что заказано
Сервис SHALL держать закрытыми собственные разделы хранилища, которые тот
публикует тем же портом. Запрос без прав владельца MUST получать отказ на
перечисление и чтение записей коллекций, на служебные разделы хранилища —
журналы запросов, резервные копии, настройки, расписание — и на правку чего бы
то ни было.
Требование заводится потому, что порт опубликован в интернет, а вместе с
переводом наружу выходит поверхность, которой у сервиса не было. Что API сервиса
сегодня открыт всякому — известно и записано моделью угроз; новая поверхность под
это знание не подпадает и закрывается здесь.
Правило доступа, оставленное пустым, значит «только владелец панели». Именно
пустым оно MUST и оставаться: непустое правило, поставленное будущей правкой
схемы, открыло бы перечисление всех записей анонимному запросу и не нарушило бы
при этом ни одного другого требования.
#### Scenario: Аноним перечисляет записи
- **WHEN** запрос без прав владельца просит список записей коллекции задач
- **THEN** приходит отказ
#### Scenario: Аноним читает служебный раздел
- **WHEN** запрос без прав владельца просит журнал запросов или список резервных
копий хранилища
- **THEN** приходит отказ
### Requirement: Владелец видит записи в панели
Сервис SHALL давать владельцу панель, где задача видна строкой, отбирается по
своему идентификатору и правится, а её файл слушается и скачивается.
Панель MUST отдаваться тем же сервисом по своему адресу и MUST не требовать
второго процесса.
Панель — вход в задачу наравне с конвейером, а не окно просмотра, и правка
состояния задачи в ней MUST подчиняться тем же правилам перехода, что и правка
из кода: служебные поля прошлого состояния — признак захвата, время захвата,
пауза, число попыток — MUST очищаться. Иначе владелец, вернувший мёртвую задачу в
работу, получит задачу, которая не выдаётся захвату до конца прежнего срока и
умирает от первого же отказа, — и не узнает об этом.
Задача, заведённая в панели руками, MUST не уносить сервис: поля, без которых
шаг конвейера не может работать, MUST быть обязательными в самой схеме, а
перечень состояний — закрытым.
Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все
записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не
сервис — это записано моделью угроз проекта.
#### Scenario: Принятая запись видна владельцу
- **GIVEN** запись принята и её задача заведена
- **WHEN** владелец отбирает задачи по идентификатору принятой
- **THEN** он видит её строкой со своим состоянием
- **AND** файл этой записи скачивается из той же строки
#### Scenario: Мёртвую задачу вернули в работу правкой в панели
- **GIVEN** задача в состоянии «мертва» с исчерпанными попытками и признаком
прежнего захвата
- **WHEN** владелец меняет её состояние на рабочее
- **THEN** признак захвата, время захвата, пауза и число попыток очищены
- **AND** ближайший захват выдаёт задачу
### Requirement: Пароль владельца от панели не лежит в конфигурации
Сервис SHALL не заводить в конфигурации ключа под пароль владельца от панели.
Пароль MUST задаваться самим владельцем, а хранилище MUST держать только его
отпечаток.
Требование стоит на инварианте проекта «Секрет не покидает конфиг» с другой
стороны: секрет, которого в конфигурации нет, не утекает вместе с ней и не
уезжает в выкладку третьим путём. Пароль от панели открывает все записи и все
файлы разом — это самое чувствительное, что есть у сервиса.
Приглашение завести владельца сервис MUST печатать только пока владельца нет, и
оно MUST истекать по времени. Приглашение равносильно паролю от панели, а
печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы
панель всякому читателю логов навсегда.
Пока владелец пароля не задал, сервис MUST работать обоими входами: панель без
владельца не мешает принимать записи.
#### Scenario: Владелец пароля ещё не задал
- **GIVEN** каталог данных пуст и владелец панели не заведён
- **WHEN** сервис запускается
- **THEN** он принимает записи обоими входами
- **AND** ни один ключ конфигурации не несёт пароля от панели
#### Scenario: Владелец заведён, приглашение больше не печатается
- **GIVEN** владелец панели заведён
- **WHEN** сервис запускается снова
- **THEN** приглашения завести владельца в журнале нет
@@ -0,0 +1,196 @@
## Критерии приёмки
### От постановки
Дословно из записи задачи `pocketbase-storage`. Файл задачи закрытие удалит —
критерии обязаны его пережить. Одно уточнение внесено ревью дизайна и отмечено
курсивом: прогон на реальных ключах Yandex запрещён проектом, поэтому
распознаватель в прогоне подставной.
- Сервис поднимается на чистом каталоге данных, накатывает свою схему сам и
принимает запись обоими входами. Оракул — запуск на пустом `data/` и прогон
записи из Telegram и через `POST /api/audio` до состояния `done`
*с подставным распознавателем `internal/adapter/recognizer/memory.go`*.
- Захват задачи воркером идёт одним запросом и не выдаёт одну запись двум
вызывающим. Оракул — тест на трёх параллельных вызовах захвата по одному
состоянию: ровно один получает запись.
- Задача, брошенная на середине, достаётся снова по истечении срока захвата, а
падающая всегда — уходит в «мертва» и из выборки исчезает. Оракулы — тест с
проставленным задним числом `acquire_time` и тест с шагом, падающим на каждой
попытке: после заданного их числа задача не выдаётся, а её состояние видно
отбором.
- Принятая запись видна в панели строкой и скачивается по ссылке
`/api/files/...` тем же файлом. Оракулы — прогон записи через
`POST /api/audio` на пустом каталоге, затем поиск её строки в коллекции задач
на `/_/` по идентификатору и запрос `/api/files/...` за тем же файлом: длина
совпадает с загруженной.
- `docs/database.md` описывает новую схему, а старые упоминания goose и goqu из
документов канона убраны. Оракул — `task gate`, шаг `docs.py check`.
### От ревью дизайна (рубрика прохода `rubric`)
Свойства узла, порождённые до чтения артефактов. Пункты 1, 11 и 12 закрыты
дельта-спеками, 7 неприменим по объявленному Non-Goal, остальные проверяются
поимённо.
- **Захват атомарен.** Критерий успеха — сам факт возврата записи, а не
последующее чтение; ноль записей отличается от отказа. Оракул — 7.2.
- **Протухший захват не создаёт двух живых исполнителей.** Срок захвата назван
числом и не меньше худшего времени шага; запись результата условна по
владельцу захвата. Оракулы — 7.3 и 7.8.
- **У каждого пути выбывания назван актор перехода.** Отказ шага, брошенная
задача, гибель процесса — все три доходят до «мертва». Оракулы — 7.4 и 7.7.
- **Узел читает состояние, которое сам же меняет.** Порядок выборки
детерминирован и имеет тай-брейк по ключу; значения, выведенные из счётчика,
определены при любом порядке параллельных операций. Оракулы — 7.2 и 7.9.
- **Идемпотентность повтора.** Падение между «работа сделана» и «результат
записан» не создаёт при повторе второго файла и второй записи; ссылка на файл
переставляется только после того, как запись о новом файле существует. Оракул
— 7.10.
- **Атомарность записи файла и уборка временного.** Обрыв и отмена не оставляют
читаемого огрызка; рабочая копия убирается на всех ветках выхода. Оракул —
7.11.
- **Ссылка на файл: кто вправе по ней пройти.** Названы право и неугадываемость;
ссылка не оседает там, где её прочтут посторонние. Оракулы — 7.12 и 9.4.
- **Границы транзакции и отмена контекста.** Частичный переход невозможен либо
назван и компенсирован порядком операций. Оракул — 7.10.
- **Источник времени и идентификаторов един.** Один формат и одна зона у всех,
кто колонку времени пишет и сравнивает, включая запросы мимо слоя записей.
Оракул — 7.9.
## 1. Зависимости и каркас хранилища
- [x] 1.1 Добавить `github.com/pocketbase/pocketbase` v0.39.10, убрать
`mattn/go-sqlite3`, `doug-martin/goqu/v9`, `pressly/goose/v3`, `gin-gonic/gin`,
`samber/slog-gin`; `go mod tidy` проходит, `CGO_ENABLED=0 go build ./...`
собирается
- [x] 1.2 Завести пакет хранилища: создание приложения PocketBase из
конфигурации, `Bootstrap()`, доступ к нему для репозиториев
- [x] 1.3 Заменить ключи `[database] path` и `[storage] path` одним ключом
каталога данных (имя выбрано человеком на чекпоинте) в `internal/config` и в
`config.dist.toml`
- [x] 1.4 Удалить каталог `migrations/*.sql`, вшивание его в бинарник и функцию
`RunMigrations`
## 2. Схема коллекций
- [x] 2.1 Написать миграцию, заводящую коллекцию `files` с полями `file`,
`location`, `object_key`, `size`
- [x] 2.2 Написать миграцию, заводящую коллекцию `transcribe_jobs` с полями
сегодняшней таблицы, без `is_error`, плюс `attempts`, плюс значение `dead` у
`state`
- [x] 2.3 Задать в схеме ограничения, которые сегодня держит компилятор: ссылка
на файл обязательна, `state` — закрытый перечень, `attempts` неотрицательно
- [x] 2.4 Оставить правила доступа обеих коллекций пустыми и проверить, что
анонимный запрос к записям получает отказ
- [x] 2.5 Проверить: на пустом каталоге сервис заводит обе коллекции, на
заведённом — не заводит второй раз
## 3. Репозитории
- [x] 3.1 Переписать `FileRepository` на записи коллекции: укладка файла потоком
из временного файла, собственное имя вида `<идентификатор><расширение>`
- [x] 3.2 Дать `FileRepository` единый способ выдать рабочую копию файла на
диске шагу, которому нужен путь, с уборкой копии на любом исходе
- [x] 3.3 Переписать `TranscriptJobRepository` на записи коллекции: `Create`,
`Save`, `GetByID`
- [x] 3.4 Написать `FindAndAcquire` одним запросом с `RETURNING`: рост
`attempts`, отбор по состоянию, паузе и сроку захвата, `ORDER BY` с
тай-брейком по ключу
- [x] 3.5 Все времена очереди писать и сравнивать в том же виде, в каком
хранилище пишет `created`/`updated` (`2006-01-02 15:04:05.000Z`, UTC)
- [x] 3.6 Сделать сохранение результата условным по признаку захвата: чужой
захват — отказ сохранения, отличимый от прочих
- [x] 3.7 Обновить `internal/contract` под новые обязанности репозиториев;
построения ссылки на файл в контракт не заводить
- [x] 3.8 Удалить пакет `internal/adapter/repo/sqlite`
## 4. Очередь: попытки, «мертва», пауза
- [x] 4.1 Убрать `IsError` из `entity.TranscribeJob`, завести `Attempts` и
состояние `StateDead`
- [x] 4.2 Обнулять `Attempts` на каждом шаге, завершившемся без отказа
- [x] 4.3 Переводить в `dead` задачу, захваченную с числом попыток сверх предела:
перевод делает захвативший, до работы шага
- [x] 4.4 Сообщать отправителю о переходе в `dead` тем же путём, каким сообщается
отказ шага
- [x] 4.5 Завести нарастающую паузу перед повтором отказавшей задачи с потолком
- [x] 4.6 Оставить задержку опроса операции распознавания числом, отдельно от
паузы повтора
- [x] 4.7 Завершать шаг без записи результата и без ответа отправителю, когда
захват за время работы достался другому
## 5. HTTP и панель
- [x] 5.1 Перевести `POST /api/audio` и `GET /api/status/:id` на роутер
PocketBase, сохранив имена полей ответа и коды
- [x] 5.2 Перевести `GET /health` и `GET /metrics` туда же
- [x] 5.3 Переписать `main.go`: `apis.Serve` вместо gin, мягкая остановка и
таймауты из конфигурации сохраняются
- [x] 5.4 Повесить хук на правку записи задачи: смена состояния чистит признак
захвата, время захвата, паузу и число попыток
- [x] 5.5 Убедиться, что панель отвечает по `/_/`, приглашение завести владельца
печатается при первом запуске и не печатается после того, как владелец заведён
## 6. Приём и конвейер
- [x] 6.1 Перевести приём (`createTranscribeJob`) на укладку записи в хранилище
через временный файл, с уборкой за собой
- [x] 6.2 Перевести шаги конвертации и распознавания на рабочую копию из 3.2
- [x] 6.3 Писать в журнал расширение записи собственным полем, а имени файла —
ни заданного сервисом, ни того, под которым он лёг в хранилище: имя вместе с
идентификатором записи собирает ссылку на скачивание
- [x] 6.4 Проверить, что имя отправителя не попадает ни в имя файла в хранилище,
ни в журнал
## 7. Проверки
- [x] 7.1 Переписать проверки приёма по HTTP под новый обработчик, сохранив все
сценарии спеки `intake`, включая запрет имени отправителя в журнале
- [x] 7.2 Тест захвата: три параллельных вызова по одному состоянию — запись
получает ровно один
- [x] 7.3 Тест протухшего захвата: `acquire_time` задним числом — задача выдаётся
снова
- [x] 7.4 Тест предела попыток: после заданного числа отказов задача в `dead`,
захвату не выдаётся, видна отбором по состоянию, а отправитель получил
сообщение
- [x] 7.5 Тест нарастающей паузы: вторая пауза длиннее первой
- [x] 7.6 Тест имени файла: запись с именем `секретное-слово.mp3` ложится в
хранилище под именем без этого слова и с расширением `.mp3`
- [x] 7.7 Тест брошенного пути: задача, чей шаг не дошёл до объявления отказа,
после заданного числа захватов уходит в `dead`
- [x] 7.8 Тест чужого захвата: шаг, потерявший задачу за время работы, результата
не пишет и отправителю не отвечает
- [x] 7.9 Тест вида времени: время захвата, положенное **не** нашим кодом, а тем
же путём, что `created`, сравнивается со сроком верно
- [x] 7.10 Тест повтора: отказ между укладкой файла и сохранением задачи не
оставляет задачу со ссылкой на несуществующий файл
- [x] 7.11 Тест уборки: после отказа шага рабочей копии во временном каталоге
не остаётся
- [x] 7.12 Тест журнала: имени файла в хранилище в журнале нет ни на одном пути
## 8. Документы
- [x] 8.1 Переписать `docs/database.md`: коллекции вместо таблиц, новая таблица
настроек с числами (предел попыток, пауза, оба срока захвата с их потолками,
задержки опроса), уход goose и goqu
- [x] 8.2 Поправить `CLAUDE.md`: строка стека без CGO, запреты с путями под новую
раскладку, инвариант «новая колонка в четырёх местах» снять или переписать
- [x] 8.3 Поправить `docs/security.md`, раздел «Из чего строятся пути и ключи»:
раскладка хранилища, имя отправителя в путь **не** попадает, ссылка на файл и
почему она не уезжает в журнал, приглашение завести владельца
- [x] 8.4 Поправить `docs/architecture.md`: компоненты, единые точки проекта,
открытые вопросы про хранилище и очередь, преамбула про состояние спек и
маркеры долга у «Очереди таблицей»
- [x] 8.5 Поправить `docs/conventions/database.md`: миграции больше не goose,
время в сыром запросе — тем же видом, что пишет хранилище
## 9. Сборка и приёмка
- [x] 9.1 Проверить сборку образа: `task image` проходит, слой не требует CGO
- [x] 9.2 `task gate` зелёный целиком
- [x] 9.3 Прогон вживую на пустом каталоге с подставным распознавателем: запись
через `POST /api/audio` доходит до `done`, видна строкой в панели, скачивается
по `/api/files/...` той же длины
- [x] 9.4 Прогон поверхности: анонимный запрос к записям коллекций и к служебным
разделам хранилища получает отказ
+37 -16
View File
@@ -10,7 +10,6 @@
Telegram делит с ним общий шаг заведения задачи, но требований на него нет:
требование, написанное без проверки, — предположение, а не норма. Первая задача,
которая трогает поведение приёма из Telegram, дописывает его сюда.
## Requirements
### Requirement: Приём записи по HTTP
@@ -25,13 +24,16 @@ MUST нести идентификатор задачи полем `job_id` и
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `created`
- **AND** содержимое записи целиком лежит в каталоге хранения одним файлом
- **AND** содержимое записи целиком лежит в хранилище одним файлом
#### Scenario: Поля с записью нет
@@ -53,17 +55,28 @@ MUST нести идентификатор задачи полем `job_id` и
своим приёму не подконтрольно.
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
файла на диске расширение было всегда.
файла в хранилище расширение было всегда.
Требование переживает смену раскладки. Умолчание хранилища, строящее имя из
имени отправителя, MUST не применяться: имя отправителя в журнал не пишется по
инварианту приватности, а изъятие из него кончается расширением — хвостом после
последней точки.
#### Scenario: Расширение взято из имени отправителя
- **WHEN** программа шлёт запись с именем `test.mp3`
- **THEN** файл в каталоге хранения имеет расширение `.mp3`
- **THEN** имя файла в хранилище оканчивается на `.mp3`
#### Scenario: Имени без расширения назначено своё
- **WHEN** программа шлёт запись с именем `test` без расширения
- **THEN** файл в каталоге хранения имеет расширение `.audio`
- **THEN** имя файла в хранилище оканчивается на `.audio`
#### Scenario: Имя отправителя в хранилище не попало
- **WHEN** программа шлёт запись с именем `секретное-слово.mp3`
- **THEN** имя файла в хранилище не содержит `секретное-слово`
- **AND** путь к этому файлу не содержит его тоже
### Requirement: Отказ чтения метаданных
@@ -86,10 +99,10 @@ MUST нести идентификатор задачи полем `job_id` и
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
откуда строку не убрать.
Расширение, взятое из этого имени, в журнале остаётся: оно стоит в собственном
имени файла на диске, и по нему прослеживается путь записи. Что именно попадает в
журнал ради прослеживаемости, нормирует требование ниже; наружу расширение
выходит только приведённым к известному виду — этому отдано отдельное требование.
Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему
прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости,
нормирует требование ниже; наружу расширение выходит только приведённым к
известному виду — этому отдано отдельное требование.
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не
@@ -119,8 +132,10 @@ MUST нести идентификатор задачи полем `job_id` и
записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и
удаление имени отправителя прослеживаемости не отнимает.
Расширение засчитывается присутствием собственного имени файла в хранилище:
отдельного поля под него приём не заводит.
Расширение засчитывается собственным полем журнальной строки. Имя, под которым
файл лёг в хранилище, приём MUST в журнал не писать: это имя — последняя часть
ссылки на скачивание, и записанное вместе с идентификатором записи оно собирает
ссылку целиком. Норму держит capability `storage`.
#### Scenario: Идентификатор, расширение и размер на месте
@@ -129,6 +144,12 @@ MUST нести идентификатор задачи полем `job_id` и
- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение
принятой записи и её размер в байтах
#### Scenario: Имени файла в хранилище в журнале нет
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью
- **THEN** имени, под которым файл лёг в хранилище, в журнале приёма нет
### Requirement: Метка метрики несёт только известное расширение
Сервис SHALL приводить расширение принятой записи к известному виду прежде, чем
@@ -147,12 +168,12 @@ MUST нести идентификатор задачи полем `job_id` и
Когда конвертацию нормируют своей capability, обязанность переезжает туда вместе
с ней.
Имя файла на диске это требование не трогает: там расширение остаётся тем, каким
пришло, — это уже нормировано требованием «Имя файла в хранилище».
Имя файла в хранилище это требование не трогает: там расширение остаётся тем,
каким пришло, — это уже нормировано требованием «Имя файла в хранилище».
Настоящий формат записи, попавшей в `other`, остаётся видимым в журнале: значение
`other` в метке означает «расширение не из перечня», а само оно стоит в поле
пути журнальной строки приёма и в поле формата строки конвертации.
`other` в метке означает «расширение не из перечня», а само оно стоит полем
журнальной строки приёма и полем формата строки конвертации.
#### Scenario: Незнакомое расширение наружу не выходит
@@ -160,7 +181,7 @@ MUST нести идентификатор задачи полем `job_id` и
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
принадлежит перечню known-форматов
- **THEN** метка метрики принимает значение `other`
- **AND** файл в каталоге хранения сохраняет пришедшее расширение
- **AND** имя файла в хранилище сохраняет пришедшее расширение
#### Scenario: Известное расширение идёт как есть
+185 -1
View File
@@ -13,7 +13,6 @@ transcribe → done | failed`, захват задачи и срок его пр
что такого поведения нет: оно живёт в коде, а требования на него не написаны,
потому что требование без проверки — предположение, а не норма. Первая задача,
которая трогает любое из перечисленного, дописывает его сюда.
## Requirements
### Requirement: Пустой прогон воркера — не отказ
@@ -76,3 +75,188 @@ transcribe → done | failed`, захват задачи и срок его пр
- **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** число попыток задачи не растёт
+233
View File
@@ -0,0 +1,233 @@
# storage Specification
## Purpose
TBD - created by archiving change pocketbase-storage. Update Purpose after archive.
## Requirements
### Requirement: Сервис поднимается на чистом каталоге данных
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
MUST завести свою схему и принимать записи обоими входами без единого ручного
шага до первого запуска.
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
читаться и не считаться источником: сервис начинает с чистого листа, и это
решение задачи, а не следствие отказа.
Схема MUST заводиться версионированными шагами, а применённый шаг MUST не
переписываться — только новым шагом. Иначе повторный запуск на уже заведённом
каталоге разошёлся бы с первым молча.
Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним
вместе, и второго пути к ним не заводится.
#### Scenario: Первый запуск на пустом каталоге
- **GIVEN** каталог данных пуст
- **WHEN** сервис запускается
- **THEN** он заводит своё хранилище и продолжает работу
- **AND** принятая следом запись доходит до состояния `done`
#### Scenario: Повторный запуск на заведённом каталоге
- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище
- **WHEN** он запускается снова
- **THEN** он не заводит схему второй раз и не теряет прежние записи
### Requirement: Файл записи живёт в хранилище
Сервис SHALL держать файл записи в хранилище, а не отдельным каталогом рядом с
ним. Файл MUST попадать туда вместе с записью, которой принадлежит, и MUST
адресоваться этой записью, а не путём на диске.
Раскладку файлов на диске выбирает хранилище. Собственного плоского каталога
записей у сервиса MUST не оставаться: файл, лежащий мимо хранилища, не попадёт
ни в панель владельца, ни в резервную копию, а ради этих двух вещей перевод и
делается.
Содержимое записи MUST не читаться в память целиком ни при укладке в хранилище,
ни при чтении из него: расчётный потолок записи — шесть часов, и такая запись в
память не помещается.
**Потолок размера записи MUST быть задан числом, выведенным из этого расчётного
потолка**, и задан он MUST быть везде, где иначе действует чужое умолчание: и у
поля файла в хранилище, и у тела запроса приёма. Умолчания здесь не «без
предела», а величины на два-три порядка меньше нужного, и оставленные как есть
они отвергают штатную запись сервиса — приём отказывает, а уже принятая запись
исчерпывает попытки на шаге конвертации.
Отказ по этому потолку MUST быть виден отправителю ответом, а не молчанием.
Шаги, которым нужен файл именем на диске — конвертация и чтение метаданных
отдают его внешней программе, — MUST получать рабочую копию **одним общим
способом**, и у этого способа MUST быть единственный способ её убрать. Уборку
зовёт шаг, и звать её он MUST на любом исходе, включая отказ. Заводить копию по
месту шагам MUST не приходиться: иначе обязанность прибрать переписывается
столько раз, сколько шагов, а забытая копия — это шестичасовая запись,
оставшаяся во временном каталоге, и узнать о ней неоткуда.
#### Scenario: Принятая запись легла в хранилище
- **WHEN** запись принята любым входом
- **THEN** её файл лежит в хранилище и связан со своей записью
- **AND** отдельного каталога записей рядом с хранилищем не появляется
#### Scenario: Запись длиннее чужого умолчания принимается
- **WHEN** в хранилище кладут запись длиннее умолчания, действующего у поля файла
- **THEN** она ложится в хранилище, а не отвергается
#### Scenario: Шаг конвейера берёт файл по записи
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** шаг конвейера берётся за эту запись
- **THEN** он получает файл по самой записи, а не по пути на диске
#### Scenario: Рабочая копия убрана после отказа шага
- **GIVEN** шагу выдана рабочая копия файла
- **WHEN** шаг завершается отказом
- **THEN** рабочей копии во временном каталоге не остаётся
### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
записи. Отданный файл MUST совпадать с принятым по длине.
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
бессрочно.
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
другое кончается в журнале и собирает ссылку не хуже успешного пути.
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
`intake`.
#### Scenario: Файл забирают по ссылке
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Ссылка ведёт в никуда
- **WHEN** запрашивают ссылку на запись, которой нет
- **THEN** приходит отказ, а не пустой ответ
#### Scenario: По журналу ссылку не собрать
- **GIVEN** запись принята и прошла конвейер
- **WHEN** читают журнал сервиса целиком
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
#### Scenario: Отказ чтения файла не называет его ключ
- **GIVEN** файл записи не читается из хранилища
- **WHEN** шаг конвейера берётся за эту запись и отказывает
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
### Requirement: Наружу хранилище отдаёт только то, что заказано
Сервис SHALL держать закрытыми собственные разделы хранилища, которые тот
публикует тем же портом. Запрос без прав владельца MUST получать отказ на
перечисление и чтение записей коллекций, на служебные разделы хранилища —
журналы запросов, резервные копии, настройки, расписание — и на правку чего бы
то ни было.
Требование заводится потому, что порт опубликован в интернет, а вместе с
переводом наружу выходит поверхность, которой у сервиса не было. Что API сервиса
сегодня открыт всякому — известно и записано моделью угроз; новая поверхность под
это знание не подпадает и закрывается здесь.
Правило доступа, оставленное пустым, значит «только владелец панели». Именно
пустым оно MUST и оставаться: непустое правило, поставленное будущей правкой
схемы, открыло бы перечисление всех записей анонимному запросу и не нарушило бы
при этом ни одного другого требования.
#### Scenario: Аноним перечисляет записи
- **WHEN** запрос без прав владельца просит список записей коллекции задач
- **THEN** приходит отказ
#### Scenario: Аноним читает служебный раздел
- **WHEN** запрос без прав владельца просит журнал запросов или список резервных
копий хранилища
- **THEN** приходит отказ
### Requirement: Владелец видит записи в панели
Сервис SHALL давать владельцу панель, где задача видна строкой, отбирается по
своему идентификатору и правится, а её файл слушается и скачивается.
Панель MUST отдаваться тем же сервисом по своему адресу и MUST не требовать
второго процесса.
Панель — вход в задачу наравне с конвейером, а не окно просмотра, и правка
состояния задачи в ней MUST подчиняться тем же правилам перехода, что и правка
из кода: служебные поля прошлого состояния — признак захвата, время захвата,
пауза, число попыток — MUST очищаться. Иначе владелец, вернувший мёртвую задачу в
работу, получит задачу, которая не выдаётся захвату до конца прежнего срока и
умирает от первого же отказа, — и не узнает об этом.
Задача, заведённая в панели руками, MUST не уносить сервис: поля, без которых
шаг конвейера не может работать, MUST быть обязательными в самой схеме, а
перечень состояний — закрытым.
Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все
записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не
сервис — это записано моделью угроз проекта.
#### Scenario: Принятая запись видна владельцу
- **GIVEN** запись принята и её задача заведена
- **WHEN** владелец отбирает задачи по идентификатору принятой
- **THEN** он видит её строкой со своим состоянием
- **AND** файл этой записи скачивается из той же строки
#### Scenario: Мёртвую задачу вернули в работу правкой в панели
- **GIVEN** задача в состоянии «мертва» с исчерпанными попытками и признаком
прежнего захвата
- **WHEN** владелец меняет её состояние на рабочее
- **THEN** признак захвата, время захвата, пауза и число попыток очищены
- **AND** ближайший захват выдаёт задачу
### Requirement: Пароль владельца от панели не лежит в конфигурации
Сервис SHALL не заводить в конфигурации ключа под пароль владельца от панели.
Пароль MUST задаваться самим владельцем, а хранилище MUST держать только его
отпечаток.
Требование стоит на инварианте проекта «Секрет не покидает конфиг» с другой
стороны: секрет, которого в конфигурации нет, не утекает вместе с ней и не
уезжает в выкладку третьим путём. Пароль от панели открывает все записи и все
файлы разом — это самое чувствительное, что есть у сервиса.
Приглашение завести владельца сервис MUST печатать только пока владельца нет, и
оно MUST истекать по времени. Приглашение равносильно паролю от панели, а
печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы
панель всякому читателю логов навсегда.
Пока владелец пароля не задал, сервис MUST работать обоими входами: панель без
владельца не мешает принимать записи.
#### Scenario: Владелец пароля ещё не задал
- **GIVEN** каталог данных пуст и владелец панели не заведён
- **WHEN** сервис запускается
- **THEN** он принимает записи обоими входами
- **AND** ни один ключ конфигурации не несёт пароля от панели
#### Scenario: Владелец заведён, приглашение больше не печатается
- **GIVEN** владелец панели заведён
- **WHEN** сервис запускается снова
- **THEN** приглашения завести владельца в журнале нет