- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка входа при старте, секция настроек и зависимость go-telegram-bot-api; из конвейера ушла доставка ответа отправителю — исход виден опросом готовности. Колонки адресата и значение источника остались в схеме: применённые шаги не переписываются - шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла; существующие строки он не проверяет, и это принято сознательно — искать их надо запросом до выкладки - ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
30 KiB
Схема хранилища
Хранилище, коллекции, правило времени и идентификаторов.
Хранилище — встроенная PocketBase 0.39.10: она держит и базу, и файлы
записей под одним каталогом данных. Ключ конфигурации — [storage] data_dir,
умолчание data. В SQLite библиотека ходит через modernc.org/sqlite, поэтому
CGO сборке не нужен.
Схему двигают шаги миграций PocketBase на Go, каталог
internal/adapter/repo/pocketbase/migrations, файл на шаг и имя файла — имя
шага. Шаг регистрируется при загрузке пакета, а накатывается при подъёме
хранилища (pocketbase.New), прежде чем стартуют воркеры и сервер. Применённый
шаг не переписывается — изменение только новым шагом: применённое хранилище
считает по имени шага.
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя: шаг гейта сверяет изменённые шаги схемы с правкой этого документа по префиксу пути, а префикс наводится только на каталог. Где этот префикс задан — conventions/go-linters.md, «Механизировано». Имена коллекций живут там же, рядом с шагом, который их заводит; пакет репозитория берёт их оттуда.
Идентификаторы записей выдаёт хранилище — 15 знаков собственного алфавита.
Свои UUID остались только в именах файлов: имя, под которым запись ложится в
хранилище, задаёт сервис, и это <uuid><расширение>.
Время — вид хранилища: строка 2006-01-02 15:04:05.000Z в UTC. Колонки
created и updated проставляет само хранилище; те же поля в сыром запросе
захвата кладёт наш код — тем же видом, потому что сравнение строк в SQLite
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
постоянную ложь молча.
Того, что единой точки генерации идентификатора и времени нет, здесь не повторяем: перечень единых точек и их отсутствий держит architecture.md, «Единые точки проекта».
Коллекции
files
Одна запись на одну физическую копию. Копий у аудиозаписи ровно две: принятая и приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не считается — она существует только потому, что провайдер распознавания читает аудио по адресу, и её ключ живёт в строке попытки распознавания.
| Поле | Тип | Что |
|---|---|---|
id |
TEXT PK | Идентификатор записи, выдаёт хранилище |
file |
file | Сам файл |
owner |
relation → users |
Владелец файла; пустого значения не принимает |
location |
select | local или s3 |
object_key |
TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется |
size |
INTEGER | Размер в байтах |
format |
TEXT | Расширение без точки, в нижнем регистре |
duration_ms |
INTEGER | Длительность, если её удалось прочитать |
created, updated |
DATETIME | Проставляет хранилище |
Поле названо location, а не storage: последним словом зовут само хранилище и
capability, и третий смысл развёл бы одно слово по разным вещам.
audio_records
Аудиозапись — центральная сущность сервиса. Домен, поля очереди и ссылки на приложения лежат здесь; содержимое — по ссылкам, отдельными строками.
| Поле | Тип | Что |
|---|---|---|
id |
TEXT PK | Идентификатор записи, выдаёт хранилище |
owner |
relation → users |
Владелец записи; пустого значения не принимает |
source |
select | api, unknown; значение telegram осталось историческим — вход убран, новых записей с ним не появляется |
title, brief |
TEXT | Заголовок и краткое описание: читаются вместе со списком |
state |
select | Рубеж: uploaded, normalized, submitted, transcribed, done; перечень закрыт схемой |
state_entered_at |
DATETIME | Время входа в рубеж — сторож застревания |
halted_at |
DATETIME | Признак остановки; рубеж при ней не стирается |
halt_reason |
select | 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 | Проставляет хранилище |
Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним, паузе и сроку протухания.
Ссылки на файлы две и порознь. Прежняя модель держала одну и переставляла её каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем хранилище, и принятого человеком файла не найти было ничем.
Остановка — признак, а не рубеж. Прежние состояния failed и dead
схлопнуты в halted_at с причиной: обе восстанавливаются одинаково — снятием
признака, — и различие между ними перестало быть структурным. Рубеж при
остановке сохраняется, поэтому запись продолжает с места остановки.
Сторожей двое. attempts ограничивает повторы внутри шага,
state_entered_at — застревание. Прежде обе обязанности несло одно число, и не
справлялось ни с одной.
texts
| Поле | Тип | Что |
|---|---|---|
record |
relation → audio_records |
Чья это расшифровка |
kind |
select | transcript или literary |
contents |
editor | Сам текст |
Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки.
Поле зовётся kind, а не format: словом format в этой же схеме зовут формат
файла.
structures
| Поле | Тип | Что |
|---|---|---|
record |
relation → audio_records |
Чья это структура |
version |
INTEGER | Версия вида разбора |
contents |
JSON | Реплики со временем |
Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор сохранённого ответа изменится раньше, чем архив пересчитают.
recognitions
Попытка распознавания у внешнего провайдера — всё, что зависит от него.
| Поле | Тип | Что |
|---|---|---|
record |
relation → audio_records |
Чья это попытка |
provider, model |
TEXT | Кем и какой моделью считано |
external_id |
TEXT | Идентификатор операции у провайдера |
source_uri |
TEXT | Адрес, по которому провайдер читает аудио |
payload |
file, защищённое | Сырой ответ провайдера целиком |
started_at, finished_at |
DATETIME | Границы операции |
Сырой ответ лежит вложением, а не колонкой. Шаг опроса читает эту строку раз в несколько секунд, а хранилище читает запись целиком: ответ на многочасовую запись ехал бы в память при каждом опросе. Хранится он потому, что результат операции у провайдера не переспрашивается.
Поле вложения помечено защищённым: сырой ответ — это полный текст речи, и умолчание библиотеки отдавало бы его по ссылке любому, кто её знает.
record_events
| Поле | Тип | Что |
|---|---|---|
record |
relation → audio_records |
Чьё это событие |
origin |
select | pipeline или human |
step |
TEXT | Имя шага |
outcome |
select | done, failed, halted, resumed |
outcome_text |
TEXT | Причина, если она есть |
duration_ms |
INTEGER | Сколько шаг занял |
Колонка текста зовётся outcome_text, а не error_text: последнее имя названо
поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант
двусмысленным.
Журнал пишется на смену рубежа, на остановку и на снятие остановки — не на каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить, что делать дальше.
topics
| Поле | Тип | Что |
|---|---|---|
owner |
relation → users |
Чей это словарь |
name |
TEXT | Название темы |
Пара «владелец и название» уникальна: словарь тем свой у каждого человека. Коллекцией, а не набором строк в записи, потому что перечень тем нужен целиком перед каждым обращением к языковой модели. Ни один шаг сегодняшнего сервиса тем не пишет и не читает — место заведено вперёд, чтобы задача, считающая темы, не платила вторым необратимым шагом схемы.
Чего в схеме больше нет
Коллекция transcribe_jobs удалена шагом 202608140002. Данных под ней не было:
сервис на сервере остановлен, а прежние записи удалены решением владельца
2026-08-14 — переноса это изменение не делало. Оставленная пустая коллекция
висела бы в панели вторым домом для понятия, которого больше нет.
Владелец записи заведён шагом 202608140001 — связью с коллекцией users в
обеих таблицах, — и шагом 202608140003 пустого значения больше не принимает.
Прежде принимал, и цену за это платили записи входа Telegram: связи чата с
учётной записью сервис не вёл. Вход убран 2026-08-14, ничью запись заводить стало
некому, и обязательность переехала из приёма в схему — туда, где её держит
хранилище, а не договорённость.
Колонки tg_chat_id и tg_reply_message_id остались от убранного входа и
кодом больше не читаются. Из схемы они не убираются: заводили их применённые
шаги 202608110001 и 202608140002, а применённый шаг не переписывается.
Выборка по владельцу сужает чтение записи: чужая, ничья и несуществующая
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер
обрабатывает записи всех. Тот же шаг сузил правило просмотра коллекции files
владельцем: прежнее правило пускало всякого вошедшего, и знание идентификатора
файловой записи равнялось праву скачать чужое аудио.
Учётная запись с записями не удаляется. Каскадное удаление у связи выключено,
но одного этого мало: при выключенном каскаде хранилище снимает ссылку и
сохраняет запись без проверок — записи остались бы, но стали бы ничьими, а ничья
запись не достаётся никому. Отказ ставит слой приложения GuardOwnerDeletion,
а не правило коллекции: панель ходит правами суперпользователя, и правило её не
судит. Считаются все коллекции с колонкой владельца — audio_records, files и
topics, — и перечень живёт одним местом: пропущенная коллекция пропускает
удаление вперёд, а наружу приезжает подсказка библиотеки про обязательную связь
вместо нашего отказа с причиной.
Правила доступа новых коллекций пусты, то есть перечислять и читать их может
только владелец панели. Содержимое записи отдаёт собственный адрес сервиса, а не
поверхность хранилища; непустое правило открыло бы перечисление коллекции впрок.
Проверено прогоном: анонимный запрос к /api/collections/*/records отвечает
403, к /api/logs, /api/backups, /api/settings и /api/crons — 401.
Представление данных
Чем физически лежит запись и что происходит при чтении и записи.
- Расшифровка лежит отдельной строкой
texts, а не колонкой записи. Захват её не тянет вовсе: он возвращает идентификатор и признак своего захвата, а колонки шаг читает отдельным чтением. Прежде расшифровка стояла колонкой той же строки и читалась при каждом опросе очереди. - Аудио лежит в раскладке хранилища:
data/storage/<коллекция>/<запись>/<имя>рядом с файлом атрибутов. Имя задаёт сервис —<uuid><расширение>; собственного суффикса хранилище не дописывает, потому что умолчание, строящее имя из имени отправителя, не применяется. Ни файлы, ни объекты в Object Storage не удаляются после завершения задачи: каталог и бакет растут неограниченно. - Файл отдаётся ссылкой
/api/files/<коллекция>/<запись>/<имя>. Поле файла помечено защищённым шагом202608120001, а правило просмотра коллекции пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном файла, который берут по сессии. Прежнее решение — «право прочитать запись даёт знание её идентификатора» — отменено задачейoidc-login2026-08-12. Имя файла в хранилище в журнал не пишется по-прежнему: оно последняя часть ссылки. - Коллекция
usersзаводится самой библиотекой, а шаг202608120001её сужает: создание записи разрешено только контексту обмена OIDC (@request.context = "oauth2"), вход по паролю и одноразовый код выключены. Без этого сужения закрытие API обходится двумя запросами — завести себе запись и войти паролем. Продление сессии закрыто слоем в приложении, а не настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда. - Захват записи — один запрос с
RETURNING, мимо записей коллекции.app.DB()направляет всё, кроме выборок, в пул с единственным соединением, поэтому захваты выстраиваются в очередь. Порядок выборки — по времени заведения и по ключу: время неуникально, и без ключа порядок обработки невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку протухания захвата и отсутствию признака остановки; срок протухания выбирается по рубежу самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, подставить его не может. - Запись результата условна по признаку захвата — инвариант «Результат пишет только держатель захвата» в CLAUDE.md, «Инварианты» (major); норма — pipeline. Здесь названо потому, что условие проверяется тем же запросом, что и сам захват.
- Список колонок задан двумя местами —
applyOwnedByPipelineвместе сapplyToRecordиrecordToAudioRecord, — плюс шагом схемы. Мест было четыре, пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и перечень перестал расти с моделью. Правило правки и его серьёзность — инвариант в CLAUDE.md, «Инварианты»; сверку держат правилаinternal/archrules. - Перечень рубежей объявлен одним дескриптором —
internal/entity/stage.go. Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя: рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту проекта не пишется в журнал и не считается в метрику. - Отказ хранилища наружу не выходит дословно. Он несёт ключ файла целиком, а
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
свой текст с идентификатором записи, а цепочку
%wобрывают. То же у выгрузки в Object Storage: отказ SDK несёт полный URL объекта.
Настройки с числовым значением
| Настройка | Значение | Где | Откуда число |
|---|---|---|---|
| Предел отказов | 5 | service/transcribe.go |
обычное умолчание, не замер |
| Пауза перед повтором | 2^(отказ−1) с, потолок 5 минут |
там же | то же |
| Срок захвата, приведение | 8 часов | entity/stage.go |
потолок записи 6 часов плюс запас |
| Срок захвата, отправка на распознавание | 8 часов | там же | то же |
| Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды |
| Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды |
| Число воркеров конвейера | 3 | конфиг, [pipeline] workers |
решение владельца; ноль — законное значение |
| Предел простоя, своя работа | 60 минут | конфиг, [pipeline] own_work_limit_minutes |
решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число меньше времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже |
| Предел простоя, чужая операция | 1440 минут | конфиг, [pipeline] foreign_work_limit_minutes |
сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого |
| Версия вида структуры реплик | 1 | entity.StructureVersion |
первая |
| Потолок тем на запись | 5 | entity.MaxTopicsPerRecord |
решение владельца: без него часовой разговор даёт два десятка тем |
| Потолок сохранённого ответа провайдера | 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 |
расчётный потолок в шесть часов с запасом на видео |
| Срок жизни сессии | нормирует access | pbrepo.SessionDuration, ставится при подъёме |
решение владельца 2026-08-12; умолчание библиотеки никем не выбрано, и спека прямо запрещает его применять |
| Потолок времени на вход у провайдера | 10 минут | controller/http/auth.go |
дольше носитель состояния не нужен |
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
У сторожа простоя есть второй потолок, и он не тот, что в настройке. Предел
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
протухания ещё не истёк. Значит для держателя, погибшего жёстко — контейнер убит
по нехватке памяти или docker kill, — запись невидима сторожу до истечения
срока захвата её рубежа, то есть восьми часов у приведения и отправки.
Замерено прогоном: до истечения срока повторный захват записи не выдаёт, и
остановка «застряла» наступает только после него. Мягкая остановка сюда не
подпадает: она снимает захват сама.
Потолок размера назван числом в двух местах сразу — у поля файла в схеме и у тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по медленному каналу переживают любой фиксированный, а стойкость к целенаправленной нагрузке объявлена вне модели угроз.
Чего среди настроек нет: режим журналирования, таймаут занятости и размер пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения файлов и объектов нет вовсе. Таймаутов у обращений к S3 и SpeechKit тоже нет — ни одного.