хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
+276
-217
@@ -1,62 +1,118 @@
|
||||
# Схема хранилища
|
||||
|
||||
Хранилище, коллекции, правило времени и идентификаторов.
|
||||
База, таблицы, раскладка файлов, правило времени и идентификаторов.
|
||||
|
||||
Хранилище — **встроенная PocketBase 0.39.10**: она держит и базу, и файлы
|
||||
записей под одним каталогом данных. Ключ конфигурации — `[storage] data_dir`,
|
||||
умолчание `data`. В SQLite библиотека ходит через `modernc.org/sqlite`, поэтому
|
||||
CGO сборке не нужен.
|
||||
Хранилище **своё**: база SQLite через `modernc.org/sqlite` (CGO сборке не нужен)
|
||||
и файлы записей своим каталогом рядом с ней. Ключ конфигурации один —
|
||||
`[storage] data_dir`, умолчание `data`. Встроенная PocketBase, державшая до
|
||||
2026-08-22 и базу, и файлы, и панель, и маршрутизатор, ушла из проекта целиком —
|
||||
задача `storage-without-pocketbase`,
|
||||
[ADR](adr/ADR-2026-08-22-storage-without-pocketbase.md).
|
||||
|
||||
Схему двигают **шаги миграций PocketBase** на Go, каталог
|
||||
`internal/adapter/repo/pocketbase/migrations`, файл на шаг и имя файла — имя
|
||||
шага. Шаг регистрируется при загрузке пакета, а накатывается при подъёме
|
||||
хранилища (`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый
|
||||
шаг не переписывается — изменение только новым шагом: применённое хранилище
|
||||
считает по имени шага.
|
||||
**База принимает одного писателя.** Пишущий пул держит одно соединение — драйвер
|
||||
пишет единственным, и несколько воркеров, пришедших писать разом мимо этого
|
||||
правила, получают отказ по занятости на записи результата шага, то есть после
|
||||
оплаченной работы. Чтение идёт отдельным пулом: в журнале упреждающей записи
|
||||
читатели не мешают писателю.
|
||||
|
||||
Журнал упреждающей записи, соблюдение внешних ключей и ожидание занятой базы
|
||||
задаются **строкой подключения обоих пулов**, а не запросом после открытия: две
|
||||
из трёх настроек в SQLite принадлежат соединению, а не базе, а пул заводит новые
|
||||
соединения по мере надобности — запрос настроил бы одно из многих. Операция,
|
||||
которая читает и следом пишет, идёт целиком по пишущему соединению: читающую
|
||||
транзакцию SQLite до пишущей не повышает и отказывает по занятости немедленно.
|
||||
|
||||
Схему двигают **шаги `github.com/pressly/goose/v3`** — библиотекой, а не
|
||||
командной строкой. Каталог `internal/adapter/repo/sqlite/migrations`, файл на
|
||||
шаг, версия шага — число в начале имени файла. Перечень шагов приходит
|
||||
провайдеру доводом, провайдер заводится в точке входа и получает пишущий пул,
|
||||
накат идёт **до подъёма входов и до старта воркеров**, а отказ шага роняет старт.
|
||||
Применённый шаг не переписывается — изменение только новым шагом.
|
||||
|
||||
Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же
|
||||
соединении. Порядок шагов детерминирован и выводится из версии, а не из порядка
|
||||
чтения каталога; две одинаковых версии дают отказ сбора.
|
||||
|
||||
**Исключающую блокировку наката держим сами.** Библиотека под SQLite её не
|
||||
поставляет вовсе — её запиратели объявлены только для PostgreSQL, а провайдер без
|
||||
запирателя накатывает без всякой блокировки. Замок берётся на файле
|
||||
`data/migrate.lock` (`syscall.Flock`, `LOCK_EX`) и снимается закрытием
|
||||
дескриптора; с умершим процессом его снимает ядро, поэтому просроченного замка,
|
||||
который надо чистить руками, не остаётся.
|
||||
|
||||
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя:
|
||||
шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу
|
||||
пути**, а префикс наводится только на каталог. Где этот префикс задан —
|
||||
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано». Имена коллекций живут там же, рядом с шагом, который их заводит; пакет
|
||||
репозитория берёт их оттуда.
|
||||
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано».
|
||||
|
||||
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
|
||||
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
|
||||
хранилище, задаёт сервис, и это `<uuid><расширение>`.
|
||||
**Идентификаторы** — ULID в нижнем регистре, `TEXT`, 26 знаков алфавита
|
||||
Crockford. Выдаёт их приложение единой точкой `internal/ident`; внутри одной
|
||||
миллисекунды выдача монотонна, потому что колонка времени несёт секунды и
|
||||
порядок записей одной секунды задаёт ключ. Идентификатор, пришедший снаружи,
|
||||
разбирается на границе: разбор проверяет вид и приводит регистр, а негодный
|
||||
считается несуществующей записью и до базы не доходит.
|
||||
|
||||
**Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки
|
||||
`created` и `updated` проставляет само хранилище; те же поля в сыром запросе
|
||||
захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite
|
||||
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
|
||||
постоянную ложь молча.
|
||||
Тем же идентификатором зовётся **подкаталог записи** в каталоге данных, а имя
|
||||
файла внутри него — `<ULID><расширение>`.
|
||||
|
||||
Того, что единой точки генерации идентификатора и времени нет, здесь не
|
||||
повторяем: перечень единых точек и их отсутствий держит
|
||||
[architecture.md](architecture.md), «Единые точки проекта».
|
||||
**Время** — `TEXT` в RFC 3339, UTC, суффикс `Z`, секундная точность:
|
||||
`2006-01-02T15:04:05Z`. Ширина записи постоянная, поэтому лексикографический
|
||||
порядок совпадает с хронологией. Вид один на **все** колонки времени, включая
|
||||
те, что пишет только сам сервис: своего типа времени у SQLite нет, колонка
|
||||
хранит то, что в неё положили, и колонка, заполненная то одним видом, то другим,
|
||||
обратила бы условие срока протухания захвата в постоянную истину или ложь молча.
|
||||
|
||||
## Коллекции
|
||||
Время ставит приложение единой точкой `internal/clock`. **Умолчаний вида
|
||||
`CURRENT_TIMESTAMP` в схеме нет**: умолчание писало бы свой вид времени, а
|
||||
вставка, забывшая проставить время, при нём прошла бы молча.
|
||||
|
||||
## Таблицы
|
||||
|
||||
**Перечни значений держит код, а не схема.** Прежде рубеж, причина остановки и
|
||||
вид текста были закрыты `CHECK`-подобным типом хранилища, потому что панель
|
||||
владельца правила запись руками и вправе была завести значение, которого сервис
|
||||
не знает. Панели нет, правка идёт только нашим кодом, и закрытый перечень в схеме
|
||||
остался бы ценой — новое значение стоило бы нового шага — без покупателя.
|
||||
|
||||
### `users`
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | ULID, выдаёт приложение |
|
||||
| `provider_login` | TEXT, уникален | Логин человека **у провайдера**: то значение, которым его называет обратный прокси заголовком `Remote-User`. Ключ учётной записи |
|
||||
| `name` | TEXT | Имя, пригодное к показу; берётся при заведении и вторым обращением не переписывается |
|
||||
| `email` | TEXT | Адрес почты; необязателен |
|
||||
| `created_at`, `updated_at` | TEXT | Время |
|
||||
|
||||
Уникальность почты держится **частичным** индексом (`WHERE email <> ''`), поэтому
|
||||
записи без почты уживаются друг с другом. Уникальность логина — обычным.
|
||||
|
||||
Ключом почта не служит вовсе: адрес меняется, и первое обращение с чужим адресом
|
||||
досталось бы чужой записи.
|
||||
|
||||
### `files`
|
||||
|
||||
Одна запись на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
|
||||
Одна строка на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
|
||||
приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не
|
||||
считается — она существует только потому, что провайдер распознавания читает
|
||||
аудио по адресу, и её ключ живёт в строке попытки распознавания.
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `file` | file | Сам файл |
|
||||
| `owner` | relation → `users` | Владелец файла; пустого значения не принимает |
|
||||
| `location` | select | `local` или `s3` |
|
||||
| `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется |
|
||||
| `size` | INTEGER | Размер в байтах |
|
||||
| `id` | TEXT PK | ULID |
|
||||
| `owner_id` | TEXT → `users(id)` | Владелец копии; пустого значения не принимает |
|
||||
| `record_id` | TEXT | Запись, которой копия принадлежит: имя её подкаталога |
|
||||
| `file_name` | TEXT | Имя файла в этом подкаталоге; задаёт сервис |
|
||||
| `size_bytes` | INTEGER | Размер копии в байтах |
|
||||
| `format` | TEXT | Расширение без точки, в нижнем регистре |
|
||||
| `duration_ms` | INTEGER | Длительность, если её удалось прочитать |
|
||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
||||
| `created_at` | TEXT | Время |
|
||||
|
||||
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
|
||||
capability, и третий смысл развёл бы одно слово по разным вещам.
|
||||
**Внешнего ключа на аудиозапись у `record_id` нет намеренно.** Приём заводит
|
||||
файл **до** самой записи — подкаталог назван её идентификатором, и знать его надо
|
||||
раньше, — и обязательная связь отвергала бы первую же принятую запись. Владелец
|
||||
при этом лежит своей колонкой, а не выводится через запись: файл переживает свою
|
||||
запись, и заведённый шагом до её сохранения остаётся с владельцем и без ссылки.
|
||||
|
||||
### `audio_records`
|
||||
|
||||
@@ -65,36 +121,37 @@ capability, и третий смысл развёл бы одно слово п
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `owner` | relation → `users` | Владелец записи; пустого значения не принимает |
|
||||
| `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется |
|
||||
| `id` | TEXT PK | ULID |
|
||||
| `owner_id` | TEXT → `users(id)` | Владелец записи; пустого значения не принимает |
|
||||
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
|
||||
| `original_filename` | TEXT ≤ 255 | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки |
|
||||
| `duration_ms` | INTEGER ≥ 0 | Длительность **принятого**, миллисекунды; ставит приём и всегда |
|
||||
| `size_bytes` | INTEGER ≥ 0 | Размер **принятого**, байты |
|
||||
| `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой |
|
||||
| `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания |
|
||||
| `halted_at` | DATETIME | Признак остановки; рубеж при ней не стирается |
|
||||
| `halt_reason` | select | `step_failed`, `attempts_exhausted`, `stuck` |
|
||||
| `original_filename` | TEXT | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки |
|
||||
| `duration_ms` | INTEGER, обязателен | Длительность **принятого**, миллисекунды; ставит приём и всегда |
|
||||
| `size_bytes` | INTEGER, обязателен | Размер **принятого**, байты |
|
||||
| `state` | TEXT | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done` |
|
||||
| `state_entered_at` | TEXT | Время входа в рубеж — сторож застревания |
|
||||
| `halted_at` | TEXT | Признак остановки; рубеж при ней не стирается |
|
||||
| `halt_reason` | TEXT | `step_failed`, `attempts_exhausted`, `stuck` |
|
||||
| `error_text` | TEXT | Текст ошибки, машинный |
|
||||
| `acquisition_id` | TEXT | Признак **этого** захвата, уникальный для каждого |
|
||||
| `acquire_expires_at` | DATETIME | Срок протухания захвата; приезжает с рубежом |
|
||||
| `delay_time` | DATETIME | Не брать запись раньше этого времени |
|
||||
| `attempts` | INTEGER ≥ 0 | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании |
|
||||
| `original_file` | relation → `files` | Принятая копия |
|
||||
| `normalized_file` | relation → `files` | Копия, приведённая к рабочему формату |
|
||||
| `transcript_text`, `literary_text` | relation → `texts` | Тексты записи |
|
||||
| `structure` | relation → `structures` | Структура реплик |
|
||||
| `recognition` | relation → `recognitions` | Попытка распознавания |
|
||||
| `topics` | relation → `topics`, до 5 | Темы записи |
|
||||
| `tg_chat_id` | INTEGER | Адресат ответа у записи убранного входа; кодом не читается |
|
||||
| `tg_reply_message_id` | INTEGER | Ответное сообщение у неё же; кодом не читается |
|
||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
||||
| `acquire_expires_at` | TEXT | Срок протухания захвата; приезжает с рубежом |
|
||||
| `delay_time` | TEXT | Не брать запись раньше этого времени |
|
||||
| `attempts` | INTEGER | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании |
|
||||
| `original_file_id` | TEXT → `files(id)` | Принятая копия |
|
||||
| `normalized_file_id` | TEXT → `files(id)` | Копия, приведённая к рабочему формату |
|
||||
| `transcript_text_id`, `literary_text_id` | TEXT | Тексты записи |
|
||||
| `structure_id` | TEXT | Структура реплик |
|
||||
| `recognition_id` | TEXT | Попытка распознавания |
|
||||
| `created_at`, `updated_at` | TEXT | Время |
|
||||
|
||||
Индексов три. Первый — по паре «рубеж и признак остановки»: по ним, паузе и
|
||||
сроку протухания идёт выборка захвата. Два других завела страница списка приложения:
|
||||
`(owner, created DESC, id DESC)` под страницу «новыми сверху» и
|
||||
`(owner, state, halted_at)` под отбор тремя состояниями.
|
||||
Индексов два. `idx_audio_records_acquire` — `(state, halted_at, created_at, id)`:
|
||||
по нему идёт отбор захвата, и по нему же он берёт запись в определённом порядке.
|
||||
`idx_audio_records_owner_page` — `(owner_id, created_at, id)`: под страницу
|
||||
списка, сужаемую владельцем и режущуюся полным ключом сортировки.
|
||||
|
||||
Оба индекса заведены **начальным шагом**, а не отложены: применённый шаг схемы не
|
||||
переписывается, и добавление индекса стоило бы отдельного шага. Проверено
|
||||
`EXPLAIN QUERY PLAN`: ни отбор захвата, ни страница списка не показывают полного
|
||||
сканирования таблицы.
|
||||
|
||||
**Ведущая колонка у ленты — владелец, и потому индекс захвата ей не помогает
|
||||
ничем.** Замер на задаче `json-api-for-spa` 2026-08-15: без своего индекса
|
||||
@@ -109,21 +166,20 @@ capability, и третий смысл развёл бы одно слово п
|
||||
узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное
|
||||
название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит
|
||||
извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя
|
||||
файла в хранилище и в журнал оно по-прежнему не идёт.
|
||||
файла на диске и в журнал оно по-прежнему не идёт.
|
||||
|
||||
**Длительность и размер лежат и на записи, и на её файле, и равенство между ними
|
||||
не поддерживается никем — намеренно.** На записи снимок **принятого**, взятый
|
||||
приёмом один раз; на файле — величины нынешней копии файла.
|
||||
Уточнение длительности меняет вторые и не трогает первые: это разные вопросы —
|
||||
«что человек прислал» и «что лежит сейчас». Колонками записи они нужны потому,
|
||||
что показываются в списке, а список читается без содержимого. Решение владельца
|
||||
от 2026-08-15.
|
||||
приёмом один раз; на файле — величины нынешней копии. Уточнение длительности
|
||||
меняет вторые и не трогает первые: это разные вопросы — «что человек прислал» и
|
||||
«что лежит сейчас». Колонками записи они нужны потому, что показываются в списке,
|
||||
а список читается без содержимого. Решение владельца от 2026-08-15.
|
||||
|
||||
**«Неизвестно» эти колонки не выражают, и это решение владельца от 2026-08-15.**
|
||||
Числовая колонка хранилища пустого значения не держит: пустое она кладёт нулём.
|
||||
Платить за отличимость четвёртой колонкой-признаком или текстовым типом у чисел
|
||||
не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными
|
||||
метаданными отвергается отказом и не заводится вовсе.
|
||||
**«Неизвестно» эти колонки не выражают**, и это то же решение владельца: обе
|
||||
величины ставит приём и ставит всегда — запись с непрочитанными метаданными
|
||||
отвергается отказом и не заводится вовсе. Обе объявлены обязательными: пустое
|
||||
значение, которое схема теперь допустить может, завело бы третий смысл, которого
|
||||
никто не читает.
|
||||
|
||||
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
|
||||
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
|
||||
@@ -131,20 +187,32 @@ capability, и третий смысл развёл бы одно слово п
|
||||
|
||||
**Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead`
|
||||
схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием
|
||||
признака, — и различие между ними перестало быть структурным. Рубеж при
|
||||
остановке сохраняется, поэтому запись продолжает с места остановки.
|
||||
признака, — и различие между ними перестало быть структурным.
|
||||
|
||||
**Сторожей двое.** `attempts` ограничивает повторы внутри шага,
|
||||
`state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не
|
||||
справлялось ни с одной.
|
||||
|
||||
### `record_topics`
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `record_id` | TEXT → `audio_records(id)` | Запись |
|
||||
| `topic_id` | TEXT → `topics(id)` | Тема |
|
||||
|
||||
Первичный ключ — пара целиком. Потолок в пять тем на запись держит **триггер**:
|
||||
без него часовой разговор даёт два десятка тем, и словарь распухает за неделю.
|
||||
Число берётся у домена — то же самое, которое сервис объявляет приложению.
|
||||
|
||||
### `texts`
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `record` | relation → `audio_records` | Чья это расшифровка |
|
||||
| `kind` | select | `transcript` или `literary` |
|
||||
| `contents` | editor | Сам текст |
|
||||
| `id` | TEXT PK | ULID |
|
||||
| `record_id` | TEXT → `audio_records(id)` | Чья это расшифровка |
|
||||
| `kind` | TEXT | `transcript` или `literary` |
|
||||
| `contents` | TEXT | Сам текст |
|
||||
| `created_at`, `updated_at` | TEXT | Время |
|
||||
|
||||
Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки.
|
||||
Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат
|
||||
@@ -154,9 +222,11 @@ capability, и третий смысл развёл бы одно слово п
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `record` | relation → `audio_records` | Чья это структура |
|
||||
| `id` | TEXT PK | ULID |
|
||||
| `record_id` | TEXT → `audio_records(id)` | Чья это структура |
|
||||
| `version` | INTEGER | Версия вида разбора |
|
||||
| `contents` | JSON | Реплики со временем |
|
||||
| `contents` | TEXT | Реплики со временем, JSON |
|
||||
| `created_at`, `updated_at` | TEXT | Время |
|
||||
|
||||
Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор
|
||||
сохранённого ответа изменится раньше, чем архив пересчитают.
|
||||
@@ -167,92 +237,86 @@ capability, и третий смысл развёл бы одно слово п
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `record` | relation → `audio_records` | Чья это попытка |
|
||||
| `id` | TEXT PK | ULID |
|
||||
| `record_id` | TEXT → `audio_records(id)` | Чья это попытка |
|
||||
| `provider`, `model` | TEXT | Кем и какой моделью считано |
|
||||
| `external_id` | TEXT | Идентификатор операции у провайдера |
|
||||
| `source_uri` | TEXT | Адрес, по которому провайдер читает аудио |
|
||||
| `payload` | file, **защищённое** | Сырой ответ провайдера целиком |
|
||||
| `started_at`, `finished_at` | DATETIME | Границы операции |
|
||||
| `payload_file` | TEXT | Имя файла с сохранённым ответом провайдера |
|
||||
| `started_at`, `finished_at` | TEXT | Границы операции |
|
||||
| `created_at`, `updated_at` | TEXT | Время |
|
||||
|
||||
**Сырой ответ лежит вложением, а не колонкой.** Шаг опроса читает эту строку раз
|
||||
в несколько секунд, а хранилище читает запись целиком: ответ на многочасовую
|
||||
запись ехал бы в память при каждом опросе. Хранится он потому, что результат
|
||||
операции у провайдера не переспрашивается.
|
||||
|
||||
Поле вложения помечено защищённым: сырой ответ — это полный текст речи, и
|
||||
умолчание библиотеки отдавало бы его по ссылке любому, кто её знает.
|
||||
**Сохранённый ответ лежит третьим файлом в подкаталоге записи, а не колонкой.**
|
||||
Шаг опроса читает эту строку раз в несколько секунд, а репозиторий читает строку
|
||||
целиком: ответ на многочасовую запись, положенный колонкой, ехал бы в память при
|
||||
каждом опросе. Хранится он потому, что результат операции у провайдера не
|
||||
переспрашивается. Копией аудио он при этом не считается — их у записи по-прежнему
|
||||
две, — и адреса, которым его читают снаружи, у сервиса нет вовсе.
|
||||
|
||||
### `record_events`
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `record` | relation → `audio_records` | Чьё это событие |
|
||||
| `origin` | select | `pipeline` или `human` |
|
||||
| `id` | TEXT PK | ULID |
|
||||
| `record_id` | TEXT → `audio_records(id)` | Чьё это событие |
|
||||
| `origin` | TEXT | `pipeline` или `human` |
|
||||
| `step` | TEXT | Имя шага |
|
||||
| `outcome` | select | `done`, `failed`, `halted`, `resumed` |
|
||||
| `outcome` | TEXT | `done`, `failed`, `halted`, `resumed` |
|
||||
| `outcome_text` | TEXT | Причина, если она есть |
|
||||
| `duration_ms` | INTEGER | Сколько шаг занял |
|
||||
| `created_at` | TEXT | Время |
|
||||
|
||||
Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо
|
||||
поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант
|
||||
двусмысленным.
|
||||
|
||||
Журнал пишется на смену рубежа, на остановку и на снятие остановки — не на
|
||||
Журнал пишется на смену рубежа, на остановку и на возврат в работу — не на
|
||||
каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить,
|
||||
что делать дальше.
|
||||
что делать дальше. Происхождение `human` пишет сегодня подкоманда оснастки,
|
||||
возвращающая остановленную запись в работу: другого писателя, кроме конвейера, у
|
||||
журнала не осталось.
|
||||
|
||||
### `topics`
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `owner` | relation → `users` | Чей это словарь |
|
||||
| `id` | TEXT PK | ULID |
|
||||
| `owner_id` | TEXT → `users(id)` | Чей это словарь |
|
||||
| `name` | TEXT | Название темы |
|
||||
| `created_at`, `updated_at` | TEXT | Время |
|
||||
|
||||
Пара «владелец и название» уникальна: словарь тем свой у каждого человека.
|
||||
Коллекцией, а не набором строк в записи, потому что перечень тем нужен целиком
|
||||
перед каждым обращением к языковой модели. Ни один шаг сегодняшнего сервиса тем
|
||||
не пишет и не читает — место заведено вперёд, чтобы задача, считающая темы, не
|
||||
платила вторым необратимым шагом схемы.
|
||||
Отдельной таблицей, а не набором строк в записи, потому что перечень тем нужен
|
||||
целиком перед каждым обращением к языковой модели. Ни один шаг сегодняшнего
|
||||
сервиса тем не пишет и не читает — место заведено вперёд, чтобы задача,
|
||||
считающая темы, не платила вторым необратимым шагом схемы.
|
||||
|
||||
### Чего в схеме больше нет
|
||||
|
||||
Коллекция `transcribe_jobs` удалена шагом `202608140002`. Данных под ней не было:
|
||||
сервис на сервере остановлен, а прежние записи удалены решением владельца
|
||||
2026-08-14 — переноса это изменение не делало. Оставленная пустая коллекция
|
||||
висела бы в панели вторым домом для понятия, которого больше нет.
|
||||
**Каталог шагов PocketBase удалён целиком, и на его месте стоит один шаг
|
||||
начальной схемы** — `202608220002_init.go`. Это разовое снятие инварианта
|
||||
«применённая миграция не переписывается», решением владельца от 2026-08-22:
|
||||
стадия проекта — стройка, на сервере данных нет, сервис остановлен, а новая база
|
||||
ведёт учёт применённого своей таблицей, которой отметки прежнего каталога не
|
||||
годятся вовсе. Снятие кончается этим шагом.
|
||||
|
||||
**Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в
|
||||
обеих таблицах, — и шагом `202608140003` пустого значения больше не принимает.
|
||||
Прежде принимал, и цену за это платили записи входа Telegram: связи чата с
|
||||
учётной записью сервис не вёл. Вход убран 2026-08-14, ничью запись заводить стало
|
||||
некому, и обязательность переехала из приёма в схему — туда, где её держит
|
||||
хранилище, а не договорённость.
|
||||
**Колонок `location` и `source` в новой схеме нет.** Обе писались одним значением
|
||||
и не читались никем: в `location` уходило `local`, второго значения (`s3`) не
|
||||
писал ни один шаг; в `source` всякий приём писал `api`, а второе значение
|
||||
(`telegram`) держалось ссылкой из применённого шага, а не потребителем. Шаги
|
||||
ушли, и держать их стало нечем. Поле, у которого появится читатель, вернётся
|
||||
одним новым шагом схемы.
|
||||
|
||||
**Колонки `tg_chat_id` и `tg_reply_message_id`** остались от убранного входа и
|
||||
кодом больше не читаются. Из схемы они не убираются: заводили их применённые
|
||||
шаги `202608110001` и `202608140002`, а применённый шаг не переписывается.
|
||||
**Колонок `tg_chat_id`, `tg_reply_message_id` и `object_key` нет по той же
|
||||
причине:** их держал применённый шаг, которого больше не существует.
|
||||
|
||||
Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая
|
||||
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер
|
||||
обрабатывает записи всех. Тот же шаг сузил правило просмотра коллекции `files`
|
||||
владельцем: прежнее правило пускало всякого вошедшего, и знание идентификатора
|
||||
файловой записи равнялось праву скачать чужое аудио.
|
||||
|
||||
**Учётная запись с записями не удаляется.** Каскадное удаление у связи выключено,
|
||||
но одного этого мало: при выключенном каскаде хранилище снимает ссылку и
|
||||
сохраняет запись без проверок — записи остались бы, но стали бы ничьими, а ничья
|
||||
запись не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`,
|
||||
а не правило коллекции: панель ходит правами суперпользователя, и правило её не
|
||||
судит. Считаются все коллекции с колонкой владельца — `audio_records`, `files` и
|
||||
`topics`, — и перечень живёт одним местом: пропущенная коллекция пропускает
|
||||
удаление вперёд, а наружу приезжает подсказка библиотеки про обязательную связь
|
||||
вместо нашего отказа с причиной.
|
||||
|
||||
**Правила доступа новых коллекций пусты**, то есть перечислять и читать их может
|
||||
только владелец панели. Содержимое записи отдаёт собственный адрес сервиса, а не
|
||||
поверхность хранилища; непустое правило открыло бы перечисление коллекции впрок.
|
||||
Проверено прогоном: анонимный запрос к `/api/collections/*/records` отвечает
|
||||
`403`, к `/api/logs`, `/api/backups`, `/api/settings` и `/api/crons` — `401`.
|
||||
**Учётная запись с записями не удаляется**, и держит это схема обязательной
|
||||
связью, а не проверка вызывающего: `audio_records`, `files` и `topics` ссылаются
|
||||
на `users(id)` без каскада, а соблюдение внешних ключей включено на каждом
|
||||
соединении обоих пулов. Прежде запрет ставил слой приложения — сборка, забывшая
|
||||
его позвать, теряла защиту молча, и теряла. Адреса, которым учётную запись
|
||||
удаляют, у сервиса нет вовсе; способа удалить записи тоже нет, и это осознанный
|
||||
тупик до задачи про удаление записи.
|
||||
|
||||
## Представление данных
|
||||
|
||||
@@ -260,70 +324,63 @@ capability, и третий смысл развёл бы одно слово п
|
||||
|
||||
- **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват
|
||||
её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а
|
||||
колонки шаг читает отдельным чтением. Прежде расшифровка стояла колонкой той
|
||||
же строки и читалась при каждом опросе очереди.
|
||||
- **Аудио лежит в раскладке хранилища:**
|
||||
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
|
||||
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
|
||||
потому что умолчание, строящее имя из имени отправителя, не применяется. Ни
|
||||
файлы, ни объекты в Object Storage не удаляются после завершения задачи:
|
||||
каталог и бакет растут неограниченно.
|
||||
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
||||
помечено защищённым шагом `202608120001`, а правило просмотра коллекции
|
||||
пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном
|
||||
файла, который берёт узнанный. Прежнее решение — «право прочитать запись даёт
|
||||
знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла
|
||||
в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки.
|
||||
- **Коллекция `users`** заводится самой библиотекой, а два наших шага её сужают.
|
||||
`202608120001` выключил вход по паролю и одноразовый код; `202608220001`
|
||||
довершил: снял настройки OAuth2 и **все пять правил доступа** — перечисление,
|
||||
чтение, создание, правку и удаление, — оставив их пустыми, что у хранилища
|
||||
означает «только владелец панели».
|
||||
|
||||
Правку и удаление умолчание библиотеки открывало владельцу записи
|
||||
(`id = @request.auth.id`), и до переезда входа это ничему не мешало: слой
|
||||
предъявления жил под корнем приложения, и браузер до поверхности хранилища не
|
||||
дотягивался. С узнаванием по заголовку она достижима, а ключ учётной записи
|
||||
лежит теперь обычной колонкой — правка своей записи была бы присвоением чужого
|
||||
имени. Наш код читает и заводит запись мимо правил, панель работает
|
||||
суперпользователем, своих экранов профиля сервис не заводит.
|
||||
|
||||
- **Ключ учётной записи — колонка `provider_login`** с уникальным индексом,
|
||||
заведена шагом `202608220001`. В ней логин человека **у провайдера** — то
|
||||
значение, которым его называет обратный прокси заголовком. По нему запись
|
||||
ищется и по нему же заводится при первом обращении.
|
||||
|
||||
Почта в той же коллекции переведена в необязательную тем же шагом: провайдер
|
||||
не обязан её приносить, а ключом она не служит. Уникальность почты держится
|
||||
**частичным** индексом (`WHERE email != ''`), поэтому записи без почты
|
||||
уживаются друг с другом; уникальность логина — обычным, поэтому двух записей с
|
||||
пустым ключом схема не примет вовсе.
|
||||
- **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции.
|
||||
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
|
||||
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
||||
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
|
||||
невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку протухания
|
||||
захвата и отсутствию признака остановки; срок протухания выбирается по рубежу
|
||||
самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, подставить
|
||||
его не может.
|
||||
колонки шаг читает отдельным чтением.
|
||||
- **Файлы записи лежат подкаталогом на запись:**
|
||||
`data/records/<ULID записи>/<имя>`. Внутри — принятая копия, приведённая копия
|
||||
и сохранённый ответ провайдера. Так копии одной записи лежат вместе, а запись
|
||||
убирается целиком одним движением; плоский каталог, где копии различаются
|
||||
приставкой в имени, обращал бы уборку в перебор по маске. Имя, данное
|
||||
отправителем, не попадает ни в имя файла, ни в путь к нему. Ни файлы, ни
|
||||
объекты в Object Storage не удаляются после завершения записи: каталог и бакет
|
||||
растут неограниченно.
|
||||
- **Укладка атомарна:** содержимое пишется во временное имя **в том же
|
||||
подкаталоге записи** и переименовывается в рабочее только после того, как поток
|
||||
дочитан до конца без отказа. Строка о файле заводится **после** этого;
|
||||
содержимое легло, а строка не сохранилась — уложенный файл убирается.
|
||||
- **Файл отдаётся адресом приложения** —
|
||||
`GET /app/audiorecords/{id}/file?copy=original|normalized`, — и право пройти по
|
||||
нему даёт узнавание пришедшего и владение записью. Значений на предъявителя
|
||||
сервис не выдаёт вовсе: ни короткого токена файла, ни подписанной ссылки со
|
||||
сроком. Отзыв доступа доходит до файла сразу, а не через срок жизни выданного
|
||||
значения. Имя файла на диске в журнал не пишется и в ответ не идёт.
|
||||
- **Учётная запись заводится первым обращением** — поиск по `provider_login` и
|
||||
вставка идут одной транзакцией на пишущем соединении. Два отказа уникальности
|
||||
различаются повторным поиском по ключу: нашёлся — гонка двух первых обращений
|
||||
одним логином, не нашёлся — занятая почта, и запись заводится без неё.
|
||||
- **Захват записи — один запрос `UPDATE … RETURNING`** по пишущему соединению:
|
||||
выбор подходящей записи и пометка её захваченной идут вместе. Порядок выборки —
|
||||
по времени заведения **и по ключу**: время неуникально, и без ключа порядок
|
||||
обработки невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку
|
||||
протухания захвата и отсутствию признака остановки; срок протухания выбирается
|
||||
по рубежу самой записи прямо в запросе — воркер, ещё не знающий, что вытянет,
|
||||
подставить его не может.
|
||||
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет
|
||||
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
|
||||
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому,
|
||||
что условие проверяется тем же запросом, что и сам захват.
|
||||
- **Список колонок задан двумя местами** — `applyOwnedByPipeline` вместе с
|
||||
`applyToRecord` и `recordToAudioRecord`, — плюс шагом схемы. Мест было четыре,
|
||||
пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и
|
||||
перечень перестал расти с моделью. Правило правки и его серьёзность —
|
||||
инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты»; сверку держат правила
|
||||
норма — [pipeline](../openspec/specs/pipeline/spec.md). Условие стоит в самом
|
||||
запросе правки, поэтому между проверкой и записью не остаётся окна.
|
||||
- **Колонки записи отображаются по имени**: именованные параметры запроса и место
|
||||
назначения, найденное по имени колонки. У аудиозаписи поля одного типа идут
|
||||
длинным непрерывным рядом, и позиционный список дал бы сдвиг на одно поле,
|
||||
который компилируется молча и кладёт идентификатор файла в колонку текста.
|
||||
Перечень задан двумя местами — `writeOwnedByPipeline` вместе с `writeRecord` и
|
||||
`readRecordColumns`, — плюс шагом схемы; правило правки и его серьёзность —
|
||||
инвариант в [CLAUDE.md](../CLAUDE.md), сверку держат правила
|
||||
`internal/archrules`.
|
||||
- **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`.
|
||||
Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя:
|
||||
рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон
|
||||
по инварианту проекта не пишется в журнал и не считается в метрику.
|
||||
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
|
||||
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
|
||||
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
|
||||
в Object Storage: отказ SDK несёт полный URL объекта.
|
||||
- **Отказ базы наружу не выходит дословно.** Отказы чтения и укладки называют
|
||||
запись её идентификатором и не несут ни имени файла, ни пути к нему: имя —
|
||||
часть пути к чужому аудио. То же у выгрузки в Object Storage: отказ SDK несёт
|
||||
полный URL объекта.
|
||||
- **Обращения к базе идут с собственным контекстом**, а не с контекстом запроса.
|
||||
Отменять там нечего: операции местные и короткие, а единственное ожидание —
|
||||
занятая база — задано числом. За отмену платили бы дважды: шаг, прерванный
|
||||
остановкой сервиса, перестал бы освобождать захват и писать причину остановки —
|
||||
то есть отмена ломала бы ровно ту уборку, ради которой она и делается. Отмена,
|
||||
которой сервис распоряжается по-настоящему, доходит до `ffmpeg` и до платного
|
||||
распознавания.
|
||||
|
||||
## Настройки с числовым значением
|
||||
|
||||
@@ -336,41 +393,46 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды |
|
||||
| Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды |
|
||||
| Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение |
|
||||
| Ожидание занятой базы | 5000 миллисекунд | конфиг, `[storage] busy_timeout_ms` | выведено из числа воркеров, а не замерено: пишет сервис короткими операциями, и очередь из трёх воркеров укладывается в него с запасом |
|
||||
| Соединений в читающем пуле | 4 | конфиг, `[storage] read_connections` | число воркеров плюс запас под запросы приложения; пишущее соединение при этом всегда одно и настройкой не делается |
|
||||
| Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже |
|
||||
| Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого |
|
||||
| Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая |
|
||||
| Умолчание размера страницы списка | 30 | `controller/http.DefaultPageLimit` | столько помещается на экран телефона без прокрутки в два экрана |
|
||||
| Потолок размера страницы списка | 100 | `controller/http.MaxPageLimit` | против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром |
|
||||
| Ограничитель частоты под `/app/` | 120 запросов за 60 секунд | `controller/http.appRateMaxRequests`, `appRateWindowSec` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи |
|
||||
|
||||
**Адрес спрашивающего берётся из `X-Forwarded-For`, и это назначается кодом при
|
||||
подъёме** — `controller/http.ApplyTrustedProxyHeaders`. Без этого хранилище
|
||||
ключует счётчик адресом пира, а пир с переездом входа на заголовок всегда один и
|
||||
тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и восемь
|
||||
одновременно открытых карточек выбирают его целиком. Требование к контуру,
|
||||
которое отсюда следует, записано в [security.md](security.md), «Периметр»:
|
||||
`X-Forwarded-For` прокси обязан перезаписывать, а не дописывать.
|
||||
| Срок жизни неиспользуемого счётчика ограничителя | 10 минут | `controller/http.staleBudgetAge` | карта счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда спрашивающий кладёт по строке на каждый свой адрес |
|
||||
| Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет |
|
||||
| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
|
||||
| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла |
|
||||
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
|
||||
| Срок хранения ресурса приложения | 1 год | `controller/http.assetMaxAgeSeconds` | имена ресурсов несут отпечаток содержимого, поэтому ответ устареть не может; срок ставится только файлам из каталога сборщика, всё прочее браузер спрашивает заново |
|
||||
| Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих |
|
||||
| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем |
|
||||
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
|
||||
| Задержка между проверками операции | 5 секунд | там же | как было |
|
||||
| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
|
||||
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
|
||||
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
|
||||
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
|
||||
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
|
||||
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
|
||||
| Предел длины логина у провайдера | 255 знаков | `pbrepo.MaxProviderLoginLength` и колонка `provider_login` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени в умолчании библиотеки |
|
||||
| Предел длины логина у провайдера | 255 знаков | `entity.MaxProviderLoginLength` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени, пригодного к показу |
|
||||
| Предел длины имени, пригодного к показу | 255 знаков | `entity.MaxDisplayNameLength` | то же |
|
||||
| Длина идентификатора | 26 знаков | `ident.Len` | ширина записи ULID |
|
||||
|
||||
Три числа отсюда ушли 2026-08-22 вместе с собственным входом: срок жизни сессии,
|
||||
потолок времени на вход у провайдера и таймаут обмена кода. Сессия не выдаётся
|
||||
вовсе, обменивать код не на что, а отзыв доступа судит провайдер на каждом
|
||||
запросе — задержке, которую измерял срок сессии, теперь неоткуда взяться.
|
||||
**Адрес спрашивающего ограничитель берёт из `X-Forwarded-For` — и только тогда,
|
||||
когда соединение пришло с адреса из объявленного перечня доверенных.** Без этого
|
||||
счётчик ведётся по адресу пира, а пир с переездом входа на заголовок всегда один
|
||||
и тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и
|
||||
восемь одновременно открытых карточек выбирают его целиком. Обратная ошибка —
|
||||
верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
|
||||
ограничивает. Сама цепочка читается справа налево с отбрасыванием доверенных
|
||||
адресов, поэтому дописывающий прокси правилом покрыт; почему так —
|
||||
[security.md](security.md), «Периметр».
|
||||
|
||||
Числа, ушедшие отсюда со встроенным хранилищем: потолок сохранённого ответа
|
||||
провайдера и потолок структуры реплик — их держало поле коллекции, а теперь ответ
|
||||
лежит файлом, а структура текстовой колонкой; жизнь приглашения завести владельца
|
||||
панели — панели нет. Прежде, вместе с собственным входом, ушли срок жизни сессии,
|
||||
потолок времени на вход у провайдера и таймаут обмена кода.
|
||||
|
||||
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
|
||||
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
|
||||
@@ -381,15 +443,12 @@ capability, и третий смысл развёл бы одно слово п
|
||||
остановка «застряла» наступает только после него. Мягкая остановка сюда не
|
||||
подпадает: она снимает захват сама.
|
||||
|
||||
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
|
||||
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
|
||||
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
|
||||
на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее
|
||||
примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по
|
||||
медленному каналу переживают любой фиксированный, а стойкость к целенаправленной
|
||||
нагрузке объявлена вне модели угроз.
|
||||
**Потолок размера назван числом там, где иначе действует умолчание** — у тела
|
||||
запроса приёма, и назван дважды: объявленная длина судится заранее, а
|
||||
необъявленная и солгавшая ловятся на чтении. Умолчания здесь не «без предела», а
|
||||
величины на два-три порядка меньше нужного. Таймаут чтения запроса снят: шесть
|
||||
часов записи по медленному каналу переживают любой фиксированный, а стойкость к
|
||||
целенаправленной нагрузке объявлена вне модели угроз.
|
||||
|
||||
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
|
||||
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
|
||||
файлов и объектов нет вовсе. Таймаутов у
|
||||
обращений к S3 и SpeechKit тоже нет — ни одного.
|
||||
Чего среди настроек **нет**: срока хранения файлов и объектов нет вовсе.
|
||||
Таймаутов у обращений к S3 и SpeechKit тоже нет — ни одного.
|
||||
|
||||
Reference in New Issue
Block a user