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

323 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
Сегодня встроенное хранилище держит в этом коде шесть ролей сразу, и только две
из них про хранение. Что именно оно держит, чем за это плачено и чем платит
уход — измерено разведкой
[docs/research/storage-without-pocketbase.md](../../../docs/research/storage-without-pocketbase.md);
варианты («оставить как есть», «уйти целиком», «уйти в два шага», «локализовать
протечку») названы там же таблицей, а выбор и причины отказа от остальных
записаны решением
[ADR-2026-08-22-storage-without-pocketbase](../../../docs/adr/ADR-2026-08-22-storage-without-pocketbase.md).
Второй раз здесь они не переписываются.
Обстоятельство, которое делает работу дешёвой именно сейчас: стадия проекта —
стройка, на сервере данных нет, сервис остановлен. Раскладка каталога данных
названа необратимой, и цена её смены сегодня нулевая.
Ниже — только те решения, которых в ADR и разведке нет: они выбирают форму, а
не направление.
## Goals / Non-Goals
**Goals:**
- Человек, открывший приложение, не замечает смены вовсе: адреса приложения,
формы запросов и ответов остаются прежними.
- Второго периметра на порту сервиса не остаётся: всё, что отвечает, написано
нами и судится нашими правилами.
- Файл записи достаётся только его владельцу, и право пройти по ссылке даёт
узнавание, а не значение, выданное на предъявителя.
- Захват записи остаётся неделимым, и результат по-прежнему пишет только
держатель захвата.
- Сервис поднимается на пустом каталоге данных сам, без ручного шага.
**Non-Goals:**
- Замены панели владельца это изменение не приносит: экраны правки записи и
прослушивания несут отдельные задачи. До них у владельца сервиса остаётся одно
действие — возврат остановленной записи в работу, — и делает его подкоманда
оснастки, а не экран.
- Переноса прежних данных нет: переносить нечего.
- Загрузка частями, узнавание записи по хеш-сумме, удаление записи и вторая
копия рядом становятся выполнимыми, но этим изменением не делаются.
- Состав полей карточки и списка не меняется ни одним полем.
## Decisions
### Файлы записей лежат каталогом на запись
Каталог данных получает раздел записей, и каждой записи в нём принадлежит свой
подкаталог, названный её идентификатором. Внутри лежат копии — принятая и
приведённая, — под именами, которые задаёт сервис.
Что человек увидит иначе: ничего. Что станет возможным — вторая копия рядом с
первой, дозапись частями и уборка записи целиком одним движением; сегодня всё
это упирается в чужую раскладку.
Рассмотрено и отвергнуто:
- **плоский каталог, имя файла — идентификатор с расширением.** Отвергнут: две
копии одной записи в плоском каталоге различаются только приставкой в имени, и
уборка записи превращается в перебор по маске;
- **раскладка по первым знакам идентификатора** (как у git). Отвергнута: она
лечит переполнение каталога тысячами имён, а расчётная нагрузка — единицы
записей в день; платить за это нечитаемыми путями сегодня не за что.
### Файл отдаётся адресом приложения, а не адресом хранилища
Файл записи уходит адресом под корнем приложения, и вид копии называет
спрашивающий — тем же способом, каким называется вид текста. Обработчик судит
владельца записи и отвечает на чужую и на несуществующую одинаково.
Что человек увидит иначе: короткого токена файла больше нет, и порядок из трёх
шагов («узнавание → токен → ссылка») становится одним шагом. Отзыв доступа
доходит до файла сразу, а не через срок жизни токена.
Рассмотрено и отвергнуто:
- **своё значение на предъявителя, выдаваемое узнанному** — то, что было.
Отвергнуто: узнавание идёт на каждом запросе, второе значение ничего не
добавляет и создаёт окно, в котором отозванный доступ ещё работает;
- **ссылка со сроком и подписью** (как у объектных хранилищ). Отвергнута: та же
цена, что и у токена, плюс свой секрет подписи, которого у сервиса нет.
### Шаги схемы двигает `goose`, а не свой раннер
Шаги схемы накатывает `github.com/pressly/goose/v3` — **библиотекой, а не
командной строкой**: каталог шагов вшит в бинарник, провайдер заводится в точке
входа и получает пишущий пул базы, накат идёт до подъёма входов и до старта
воркеров, отказ шага роняет старт. Ни отдельного исполняемого файла, ни своего
шага выкладки не заводится.
Инструмент выбран решением владельца 2026-08-22. Он уже был в этом проекте и
ушёл вместе с PocketBase: возврат не приносит нового знания, а снимает часть
работы.
Из трёх норм, которые накат обязан выполнять, `goose` даёт две — и даёт по
факту, а не по обещанию:
- **шаг и отметка о нём идут одной транзакцией.** Накат одного шага открывает
транзакцию на том же соединении и внутри неё выполняет и сам шаг, и вставку
версии в таблицу учёта (`provider_run.go`: `beginTx` вокруг `runMigration` и
`maybeInsertOrDelete`). Отменяет это только сам шаг — пометкой `-- +goose NO
TRANSACTION`, — и мы её не ставим;
- **порядок шагов детерминирован.** Версия шага читается числом из имени файла
до первого знака подчёркивания (`NumericComponent` в `migration.go`), а
собранные шаги сортируются по этому числу (`sort.Slice` в
`provider_collect.go`). Порядок обхода каталога на исход не влияет: его
результат пересортировывается. Две одинаковых версии дают отказ сбора с обоими
путями, а не молчаливый выбор одной.
Третьей нормы — **исключающей блокировки наката**`goose` под SQLite не даёт
вовсе. Пакет `lock` поставляет два запирателя, и оба для PostgreSQL:
`NewPostgresSessionLocker` и `NewPostgresTableLocker`; интерфейсы `SessionLocker`
и `Locker` объявлены вместе с отказом `ErrLockNotImplemented`, а провайдер,
которому запиратель не задан, накатывает без всякой блокировки.
Блокировка остаётся нашей заботой и закрывается замком на файле в каталоге
данных: `syscall.Flock` с `LOCK_EX` берётся до наката и снимается после, а с
умершим процессом его снимает ядро — просроченного замка, который надо чистить
руками, не остаётся. Второй процесс, поднятый на том же каталоге, ждёт замка
либо отказывает. Норму держит требование capability `storage` «Сервис
поднимается на чистом каталоге данных», и написана она про поведение, а не про
инструмент.
Что человек увидит иначе: ничего, пока всё цело. Отказ схемы становится отказом
старта, а не отказом каждого запроса потом.
Рассмотрено и отвергнуто:
- **свой раннер** — чтение каталога шагов, своя таблица учёта, своя сортировка.
Отвергнут: три нормы выше ему пришлось бы выполнять самому, две из них
`goose` уже выполняет, а третья остаётся нашей при любом выборе. Плата за
свой раннер — код, который надо писать, отлаживать и держать, — вносится ради
экономии одной зависимости, уже бывшей в проекте;
- **схема одним файлом, приводимым к желаемому виду при старте.** Отвергнута:
инвариант проекта требует версионированных шагов и запрещает переписывать
применённый.
### Прежние шаги схемы заменяются одним шагом начальной схемы
Применённые шаги PocketBase удаляются каталогом целиком, и на их месте встаёт
один шаг, заводящий схему сразу такой, какая нужна. Это **снятие инварианта
проекта «Миграция, уехавшая на сервер, не переписывается»** — разовое, решением
владельца от 2026-08-22.
Причина: стадия проекта — стройка, на сервере данных нет, сервис остановлен,
выкладка пойдёт с чистого листа. Переписывать нечего: ни один из прежних шагов
не применён ни к одной живой базе, а новая база заводится другим инструментом и
другой таблицей учёта — отметки прежнего каталога ей не годятся вовсе.
Граница: снятие разовое и кончается этим изменением. Шаг начальной схемы, уехав
на сервер, подпадает под инвариант как всякий прежний — менять его можно будет
только новым шагом. Шаг гейта `migrations` при этом обязан смотреть на новый
каталог: оставленный на старом, он прочитает удаление прежних файлов как
переписывание применённого и покраснеет.
### База принимает одного писателя
Соединение для записи одно, чтение идёт своим пулом; журнал упреждающей записи
включён, соблюдение внешних ключей включено, ожидание занятой базы задано числом.
Все три задаются строкой подключения обоих пулов, а не запросом после открытия:
две из трёх настроек в SQLite принадлежат соединению, а не базе, и пул заводит
новые соединения по мере надобности. Числа переезжают в дом числовых настроек
проекта.
**Два числа заводятся ключами конфига сразу, этим же изменением:**
- `[storage] busy_timeout_ms` — сколько ждать занятую базу, миллисекунды;
- `[storage] read_connections` — сколько соединений держит читающий пул.
Оба крутят при одном и том же отказе — «база занята» под несколькими
воркерами, — и число воркеров у сервиса уже настраивается. Константами кода они
означали бы, что подбор ответа на этот отказ требует пересборки образа.
Операция, которая читает и следом пишет, идёт целиком на пишущем соединении:
читающую транзакцию SQLite до пишущей не повышает и отказывает по занятости сразу,
не дожидаясь заданного числом срока. Так идут захват записи и накат шага схемы.
Узнавание устроено иначе, и это решение владельца от 2026-08-23. Учётная запись
ищется читающим пулом, а пишущая транзакция открывается только когда поиск ничего
не нашёл; окно между двумя соединениями закрывает повторный поиск внутри
транзакции, а уникальность держит схема. Прежнее устройство брало писателя на
каждом запросе приложения — включая опрос карточки и запрос куска записи, — и
цена этого измерена: очередь к единственному пишущему соединению ожиданием
занятой базы не ограничена и отказом не кончается, а ждёт столько, сколько
занят писатель.
Что человек увидит иначе: ничего, пока настройка верна. Ошибка здесь видна
отказами «база занята» под несколькими воркерами — тем самым, что чужая
библиотека держала за нас двумя пулами, — а ошибка во внешних ключах не видна
вовсе: ничья запись просто заводится.
Рассмотрено и отвергнуто: **один пул на всё** — отвергнут разведкой,
измерившей, что драйвер пишет единственным соединением.
### Пакет хранилища называется `internal/adapter/repo/sqlite`
Пакет `internal/adapter/repo/pocketbase` уходит целиком, и на его месте встаёт
`internal/adapter/repo/sqlite`. Имя выбирается здесь, а не при написании кода: оно
разойдётся по импортам, по правилам `internal/archrules`, которые указывают в
пакет строкой, и по документам канона — переименование после этого стоит дороже
самого выбора.
Названо по драйверу, а не по роли: соседи в `internal/adapter` названы тем же
способом — `converter`, `metaviewer`, `recognizer`, — и «repo/sqlite» читается как
«репозитории поверх SQLite» без знания кода.
Рассмотрено и отвергнуто: **`internal/adapter/repo/db`** — не называет ничего, а
второй драйвер рядом с ним пришлось бы назвать по имени, и пара вышла бы
разнородной.
### Захват остаётся одним запросом с возвратом
Выбор пригодной записи и пометка её захваченной идут одним запросом, и он же
возвращает идентификатор записи и признак этого захвата. Признак уникален для
каждого захвата, и запись результата условна по нему.
Решение подтверждается, а не принимается заново: замер, которым выбрана очередь
таблицей, снят на том же драйвере, который остаётся после ухода. Отменяется одно
слово — таблица перестаёт быть коллекцией.
### Ограничитель частоты становится своим
Бюджет считается по адресу спрашивающего под корнем приложения. Объявленная
приложению частота опроса карточки по-прежнему выводится из доли этого бюджета,
а не из своей константы.
Что человек увидит иначе: включение нашего правила перестаёт вводить в действие
чужие правила на чужой поверхности — их больше нет.
### Панель не заменяется ничем, а возврат в работу делает подкоманда оснастки
Владелец сервиса теряет панель, и заменяющего её экрана это изменение не
приносит. Единственное действие, которое он делал панелью и которое нельзя
отложить до экранов, — возврат остановленной записи в работу — переезжает в
набор инструментов разработчика, `cmd/devtools`, отдельной подкомандой. Она
открывает базу того же каталога данных, зовёт домен и пишет событие журнала
записи с происхождением «человек».
Причины две, и каждая своя.
Первая: возврат в работу — не одно поле. Сбросить надо признак остановки, признак
захвата и срок его протухания, число отказов, паузу и время входа в рубеж; норму
держит capability `pipeline`. Рука, забывшая любое из них, оставляет запись либо
невидимой для захвата, либо останавливаемой снова первым же захватом — молча, без
единой строки. Подкоманда колонок не пишет: правило исполняет домен, а она
назначает порядок шагов.
Вторая: `entity.EventOriginHuman` теряет иначе единственного писателя — его писала
панель. Происхождение события в журнале записи стало бы константой, и различие
«это сделал конвейер» против «это сделал человек» перестало бы значить что-либо.
Что человек увидит иначе: вместо таблицы с полями у владельца одна команда с
одним предметом. Экраны приносят отдельные задачи, и подкоманда живёт до них.
Рассмотрено и отвергнуто: **правка строки в базе руками** — то, чем возврат
описывался до чекпоинта. Отвергнута: перечисленные выше поля человек за
клавиатурой сбрасывает по памяти, а событие журнала записи не пишется вовсе.
### Колонок `location` и `source` в новой схеме нет
Обе колонки заводятся сегодня, пишутся одним значением и не читаются никем.
`location` у сущности файла: туда пишется `local`, и второго значения (`s3`) не
пишет ни один шаг. Ветвления по нему в коде нет.
`source` у аудиозаписи: всякий приём пишет туда `api`. Второе значение,
`telegram`, держалось не потребителем, а ссылкой из применённого шага схемы —
вход Telegram убран 2026-08-14, и константа осталась только потому, что
применённый шаг не переписывается. Шаги уходят, и держать её больше нечем.
Ни одной колонки в новой схеме не заводится, и поля уходят из сущностей домена и
из их отображения в строки базы. Цена возврата названа прямо: поле, у которого
появится читатель — вторая копия в объектном хранилище либо второй вход, —
вернётся одним новым шагом схемы, и платится это тогда, а не сейчас.
### Колонки записи пишутся и читаются по имени
Отображение сущности в строку базы работает **именованными параметрами** запроса
и сканированием **по имени колонки**, а не позиционными списками.
Причина в самой сущности: у аудиозаписи поля одного типа — необязательной
строки — идут длинным непрерывным рядом, и ссылки на файл, на структуру реплик,
на два вида текста и на попытку распознавания стоят в нём подряд. Позиционный
список даёт сдвиг на одно поле, который компилируется молча и кладёт
идентификатор файла в колонку текста. По имени такого сдвига не существует
вовсе: лишнее имя или недостающее — отказ запроса, а не тихая подмена значения.
Инвариант проекта о колонках записи, правящихся в двух местах сразу, эта форма не
снимает: колонку по-прежнему можно забыть в отображении или в шаге схемы. Она
снимает **другую** поломку — ту, где колонка не забыта, а перепутана местом.
Сторожа инварианта в `internal/archrules` переписываются под эту форму: сегодня
они построены на динамической записи по имени колонки в API уходящей библиотеки.
## Risks / Trade-offs
- **Владелец остаётся без панели, а экранов ещё нет** → возврат остановленной
записи в работу делает подкоманда `cmd/devtools`, а прочая правка ждёт экранов;
сервис на стройке, живых записей нет, и цена ограничена этим окном.
- **Раскладка каталога данных меняется необратимо** → цена нулевая сегодня и
перестаёт быть нулевой после первой боевой записи; смена сделана до выкладки.
- **Отдача файла написана нами и может отдать чужое** → правило одно: обработчик
судит владельца записи, а чужая запись отвечает тем же, чем несуществующая;
проверка на это стоит критерием приёмки.
- **Единственный писатель настроен неверно** → отказы «база занята» под
несколькими воркерами; ожидание и размер читающего пула заданы ключами
`[storage] busy_timeout_ms` и `[storage] read_connections` и стоят в доме
числовых настроек проекта.
- **Шаг схемы не накатился, а сервис поднялся** → накат идёт до подъёма входов,
и его отказ роняет старт; критерий приёмки требует чистого журнала до строки о
готовности.
- **Два процесса накатывают схему на одном каталоге** → блокировки под SQLite
`goose` не даёт, и замок на файле берём сами; забытый замок виден только на
чистой базе, которую заводят один раз, поэтому проверка на два одновременных
наката стоит критерием приёмки.
- **Прежние адреса продолжают отвечать чем-то посторонним** → пути хранилища и
панели перестают быть корнями сервиса и подпадают под общее правило
неизвестного пути; критерий приёмки проверяет и подменённый знак в адресе
панели.
- **Норма отказа «узнан, но учётной записи нет» теряет свой единственный
случай** → ветвь снимается вместе с ним; вернуть её придётся задаче о личных
токенах, и там же она получит свой случай.