- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
323 lines
28 KiB
Markdown
323 lines
28 KiB
Markdown
## 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` не даёт, и замок на файле берём сами; забытый замок виден только на
|
||
чистой базе, которую заводят один раз, поэтому проверка на два одновременных
|
||
наката стоит критерием приёмки.
|
||
- **Прежние адреса продолжают отвечать чем-то посторонним** → пути хранилища и
|
||
панели перестают быть корнями сервиса и подпадают под общее правило
|
||
неизвестного пути; критерий приёмки проверяет и подменённый знак в адресе
|
||
панели.
|
||
- **Норма отказа «узнан, но учётной записи нет» теряет свой единственный
|
||
случай** → ветвь снимается вместе с ним; вернуть её придётся задаче о личных
|
||
токенах, и там же она получит свой случай.
|