хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась панель владельца, а gin, goqu, goose и требование CGO ушли - захват задачи стал одним запросом с RETURNING; заведены число попыток, состояние dead и нарастающая пауза вместо признака is_error - имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
@@ -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 Прогон поверхности: анонимный запрос к записям коллекций и к служебным
|
||||
разделам хранилища получает отказ
|
||||
Reference in New Issue
Block a user