- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
140 lines
9.7 KiB
Markdown
140 lines
9.7 KiB
Markdown
# PocketBase: умолчания, которые ломают штатный сценарий
|
||
|
||
**Записка о прошлом.** PocketBase ушла из проекта целиком 2026-08-22 —
|
||
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
|
||
Умолчания ниже принадлежат ушедшей библиотеке и ни на что в сервисе не влияют.
|
||
Живое из записки переехало в [../database.md](../database.md), «Настройки с
|
||
числовым значением», — потолок размера одной записи, потолок тела запроса и
|
||
снятый таймаут чтения, — и в
|
||
[ADR-2026-08-15-owner-required-by-schema](../adr/ADR-2026-08-15-owner-required-by-schema.md).
|
||
|
||
Наблюдения, снятые по ходу задачи `pocketbase-storage` уже на своём коде. От
|
||
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
|
||
панель**, эта — **что библиотека делает молча**, если её не переубедить.
|
||
|
||
Наблюдения нашлись ревью, а не чтением документации, и все об одном роде промаха:
|
||
объявление библиотеки выглядит как «ограничения нет» либо «ограничение есть», а
|
||
значит обратное.
|
||
|
||
## Как снималось
|
||
|
||
Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге
|
||
данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые
|
||
данные и ключи не участвовали. Числа сняты 2026-08-11 и 2026-08-12, последнее
|
||
наблюдение — 2026-08-15.
|
||
|
||
## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела»
|
||
|
||
`&core.FileField{MaxSize: 0}` читается библиотекой как её собственное умолчание:
|
||
|
||
```
|
||
core/field_file.go:28 const DefaultFileFieldMaxSize int64 = 5 << 20
|
||
core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
|
||
```
|
||
|
||
Проверено укладкой: файл в 6 МиБ отвергается на сохранении записи —
|
||
`the maximum allowed file size is 5242880 bytes`. Прогон через боевой роутер дал
|
||
границу дословно:
|
||
|
||
| тело запроса | ответ |
|
||
| --- | --- |
|
||
| 4 194 304 байта | `201` |
|
||
| 5 238 784 байта | `201` |
|
||
| 5 246 976 байт | `500` |
|
||
| 34 603 008 байт | `413` |
|
||
|
||
**Цена для сервиса:** 5 МиБ — это примерно 5,5 минут mp3 при 128 кбит/с. Отвергалась
|
||
бы не только длинная запись на приёме: результат конвертации в ogg переваливает
|
||
тот же порог примерно на пятой минуте, и **уже принятая** задача исчерпывала бы
|
||
попытки на шаге конвертации.
|
||
|
||
## Тело запроса режется на 32 МиБ раньше обработчика
|
||
|
||
`apis/base.go:36` вешает `BodyLimit(DefaultMaxBodySize)` на **корневой** роутер,
|
||
то есть и на чужие маршруты; `apis/middlewares_body_limit.go:14` —
|
||
`const DefaultMaxBodySize int64 = 32 << 20`. Ответ `413` уходит мимо обработчика,
|
||
без строки в журнале приёма (последняя строка таблицы выше).
|
||
|
||
Снимается на маршруте: `.Bind(apis.BodyLimit(<своё число>))`.
|
||
|
||
## Таймаут чтения запроса — пять минут
|
||
|
||
`apis/serve.go:151` ставит `ReadTimeout: 5 * time.Minute`. Заливка шестичасовой
|
||
записи по медленному каналу его переживает: соединение рвётся на середине.
|
||
Снимается в хуке `OnServe` — `se.Server.ReadTimeout = 0`.
|
||
|
||
## Суффикс к имени файла дописывает конструктор, а не укладка
|
||
|
||
Первая записка наблюдала `sample.ogg → sample_uztrv6wvz3.ogg` и читала это как
|
||
свойство хранилища. Наблюдение верно **только когда имя строит сама библиотека**:
|
||
десять случайных знаков добавляет `normalizeName`, вызываемый из
|
||
`filesystem.NewFileFrom*`. Имя, положенное в поле `File.Name` после
|
||
конструктора, ложится на диск дословно:
|
||
|
||
```
|
||
задано 11111111-2222-3333-4444-555555555555.mp3
|
||
на диске 11111111-2222-3333-4444-555555555555.mp3
|
||
```
|
||
|
||
**Цена:** тот, кто задаёт имя сам, не получает от суффикса никакой
|
||
неугадываемости — и защищать ссылку на файл ему приходится другим.
|
||
|
||
## Хук правки записи не различает, кто пишет
|
||
|
||
`app.OnRecordUpdate(<коллекция>)` — событие **модели**: оно срабатывает на каждом
|
||
`app.Save`, включая сохранение из собственного кода. Хук, написанный «для
|
||
панели», правил записи конвейера: проверено прогоном — задержка, поставленная
|
||
шагом вместе со сменой состояния, обнулялась тем же сохранением.
|
||
|
||
Различает источник `app.OnRecordUpdateRequest(<коллекция>)`: оно поднимается
|
||
только на правку запросом, а код, пишущий мимо HTTP-слоя, под него не попадает.
|
||
|
||
## Приглашение завести владельца панели живёт полчаса
|
||
|
||
`apis/installer.go:31` — `systemSuperuser.NewStaticAuthToken(30 * time.Minute)`;
|
||
печатается только пока владельца нет (`needInstallerSuperuser`). Проверено
|
||
прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения
|
||
владельца при следующем запуске её нет.
|
||
|
||
## Обязательность связи проверяется у записи, а не у колонки
|
||
|
||
`Required` у поля связи — правило **проверки записи при сохранении**, а не
|
||
ограничение таблицы. Шаг схемы, объявляющий колонку обязательной на базе, где уже
|
||
лежат строки с пустым значением, проходит **зелёным** и такие строки оставляет:
|
||
|
||
```
|
||
core/field_relation.go:156 ColumnType отдаёт TEXT DEFAULT '' NOT NULL — от Required не зависит
|
||
core/collection_validate.go ни одной проверки, читающей существующие строки
|
||
```
|
||
|
||
Проверено прогоном 2026-08-15 на копии хранилища во временном каталоге: строка с
|
||
пустым владельцем заведена до шага, шаг применён тем же кодом, что и на подъёме,
|
||
и вывод:
|
||
|
||
```
|
||
STEP 003 (Required=true) поверх ничьей записи: err=<nil>
|
||
ПОСЛЕ ШАГА: строка на месте, owner=""
|
||
Save остановленной ничьей записи: err=failed to update audio record: owner: cannot be blank.
|
||
```
|
||
|
||
Следствие для нас: оставленная строка становится **незакрываемой**. Захват идёт
|
||
сырым запросом мимо проверки и выдаёт её воркеру, а всякое сохранение отказывает —
|
||
включая то, которым ставится признак остановки. Искать такие строки надо запросом
|
||
до выкладки, а не прогоном самого шага: прогон чистую базу от грязной не
|
||
отличает. Цена решения записана в
|
||
[adr/ADR-2026-08-15-owner-required-by-schema.md](../adr/ADR-2026-08-15-owner-required-by-schema.md).
|
||
|
||
## Чего эта записка не узнала
|
||
|
||
- **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из
|
||
шестичасовой записи с запасом на видео, а не замером: настоящего распределения
|
||
длин у сервиса нет.
|
||
- **Как ведёт себя укладка файла в несколько гигабайт.** Самая длинная проверенная
|
||
запись — 9,6 МБ (десять минут mp3). Потоковую укладку это подтверждает, предел
|
||
— нет.
|
||
- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено
|
||
тестом на одной машине, не живой нагрузкой.
|
||
- **Сколько строк с пустой связью выдерживает смена признака обязательности.**
|
||
Проверено на одной строке: суть наблюдения — сам факт отсутствия проверки, а не
|
||
её цена на объёме.
|