# Схема хранилища База, таблицы, раскладка файлов, правило времени и идентификаторов. Хранилище **своё**: база 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). **База принимает одного писателя.** Пишущий пул держит одно соединение — драйвер пишет единственным, и несколько воркеров, пришедших писать разом мимо этого правила, получают отказ по занятости на записи результата шага, то есть после оплаченной работы. Чтение идёт отдельным пулом: в журнале упреждающей записи читатели не мешают писателю. Журнал упреждающей записи, соблюдение внешних ключей и ожидание занятой базы задаются **строкой подключения обоих пулов**, а не запросом после открытия: две из трёх настроек в 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), «Механизировано». **Идентификаторы** — ULID в нижнем регистре, `TEXT`, 26 знаков алфавита Crockford. Выдаёт их приложение единой точкой `internal/ident`; внутри одной миллисекунды выдача монотонна, потому что колонка времени несёт секунды и порядок записей одной секунды задаёт ключ. Идентификатор, пришедший снаружи, разбирается на границе: разбор проверяет вид и приводит регистр, а негодный считается несуществующей записью и до базы не доходит. Тем же идентификатором зовётся **подкаталог записи** в каталоге данных, а имя файла внутри него — `<расширение>`. **Время** — `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 | ULID | | `owner_id` | TEXT → `users(id)` | Владелец копии; пустого значения не принимает | | `record_id` | TEXT | Запись, которой копия принадлежит: имя её подкаталога | | `file_name` | TEXT | Имя файла в этом подкаталоге; задаёт сервис | | `size_bytes` | INTEGER | Размер копии в байтах | | `format` | TEXT | Расширение без точки, в нижнем регистре | | `duration_ms` | INTEGER | Длительность, если её удалось прочитать | | `created_at` | TEXT | Время | **Внешнего ключа на аудиозапись у `record_id` нет намеренно.** Приём заводит файл **до** самой записи — подкаталог назван её идентификатором, и знать его надо раньше, — и обязательная связь отвергала бы первую же принятую запись. Владелец при этом лежит своей колонкой, а не выводится через запись: файл переживает свою запись, и заведённый шагом до её сохранения остаётся с владельцем и без ссылки. ### `audio_records` Аудиозапись — центральная сущность сервиса. Домен, поля очереди и ссылки на приложения лежат здесь; содержимое — по ссылкам, отдельными строками. | Поле | Тип | Что | | --- | --- | --- | | `id` | TEXT PK | ULID | | `owner_id` | TEXT → `users(id)` | Владелец записи; пустого значения не принимает | | `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком | | `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` | 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 | Время | Индексов два. `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: без своего индекса страница сканировала таблицу целиком и досортировывала результат во временном дереве, а рост архива с 5 тысяч строк до 200 тысяч растил время одной страницы владельца в двадцать-тридцать раз — при неизменных сорока его собственных записях. Цена росла с **чужими** записями, потому что сервис объявлен архивом и хранит их бессрочно. **Имя файла и заголовок — разные колонки.** Заголовок несёт название, которое дал человек либо посчитала языковая модель; имя файла — то, по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя файла на диске и в журнал оно по-прежнему не идёт. **Длительность и размер лежат и на записи, и на её файле, и равенство между ними не поддерживается никем — намеренно.** На записи снимок **принятого**, взятый приёмом один раз; на файле — величины нынешней копии. Уточнение длительности меняет вторые и не трогает первые: это разные вопросы — «что человек прислал» и «что лежит сейчас». Колонками записи они нужны потому, что показываются в списке, а список читается без содержимого. Решение владельца от 2026-08-15. **«Неизвестно» эти колонки не выражают**, и это то же решение владельца: обе величины ставит приём и ставит всегда — запись с непрочитанными метаданными отвергается отказом и не заводится вовсе. Обе объявлены обязательными: пустое значение, которое схема теперь допустить может, завело бы третий смысл, которого никто не читает. **Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем хранилище, и принятого человеком файла не найти было ничем. **Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead` схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием признака, — и различие между ними перестало быть структурным. **Сторожей двое.** `attempts` ограничивает повторы внутри шага, `state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не справлялось ни с одной. ### `record_topics` | Поле | Тип | Что | | --- | --- | --- | | `record_id` | TEXT → `audio_records(id)` | Запись | | `topic_id` | TEXT → `topics(id)` | Тема | Первичный ключ — пара целиком. Потолок в пять тем на запись держит **триггер**: без него часовой разговор даёт два десятка тем, и словарь распухает за неделю. Число берётся у домена — то же самое, которое сервис объявляет приложению. ### `texts` | Поле | Тип | Что | | --- | --- | --- | | `id` | TEXT PK | ULID | | `record_id` | TEXT → `audio_records(id)` | Чья это расшифровка | | `kind` | TEXT | `transcript` или `literary` | | `contents` | TEXT | Сам текст | | `created_at`, `updated_at` | TEXT | Время | Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки. Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат файла. ### `structures` | Поле | Тип | Что | | --- | --- | --- | | `id` | TEXT PK | ULID | | `record_id` | TEXT → `audio_records(id)` | Чья это структура | | `version` | INTEGER | Версия вида разбора | | `contents` | TEXT | Реплики со временем, JSON | | `created_at`, `updated_at` | TEXT | Время | Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор сохранённого ответа изменится раньше, чем архив пересчитают. ### `recognitions` Попытка распознавания у внешнего провайдера — всё, что зависит от него. | Поле | Тип | Что | | --- | --- | --- | | `id` | TEXT PK | ULID | | `record_id` | TEXT → `audio_records(id)` | Чья это попытка | | `provider`, `model` | TEXT | Кем и какой моделью считано | | `external_id` | TEXT | Идентификатор операции у провайдера | | `source_uri` | TEXT | Адрес, по которому провайдер читает аудио | | `payload_file` | TEXT | Имя файла с сохранённым ответом провайдера | | `started_at`, `finished_at` | TEXT | Границы операции | | `created_at`, `updated_at` | TEXT | Время | **Сохранённый ответ лежит третьим файлом в подкаталоге записи, а не колонкой.** Шаг опроса читает эту строку раз в несколько секунд, а репозиторий читает строку целиком: ответ на многочасовую запись, положенный колонкой, ехал бы в память при каждом опросе. Хранится он потому, что результат операции у провайдера не переспрашивается. Копией аудио он при этом не считается — их у записи по-прежнему две, — и адреса, которым его читают снаружи, у сервиса нет вовсе. ### `record_events` | Поле | Тип | Что | | --- | --- | --- | | `id` | TEXT PK | ULID | | `record_id` | TEXT → `audio_records(id)` | Чьё это событие | | `origin` | TEXT | `pipeline` или `human` | | `step` | TEXT | Имя шага | | `outcome` | TEXT | `done`, `failed`, `halted`, `resumed` | | `outcome_text` | TEXT | Причина, если она есть | | `duration_ms` | INTEGER | Сколько шаг занял | | `created_at` | TEXT | Время | Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант двусмысленным. Журнал пишется на смену рубежа, на остановку и на возврат в работу — не на каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить, что делать дальше. Происхождение `human` пишет сегодня подкоманда оснастки, возвращающая остановленную запись в работу: другого писателя, кроме конвейера, у журнала не осталось. ### `topics` | Поле | Тип | Что | | --- | --- | --- | | `id` | TEXT PK | ULID | | `owner_id` | TEXT → `users(id)` | Чей это словарь | | `name` | TEXT | Название темы | | `created_at`, `updated_at` | TEXT | Время | Пара «владелец и название» уникальна: словарь тем свой у каждого человека. Отдельной таблицей, а не набором строк в записи, потому что перечень тем нужен целиком перед каждым обращением к языковой модели. Ни один шаг сегодняшнего сервиса тем не пишет и не читает — место заведено вперёд, чтобы задача, считающая темы, не платила вторым необратимым шагом схемы. ### Чего в схеме больше нет **Каталог шагов PocketBase удалён целиком, и на его месте стоит один шаг начальной схемы** — `202608220002_init.go`. Это разовое снятие инварианта «применённая миграция не переписывается», решением владельца от 2026-08-22: стадия проекта — стройка, на сервере данных нет, сервис остановлен, а новая база ведёт учёт применённого своей таблицей, которой отметки прежнего каталога не годятся вовсе. Снятие кончается этим шагом. **Колонок `location` и `source` в новой схеме нет.** Обе писались одним значением и не читались никем: в `location` уходило `local`, второго значения (`s3`) не писал ни один шаг; в `source` всякий приём писал `api`, а второе значение (`telegram`) держалось ссылкой из применённого шага, а не потребителем. Шаги ушли, и держать их стало нечем. Поле, у которого появится читатель, вернётся одним новым шагом схемы. **Колонок `tg_chat_id`, `tg_reply_message_id` и `object_key` нет по той же причине:** их держал применённый шаг, которого больше не существует. **Учётная запись с записями не удаляется**, и держит это схема обязательной связью, а не проверка вызывающего: `audio_records`, `files` и `topics` ссылаются на `users(id)` без каскада, а соблюдение внешних ключей включено на каждом соединении обоих пулов. Прежде запрет ставил слой приложения — сборка, забывшая его позвать, теряла защиту молча, и теряла. Адреса, которым учётную запись удаляют, у сервиса нет вовсе; способа удалить записи тоже нет, и это осознанный тупик до задачи про удаление записи. ## Представление данных Чем физически лежит запись и что происходит при чтении и записи. - **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а колонки шаг читает отдельным чтением. - **Файлы записи лежат подкаталогом на запись:** `data/records//<имя>`. Внутри — принятая копия, приведённая копия и сохранённый ответ провайдера. Так копии одной записи лежат вместе, а запись убирается целиком одним движением; плоский каталог, где копии различаются приставкой в имени, обращал бы уборку в перебор по маске. Имя, данное отправителем, не попадает ни в имя файла, ни в путь к нему. Ни файлы, ни объекты в 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). Условие стоит в самом запросе правки, поэтому между проверкой и записью не остаётся окна. - **Колонки записи отображаются по имени**: именованные параметры запроса и место назначения, найденное по имени колонки. У аудиозаписи поля одного типа идут длинным непрерывным рядом, и позиционный список дал бы сдвиг на одно поле, который компилируется молча и кладёт идентификатор файла в колонку текста. Перечень мест, где правится колонка, и серьёзность правила — инвариант «Колонки записи правятся в трёх местах» в [CLAUDE.md](../CLAUDE.md), «Инварианты»; сверку держат правила `internal/archrules`. - **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`. Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя: рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту проекта не пишется в журнал и не считается в метрику. - **Отказ базы наружу не выходит дословно.** Отказы чтения и укладки называют запись её идентификатором и не несут ни имени файла, ни пути к нему: имя — часть пути к чужому аудио. То же у выгрузки в Object Storage: отказ SDK несёт полный URL объекта. - **Обращения к базе идут с собственным контекстом**, а не с контекстом запроса. Отменять там нечего: операции местные и короткие, а единственное ожидание — занятая база — задано числом. За отмену платили бы дважды: шаг, прерванный остановкой сервиса, перестал бы освобождать захват и писать причину остановки — то есть отмена ломала бы ровно ту уборку, ради которой она и делается. Отмена, которой сервис распоряжается по-настоящему, доходит до `ffmpeg` и до платного распознавания. ## Настройки с числовым значением | Настройка | Значение | Где | Откуда число | | --- | --- | --- | --- | | Предел отказов | 5 | `service/transcribe.go` | обычное умолчание, не замер | | Пауза перед повтором | `2^(отказ−1)` с, потолок 5 минут | там же | то же | | Срок захвата, приведение | 8 часов | `entity/stage.go` | потолок записи 6 часов плюс запас | | Срок захвата, отправка на распознавание | 8 часов | там же | то же | | Срок захвата, опрос операции | 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` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи | | Срок жизни неиспользуемого счётчика ограничителя | 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` | имена ресурсов несут отпечаток содержимого, поэтому ответ устареть не может; срок ставится только файлам из каталога сборщика, всё прочее браузер спрашивает заново | | Задержка перед первой проверкой операции | 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` | — | | Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео | | Предел длины логина у провайдера | 255 знаков | `entity.MaxProviderLoginLength` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени, пригодного к показу | | Предел длины имени, пригодного к показу | 255 знаков | `entity.MaxDisplayNameLength` | то же | | Длина идентификатора | 26 знаков | `ident.Len` | ширина записи ULID | **Адрес спрашивающего ограничитель берёт из `X-Forwarded-For` — и только тогда, когда соединение пришло с адреса из объявленного перечня доверенных.** Без этого счётчик ведётся по адресу пира, а пир с переездом входа на заголовок всегда один и тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и восемь одновременно открытых карточек выбирают его целиком. Обратная ошибка — верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он ограничивает. Как читается цепочка — спека [archive](../openspec/specs/archive/spec.md); здесь только числа бюджета. Числа, ушедшие отсюда со встроенным хранилищем: потолок сохранённого ответа провайдера и потолок структуры реплик — их держало поле коллекции, а теперь ответ лежит файлом, а структура текстовой колонкой; жизнь приглашения завести владельца панели — панели нет. Прежде, вместе с собственным входом, ушли срок жизни сессии, потолок времени на вход у провайдера и таймаут обмена кода. **У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок протухания ещё не истёк. Значит для держателя, погибшего жёстко — контейнер убит по нехватке памяти или `docker kill`, — запись невидима сторожу до истечения **срока захвата** её рубежа, то есть восьми часов у приведения и отправки. Замерено прогоном: до истечения срока повторный захват записи не выдаёт, и остановка «застряла» наступает только после него. Мягкая остановка сюда не подпадает: она снимает захват сама. **Потолок размера назван числом там, где иначе действует умолчание** — у тела запроса приёма, и назван дважды: объявленная длина судится заранее, а необъявленная и солгавшая ловятся на чтении. Умолчания здесь не «без предела», а величины на два-три порядка меньше нужного. Таймаут чтения запроса снят: шесть часов записи по медленному каналу переживают любой фиксированный, а стойкость к целенаправленной нагрузке объявлена вне модели угроз. Чего среди настроек **нет**: срока хранения файлов и объектов нет вовсе. Таймаутов у обращений к S3 и SpeechKit тоже нет — ни одного.