хранилище, файлы записей и очередь переведены на встроенную 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
+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** приглашения завести владельца в журнале нет