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