Compare commits

...
9 Commits
Author SHA1 Message Date
av d88e56efcb claude.md: объявлена стадия стройки и выкладка с чистого листа
- на сервере данных нет и сервис остановлен, совместимость с ним не требуется
- запрет на боевой каталог и на переписанный шаг схемы этим не снимается
2026-08-15 08:12:18 +03:00
av b4b19db6e4 tasks: заведён урожай ревью remove-telegram-intake
- шесть записей по кластерам причин: журнал под внешним значением, нулевой
  код ответа в журнале, затирание вложения бедным ответом, открытый анониму
  адрес подтверждения почты, рубеж расшифровки без работы, пределы длительности
- находка про код 500 у отказа приёма дописана в json-api-for-spa: там живёт
  единая точка отображения доменной ошибки
- telegram-account-link и bot-api-only-through-bot-client оставлены с оговоркой,
  что предмета у них нет до возвращения входа
2026-08-15 07:46:16 +03:00
av 8f7c3a057a удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка
  входа при старте, секция настроек и зависимость go-telegram-bot-api; из
  конвейера ушла доставка ответа отправителю — исход виден опросом готовности.
  Колонки адресата и значение источника остались в схеме: применённые шаги не
  переписываются
- шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла;
  существующие строки он не проверяет, и это принято сознательно — искать их
  надо запросом до выкладки
- ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ
  распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала
  быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image
  до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
2026-08-15 07:24:35 +03:00
av 97ceb7bb69 заведена задача failure-verdict-vs-retry 2026-08-14 20:38:12 +03:00
av 9a964f2efc закрыта задача record-centric-model 2026-08-14 20:20:58 +03:00
av 1576d06735 внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
2026-08-14 20:20:33 +03:00
av d079f03350 заведена задача на перестройку модели вокруг аудиозаписи
- центральная сущность — аудиозапись: файлы, тексты, структура реплик и темы
  живут отдельными строками, поля очереди перестают соседствовать с содержимым,
  а провайдерское уезжает в свою таблицу
- конвейер становится цепочкой рубежей с остановкой признаком: рубеж не
  стирается, и запись перезапускается с места остановки
- воркеры теряют специализацию, их число задаётся конфигом
2026-08-14 16:46:40 +03:00
av a67cdee382 закрыта задача record-ownership 2026-08-14 12:19:19 +03:00
av 8af8ec2e54 у записи появился владелец: чужую больше не отдают
- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы
  `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и
  несуществующая дают один ответ; правило просмотра файлов сужено им же
- приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи
  пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный
  файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается
- удаление учётной записи с записями отвергается стражем, и вешает его сама
  сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
2026-08-14 12:18:11 +03:00
146 changed files with 13384 additions and 4737 deletions
-2
View File
@@ -154,8 +154,6 @@ linters:
- (*os.File).Close - (*os.File).Close
- (io.ReadCloser).Close - (io.ReadCloser).Close
- os.Remove - os.Remove
# Метод сам логирует ошибку отправки, вызывающему она не нужна
- (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send
exclusions: exclusions:
rules: rules:
+66 -36
View File
@@ -9,11 +9,12 @@
## Что это ## Что это
Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и Сервис расшифровки аудио в текст. Принимает запись одним входом — HTTP API, —
HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex
Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач, SpeechKit и отдаёт текст тому, кто запись загрузил, по опросу готовности.
метаданные и сами файлы лежат во встроенной PocketBase, и она же даёт владельцу Состояние записей, метаданные и сами файлы лежат во встроенной PocketBase, и она
панель администратора. же даёт владельцу панель администратора. Вход Telegram убран 2026-08-14 —
временно, до задачи, которая свяжет чат с учётной записью.
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
руками не правит и в форматы документов не экспортирует, учётных записей не руками не правит и в форматы документов не экспортирует, учётных записей не
@@ -25,7 +26,7 @@ Yandex SpeechKit и возвращает текст туда, откуда пр
## Стек ## Стек
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и
панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object панель администратора, — `aws-sdk-go-v2` для Object
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка — Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`. Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
@@ -33,7 +34,7 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
Что нарушать нельзя. Что нарушать нельзя.
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit, пара ключей Object - **Секрет не покидает конфиг.** Ключ SpeechKit, пара ключей Object
Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и
в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют
вручную во всех местах выкладки. **critical** вручную во всех местах выкладки. **critical**
@@ -53,14 +54,13 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
приведённым к перечню известных форматов. Границу держит спека `intake`, приведённым к перечню известных форматов. Границу держит спека `intake`,
цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md), цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md),
остаток — [docs/security.md](docs/security.md). остаток — [docs/security.md](docs/security.md).
- **Бот отвечает только тем, кто в белом списке.** Бот проверяет отправителя до
любой работы, включая скачивание файла. Нарушение обратимо правкой конфига, но
чужие записи к тому моменту уже обработаны за наши деньги. **critical**
- **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет - **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет
задачу пригодной к повтору, либо переводит её в `failed` и сообщает запись пригодной к повтору, либо ставит на неё признак остановки с причиной —
пользователю. Молчаливый выход из шага без записи в лог и без смены состояния и тогда причина видна её владельцу опросом готовности, а владельцу сервиса
запрещён. Обратимо повторной отправкой, но пользователь об этом не узнает. журналом. Молчаливый выход из шага без записи в лог и без смены состояния
**major** запрещён. Обязанность сменила направление 2026-08-14 вместе с убранным входом
Telegram: прежде об отказе сообщали, теперь отказ доступен спросившему, и
отправитель, который не спрашивает, о нём не узнаёт. **major**
- **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не - **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
запись раз в секунду на каждый воркер. **major** запись раз в секунду на каждый воркер. **major**
@@ -72,15 +72,39 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
записи. **critical** записи. **critical**
- **Колонки очереди правятся в четырёх местах** пакета хранилища — - **Колонки записи правятся в двух местах** пакета хранилища —
`applyToRecord`, `recordToJob`, константа `acquireColumns` и структура `applyOwnedByPipeline` вместе с `applyToRecord` и `recordToAudioRecord`, —
`acquiredRow` с её `toJob`, — плюс шаг схемы. Компилятор видит два из них. плюс шаг схемы. Компилятор не видит ни одного: колонка, забытая в одном из
Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата них, теряется молча — запись сохранится без поля либо приедет с нулевым.
нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения: Мест было четыре, пока захват перечислял колонки поимённо; теперь он
поле теряется **только у задачи, попавшей к воркеру**. **major** возвращает идентификатор и признак своего захвата, и перечень перестал расти
- **Результат пишет только держатель захвата.** Шаг, чей захват за время работы с моделью. Сверку держат правила `internal/archrules`. **major**
достался другому, завершается без записи и без ответа отправителю. Иначе два - **Рубеж объявляется одним дескриптором** — `internal/entity/stage.go`. Из него
воркера пишут в одну задачу по очереди, а отправитель получает два ответа. выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя;
перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в
отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
ниже не пишется в журнал и не считается в метрику: запись встанет без единого
следа. Сверку держат правила `internal/archrules`. **major**
- **Результат пишет только держатель захвата, и держатель узнаётся значением.**
Признак захвата уникален для каждого захвата, и запись результата условна по
нему, а не по занятости записи. Шаг, чей захват за время работы достался
другому — по протуханию срока или после того, как человек снял признак
остановки в панели, — завершается без записи результата. Условие
по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись
по очереди, портя её результат. **major**
- **У записи есть владелец, и колонка пустого значения не принимает.** Ничья
запись не заводится ничем — ни приёмом, ни конвейером, ни рукой в панели, — и
держит это схема хранилища, а не договорённость. Пока обязательность жила в
одном приёме, ничью запись заводили в панели, она уходила в конвейер, стоила
денег на распознавание и не доставалась потом никому. Правило со стороны
спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной
записью, потому что схема запрещает **заводить** ничью, а это правило —
**спрашивать** ничьим именем. **major**
- **Остановленная запись несёт причину, какой бы та ни была.** Причин три —
приговор шага, исчерпанные отказы, застревание, — и каждая записывается в саму
запись и в её журнал событий. Остановленная запись захвату не выдаётся, значит
исход «пригодна к повтору» исключён, и другого следа у неё не будет.
Обязанность, записанная у одной причины, у остальных читалась бы как снятая.
**major** **major**
## Команды ## Команды
@@ -183,18 +207,16 @@ task gate # весь набор проверок разом
- **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и - **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и
база (`data/data.db`), и записи живых людей база (`data/data.db`), и записи живых людей
(`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его (`data/storage/<коллекция>/<запись>/`). На стройке под ним пусто и сервис
ронять и пересоздавать можно свободно. остановлен — запрет от этого не снимается: каталог принадлежит серверу, и
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой,
перехватывает обновления у работающего, и пользователь теряет ответы. Запускай его ронять и пересоздавать можно свободно.
с `telegram.enabled = false`: сервис поднимается без Telegram, к нему не уходит - **Локальный запуск не ходит наружу.** Секции `[auth]` и `[yandex]`
ни одного обращения, и работает он одним входом, по HTTP. Пустого проверяются на старте, но наружу при этом не обращаются, так что годятся
`bot_token` для этого мало и больше не значит ничего: включён вход или нет, выдуманные непустые значения — адреса `[auth]` должны лишь разбираться как
решает отдельный признак `telegram.enabled`, а пустой ключ при `enabled = true` ссылки. Расшифровка при выдуманных ключах не работает: её подменяют
роняет старт. Выключенного входа `internal/adapter/recognizer/memory.go`. Подробности строками в
для подъёма тоже мало: секции `[auth]` и `[yandex]` проверяются на старте, но `config.example.toml`.
наружу при этом не ходят, так что годятся выдуманные непустые значения;
подробности строками в `config.example.toml`.
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage - **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён — оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
подставляй `internal/adapter/recognizer/memory.go`. подставляй `internal/adapter/recognizer/memory.go`.
@@ -216,6 +238,14 @@ task gate # весь набор проверок разом
## Работа ## Работа
- **Стадия проекта — стройка** (`[tasks] stage = "build"`, объявлена в
[tasks/BACKLOG.md](tasks/BACKLOG.md)). Приложение строим заново: на сервере
данных нет, сервис остановлен, выкладка пойдёт с чистого листа. Совместимость
с тем, что уже лежит на сервере, поэтому не требуется — переносить нечего: ни
базы, ни файлов записей, ни истории. Что это **не** отменяет: гейт краснеет на
переписанном шаге схемы, как и краснел, и снятие этого запрета — отдельное
решение человека; боевой каталог данных остаётся под запретом; выкладку
по-прежнему запускает человек.
- **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет. - **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет.
- **Сообщение коммита** без трейлера `Co-Authored-By`. - **Сообщение коммита** без трейлера `Co-Authored-By`.
- **Необратимое** (спрашивается у человека всегда): применённая миграция, формат - **Необратимое** (спрашивается у человека всегда): применённая миграция, формат
@@ -234,7 +264,7 @@ task gate # весь набор проверок разом
- Документация, комментарии, сообщения коммитов — русский. - Документация, комментарии, сообщения коммитов — русский.
- Код и идентификаторы — английский. - Код и идентификаторы — английский.
- Текст, который видит пользователь Telegram, — русский. - Текст, который видит пользователь сервиса, — русский.
- **Точного числа накопленного в документах нет.** «Три capability», «пять - **Точного числа накопленного в документах нет.** «Три capability», «пять
прогонов ревью», «две типизированные ошибки» расходятся с действительностью на прогонов ревью», «две типизированные ошибки» расходятся с действительностью на
первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина
+6 -11
View File
@@ -1,10 +1,9 @@
# Transcriber Service # Transcriber Service
Сервис расшифровки аудиозаписей. Два входа — Telegram-бот и HTTP API. Сервис расшифровки аудиозаписей. Вход один — HTTP API.
## Возможности ## Возможности
- Приём аудио из Telegram: голосовые сообщения, аудиофайлы и документы с аудио
- Приём аудиофайлов через HTTP API - Приём аудиофайлов через HTTP API
- Конвертация в ogg через ffmpeg - Конвертация в ogg через ffmpeg
- Распознавание речи через Yandex SpeechKit - Распознавание речи через Yandex SpeechKit
@@ -15,7 +14,6 @@
- **Язык**: Go 1.26, CGO не нужен - **Язык**: Go 1.26, CGO не нужен
- **Веб-фреймворк**: gin-gonic/gin - **Веб-фреймворк**: gin-gonic/gin
- **Telegram**: go-telegram-bot-api
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3) - **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
- **Конвертация**: ffmpeg - **Конвертация**: ffmpeg
- **Хранилище, файлы и панель**: встроенная PocketBase - **Хранилище, файлы и панель**: встроенная PocketBase
@@ -41,12 +39,11 @@
Сервер запустится на порту из `[server] port`, по умолчанию 8080. Нужен Сервер запустится на порту из `[server] port`, по умолчанию 8080. Нужен
установленный `ffmpeg`. установленный `ffmpeg`.
### Белый список Telegram ### Кого пускают
Бот отвечает только тем, кто перечислен в конфиге. Кого и по какому признаку он Приём и опрос закрыты сессией OIDC. Что её выдаёт и чем она предъявляется —
пускает — [docs/security.md](docs/security.md), «Что разграничивает доступ»; [docs/security.md](docs/security.md), «Что разграничивает доступ»; известные
известные прорехи образца конфига, включая недостающий ключ белого списка, — прорехи образца конфига — [docs/conventions/config.md](docs/conventions/config.md).
[docs/conventions/config.md](docs/conventions/config.md).
## Деплой ## Деплой
@@ -94,13 +91,11 @@ transcriber/
│ ├── service/ # Конвейер расшифровки │ ├── service/ # Конвейер расшифровки
│ ├── controller/ │ ├── controller/
│ │ ├── http/ # HTTP-обработчики │ │ ├── http/ # HTTP-обработчики
│ │ ├── tg/ # Telegram-бот
│ │ └── worker/ # Фоновые воркеры │ │ └── worker/ # Фоновые воркеры
│ └── adapter/ │ └── adapter/
│ ├── converter/ffmpeg/ # Конвертация аудио │ ├── converter/ffmpeg/ # Конвертация аудио
│ ├── metaviewer/ffmpeg/ # Длительность аудио │ ├── metaviewer/ffmpeg/ # Длительность аудио
│ ├── recognizer/yandex/ # SpeechKit + Object Storage │ ├── recognizer/yandex/ # SpeechKit + Object Storage
│ ├── telegram/ # Отправка сообщений
│ └── repo/pocketbase/ # Репозитории, схема коллекций, правила панели │ └── repo/pocketbase/ # Репозитории, схема коллекций, правила панели
└── data/ # Каталог данных: база и файлы записей вместе └── data/ # Каталог данных: база и файлы записей вместе
├── data.db # База хранилища (создаётся автоматически) ├── data.db # База хранилища (создаётся автоматически)
@@ -109,7 +104,7 @@ transcriber/
## Хранилище ## Хранилище
Две коллекции, `files` и `transcribe_jobs`. Поля, ключи, правило времени и Коллекции хранилища — аудиозапись и её приложения. Поля, ключи, правило времени и
идентификаторов, а также механика захвата задачи воркером — идентификаторов, а также механика захвата задачи воркером —
[docs/database.md](docs/database.md). Панель владельца — по адресу `/_/` того же [docs/database.md](docs/database.md). Панель владельца — по адресу `/_/` того же
порта; пароль от неё задаёт сам владелец по приглашению, которое сервис печатает порта; пароль от неё задаёт сам владелец по приглашению, которое сервис печатает
+27 -38
View File
@@ -9,6 +9,33 @@ force_shutdown_timeout = 20
[storage] [storage]
data_dir = "data" data_dir = "data"
# Конвейер расшифровки.
[pipeline]
# Число рабочих потоков. Специализации у них нет: каждый берёт любую пригодную к
# работе запись и выбирает шаг по её рубежу.
#
# Ноль — законное значение, а не поломка: сервис поднимается, записи
# принимаются и не двигаются. Годится местному запуску и выкладке, где конвейер
# надо остановить, не роняя приём.
workers = 3
# Предел простоя записи там, где работу делаем мы сами, в минутах.
#
# Сторож ловит **зависание**, а не долгую работу: пока шаг идёт, запись занята
# захватом, и живой процесс наблюдается сам по себе. Час меньше времени, которое
# многочасовая запись занимает на приведении, и это принято сознательно
# (решение владельца 2026-08-14): цена ложной остановки — одно движение
# владельца, потому что остановка обратима и рубежа не стирает.
own_work_limit_minutes = 60
# Предел простоя там, где ждём операцию внешнего сервиса, в минутах.
#
# Сколько идёт распознавание долгой записи, никто не мерил, поэтому ошибаемся в
# сторону долгого: ложная остановка хуже поздней. Откладывание опроса этот
# отсчёт не двигает — иначе зависшая у провайдера операция опрашивалась бы
# вечно.
foreign_work_limit_minutes = 1440
# Yandex Cloud Configuration # Yandex Cloud Configuration
[yandex] [yandex]
# ID папки в Yandex Cloud (получить в консоли Yandex Cloud) # ID папки в Yandex Cloud (получить в консоли Yandex Cloud)
@@ -58,41 +85,3 @@ redirect_url = "https://transcriber.example.com/auth/callback"
# Признак `Secure` у куки сессии. Умолчание true; false только для локального # Признак `Secure` у куки сессии. Умолчание true; false только для локального
# запуска по http://localhost, где браузер такую куку не сохранит # запуска по http://localhost, где браузер такую куку не сохранит
secure_cookie = true secure_cookie = true
# Telegram Bot Configuration
[telegram]
# Нужен ли сервису вход Telegram. Ключ **обязателен**: умолчания у него нет, и
# файл без него негоден — сервис выходит с ошибкой настройки, назвав недостающий
# ключ. Умолчание было бы угаданным намерением, а признак заведён затем, чтобы
# намерение объявляли: любое умолчание делает одну из двух ошибок тихой — либо
# бот молча пропадает, либо файл без признака молча работает.
#
# false — сервис поднимается без Telegram и работает одним входом, по HTTP. Бот
# не заводится, к Telegram не уходит ни одного обращения, записи из Telegram не
# принимаются, а ответы на задачи, принятые оттуда прежде, не уходят —
# недоставка видна записью журнала, расшифровка достаётся из панели и по HTTP.
# О выключенном входе сервис говорит одной записью журнала «к сведению»: это
# выбор владельца, а не отклонение.
#
# true — сервис поднимает бота. Пустой bot_token при этом роняет старт: бота по
# пустому ключу не существует. Старт роняет и ответ Telegram «такого бота нет» —
# это опечатка в ключе, ждать тут нечего. А вот недоступность Telegram (сеть,
# DNS, авария Bot API) подъёму не мешает: сервис встаёт без бота и предупреждает
# записью журнала, потому что основной вход у него другой.
#
# Локальный прогон идёт с false — боевым токеном запускаться запрещено: второй
# процесс с тем же токеном перехватывает обновления у работающего. Выключенного
# входа для подъёма мало: секции [auth] и [yandex] проверяются на старте и
# роняют процесс на пустых ключах. Наружу при старте не ходит ни одна из них,
# поэтому для локального прогона годятся выдуманные непустые значения — адреса
# [auth] должны лишь разбираться как ссылки. Расшифровка при выдуманных ключах
# не работает: её подменяют в коде.
enabled = false
# Токен Telegram бота (получить у @BotFather в Telegram). Только ключ доступа:
# включением входа он больше не заведует, этим занят enabled выше. При
# enabled = false не читается вовсе.
bot_token = ""
# Таймаут обновлений Telegram бота (в секундах)
update_timeout = 10
@@ -2,6 +2,7 @@
- **Дата:** 2026-08-13 - **Дата:** 2026-08-13
- **Источник:** openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md - **Источник:** openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md
- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа
## Решение ## Решение
@@ -2,6 +2,7 @@
- **Дата:** 2026-08-13 - **Дата:** 2026-08-13
- **Источник:** openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md - **Источник:** openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md
- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа
## Решение ## Решение
@@ -0,0 +1,63 @@
# ADR-2026-08-14. Учётная запись с записями не удаляется, и это осознанный тупик
- **Дата:** 2026-08-14
- **Источник:** [openspec/changes/archive/2026-08-14-record-ownership/design.md](../../openspec/changes/archive/2026-08-14-record-ownership/design.md), раздел `Open Questions`
## Решение
Удаление учётной записи, у которой остались задачи расшифровки либо файлы,
отвергается — отказом с названной причиной. Способа удалить записи в сервисе нет
вовсе, поэтому до задачи про удаление записи такая учётная запись не удаляется
никак: ни владельцем панели, ни самим человеком.
Решение принято человеком на чекпоинте задачи `record-ownership` из трёх
предложенных способов.
Дословно из источника:
> **Что делать с записями удалённого пользователя?** Связь при выключенном
> каскаде снимает ссылку — записи остаются, но становятся ничьими и
> недостижимыми по API навсегда. Способы: запретить удаление учётной записи, пока
> у неё есть записи; держать рядом со связью неизменяемый снимок идентификатора;
> признать потерю ценой и записать её.
## Почему
Колонка владельца — связь с учётной записью, и каскадное удаление у неё
выключено: сервис объявлен архивом и молча удалить чужой архив не вправе. Одного
этого мало, и проверка по исходникам `pocketbase@v0.39.10` показала почему: при
выключенном каскаде хранилище **вынимает** идентификатор из поля связи и
сохраняет запись без проверок. Задачи остались бы на месте, но стали бы ничьими —
а ничья запись по правилу той же задачи не достаётся по API никому. Архив
человека исчезал бы молча, и восстановить владельца было бы нечем: прежнего
значения не остаётся нигде.
Прежнее обоснование выбора связи вместо строки — «связь удержит целостность» —
было неверным, и это выяснилось на ревью дизайна.
## Чем платим
Владелец панели упирается в отказ, а выхода из него сегодня нет: удаление записи
приносит отдельная задача. Тупик назван прямо, а не обнаружен потом.
Отказ обязан доезжать до спрашивающего: хранилище пропускает наружу только свою
ошибку роутера, а всякую другую подменяет сообщением про обязательную связь.
Подсказка эта ведущая — единственная обязательная связь у задачи это файл, — и
владелец панели, поверив ей, пошёл бы удалять записи руками, то есть делать ровно
то необратимое, ради предотвращения чего запрет и заведён. Это нашло ревью кода.
## Что рассматривалось и отвергнуто
- **Неизменяемый снимок идентификатора рядом со связью.** Пережил бы удаление, и
запись можно было бы вернуть человеку. Отвергнуто: владельцем становится любая
строка, и целостность, ради которой выбрана связь, теряется.
- **Признать потерю ценой и записать её.** Дешевле всего сегодня — удаления
пользователей в сервисе нет вовсе. Отвергнуто: архив, теряемый одной кнопкой в
панели, противоречит решению от 2026-08-11 о том, что сервис — архив.
## Связанное
Запрет ставит сама сборка хранилища, а не вызывающий: сборка, забывшая его
позвать, теряет защиту молча — и теряла, пока его добавляли отдельной строкой
запуска. Норма — `openspec/specs/storage`, «Учётная запись с записями не
удаляется».
@@ -0,0 +1,51 @@
# Остановка записи — признак, а не рубеж
- **Дата:** 2026-08-14
- **Источник:** openspec/changes/archive/2026-08-14-record-centric-model/design.md,
раздел «Остановка — признак, а не рубеж»
## Решение
Прежние состояния отказа и смерти (`failed`, `dead`) схлопнуты в **признак
остановки** с причиной: `halted_at`, `halt_reason`, `error_text`. Достигнутый
рубеж при остановке не стирается, и снятие признака продолжает работу с того
места, где запись встала.
## Почему
Цитата источника:
> **Признак** — принято: рубеж переживает остановку, продолжение идёт с места
> остановки, массовый перезапуск после выкатки правки делается одним
> обновлением, а различие «мы рассудили» против «мы перестали пробовать»
> остаётся причиной, которую человек читает.
Отвергнуты два варианта, оба с названной ценой:
> **Отдельное состояние на каждую причину** — отвергнуто: перечень состояний
> закрыт схемой, и каждая новая причина стоила бы необратимого шага.
>
> **Оставить как есть** — отвергнуто: именно из-за этого перезапись состояния
> руками в панели остаётся единственным способом вернуть запись в работу, и
> делается он наугад.
Прежняя модель описана решением
[ADR-2026-08-11-queue-as-pocketbase-collection](ADR-2026-08-11-queue-as-pocketbase-collection.md):
там состояние «мертва» заводилось взамен признака `is_error`, и довод был тот
же — «два способа вывести задачу из выборки расходятся». Довод устоял, а
носитель сменился: теперь единственный способ вывести запись из выборки — этот
признак, и состояние его больше не дублирует.
## Последствия
- `+` перезапуск перестал быть догадкой: запись продолжает с сохранённого
рубежа, а не начинает конвейер заново.
- `+` новая причина остановки стоит значения в закрытом перечне причин, а не
нового состояния и не нового шага схемы.
- `+` массовый возврат в работу после выкатки правки делается одним обновлением
колонки.
- `` в выборке захвата появилось четвёртое условие, и рубеж перестал быть
единственным, что выводит запись из работы: читать состояние записи теперь
надо двумя полями.
- `` перечень причин закрыт схемой, то есть новая причина всё же требует шага
схемы — дешевле прежнего, но не бесплатно.
@@ -0,0 +1,47 @@
# Ответ распознавателя хранится дословно, двоичной формой и вложением
- **Дата:** 2026-08-14
- **Источник:** openspec/changes/archive/2026-08-14-record-centric-model/design.md,
раздел «Сырой ответ провайдера хранится вложением, а не колонкой»
## Решение
Ответ SpeechKit сохраняется целиком — сообщения потока подряд, каждое своей
двоичной записью с длиной впереди, — и лежит **вложением** коллекции попыток
распознавания, а не колонкой.
## Почему
Цитата источника:
> Хранится он вообще потому, что **результат операции у провайдера не
> переспрашивается**. Отвергнутый вариант — не хранить и разобрать на лету:
> дешевле сегодня, но связь реплики с говорящим мы строить пока не умеем, и
> когда научимся, архив пересчитать будет не из чего, а повторная операция стоит
> денег за каждую запись.
Вложением, а не колонкой:
> Ответ на многочасовую запись — мегабайты. Хранилище читает запись целиком, а
> шаг опроса читает строку попытки раз в несколько секунд: положенный колонкой,
> ответ ехал бы в память при каждом опросе — тот же промах, что расшифровка в
> перечне колонок захвата сегодня.
Двоичной формой, а не текстовой, — решение ревью кода того же изменения. Замер:
текстовое представление собирается по нашей скомпилированной схеме и **молча
выбрасывает поля, которых в ней нет**, а провайдер добавляет их без
предупреждения. Двоичная форма неизвестные поля переносит: они переживают запись
и чтение и станут читаемыми, когда схема обновится. Ради этого архив и заводился.
## Последствия
- `+` архив пересчитывается из сохранённого без единого рубля: связь реплики с
говорящим станет доступна, когда мы научимся её читать.
- `+` шаг опроса читает строку попытки, не поднимая мегабайты в память.
- `` **формат файла на диске объявлен необратимым**: сохранённое не читается
глазами и не разбирается ничем, кроме нашего же кода, а прочесть архив без
сервиса нельзя вовсе.
- `` каталог данных растёт быстрее прежнего: ответ многословнее самой
расшифровки — несёт альтернативы, время каждого слова и разбор говорящих.
Потолок в 256 МиБ на вложение назван строкой в `database.md`, а сколько там на
деле у шестичасовой записи, не мерил никто.
@@ -0,0 +1,45 @@
# Предел простоя остаётся часом, хотя он короче самой работы
- **Дата:** 2026-08-14
- **Источник:** openspec/changes/archive/2026-08-14-record-centric-model/design.md,
раздел «Сторожей двое, и предела времени — два числа»
## Решение
Сторож застревания ограничивает время записи в рубеже двумя числами: **час** на
свою работу, **сутки** на ожидание чужой операции. Час меньше времени, которое
многочасовая запись занимает на приведении, и это принято сознательно.
## Почему
Ревью дизайна показало, что число противоречит расчётному потолку записи:
> Расчётный потолок записи — шесть часов, приведение такой записи идёт дольше
> часа по построению, а срок захвата шага приведения стоит сегодня восемью
> часами. Значит длинная запись, отказавшая один раз и ждущая повтора дольше
> часа, будет остановлена сторожем застревания вместо расшифровки.
Предложено было вывести предел из срока захвата — двенадцать часов на свою
работу. Владелец решил оставить час, и довод записан цитатой:
> Оставляем час. Тут нужно принять, что это скорее про зависшую задачу, потому
> что пока идёт обработка даже длинной записи мы всегда можем проверить, жив ли
> процесс конвертера.
Довод держится на том, что остановка теперь **обратима**: она не стирает рубежа,
и снятие признака возвращает запись туда, где она стояла (см.
[ADR-2026-08-14-halt-is-a-flag-not-a-stage](ADR-2026-08-14-halt-is-a-flag-not-a-stage.md)).
Цена ложной остановки поэтому равна одному движению владельца, а не потерянной
записи.
## Последствия
- `+` зависшая запись обнаруживается за час, а не за восемь.
- `+` число живёт в настройках и правится без шага схемы, если класс начнёт
всплывать.
- `` длинная запись, отказавшая один раз и прождавшая повтора дольше часа,
останавливается как застрявшая — владельцу приходится снимать признак руками.
- `` предел этот работает только по записи, вернувшейся в выборку. У держателя,
погибшего жёстко, запись невидима сторожу до истечения **срока захвата** её
рубежа, то есть восьми часов у приведения; замер и оговорка стоят строкой в
`database.md`.
@@ -0,0 +1,59 @@
# Обязательность владельца держит схема, а не приём
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
разделы «Схема теряет только необязательность владельца» и «Владелец записи
перестаёт быть необязательным и в модели»
## Решение
Колонка владельца у аудиозаписи и у файла перестала принимать пустое значение —
шагом схемы `202608140003`. Ничья запись не заводится ничем: ни приёмом, ни
конвейером, ни рукой в панели. Поле владельца в модели стало обычной строкой
вместо ссылки, которой позволено отсутствовать.
Существующие строки шаг **не проверяет**, и это принято сознательно: искать ничьи
строки надо запросом до выкладки.
## Почему
Цитата источника:
> **Держать обязательность одним приёмом, схему не трогать.** Так было задумано
> сперва, и это оставляло дыру: ничью запись заводили руками в панели, она
> уходила в конвейер, стоила денег на распознавание и не доставалась потом
> никому. Решение владельца от 2026-08-14 — обязательность держит схема.
Прежнее решение было обратным и записано спекой `storage`: «Колонка MUST
допускать пустое значение… Обязательность для приёма по HTTP держит сама
capability `intake`, а не схема». Цену за него платили записи входа Telegram — у
них владельца не было по построению. Вход убран
([ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md)),
исключение исчезло вместе с ним, и владелец сервиса подтвердил, что записей без
владельца в боевой базе нет.
Про непроверку существующих строк цитата источника:
> **Проверяется это запросом, а не прогоном шага**, и разница выяснилась ревью с
> оракулом: хранилище держит обязательность связи проверкой записи при
> сохранении, а не ограничением таблицы. Смена признака на базе с ничьей записью
> проходит зелёным и такую запись оставляет… Заставить шаг считать строки самому
> владелец решил не делать: безопасность держится ручной проверкой, и она названа
> первым шагом плана перехода.
Правило «пустой владелец не совпадает ни с одной записью» при этом осталось и
избыточным не стало: схема запрещает **заводить** ничью запись, а правило —
**спрашивать** ничьим именем.
## Последствия
- `+` значения «владельца нет» не существует ни на одном уровне: ни в схеме, ни в
модели, ни в отборе.
- `+` дыра «ничью запись заводят руками в панели» закрыта тем же механизмом, что
и приём, — одним, а не двумя.
- `` откат шага возвращает необязательность, но операционно недостижим: команд
библиотеки сервис не подключает, и это верно для всех шагов схемы проекта.
- `` ничья запись, если её проглядят перед выкладкой, становится незакрываемой:
захват выдаёт её воркеру, а всякое сохранение — включая то, которым ставится
признак остановки, — отказывает. Следа не остаётся ни в метрике, ни в журнале
событий, только строка в логе контейнера.
@@ -0,0 +1,40 @@
# Метка убранного входа не выставляется вовсе, а не обнуляется
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
раздел «Метка убранного входа не выставляется вовсе»
## Решение
Признак поднятого входа остался, а метки убранного входа в метриках нет вовсе —
ни со значением единицы, ни со значением нуля. Ряд `transcriber_intake_up` с
меткой `telegram` не появляется после выкладки.
## Почему
Цитата источника:
> Признак поднятого входа остаётся, метка `telegram` у него больше не появляется.
> Ноль вместо неё читается как «вход есть, но не поднялся», то есть как поломка;
> владелец, у которого на этот признак стоит отбор, увидел бы аварию на ровном
> месте.
Отвергнут очевидный подход — оставить ряд со значением нуля. Он выглядит
бережнее (отбор не ломается), но говорит неправду: значение нуля у этого признака
означает именно неподнятый вход, а не отсутствующий.
С единственным оставшимся входом проверяемым осталось только **множество меток**:
значение нуля у него недостижимо, потому что страница метрик отдаётся тем же
сервером, что и приём, — чтобы прочитать признак, надо дотянуться до входа, о
котором он сообщает. Различать поднятый и неподнятый вход признак станет снова,
когда входов у сервиса станет больше одного.
## Последствия
- `+` наблюдатель не видит вечного нуля, который читался бы как незакрытая
авария.
- `` отбор вида `transcriber_intake_up == 0` по убранному входу перестаёт
срабатывать молча: исчезновение ряда ловится `absent()`, а не сравнением.
Владельцу, если такой отбор был заведён, править его руками.
- `` требование «различать поднятый и неподнятый» стало непроверяемым до
возвращения второго входа, и это сказано в самом требовании прямо.
@@ -0,0 +1,63 @@
# Вход Telegram убран целиком, а не выключен признаком
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
разделы «Context» и «Формы решения, между которыми выбирали»
## Решение
Вход Telegram убран из сервиса целиком: клиент, транспорт обновлений, отправитель
сообщений, сборка входа при старте, список допущенных людей, секция настроек и
зависимость. Убран **временно** — возврат заводится новым изменением вместе со
связью чата с учётной записью.
Хранилище при этом не тронуто: колонки `tg_chat_id`, `tg_reply_message_id` и
значение `telegram` перечня источников остаются в схеме вместе с записями,
которые их заполнили.
## Почему
Цитата источника:
> Сервис принимает записи двумя входами, и входы расходятся в главном: у записи,
> пришедшей из приложения, есть владелец, а у записи, пришедшей от бота, владельца
> нет и быть не может — связи чата с учётной записью сервис не ведёт. Пока такие
> записи заводятся, правило «каждая запись принадлежит человеку» действует
> наполовину.
Отвергнуты две формы решения, обе с названной ценой:
> **Выключить вход признаком, код оставить.** Признак `telegram.enabled` заведён
> 2026-08-13 и обязателен, а приём по HTTP владельца уже требует: одна правка
> ключа в боевом файле даёт «новых записей без владельца не заводится» ценой ноля
> строк кода и мгновенным возвратом. Отвергнуто по причине из раздела «Why»:
> двойная модель остаётся в коде, и оговорку про бота продолжает платить каждая
> следующая задача.
>
> **Сузить бота до исходящего канала.** Приём убрать, отправку оставить с одним
> адресатом — чатом владельца строкой настроек. Отвергнуто потому, что заводит
> понятие «канал уведомления владельца», которое тут же переделает задача
> `ntfy-delivery`.
Решениями, которые это изменение отменяет, были
[ADR-2026-08-13-telegram-intent-declared-not-inferred](ADR-2026-08-13-telegram-intent-declared-not-inferred.md)
и
[ADR-2026-08-13-telegram-outage-does-not-block-startup](ADR-2026-08-13-telegram-outage-does-not-block-startup.md):
оба нормировали подъём входа, которого больше нет. Доводы их при этом устояли и
понадобятся возврату — оба продолжают отвечать на вопрос «что делать с входом,
чей внешний собеседник недоступен».
## Последствия
- `+` модель одна: оговорка про запись без владельца ушла из спек приёма,
доступа, конвейера и хранилища.
- `+` зависимость `go-telegram-bot-api` ушла из манифеста вместе с двумя путями
утечки токена, которые проект закрывал двумя задачами.
- `` у сервиса не осталось входа, которым человек может воспользоваться:
приложения нет, личных ключей для программ нет, и до этих задач запись кладут
собранным руками запросом с сессией из браузера. Владелец окно принял.
- `` записи, застрявшие в конвейере на минуту выкладки, доходят до текста, и
ответа в чат по ним не уходит. Смягчения нет: чат и есть убираемый вход.
- `` бот у Telegram остаётся зарегистрированным и на вид живым, а ключ доступа —
в настройках выкладки под возврат входа (решение владельца от 2026-08-14).
Отправитель голосового не получит ни ответа, ни отказа.
+9 -2
View File
@@ -35,8 +35,15 @@
| Дата | Запись | Статус | | Дата | Запись | Статус |
| --- | --- | --- | | --- | --- | --- |
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | | | 2026-08-15 | [Вход Telegram убран целиком, а не выключен признаком](ADR-2026-08-15-telegram-intake-removed-temporarily.md) | |
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | | | 2026-08-15 | [Обязательность владельца держит схема, а не приём](ADR-2026-08-15-owner-required-by-schema.md) | |
| 2026-08-15 | [Метка убранного входа не выставляется вовсе, а не обнуляется](ADR-2026-08-15-removed-intake-has-no-metric-label.md) | |
| 2026-08-14 | [Предел простоя остаётся часом, хотя он короче самой работы](ADR-2026-08-14-stuck-limit-stays-an-hour.md) | |
| 2026-08-14 | [Ответ распознавателя хранится дословно, двоичной формой и вложением](ADR-2026-08-14-provider-payload-stored-verbatim.md) | |
| 2026-08-14 | [Остановка записи — признак, а не рубеж](ADR-2026-08-14-halt-is-a-flag-not-a-stage.md) | |
| 2026-08-14 | [Учётная запись с записями не удаляется, и это осознанный тупик](ADR-2026-08-14-account-with-records-is-not-deleted.md) | |
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | | | 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | | | 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | | | 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
+82 -74
View File
@@ -17,33 +17,41 @@
- [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие - [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
входов**: приём и опрос за сессией, имя отправителя не доходит ни до входов**: приём и опрос за сессией, имя отправителя не доходит ни до
хранилища, ни до журнала, метка метрики несёт только известное расширение, а хранилища, ни до журнала, метка метрики несёт только известное расширение, а
выключенный вход Telegram не мешает подъёму. Задачи наблюдатель видит единственный поднятый вход. Задачи
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11, `http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
`pocketbase-storage` и `oidc-login` 2026-08-12, `pocketbase-storage` и `oidc-login` 2026-08-12,
`local-run-without-telegram-token` 2026-08-13. Приём из Telegram по существу — `local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
кто допущен и как забирается запись — здесь по-прежнему не описан; 2026-08-14;
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват - [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
задачи и срок его протухания, число попыток, состояние «мертва», пауза перед задачи и срок его протухания, число попыток, остановка признаком, пауза перед
повтором и недоставленный ответ отправителю: задачи повтором и молчание конвейера наружу: задачи
`errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12 и `errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12,
`local-run-without-telegram-token` 2026-08-13. Переходы состояний и отмена `local-run-without-telegram-token` 2026-08-13 и `remove-telegram-intake`
2026-08-14. Переходы состояний и отмена
контекста посреди шага остаются контекста посреди шага остаются
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки; долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные - [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage` и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
2026-08-12; 2026-08-12;
- [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания
у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется
целиком и вложением, как из сохранённого строится структура реплик без
повторной оплаты и почему разбор формата провайдера не доходит до конвейера.
Задача `record-centric-model` 2026-08-14;
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли - [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
её прекращает и какие адреса остаются открытыми. Задача `oidc-login` её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший 2026-08-12. Здесь же разграничение записей по владельцу: принятая запись
видит всё, что видел прежде аноним. принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей
записи не бывает вовсе — колонка владельца пустого значения не принимает.
Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14.
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде.
коде. Задача, которая его трогает, дописывает спеку своей capability. Задача, которая его трогает, дописывает спеку своей capability.
## Принципы ## Принципы
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и - **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и
делят одну базу. Отдельного воркер-процесса нет намеренно. делят одну базу. Отдельного воркер-процесса нет намеренно.
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость - **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость
захвата и порядок выборки нормирует захвата и порядок выборки нормирует
@@ -57,7 +65,7 @@
[pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается [pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
в работу»; здесь это принцип письма шага, а не описание поведения. в работу»; здесь это принцип письма шага, а не описание поведения.
- **Ядро зависит от интерфейсов.** `internal/service` знает только - **Ядро зависит от интерфейсов.** `internal/service` знает только
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в `internal/contract`; ffmpeg, Yandex и хранилище подставляются в
`main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и `main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и
они же держат обратные направления: транспорты не знают друг о друге, адаптер они же держат обратные направления: транспорты не знают друг о друге, адаптер
не знает ни ядра, ни транспортов. не знает ни ядра, ни транспортов.
@@ -70,37 +78,29 @@
Каждый — строкой со ссылкой на capability, а не пересказом её требований. Каждый — строкой со ссылкой на capability, а не пересказом её требований.
<!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приём из Telegram, деление длинного текста по словам --> <!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приведение записи к рабочему формату -->
| Компонент | Где | Что делает | | Компонент | Где | Что делает |
| --- | --- | --- | | --- | --- | --- |
| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу |
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи | | HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
| Воркеры | `internal/controller/worker` | Крутят по одному шагу конвейера, опрашивая базу | | Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
| Сервис расшифровки | `internal/service` | Конвейер: приём, конвертация, распознавание, отдача результата | | Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности | | Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit | | Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам | | Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом |
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций | | Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки задачи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» | | Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: цепочка переходов состояний --> Цепочка рубежей — `uploaded``normalized``submitted``transcribed`
`done`; рубеж называет достигнутое, а не предстоящее, и нормирует его
Конвейер: `created``converted``transcribe``done` либо `failed`. Каждый [pipeline](../openspec/specs/pipeline/spec.md), «Рубеж записи называет
переход двигает свой воркер, и каждый опрашивает базу раз в секунду. Что достигнутое». Отказ рубежом не является: он ставит признак остановки, а рубеж
делает задача, исчерпавшая попытки, нормирует сохраняется — там же, «Остановка записи — признак, а не рубеж». Шаг выбирается
[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние по рубежу одним местом, воркеры к шагам не привязаны, а их число приходит
«мертва»». настройкой.
## Внешние границы и форматы ## Внешние границы и форматы
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
Файл скачивается по ссылке `file.Link(token)` запросом с контекстом, клиентом
самого бота. Клиента заводит единая точка `internal/adapter/telegram`: токен
стоит в пути каждого обращения, и снятие адреса с отказа живёт там —
[conventions/logging.md](conventions/logging.md), «Безопасность: что не
логируем». Telegram не отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с - **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением. `UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель - **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
@@ -114,26 +114,23 @@
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном - **Где работает, что рядом, кто перезапускает:** один контейнер на личном
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом — сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
обратный прокси, который публикует HTTP-порт наружу. обратный прокси, который публикует HTTP-порт наружу.
- **Порядок выкладки задаётся по ключу, а не по файлу целиком.** Общего правила - **Порядок выкладки: конфиг после образа.** Прежде здесь стояло правило,
«сперва образ» или «сперва конфиг» нет: два ключа секции Telegram требуют разное для двух ключей секции Telegram; с убранным входом оно потеряло предмет
противоположного, и оба правила действуют одновременно. целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть.
- **Признак включения `telegram.enabled` едет в конфиг раньше образа.** Он Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
обязателен с 2026-08-13, умолчания у него нет, и образ, который его ждёт, файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
без него выходит с кодом 1 **до** открытия порта — вместе с HTTP, панелью и сервис поднимает молча.
конвейером. Прежний образ лишний ключ TOML просто не читает, поэтому ранняя - **Откат образа через шаг схемы `202608140002` не работает и не говорит об
правка конфига безопасна, а поздняя роняет сервис. этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только
- **Пустой ключ доступа `telegram.bot_token` едет позже образа.** Образы те шаги, которые знает сам бинарь: прежний образ шагов новее не видит,
старше 2026-08-13 роняли старт на пустом ключе, тоже до открытия порта. поднимается **без единой ошибки** и отвечает зелёной пробой здоровья — после
- **Откат при выключенном входе** допустим только на образ от 2026-08-13 и чего всякое обращение к очереди отказывает «коллекции нет». Проверено прогоном
новее. На более старом состояния «сервис поднят, бот опущен» не существует двух бинарей на одном каталоге данных.
вовсе: пустой ключ роняет старт, негодный роняет старт, годный поднимает
бота. Откат туда делают с непустым годным ключом, приняв, что бот поднимется.
- **Откат образа при `enabled = false` и заполненном ключе** отменяет решение
владельца молча: прежний образ признака не видит и поднимает бота. Если вход
был выключен потому, что бот с этим токеном поднят где-то ещё, два процесса
поделят один длинный опрос и часть ответов до людей не дойдёт.
Ревью кода воспроизвело порядок на прежней версии, живой прогон — на нынешней. Значит штатное средство владельца на инциденте — «вернём прошлый образ» — с
этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа;
обратного шага схемы нет и не планируется. Порог перехода назван прямо: до
выкладки `record-centric-model` откат образа работает, после — нет.
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает - **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
медленно» читается вместе с тем, что таймаута нет ни у одного обращения медленно» читается вместе с тем, что таймаута нет ни у одного обращения
наружу — [database.md](database.md), «Настройки с числовым значением»: наружу — [database.md](database.md), «Настройки с числовым значением»:
@@ -142,32 +139,40 @@
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор | | Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняют только ошибки настройки — ответ «такого бота нет» и включённый вход с пустым ключом доступа. Норму держит [intake](../openspec/specs/intake/spec.md), «Признак включения решает, поднимается ли вход Telegram» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации | | Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; опрос готовности отдаёт рубеж `done` без поля текста |
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — | | ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | | Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла». Остановка сервиса — исход другой: процесс убивают контекстом, и задача остаётся на повтор, не тратя попытки | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании | | ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — | | Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — | | Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота - **Кто заметит отказ и когда:** тот, кто загрузил запись, — опросом готовности:
или по сообщению об ошибке. Владелец — по метрике остановленная запись отдаёт признак остановки. Владелец — по метрике
`transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера. `transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
Отдельного оповещения нет. называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос, имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
воркеры опрашивают базу вхолостую с паузой из «падает приведение» отличается от «падает распознавание». Плюс логи
контейнера. Отдельного оповещения нет.
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
на смену рубежа, на остановку и на снятие остановки; читает его человек в
панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет.
- **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу
вхолостую с паузой из
[database.md](database.md), «Настройки с числовым значением». [database.md](database.md), «Настройки с числовым значением».
## Единые точки проекта ## Единые точки проекта
| Что | Где | | Что | Где |
| --- | --- | | --- | --- |
| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа | | Приём аудио и заведение записи | `TranscribeService.createRecord` — единственный путь, которым запись появляется в хранилище |
| Правка задачи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает | | Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` — один запрос с `RETURNING` | | Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
| Выбор шага по рубежу | `TranscribeService.stepFor` — таблица, а не привязка к воркеру |
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг | | Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния | | Переход записи на рубеж | `entity.AudioRecord.MoveToState` — чистит служебные поля прошлого рубежа и ставит время входа |
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю | | Откладывание работы | `entity.AudioRecord.Postpone` — ставит паузу и снимает захват, рубежа не трогая |
| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; запись причины и события — `TranscribeService.halt`, одно место на все причины |
| Разбор конфигурации | `internal/config.LoadConfig` | | Разбор конфигурации | `internal/config.LoadConfig` |
| Чтение времени | `internal/clock``Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера | | Чтение времени | `internal/clock``Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
| Метрики | `internal/metrics`, префикс имени `transcriber_` | | Метрики | `internal/metrics`, префикс имени `transcriber_` |
@@ -197,7 +202,8 @@
[access](../openspec/specs/access/spec.md), решения — [access](../openspec/specs/access/spec.md), решения —
[ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md) [ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md)
и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md). и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md).
**Не решено одно:** как связываются пользователь Telegram и пользователь веба. **Не решено одно:** как связать чат Telegram с учётной записью — от этого
зависит возвращение убранного входа.
Панель администратора при этом Authelia не закрывает: у неё свой пароль Панель администратора при этом Authelia не закрывает: у неё свой пароль
суперпользователя. суперпользователя.
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA, - **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
@@ -213,8 +219,8 @@
Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и
текст расшифровки начинает уходить на сторону — сдвиг периметра текст расшифровки начинает уходить на сторону — сдвиг периметра
[security.md](security.md). [security.md](security.md).
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём - **Долгие записи.** Потолок сегодня неизвестен и не замерялся: ограничения
из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётные `deferred-general` по длине не выяснены. Расчётные
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
записи живёт в хранилище»; откуда взято число — записи живёт в хранилище»; откуда взято число —
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта [research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
@@ -238,10 +244,12 @@
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а - **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом. сервис определяет содержимое сам, то ли часть записей теряется на этом.
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но - **Видео.** Дорожка из видеофайла к приёму допускается — расширение он берёт из
конвертер этот случай не проверялся. имени и о годности содержимого спрашивает источник метаданных, — но конвертер
на этом случае не проверялся.
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12 - **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована ([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)), перестроена
вокруг аудиозаписи задачей `record-centric-model` 2026-08-14 и нормирована
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: он спекой `pipeline`. Не решено, отказываться ли от холостого опроса: он
даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их
паузы, а не замер паузы, а не замер
+7 -18
View File
@@ -5,7 +5,7 @@
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту. **Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: комментариями снабжена половина полей; единого места проверки на старте Главные: комментариями снабжена половина полей; единого места проверки на старте
нет: у секций `[auth]` и `[telegram]` свой `Validate()` в `main.go`, а пустые нет: у секций `[auth]` и `[pipeline]` свой `Validate()` в `main.go`, а пустые
ключи `[yandex]` ловит конструктор распознавателя. ключи `[yandex]` ловит конструктор распознавателя.
**Механизировано:** запрет `os.Getenv``forbidigo` в `.golangci.yml` **Механизировано:** запрет `os.Getenv``forbidigo` в `.golangci.yml`
@@ -46,7 +46,6 @@
port = <N> # порт HTTP-сервера port = <N> # порт HTTP-сервера
shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды
force_shutdown_timeout = <N> # ждать остановки воркеров, секунды force_shutdown_timeout = <N> # ждать остановки воркеров, секунды
users_while_list = ["<@name>"] # кому отвечает бот; строка автора Telegram
``` ```
Значения намеренно заменены плейсхолдерами: предмет конвенции — форма Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
@@ -58,9 +57,6 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»). Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
*Расхождение:* секции `[server]` в `config.example.toml` не хватает поля
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами *Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не
говорит, какой формы значение здесь ждут. Пустым оставлен только говорит, какой формы значение здесь ждут. Пустым оставлен только
@@ -91,7 +87,7 @@ Ansible из `pet-project-server`). Приложение просто читае
секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не
репозиторий и не окружение. репозиторий и не окружение.
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`, - Секретные поля transcriber: `yandex.speech_kit_api_key`,
`yandex.object_storage_access_key_id`, `yandex.object_storage_access_key_id`,
`yandex.object_storage_secret_access_key`, `auth.client_secret`. `yandex.object_storage_secret_access_key`, `auth.client_secret`.
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`, - Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
@@ -130,16 +126,8 @@ Ansible из `pet-project-server`). Приложение просто читае
TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс
выходит с кодом 1. Единого места проверки нет. выходит с кодом 1. Единого места проверки нет.
Под это расхождение больше не подпадают два ключа секции `[telegram]` — признак Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
включения и ключ доступа, — и проверок у них две. Третий ключ секции, входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
`update_timeout`, границ по-прежнему не проверяет никто, и ноль в нём обращает
длинный опрос в непрерывный. Обязательность признака включения судит загрузчик — только разбор отличает
«ключ не задан» от «ключ задан ложным», потому что нулевое значение `bool` у
обоих одинаковое. Заполненность ключа доступа судит `TelegramConfig.Validate()` из
`main.go`, рядом с проверкой `[auth]`: пустой `bot_token` при `enabled = true`
ошибка настройки и отказ старта. Непустой негодный по-прежнему судится при сборке
клиента, до подъёма сервера. Нормирует это `openspec/specs/intake`, «Признак
включения решает, поднимается ли вход Telegram».
Секция `[auth]` — первая, у которой проверка своя и стоит на старте: Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет `AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
@@ -165,5 +153,6 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
комментария у самого поля), ни по нулевому значению типа; присутствие ключа комментария у самого поля), ни по нулевому значению типа; присутствие ключа
судит **разбор**`MetaData.IsDefined` из `toml.DecodeFile`, — потому что судит **разбор**`MetaData.IsDefined` из `toml.DecodeFile`, — потому что
значение отличить «не задано» от «задано нулём» не позволяет. В значение отличить «не задано» от «задано нулём» не позволяет. В
`config.example.toml` у поля стоит значение свежей установки. Первое такое `config.example.toml` у поля стоит значение свежей установки. Первым таким
поле `telegram.enabled`. полем был `telegram.enabled`; секция убрана 2026-08-14, и живого примера у
правила сейчас нет.
+6 -4
View File
@@ -49,10 +49,12 @@
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые - Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код. значения держит код.
*Расхождение:* перечень состояний задачи закрыт схемой (`SelectField`), а не *Расхождение:* перечни, по которым панель владельца правит запись руками,
кодом — ради панели владельца: правка руками не должна заводить состояние, закрыты схемой (`SelectField`), а не кодом: правка руками не должна заводить
которого конвейер не знает. Цена названа: шестое состояние потребует нового значение, которого сервис не знает. Закрыты рубеж записи, причина её
шага схемы. остановки, вид текста, источник и исход события журнала. Цена названа: новое
значение любого из них потребует нового шага схемы, а применённый шаг не
переписывается. Прочие перечни остаются обычным `TEXT`.
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например - Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет `2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`). лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
+12 -14
View File
@@ -69,11 +69,10 @@ transcriber — **приложение, а не библиотека**: внеш
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError` `contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
(состояние), `contract.LostAcquisitionError` (идентификатор задачи). (состояние), `contract.LostAcquisitionError` (идентификатор задачи).
`tg.EmptyBotTokenError` был ровно тем случаем, против которого написано правило — Правило это однажды нарушал `tg.EmptyBotTokenError` — тип без полей, — и был
тип без полей, — и снят задачей `local-run-without-telegram-token` 2026-08-13; снят задачей `local-run-without-telegram-token` 2026-08-13 в пользу sentinel'а.
его место занял sentinel `telegram.ErrEmptyToken`. Рядом живёт Оба ушли из проекта 2026-08-14 вместе с входом Telegram; пример остаётся здесь
`contract.ErrDeliveryChannelDown` — тоже sentinel и по той же причине: заглушка как случай, а не как живой код.
отправителя не знает ни задачи, ни чата, и нести ей нечего.
## Граница и трансляция: приватный и публичный канал ## Граница и трансляция: приватный и публичный канал
@@ -83,8 +82,8 @@ transcriber — **приложение, а не библиотека**: внеш
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой - **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой
`%w` и контекстом. Пишется один раз на доменной границе — см. `%w` и контекстом. Пишется один раз на доменной границе — см.
[logging.md](logging.md). [logging.md](logging.md).
- **Публичный канал — пользовательские поверхности** (Telegram, веб-UI, HTTP - **Публичный канал — пользовательские поверхности** (веб-UI, HTTP API). Сюда
API). Сюда отдаём: отдаём:
- **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и - **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и
не детали реализации (`database/sql`, пути на диске, имена внешних сервисов); не детали реализации (`database/sql`, пути на диске, имена внешних сервисов);
- **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему - **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему
@@ -118,8 +117,7 @@ transcriber — **приложение, а не библиотека**: внеш
У публичной границы две поверхности, и правило сырого текста для них разное. У публичной границы две поверхности, и правило сырого текста для них разное.
- **Разовый ответ на действие** (тело HTTP-ответа, сообщение бота по результату - **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
команды) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
полная ошибка остаётся в логах по идентификатору задачи. полная ошибка остаётся в логах по идентификатору задачи.
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это - **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим **поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
@@ -131,9 +129,9 @@ transcriber — **приложение, а не библиотека**: внеш
- **внешнее значение в тексте усекается на границе, а его размер называется - **внешнее значение в тексте усекается на границе, а его размер называется
числом рядом**: без этого непонятно, насколько сокращать. числом рядом**: без этого непонятно, насколько сокращать.
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения, а *Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
пользователь Telegram видит отдельный человекочитаемый текст — это часть Наружу он при этом не выходит: опрос готовности отдаёт признак остановки без
правила соблюдена. машинного текста — эту часть правила держит спека `intake`.
## panic ## panic
@@ -145,8 +143,8 @@ transcriber — **приложение, а не библиотека**: внеш
ронял процесс. В transcriber его вешает роутер хранилища сам ронял процесс. В transcriber его вешает роутер хранилища сам
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId` (`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
живёт. Своего слоя мы не пишем. У воркеров и у бота такой границы **нет**: живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в
паника в шаге конвейера роняет процесс целиком. шаге конвейера роняет процесс целиком.
## Несколько ошибок ## Несколько ошибок
+6 -5
View File
@@ -78,7 +78,7 @@
| --- | --- | | --- | --- |
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` | | Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` |
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules``TestОшибкаНеУзнаётсяПоТексту` | | Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules``TestОшибкаНеУзнаётсяПоТексту` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove` и `send` | | Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `os.Remove` |
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml``errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит | | Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml``errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml``nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа | | Проверенный отказ не оборачивается в `return nil` | `.golangci.yml``nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
| Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml``rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде | | Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml``rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде |
@@ -89,9 +89,10 @@
| Правило | Где механизировано | | Правило | Где механизировано |
| --- | --- | | --- | --- |
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` | | Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` | | Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` |
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` | | Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
| Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | `internal/archrules` → правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда | | Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком |
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
### Отмена и внешний собеседник ### Отмена и внешний собеседник
@@ -116,7 +117,7 @@
| --- | --- | | --- | --- |
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml``forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` | | Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml``forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml``testifylint` | | Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml``testifylint` |
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» | | Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик и логгер; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml``nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) | | Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml``nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
### Форма кода и файлов вне Go ### Форма кода и файлов вне Go
@@ -151,7 +152,7 @@
| Подавлено | Где | Почему | | Подавлено | Где | Почему |
| --- | --- | --- | | --- | --- | --- |
| `errcheck` на `defer Close`, `os.Remove` и `send` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен | | `errcheck` на `defer Close` и `os.Remove` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен |
| Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок | | Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок |
| `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе | | `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе |
| Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре | | Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре |
+24 -37
View File
@@ -28,22 +28,22 @@ OpenSpec.
`jq` без регулярных выражений. `jq` без регулярных выражений.
```json ```json
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"job accepted","capability":"intake","job_id":"…","source":"telegram","duration_seconds":137} {"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"api","duration_seconds":137}
``` ```
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`. *Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
## Сообщение ## Сообщение
- `msg` — короткая константа в нижнем регистре: `job accepted`, - `msg` — короткая константа в нижнем регистре: `record accepted`,
`recognition done`, `conversion failed`. Данные — в атрибутах: `recognition done`, `conversion failed`. Данные — в атрибутах:
`log.Info("job accepted", "job_id", id, "source", "telegram")`. `log.Info("record accepted", "record_id", id, "source", "api")`.
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не - `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
`recognize: done`. Подсистему выносим в поле `capability`, не в текст. `recognize: done`. Подсистему выносим в поле `capability`, не в текст.
- **Смена состояния задачи — единая категория `state transition`** с полями - **Смена состояния задачи — единая категория `state transition`** с полями
`from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь `from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь
жизненный цикл собирался одним отбором: жизненный цикл собирался одним отбором:
`jq 'select(.msg=="state transition" and .job_id=="…")'`. Физический эффект `jq 'select(.msg=="state transition" and .record_id=="…")'`. Физический эффект
сверх перехода — отдельная запись своей категории (`file converted`, сверх перехода — отдельная запись своей категории (`file converted`,
`text delivered`), она запись перехода не подменяет. `text delivered`), она запись перехода не подменяет.
@@ -60,7 +60,7 @@ OpenSpec.
| `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов | | `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
| `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** | | `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** |
| `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания | | `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, задача ушла в `failed`, необработанная ошибка | | `ERROR` | владельцу, в разбор | внешний сервис недоступен, запись остановлена признаком, необработанная ошибка |
Правила: Правила:
@@ -99,14 +99,14 @@ OpenSpec.
| Когда добавляем | Поля | | Когда добавляем | Поля |
| --- | --- | | --- | --- |
| на входящий HTTP-запрос | `transport` (`http`, `telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` | | на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `job_id`, `file_id`, `source` | | на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
| на запись об ошибке | `error` | | на запись об ошибке | `error` |
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` | | на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум. Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
*Расхождение:* в коде встречаются `job_id`, `file_id`, `operation_id`, *Расхождение:* в коде встречаются `record_id`, `file_id`, `operation_id`,
`worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и `worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и
пересечён с этим лишь частично. пересечён с этим лишь частично.
@@ -120,16 +120,16 @@ OpenSpec.
чтобы ключ дописывался на каждую запись сам: чтобы ключ дописывался на каждую запись сам:
```go ```go
log := log.With("job_id", job.Id, "capability", "conversion") log := log.With("record_id", record.Id, "capability", "conversion")
``` ```
- Все записи одной задачи собираются одним отбором: - Все записи одной задачи собираются одним отбором:
`jq 'select(.job_id=="…")' app.jsonl`. `jq 'select(.record_id=="…")' app.jsonl`.
## Ошибки ## Ошибки
Ошибки Go логируем как атрибут, а не как текст сообщения: Ошибки Go логируем как атрибут, а не как текст сообщения:
`log.Error("conversion failed", "error", err, "job_id", id)`. Ключ — `error`. `log.Error("conversion failed", "error", err, "record_id", id)`. Ключ — `error`.
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только - Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст
@@ -137,7 +137,7 @@ log := log.With("job_id", job.Id, "capability", "conversion")
- Логируем ошибку **один раз — на границе доменного слоя**, которая определяет - Логируем ошибку **один раз — на границе доменного слоя**, которая определяет
исход операции. Логирует эта единая точка, а не каждый транспорт — так исход операции. Логирует эта единая точка, а не каждый транспорт — так
транспорты остаются тонкими. Границы в transcriber: транспорты остаются тонкими. Границы в transcriber:
- приём записи (`CreateJobFromTelegram`, `CreateJobFromApi`); - приём записи (`CreateJobFromApi`);
- **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`, - **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
`FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера; `FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера;
- завершение и отказ задачи (`completeJob`, `failJob`). - завершение и отказ задачи (`completeJob`, `failJob`).
@@ -169,9 +169,9 @@ log := log.With("job_id", job.Id, "capability", "conversion")
**Каждый** вызов внешнего сервиса логируется. Поля: **Каждый** вызов внешнего сервиса логируется. Поля:
- `ext.service``telegram`, `speechkit`, `object-storage`, `ffmpeg`; - `ext.service``speechkit`, `object-storage`, `ffmpeg`;
- `ext.operation` — логическая операция (`getFile`, `sendMessage`, - `ext.operation` — логическая операция (`RecognizeFile`, `GetOperation`,
`RecognizeFile`, `GetOperation`, `PutObject`, `convert`); `PutObject`, `convert`);
- `ext.status_code` — код ответа, если применим; - `ext.status_code` — код ответа, если применим;
- `duration_ms` — длительность вызова; - `duration_ms` — длительность вызова;
- `retry` — номер попытки, если повторы были. - `retry` — номер попытки, если повторы были.
@@ -191,8 +191,7 @@ log := log.With("job_id", job.Id, "capability", "conversion")
*Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только *Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только
конвертация (через метрику длительности) и запуск распознавания; заливка в конвертация (через метрику длительности) и запуск распознавания; заливка в
Object Storage, скачивание файла из Telegram и опрос операции не логируются Object Storage и опрос операции не логируются никак.
никак.
## HTTP и проверка здоровья ## HTTP и проверка здоровья
@@ -218,7 +217,6 @@ Object Storage, скачивание файла из Telegram и опрос оп
Никаких секретов в полях и сообщениях. Под запретом: Никаких секретов в полях и сообщениях. Под запретом:
- токен бота Telegram;
- ключ SpeechKit и заголовок `Authorization`; - ключ SpeechKit и заголовок `Authorization`;
- пара ключей Object Storage; - пара ключей Object Storage;
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной - **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
@@ -234,28 +232,17 @@ Object Storage, скачивание файла из Telegram и опрос оп
- При сомнении не логируем значение, логируем факт его наличия - При сомнении не логируем значение, логируем факт его наличия
(`"has_api_key", true`). (`"has_api_key", true`).
- **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.** - **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.**
`*url.Error` из `net/http` встраивает полный URL запроса, а токен Telegram `*url.Error` из `net/http` встраивает полный URL запроса, а секрет иногда
живёт прямо в пути (`…/bot<TOKEN>/…`). Такую ошибку разворачивают в живёт прямо в пути. Такую ошибку разворачивают в
первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается, первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается,
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта. в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
Обращения к Telegram этому правилу следуют, и точка чистки одна на все вызовы — Живого случая у этого правила сейчас нет: единственный секрет, стоявший в пути
`internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к обращения, — токен бота, и он ушёл вместе с входом Telegram 2026-08-14. Разбор
Bot API, поэтому чистка на месте употребления закрывала бы один вызов из всех: случая и цена промаха записаны в [../review.md](../review.md), 2026-08-13:
конвенция числила утечку расхождением с оценкой «не логируется», и оценка была
- отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а неверной.
библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`,
`sendMessage`, скачивание записи и `getMe` из конструктора;
- длинный опрос печатает свои отказы **пакетным логгером самой библиотеки**,
минуя наш `slog`; логгер подменён на вычищающий (`tgbotapi.SetLogger`), и
замена точная — токен известен.
Прежде здесь стоял `http.Get(file.Link(token))`, отказ уезжал в журнал вместе с
токеном, а конвенция числила это расхождением с оценкой «не логируется», которая
была неверной. Запись — [../review.md](../review.md), 2026-08-13; оракулом
служат проверки `internal/adapter/telegram/bot_test.go`, судящие по тексту
отказа и строке журнала.
*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно *Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост (`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
@@ -277,5 +264,5 @@ Bot API, поэтому чистка на месте употребления з
## Анализ ## Анализ
- Повседневно — `jq`: `jq 'select(.job_id=="…")' app.jsonl`. - Повседневно — `jq`: `jq 'select(.record_id=="…")' app.jsonl`.
- Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла. - Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.
+1 -1
View File
@@ -108,5 +108,5 @@
узнала»). узнала»).
- **Устройство service worker и версионирование статики** — задача - **Устройство service worker и версионирование статики** — задача
[installable-pwa](../../tasks/items/installable-pwa.md). [installable-pwa](../../tasks/items/installable-pwa.md).
- **Как связываются пользователь Telegram и пользователь веба** — открытый вопрос - **Как связать чат Telegram с учётной записью** — открытый вопрос; от него зависит возвращение убранного 2026-08-14 входа
«Учётные записи» в [../architecture.md](../architecture.md). «Учётные записи» в [../architecture.md](../architecture.md).
+204 -48
View File
@@ -38,63 +38,196 @@ CGO сборке не нужен.
### `files` ### `files`
Один файл на одну физическую копию: исходник, результат конвертации и копия в Одна запись на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
Object Storage — каждая своей записью. приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не
считается — она существует только потому, что провайдер распознавания читает
аудио по адресу, и её ключ живёт в строке попытки распознавания.
| Поле | Тип | Что | | Поле | Тип | Что |
| --- | --- | --- | | --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | | `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `file` | file | Сам файл; пусто у копии в Object Storage | | `file` | file | Сам файл |
| `owner` | relation → `users` | Владелец файла; пустого значения не принимает |
| `location` | select | `local` или `s3` | | `location` | select | `local` или `s3` |
| `object_key` | TEXT | Ключ объекта; пусто у местной копии | | `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется |
| `size` | INTEGER | Размер в байтах | | `size` | INTEGER | Размер в байтах |
| `format` | TEXT | Расширение без точки, в нижнем регистре |
| `duration_ms` | INTEGER | Длительность, если её удалось прочитать |
| `created`, `updated` | DATETIME | Проставляет хранилище | | `created`, `updated` | DATETIME | Проставляет хранилище |
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
capability, и третий смысл развёл бы одно слово по разным вещам. capability, и третий смысл развёл бы одно слово по разным вещам.
### `transcribe_jobs` ### `audio_records`
Задача расшифровки и она же очередь. Аудиозапись — центральная сущность сервиса. Домен, поля очереди и ссылки на
приложения лежат здесь; содержимое — по ссылкам, отдельными строками.
| Поле | Тип | Что | | Поле | Тип | Что |
| --- | --- | --- | | --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | | `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой | | `owner` | relation → `users` | Владелец записи; пустого значения не принимает |
| `source` | select | `api`, `telegram`, `unknown` | | `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется |
| `file` | relation → `files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат | | `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
| `delay_time` | DATETIME | Не брать задачу раньше этого времени | | `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой |
| `acquisition_id` | TEXT | Кто захватил задачу | | `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания |
| `acquire_time` | DATETIME | Когда захватил; по нему считается протухание | | `halted_at` | DATETIME | Признак остановки; рубеж при ней не стирается |
| `attempts` | INTEGER ≥ 0 | Число попыток: растёт при захвате, обнуляется на шаге без отказа | | `halt_reason` | select | `step_failed`, `attempts_exhausted`, `stuck` |
| `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud |
| `transcription_text` | editor | Результат распознавания |
| `error_text` | TEXT | Текст ошибки, машинный | | `error_text` | TEXT | Текст ошибки, машинный |
| `tg_chat_id` | INTEGER | Куда отправить результат | | `acquisition_id` | TEXT | Признак **этого** захвата, уникальный для каждого |
| `tg_reply_message_id` | INTEGER | С каким сообщением связать | | `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 | Проставляет хранилище | | `created`, `updated` | DATETIME | Проставляет хранилище |
Индекс один — по `state`: выборка воркера идёт по нему, паузе и сроку захвата. Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним,
Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ паузе и сроку протухания.
этот один.
**Состояния `failed` и `dead` — разные приговоры**, и чей это приговор, нормирует **Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
«мертва»». Схеме принадлежит только закрытость перечня: шестое состояние хранилище, и принятого человеком файла не найти было ничем.
потребует нового шага.
**Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи **Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead`
может только владелец панели. Проверено прогоном: анонимный запрос к схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием
`/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`, признака, — и различие между ними перестало быть структурным. Рубеж при
`/api/settings` и `/api/crons``401`. остановке сохраняется, поэтому запись продолжает с места остановки.
**Сторожей двое.** `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`.
## Представление данных ## Представление данных
Чем физически лежит запись и что происходит при чтении и записи. Чем физически лежит запись и что происходит при чтении и записи.
- **Расшифровка лежит целиком в поле `transcription_text`** одной строкой. - **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват
Запись длиной в час даёт десятки килобайт в одной ячейке; читается она её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а
целиком при каждом чтении задачи и при каждом захвате. колонки шаг читает отдельным чтением. Прежде расшифровка стояла колонкой той
же строки и читалась при каждом опросе очереди.
- **Аудио лежит в раскладке хранилища:** - **Аудио лежит в раскладке хранилища:**
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт `data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает, сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
@@ -113,19 +246,28 @@ capability, и третий смысл развёл бы одно слово п
Без этого сужения закрытие API обходится двумя запросами — завести себе Без этого сужения закрытие API обходится двумя запросами — завести себе
запись и войти паролем. Продление сессии закрыто слоем в приложении, а не запись и войти паролем. Продление сессии закрыто слоем в приложении, а не
настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда. настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции. - **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции.
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением, `app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
заведения **и по ключу**: время неуникально, и без ключа порядок обработки заведения **и по ключу**: время неуникально, и без ключа порядок обработки
невоспроизводим. невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку протухания
захвата и отсутствию признака остановки; срок протухания выбирается по рубежу
самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, подставить
его не может.
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет - **Запись результата условна по признаку захвата** — инвариант «Результат пишет
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major); только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому, норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому,
что условие проверяется тем же запросом, что и сам захват. что условие проверяется тем же запросом, что и сам захват.
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`, - **Список колонок задан двумя местами** — `applyOwnedByPipeline` вместе с
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы. `applyToRecord` и `recordToAudioRecord`, — плюс шагом схемы. Мест было четыре,
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и
серьёзность (critical/major) — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты». перечень перестал расти с моделью. Правило правки и его серьёзность —
инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты»; сверку держат правила
`internal/archrules`.
- **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`.
Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя:
рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон
по инварианту проекта не пишется в журнал и не считается в метрику.
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а - **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
@@ -135,19 +277,24 @@ capability, и третий смысл развёл бы одно слово п
| Настройка | Значение | Где | Откуда число | | Настройка | Значение | Где | Откуда число |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Предел попыток | 5 | `service/transcribe.go` | обычное умолчание, не замер | | Предел отказов | 5 | `service/transcribe.go` | обычное умолчание, не замер |
| Пауза перед повтором | `2^(попытка−1)` с, потолок 5 минут | там же | то же | | Пауза перед повтором | `2^(отказ1)` с, потолок 5 минут | там же | то же |
| Срок захвата, конвертация | 8 часов | там же | потолок записи 6 часов плюс запас | | Срок захвата, приведение | 8 часов | `entity/stage.go` | потолок записи 6 часов плюс запас |
| Срок захвата, распознавание | 8 часов | там же | то же | | Срок захвата, отправка на распознавание | 8 часов | там же | то же |
| Срок захвата, проверка операции | 1 час | там же | опрос идёт секунды | | Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды |
| Задержка перед первой проверкой операции | 10 секунд | там же | как было | | Срок захвата, завершение | 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 секунд | там же | как было | | Задержка между проверками операции | 5 секунд | там же | как было |
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` | как было | | Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — | | Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — | | Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — |
| Срок ожидания Telegram при сборке клиента | 10 секунд | `adapter/telegram.ProbeTimeout` | решение, не замер: одно обращение за `getMe` укладывается в доли секунды, дольше Telegram считается недоступным и сервис поднимается без него. Длинный опрос этим сроком не ограничен — клиент подменяется сразу после сборки |
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — | | Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — | | Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео | | Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
@@ -155,6 +302,15 @@ capability, и третий смысл развёл бы одно слово п
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен | | Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым | | Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
протухания ещё не истёк. Значит для держателя, погибшего жёстко — контейнер убит
по нехватке памяти или `docker kill`, — запись невидима сторожу до истечения
**срока захвата** её рубежа, то есть восьми часов у приведения и отправки.
Замерено прогоном: до истечения срока повторный захват записи не выдаёт, и
остановка «застряла» наступает только после него. Мягкая остановка сюда не
подпадает: она снимает захват сама.
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у **Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
@@ -166,4 +322,4 @@ capability, и третий смысл развёл бы одно слово п
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
файлов и объектов нет вовсе. Таймаутов у файлов и объектов нет вовсе. Таймаутов у
обращений к Telegram, S3 и SpeechKit тоже нет — ни одного. обращений к S3 и SpeechKit тоже нет — ни одного.
+19 -22
View File
@@ -21,12 +21,14 @@
| --- | --- | | --- | --- |
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось | | Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи | | Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только чужая сессия, снятая из браузера и предъявленная кукой либо заголовком `Authorization`. Токен приносит `api-tokens` | | Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только чужая сессия, снятая из браузера и предъявленная кукой либо заголовком `Authorization`. Токен приносит `api-tokens` |
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11 **Вход у сервиса один — HTTP API**, и приложение строится поверх него. До
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на 2026-08-11 основным входом был Telegram-бот. 2026-08-11 основным объявили
несколько часов через Telegram не проходит вовсе. приложение: диктофонная запись на несколько часов через Telegram не проходит
вовсе. 2026-08-14 бот убран целиком — временно, до задачи, которая свяжет чат с
учётной записью. Вместе с ним из потребителей ушёл пользователь
Telegram.
Цель достигнута, когда: Цель достигнута, когда:
@@ -35,8 +37,8 @@
- запись расчётного потолка — шести часов — доходит до текста, а не прерывается - запись расчётного потолка — шести часов — доходит до текста, а не прерывается
ошибкой при достижении предела (норма — `openspec/specs/storage`); ошибкой при достижении предела (норма — `openspec/specs/storage`);
- сервисом пользуются несколько человек, и записи одного не видны другому; - сервисом пользуются несколько человек, и записи одного не видны другому;
- текст доступен там же, где загружали, — в приложении и в Telegram. Человек - текст доступен там же, где загружали. Человек узнаёт о его готовности, не
узнаёт о его готовности, не держа приложение открытым; держа приложение открытым;
- расшифровка не теряется: к записи возвращаются через месяц и находят её по - расшифровка не теряется: к записи возвращаются через месяц и находят её по
заголовку и темам; заголовку и темам;
- владелец видит расход по каждому пользователю и понимает, во что обходится - владелец видит расход по каждому пользователю и понимает, во что обходится
@@ -69,10 +71,10 @@
файл. Своей записи и работы без сети не делаем. файл. Своей записи и работы без сети не делаем.
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради - **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
речи в них. Складом произвольных файлов и папками сервис не становится. Общего речи в них. Складом произвольных файлов и папками сервис не становится. Общего
доступа к чужим записям целью тоже нет — но **сегодня он есть**: владельца у доступа к чужим записям целью нет, и с 2026-08-14 его нет и на деле: у записи
записи в модели данных не существует, и всякий вошедший видит все записи есть владелец, и чужую по её идентификатору не отдают
([security.md](security.md), «Периметр»). Это состояние, а не решение; ([security.md](security.md), «Периметр»). Закрыла это задача
закрывает его `record-ownership`. `record-ownership`.
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и - **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем: показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
пользователя, потратившего слишком много, останавливает разговор или отзыв пользователя, потратившего слишком много, останавливает разговор или отзыв
@@ -90,21 +92,16 @@
2. **Возвращение к записи.** Через месяц человек открывает список, находит 2. **Возвращение к записи.** Через месяц человек открывает список, находит
запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую
расшифровку. расшифровку.
3. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот 3. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
отвечает «обрабатываю», через минуту приходит текст ответом на то же
сообщение. Записи, чей текст длиннее предела сообщения Telegram, приходят
несколькими частями. Работает сегодня.
4. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот
отличает их по MIME-типу и расширению. Работает сегодня.
5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC: увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC:
анонимный запрос обоими адресами отклоняется. Своего входа у программы нет — анонимный запрос обоими адресами отклоняется. Своего входа у программы нет —
его заводит `api-tokens`, — как нет и разграничения записей между его заводит `api-tokens`. Записи при этом разграничены: программа с чужой
пользователями. сессией видит только записи того, чью сессию предъявила.
6. **Отказ на середине.** Конвертация или распознавание не удались — задача 4. **Отказ на середине.** Конвертация или распознавание не удались — запись
переходит в `failed`, а пользователь получает сообщение о том, что именно не получает признак остановки с причиной, и опрос готовности отдаёт этот признак
вышло, и предложение повторить. тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт: доставка
ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`.
## Референсы ## Референсы
+36 -3
View File
@@ -4,14 +4,16 @@
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт [записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
панель**, эта — **что библиотека делает молча**, если её не переубедить. панель**, эта — **что библиотека делает молча**, если её не переубедить.
Все четыре наблюдения нашлись ревью, а не чтением документации: три из них Наблюдения нашлись ревью, а не чтением документации, и все об одном роде промаха:
выглядят как «значение по умолчанию — нет ограничения», а значат обратное. объявление библиотеки выглядит как «ограничения нет» либо «ограничение есть», а
значит обратное.
## Как снималось ## Как снималось
Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге
данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые
данные и ключи не участвовали. Числа ниже сняты 2026-08-11 и 2026-08-12. данные и ключи не участвовали. Числа сняты 2026-08-11 и 2026-08-12, последнее
наблюдение — 2026-08-15.
## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела» ## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела»
@@ -86,6 +88,34 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения
владельца при следующем запуске её нет. владельца при следующем запуске её нет.
## Обязательность связи проверяется у записи, а не у колонки
`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 ГиБ на диске.** Число выбрано расчётом из - **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из
@@ -96,3 +126,6 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
— нет. — нет.
- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено - **Поведение под одновременной правкой панели и конвейера в бою.** Проверено
тестом на одной машине, не живой нагрузкой. тестом на одной машине, не живой нагрузкой.
- **Сколько строк с пустой связью выдерживает смена признака обязательности.**
Проверено на одной строке: суть наблюдения — сам факт отсутствия проверки, а не
её цена на объёме.
+34
View File
@@ -186,3 +186,37 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
способов: способов:
вместе с панелью отказ выбрасывал бы уход CGO и встроенное резервное вместе с панелью отказ выбрасывал бы уход CGO и встроенное резервное
копирование, которых у сервиса-архива нет никаких. копирование, которых у сервиса-архива нет никаких.
## Разграничение по владельцу: что выяснилось при реализации
Дописано 2026-08-14 задачей `record-ownership`. Все находки ниже получены одним
способом: чтением исходников `pocketbase@v0.39.10` из кеша модулей и прогонами
против настоящего хранилища на временном каталоге — в ходе ревью того change.
**Связь с выключенным каскадом не удерживает целостность при удалении.**
`core/record_model.go`, `deleteRefRecords`: при `CascadeDelete = false` и
необязательном поле хранилище **вынимает** идентификатор из поля связи и
сохраняет запись через `SaveNoValidate`. То есть «не уносить записи следом» и
«сохранить у них владельца» — разные вещи, и связь даёт только первое.
**Наружу проходит только ошибка роутера.** `apis/record_crud.go` заворачивает
отказ хука в `firstApiError(err, e.BadRequestError("Failed to delete record. Make
sure that the record is not part of a required relation reference.", err))`, а
`firstApiError` берёт первый аргумент, только если он `*router.ApiError`. Обычная
ошибка из хука до ответа не доезжает вовсе, и спрашивающий получает библиотечную
подсказку про обязательную связь — в нашем случае указывающую не на ту связь.
**`apis/file.go` выдаёт токен файла на предъявителя, а не на файл.** О файле при
выдаче он не спрашивает. Владельца судит переход по ссылке: правило просмотра
коллекции проверяет защищённое поле файла по учётной записи **из токена**. Значит
чужой токен получить можно всегда, а скачать по нему чужой файл — нет.
**Проверка сессии с именем коллекции отвечает `403`, а не `401`.**
`apis.RequireAuth("users")` пускает только запись названной коллекции; предъявитель
из другой — например, владелец панели — узнан, но не годится, и код отказа это
различает.
**Связь в SQLite лежит пустой строкой, а не `NULL`.** `RelationField.ColumnType`
даёт `TEXT DEFAULT '' NOT NULL`; сырой запрос и чтение через запись коллекции
совпадают побайтово. «Умолчания у колонки нет» верно по замыслу — пустое значение
не совпадает ни с кем, — но не буквально на уровне схемы.
+105 -42
View File
@@ -51,14 +51,14 @@
- повтор шага на той же задаче не создаёт лишних файлов и записей; - повтор шага на той же задаче не создаёт лишних файлов и записей;
- отвечает пользователю ровно один раз. - отвечает пользователю ровно один раз.
**Транспорт** (`internal/controller/tg`, `internal/controller/http`): **Транспорт** (`internal/controller/http`):
- проверяет право отправителя до всякой работы; - проверяет право отправителя до всякой работы;
- не логирует ошибку, которую уже залогировал доменный слой; - не логирует ошибку, которую уже залогировал доменный слой;
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст; - переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
- закрывает то, что открыл, на всех ветках выхода. - закрывает то, что открыл, на всех ветках выхода.
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`, `adapter/telegram`): **Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
- имеет таймаут и не виснет, когда внешний сервис не отвечает; - имеет таймаут и не виснет, когда внешний сервис не отвечает;
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта; - не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
@@ -69,8 +69,8 @@
**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы — **Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы —
подпакетом `migrations`): подпакетом `migrations`):
- список колонок совпадает во всех четырёх местах — `applyToRecord`, - список колонок совпадает в обоих местах — `applyOwnedByPipeline` вместе с
`recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант `applyToRecord` и `recordToAudioRecord` — и в шаге схемы (инвариант
[CLAUDE.md](../CLAUDE.md), «Инварианты»); [CLAUDE.md](../CLAUDE.md), «Инварианты»);
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только - захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
держатель захвата; держатель захвата;
@@ -109,20 +109,31 @@
приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей
`errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать `errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать
её как известную нельзя. её как известную нельзя.
- **«Захват задачи не в транзакции — гонка двух воркеров».** По построению её - **«Захват записи не в транзакции — гонка двух воркеров».** ~~По построению её
нет: три воркера читают три разных состояния, и одну строку они не делят. нет: три воркера читают три разных состояния, и одну строку они не делят.~~
Механика захвата и её слабые места — [database.md](database.md), **Отменено 2026-08-14 задачей `record-centric-model`:** построение снято. Пул
«Представление данных». Находка становится настоящей ровно тогда, когда одинаковых воркеров конкурирует за один и тот же набор записей, и второй
появится второй экземпляр процесса или второй воркер на то же состояние. воркер на тот же рубеж теперь есть всегда, когда их больше одного. Находка о
гонке захвата стала настоящей и выбрасывается только по существу — механика
захвата и её слабые места в [database.md](database.md), «Представление
данных». Строка оставлена отменённой, а не удалена: прогон, помнящий прежнюю
редакцию, иначе выбросил бы настоящую находку как известную.
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в - **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет. [database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
Новой находкой это не считается, пока не измерен рост. Новой находкой это не считается, пока не измерен рост.
- **«У записи нет владельца: вошедший видит чужие записи».** Не дефект и не - **«Запись без владельца не достаётся никому».** Строка отменена **дважды**, и
новость: приём, опрос и файл закрыты сессией OIDC с 2026-08-12, а обе отмены оставлены намеренно: прогон, помнящий любую из прежних редакций,
разграничения по владельцу нет сознательно — [security.md](security.md), иначе выбросил бы настоящую находку как известную.
«Периметр», и `openspec/specs/access`, «Purpose». Находкой считается новая
поверхность, выставленная наружу, либо путь к содержимому записи **без** До задачи `record-ownership` здесь стояло «вошедший видит чужие записи — не
сессии, а не повторение этого факта. дефект и не новость»: разграничения не было сознательно. Первая отмена
2026-08-14 завела разграничение и объявила не дефектом уже другое — запись без
владельца, принятую ботом.
Вторая отмена того же дня, задачей `remove-telegram-intake`, сняла и это:
колонка владельца пустого значения больше не принимает, ничьих записей у
сервиса не бывает вовсе. **Запись без владельца сегодня — настоящая находка**,
а не известное исключение.
### Вопросы по темам ### Вопросы по темам
@@ -136,10 +147,10 @@
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
`transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с `transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с
дефектом «остановка хоронила запись»). дефектом «остановка хоронила запись»).
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у - `operations`: появился ли таймаут у обращения к S3 и SpeechKit — ни у одного
одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**: из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `tg.go`, контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `s3.go`,
`s3.go`, `speechkit.go`, 2026-08-13). `speechkit.go`, 2026-08-13).
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и - `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
возвращает её воркеру, который логирует снова (чтение `transcribe.go`, возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
2026-08-10). 2026-08-10).
@@ -152,15 +163,14 @@
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница - `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
метрик отдаётся без проверки отправителя, и метка это поверхность пошире метрик отдаётся без проверки отправителя, и метка это поверхность пошире
журнала (журнал, запись 2026-08-11 про хвост имени). журнала (журнал, запись 2026-08-11 про хвост имени).
- `architecture`: не появился ли второй путь приёма мимо - `architecture`: не появился ли второй путь приёма мимо `createRecord` — сегодня
`createTranscribeJob` — сегодня через него идут оба входа он единственный, которым запись попадает в хранилище
([architecture.md](architecture.md), «Единые точки проекта»). ([architecture.md](architecture.md), «Единые точки проекта»).
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки — - `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
заведены четыре capability (`intake`, `pipeline`, `storage`, `access`), и заведённые capability описывают поведение не целиком, и остаток живёт в обзоре
первые две описаны частично. Поведение прочих узлов, включая под маркерами долга, а соблазн дописать туда ещё — самый большой.
приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда - `conventions`: новая колонка правится в обоих местах репозитория, а новый
ещё — самый большой. рубеж — одним дескриптором
- `conventions`: новая колонка правится во всех четырёх местах репозитория
(CLAUDE.md, «Инварианты»). (CLAUDE.md, «Инварианты»).
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим** - `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
@@ -195,12 +205,12 @@
- замена хранилища или переход на PocketBase — любой её кусок; - замена хранилища или переход на PocketBase — любой её кусок;
- смена модели очереди: захват, повторы и воркеры разом; - смена модели очереди: захват, повторы и воркеры разом;
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом; - каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
- изменение, трогающее оба входа сразу — Telegram и HTTP. - изменение, убирающее или возвращающее вход приёма целиком.
**Незнакомое здесь** (поднимает до `large`, ось формы решения): **Незнакомое здесь** (поднимает до `large`, ось формы решения):
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и - вход через OIDC и разграничение доступа: как связать чат Telegram с учётной
пользователь приложения, до начала работы назвать нельзя; записью, до начала работы назвать нельзя;
- всё, что делается на выбранном фреймворке впервые: правила - всё, что делается на выбранном фреймворке впервые: правила
[conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера [conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера
на пробном экране, а не из написанного кода, и первая же задача проверяет их на пробном экране, а не из написанного кода, и первая же задача проверяет их
@@ -214,7 +224,6 @@
**Мелкое здесь** (опускает до `small`): **Мелкое здесь** (опускает до `small`):
- правка текста, который видит пользователь Telegram;
- новая метрика в `internal/metrics`; - новая метрика в `internal/metrics`;
- правка `config.example.toml` и умолчаний `defaultConfig()` без нового поля; - правка `config.example.toml` и умолчаний `defaultConfig()` без нового поля;
- правка документов канона. - правка документов канона.
@@ -249,19 +258,19 @@ API и имя не откатываются обратной правкой по
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в `adapter/metaviewer/ffmpeg` нет; решение и его цена — в
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md); [adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять - **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
теперь можно: с `telegram.enabled = false` он встаёт и работает одним входом можно: он встаёт своим единственным входом на выдуманных непустых ключах
(`openspec/specs/intake`, «Признак включения решает, поднимается ли вход секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
Telegram»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен осмотр HTTP, панели, журнала, метрик и остановки — доступен любой задаче.
теперь любой задаче. Прежняя формулировка «всё, что требует поднять сервис целиком» Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
снята задачей `local-run-without-telegram-token` 2026-08-13; рецепт прогона `local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
сменился с пустого ключа доступа на выключенный вход задачей с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
`telegram-enabled-flag` того же дня. дня), а 2026-08-14 признак включения ушёл вместе с самим входом.
**Остаток**: за настоящий Telegram, SpeechKit и Object Storage живой прогон **Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему
по-прежнему не отвечает — боевым токеном запускаться запрещено, ключи Yandex в не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в
прогоне выдуманные, а распознавание подменяют в коде. Проверить живьём можно коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и
подъём, отказ старта, маршруты и остановку; нельзя — приём из Telegram, остановку; нельзя — расшифровку и заливку. Вход через живого провайдера OIDC
расшифровку и заливку. тоже недоступен: сессию в прогоне выдать нечем.
## Журнал дефектов ## Журнал дефектов
@@ -271,6 +280,60 @@ API и имя не откатываются обратной правкой по
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
оракул, и выдумывать оракул задним числом нельзя. оракул, и выдумывать оракул задним числом нельзя.
## 2026-08-15 — пустой второй ответ распознавателя стирал сохранённую расшифровку [пойман ревью]
- **Где:** `internal/adapter/repo/pocketbase/text_repo.go`, `TextRepository.Put`
и `StructureRepository.Put`; путь до них — `poll``storeOutcome` в
`internal/service/transcribe.go`. Кода задачи `remove-telegram-intake` дефект не
касался: она этот путь не трогала
- **Симптом:** поток от SpeechKit, закрывшийся на первом же ответе, отказом не
считается — наружу уходит пустой результат без отказа. Замена содержимого шла
безусловно, и повторный опрос той же операции клал пустое поверх сохранённой
расшифровки. Шаг при этом объявлял запись готовой: рубеж двигался, опрос
готовности отдавал `done` без текста
- **Причина:** соседний хранитель того же результата — сырой ответ провайдера —
от пустого значения защищён условием `len(raw) > 0` с самого заведения, а текст
и структура реплик такого условия не имели. Разное правило у двух хранителей
одного результата
- **Чем воспроизведён:** падающий тест враждебного прохода, переснятый триажем, —
`expected: "Личный разговор." actual: ""`. Оракул закреплён в дереве:
`internal/service/recognition_test.go`, `TestEmptySecondAnswerKeepsArchivedText`;
он же проверяет, что до второго ответа дело действительно дошло
- **Почему не поймали раньше:** повторный опрос одной операции — не редкость, но
и не штатный путь: он наступает, когда держатель захвата умер, сохранение рубежа
отказало либо человек снял остановку в панели. Ни один прогон до этого не строил
такого входа, а от чтения кода защита у соседа выглядела общей
- **Что меняем:** правило «пустое не кладётся поверх сохранённого» записано
нормой в спеку `storage` и держится **хранилищем**, а не шагом: шагов, кладущих
текст, больше одного, и правило у одного из них у остальных читалось бы как
снятое. Дефект существовал до той правки, чинился решением владельца от 2026-08-14 в
задаче, которая его нашла
## 2026-08-15 — пустая расшифровка перестала быть заметной вместе с убранным входом [пойман ревью]
- **Где:** `internal/service/transcribe.go`, шаг завершения; документы
`docs/conventions/logging.md` и `docs/architecture.md`
- **Симптом:** запись с пустым распознаванием доходила до конечного рубежа и от
успешной не отличалась ничем — ни строкой журнала, ни ответом опроса
- **Причина:** единственным следом этого случая был текст, уходивший отправителю
в чат («на записи нет текста»). Задача убрала доставку целиком, и след исчез
вместе с ней — при том, что конвенция журнала называет пустой текст
распознавания поимённым примером уровня «может стать проблемой», а обзор
архитектуры обещал заглушку
- **Чем воспроизведён:** `internal/service/recognition_test.go`,
`TestEmptyRecognitionIsNamedInJournal` — подставной распознаватель отдаёт
готовую операцию с пустым результатом, проверка судит уровень строки и
идентификатор записи
- **Почему не поймали раньше:** удаление сняло **последнего потребителя** видимого
признака, а не сам признак; такое не видно ни компилятору, ни грепу по
удаляемому имени. Нашёл проход конвенций, сверив таблицу уровней журнала с тем,
что осталось в коде
- **Что меняем:** шаг опроса пишет строку уровня «может стать проблемой» с
идентификатором записи; строка обзора архитектуры переписана на фактическое
поведение. Класс общий: **удаляя канал, проверь, не был ли он единственным
потребителем сигнала** — сигнал переживает канал только там, где его переносят
руками
## 2026-08-13 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью] ## 2026-08-13 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью]
- **Где:** `internal/config/config_test.go`, проверка «значение ключа доступа не - **Где:** `internal/config/config_test.go`, проверка «значение ключа доступа не
+63 -55
View File
@@ -10,12 +10,15 @@
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
страницу расхода. **Разграничения по владельцу нет:** всякий вошедший видит все страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14**
записи и все расшифровки, как видел их прежде аноним. Его заводит задача задачей `record-ownership`: и опрос готовности, и файл записи сужены владельцем
`record-ownership`. записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
доступа — страницы расхода для владельца сервиса.
Разграничение доступа в Telegram осталось прежним — белым списком, и с учётной Ничьих записей у сервиса больше не бывает: колонка владельца пустого значения
записью приложения он не связан. не принимает, и держит это схема хранилища. Прежде такие записи заводил вход
Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран
вместе с этим исключением.
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи **Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
@@ -55,9 +58,7 @@
| --- | --- | --- | | --- | --- | --- |
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела | | Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела |
| Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи | | Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи |
| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка | | Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов |
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи | | Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
Что добавится вместе с целевым периметром — каждый вход появляется своей Что добавится вместе с целевым периметром — каждый вход появляется своей
@@ -78,9 +79,10 @@
## Куда уходит содержимое записи ## Куда уходит содержимое записи
Сегодня запись и её текст покидают наш сервер тремя путями: файл уезжает в Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
Yandex Object Storage, оттуда его читает SpeechKit, а текст возвращается в Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
Telegram отправителю. 2026-08-14 вместе с убранным входом: текст теперь достаётся только по опросу
готовности и в панели владельца.
Целевой периметр добавляет три пути, каждый — своей задачей: Целевой периметр добавляет три пути, каждый — своей задачей:
@@ -108,7 +110,17 @@ Telegram отправителю.
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
каталогов, но это единственное, что стоит между входом и именем файла. каталогов, но это единственное, что стоит между входом и именем файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с - **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет. расширением. Бакет один на все записи, префикса по пользователю нет. С
2026-08-14 копия там файлом записи не считается: она существует лишь потому,
что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
распознавания.
- **Вторая раскладка файла на диске** появилась 2026-08-14 вместе с сохранённым
ответом провайдера: `data/storage/<recognitions>/<попытка>/<имя>.payload`. Имя
задаёт сервис, как и у аудио. Содержимое там — **полный текст речи**, а не
метаданные, поэтому поле помечено защищённым, правило просмотра коллекции
оставлено пустым, и ссылка на вложение подпадает под тот же запрет, что и
ссылка на аудио: в журнал она не пишется. Проверено прогоном: без сессии, с
чужим и со своим токеном файла ссылка отвечает «не найдено».
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла - **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
можно только с коротким токеном файла, который выдаётся по сессии, и запрос можно только с коротким токеном файла, который выдаётся по сессии, и запрос
@@ -117,12 +129,14 @@ Telegram отправителю.
остаётся: **имя файла в хранилище в журнал не пишется** остаётся: **имя файла в хранилище в журнал не пишется**
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку — иначе строка журнала вместе с идентификатором записи собирала бы ссылку
целиком и работала бы бессрочно. В журнал идёт расширение своим полем. целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное, - **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное,
что защищает `GET /api/status/:id`. что защищает `GET /api/status/:id`.
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят - **Поверхность самого хранилища.** Вместе с переводом наружу выходят
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`, `/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то `/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
есть доступны они только владельцу панели; коды, снятые прогоном, — есть доступны они только владельцу панели, — и коллекции, заведённые
2026-08-14, тоже: содержимое записи отдаёт собственный адрес сервиса, а не
поверхность хранилища. Коды, снятые прогоном, —
[database.md](database.md), «Коллекции», норма — [database.md](database.md), «Коллекции», норма —
[storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только [storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только
то, что заказано». то, что заказано».
@@ -140,10 +154,6 @@ Telegram отправителю.
## Что разграничивает доступ ## Что разграничивает доступ
- **Telegram** — белый список `[server] users_while_list`. Сверяется со строкой
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
меняется владельцем в любой момент: список привязан к изменяемому значению.
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется - **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом
([database.md](database.md), «Настройки с числовым значением»). ([database.md](database.md), «Настройки с числовым значением»).
@@ -173,9 +183,9 @@ Telegram отправителю.
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
эти адреса не несут. эти адреса не несут.
Владения записью в модели данных по-прежнему нет: у задачи нет пользователя. Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
Знание UUID задачи и есть право её читать — теперь для всякого вошедшего, а не есть владелец. Знание идентификатора задачи правом её читать больше не является
для всякого встречного. — читает её тот, кто её принёс.
Целевой периметр заводит четыре механизма вместо одного белого списка; первый из Целевой периметр заводит четыре механизма вместо одного белого списка; первый из
них уже стоит: них уже стоит:
@@ -183,13 +193,10 @@ Telegram отправителю.
| Механизм | Что даёт | Чья задача | | Механизм | Что даёт | Чья задача |
| --- | --- | --- | | --- | --- | --- |
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — **сделано 2026-08-12** | `oidc-login` | | Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — **сделано 2026-08-12** | `oidc-login` |
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» | `record-ownership` | | Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено»**сделано 2026-08-14** | `record-ownership` |
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` | | Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` | | Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
Белый список Telegram при этом перестаёт быть отдельным механизмом: право
писать боту выводится из учётной записи (`telegram-account-link`).
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
модели нет вовсе: до него всё разграничение сводилось к «свой или чужой». модели нет вовсе: до него всё разграничение сводилось к «свой или чужой».
Откуда он берётся — из группы OIDC или из конфигурации — не решено Откуда он берётся — из группы OIDC или из конфигурации — не решено
@@ -206,11 +213,17 @@ Telegram отправителю.
## Что чувствительнее чего ## Что чувствительнее чего
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка; 1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
это самое чувствительное, что здесь есть. это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним. колонкой, а шестью коллекциями: сама запись (заголовок и краткое описание),
3. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage. `texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
`recognitions` (**сырой ответ провайдера вложением — полный текст речи**),
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается
наравне с записью — норму держит спека `storage`.
2. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
Утечка оплачивается деньгами и доступом к бакету. Утечка оплачивается деньгами и доступом к бакету.
4. **Белый список пользователей**сам по себе перечень имён. 3. **Секрет клиента OIDC** — вместе с адресами провайдера открывает вход в
приложение от чужого имени.
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита. кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
@@ -266,30 +279,14 @@ Telegram отправителю.
приведения хвост читал бы кто угодно из интернета, а множеством значений метки приведения хвост читал бы кто угодно из интернета, а множеством значений метки
распоряжался бы анонимный отправитель. распоряжался бы анонимный отправитель.
Приём из Telegram имени, данного человеком, до сервиса не доводит: оттуда Два пути утечки токена бота — адрес Bot API в отказе транспорта и отказ сборки
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки клиента — закрыты задачами `no-user-filename-in-log` и
типа файла не идёт. `local-run-without-telegram-token` 2026-08-13 и потеряли предмет 2026-08-14
вместе с убранным входом: ни клиента, ни токена у сервиса больше нет. Разбор
случая остался в [review.md](review.md) — он про класс, а не про Telegram.
Токен бота стоит в пути **каждого** обращения к Bot API (`bot<TOKEN>/getFile`, Путь, который остался, закрыт задачей `telegram-enabled-flag` 2026-08-13, и он
`…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание **шире всякого одного ключа**: до неё утечь мог любой секрет конфига. Отказ разбора файла
(`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их
уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы
скачивания и отправки пишутся в журнал. Теперь адрес на границе клиента снимает
свой `Do``internal/adapter/telegram`, `NewBot`: он чистит отказ, а
подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые
она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему
отдают готовым. Правило — [conventions/logging.md](conventions/logging.md),
случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`.
Ещё один путь закрыт задачей `local-run-without-telegram-token` 2026-08-13, и до
неё он был открыт: токен, не разбирающийся как часть адреса (перенос строки из
шаблона выкладки, невычищенная `%`-последовательность), роняет сборку клиента
**раньше** обращения к нему — то есть мимо чистки на границе клиента. Отказ
конструктора теперь чистится отдельно. Нашло это ревью кода тремя проходами
независимо; оракул — там же, в `bot_test.go`.
Третий путь закрыт задачей `telegram-enabled-flag` 2026-08-13, и он **шире
токена бота**: до неё утечь мог любой секрет конфига. Отказ разбора файла
настроек пересказывался как есть, а библиотека разбора собирает текст отказа из настроек пересказывался как есть, а библиотека разбора собирает текст отказа из
разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка
секретного ключа с оборванной кавычкой — типовая поломка криво собранного секретного ключа с оборванной кавычкой — типовая поломка криво собранного
@@ -332,6 +329,17 @@ Telegram отправителю.
вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить
бессрочно, и тем же днём заведена задача `delete-record`: своя запись бессрочно, и тем же днём заведена задача `delete-record`: своя запись
убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста. убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста.
Пока она не сделана, единственный способ убрать запись — руками в базе и в Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
каталоге на сервере. Учёт расхода удалению не подлежит по решению человека: строки потребления текста не содержат.
деньги потрачены, а строки потребления текста не содержат.
**Руками запись сегодня не удаляется, и прежняя строка об этом была неверна.**
Проверено прогоном 2026-08-14: содержимое живёт в коллекциях, перечисленных
выше («Что чувствительнее чего»), связи приложений с записью обязательны и
каскада не имеют, поэтому удаление самой
строки записи отвергается хранилищем, а удаление её файлов проходит молча.
Владелец, выполнивший прежнюю процедуру, стирает аудио и **оставляет полный
текст речи** — расшифровку, разбивку по репликам и сырой ответ провайдера
файлом на диске. Порядок, которым запись убирается на самом деле: сперва
строки приложений — журнал событий, попытка распознавания вместе с её
вложением, структура, тексты, — потом сама запись, потом её файлы. До
`delete-record` это единственный способ, и он ручной целиком.
+3 -5
View File
@@ -10,7 +10,6 @@ require (
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
github.com/aws/smithy-go v1.27.7 github.com/aws/smithy-go v1.27.7
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/pocketbase/dbx v1.12.0 github.com/pocketbase/dbx v1.12.0
@@ -19,6 +18,7 @@ require (
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/yandex-cloud/go-genproto v0.17.0 github.com/yandex-cloud/go-genproto v0.17.0
google.golang.org/grpc v1.82.1 google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
) )
require ( require (
@@ -49,7 +49,6 @@ require (
github.com/go-sql-driver/mysql v1.9.2 // indirect github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect github.com/mattn/go-isatty v0.0.23 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
@@ -65,15 +64,14 @@ require (
github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/crypto v0.54.0 // indirect golang.org/x/crypto v0.54.0 // indirect
golang.org/x/image v0.44.0 // indirect golang.org/x/image v0.45.0 // indirect
golang.org/x/net v0.57.0 // indirect golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.74.1 // indirect modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
+8 -10
View File
@@ -74,8 +74,6 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -162,10 +160,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
@@ -178,11 +176,11 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+64 -7
View File
@@ -2,22 +2,79 @@ package recognizer
import ( import (
"context" "context"
"encoding/json"
"errors"
"io" "io"
"git.vakhrushev.me/av/transcriber/internal/entity"
"github.com/google/uuid" "github.com/google/uuid"
"git.vakhrushev.me/av/transcriber/internal/entity"
) )
// MemoryAudioRecognizer — подставной распознаватель для местного запуска и
// проверок. Прогон на реальных ключах ради проверки кода запрещён: распознавание
// и хранение в Object Storage оплачиваются по факту.
//
// Сырой ответ он отдаёт своего вида, но настоящего: тем же путём, что и живой
// адаптер, — сохранённые байты разбираются обратно в реплики, и структура
// строится без единого обращения наружу.
type MemoryAudioRecognizer struct{} type MemoryAudioRecognizer struct{}
func (r *MemoryAudioRecognizer) Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error) { const memoryProvider = "memory"
func (r *MemoryAudioRecognizer) Provider() string { return memoryProvider }
func (r *MemoryAudioRecognizer) Model() string { return "memory" }
func (r *MemoryAudioRecognizer) Upload(ctx context.Context, file io.Reader, objectKey string) (string, error) {
return "memory://" + objectKey, nil
}
func (r *MemoryAudioRecognizer) ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
return false, nil
}
func (r *MemoryAudioRecognizer) Submit(ctx context.Context, sourceURI string) (string, error) {
return uuid.NewString(), nil return uuid.NewString(), nil
} }
func (r *MemoryAudioRecognizer) GetRecognitionText(ctx context.Context, operationID string) (string, error) { func (r *MemoryAudioRecognizer) CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
return "Foo bar, Baz.", nil
}
func (r *MemoryAudioRecognizer) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
return entity.NewCompletedResult(), nil return entity.NewCompletedResult(), nil
} }
func (r *MemoryAudioRecognizer) Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
replicas := []entity.Replica{
{StartMs: 0, EndMs: 1000, Text: "Foo bar,"},
{StartMs: 1000, EndMs: 2000, Text: "Baz."},
}
raw, err := json.Marshal(replicas)
if err != nil {
return nil, errors.New("failed to encode memory payload")
}
return &entity.RecognitionOutcome{
Replicas: replicas,
PlainText: "Foo bar, Baz.",
Raw: raw,
}, nil
}
func (r *MemoryAudioRecognizer) Parse(raw []byte) (*entity.RecognitionOutcome, error) {
var replicas []entity.Replica
if err := json.Unmarshal(raw, &replicas); err != nil {
return nil, errors.New("failed to decode memory payload")
}
var plain []byte
for _, replica := range replicas {
if len(plain) > 0 {
plain = append(plain, ' ')
}
plain = append(plain, replica.Text...)
}
return &entity.RecognitionOutcome{
Replicas: replicas,
PlainText: string(plain),
Raw: raw,
}, nil
}
@@ -0,0 +1,138 @@
package yandex
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3"
"google.golang.org/protobuf/proto"
)
// Сохранённый ответ провайдера — единственное, из чего пересчитывается архив:
// результат операции у SpeechKit не переспрашивается, и повторное распознавание
// стоит денег. Поэтому проверки ниже судят не «разбор чего-то вернул», а
// сохранность самого ответа.
// response собирает ответ потока с одной репликой.
func response(text string, start, end int64) *stt.StreamingResponse {
return &stt.StreamingResponse{
Event: &stt.StreamingResponse_FinalRefinement{
FinalRefinement: &stt.FinalRefinement{
Type: &stt.FinalRefinement_NormalizedText{
NormalizedText: &stt.AlternativeUpdate{
Alternatives: []*stt.Alternative{
{Text: text, StartTimeMs: start, EndTimeMs: end},
},
},
},
},
},
}
}
// Сохранённое читается обратно тем же: реплики со временем и плоский текст.
func TestPayloadSurvivesRoundTrip(t *testing.T) {
responses := []*stt.StreamingResponse{
response("Первая реплика.", 0, 900),
response("Вторая реплика.", 900, 1800),
}
raw, err := encodeResponses(responses)
require.NoError(t, err)
require.NotEmpty(t, raw)
decoded, err := decodeResponses(raw)
require.NoError(t, err)
require.Len(t, decoded, 2)
outcome := outcomeFromResponses(decoded)
require.Len(t, outcome.Replicas, 2)
assert.Equal(t, "Первая реплика.", outcome.Replicas[0].Text)
assert.Equal(t, int64(0), outcome.Replicas[0].StartMs)
assert.Equal(t, int64(900), outcome.Replicas[0].EndMs)
assert.Equal(t, "Вторая реплика.", outcome.Replicas[1].Text)
assert.Equal(t, "Первая реплика. Вторая реплика.", outcome.PlainText)
}
// Ради этого свойства сохранение и сделано двоичным. Провайдер добавляет поля
// без предупреждения, и текстовое представление, собранное по нашей
// скомпилированной схеме, выбросило бы их молча — а пересчитать архив было бы
// уже не из чего: операция не переспрашивается.
func TestUnknownProviderFieldSurvivesStorage(t *testing.T) {
original := response("Реплика.", 0, 500)
// Так выглядит поле, которого наша схема не знает: провайдер прислал его,
// разбор положил в неизвестные.
unknown := protoimplUnknown(t)
original.ProtoReflect().SetUnknown(unknown)
require.NotEmpty(t, original.ProtoReflect().GetUnknown(), "неизвестное поле поставлено")
raw, err := encodeResponses([]*stt.StreamingResponse{original})
require.NoError(t, err)
decoded, err := decodeResponses(raw)
require.NoError(t, err)
require.Len(t, decoded, 1)
assert.Equal(t, []byte(unknown), []byte(decoded[0].ProtoReflect().GetUnknown()),
"неизвестное провайдерское поле пережило запись и чтение")
// И известное при этом на месте.
outcome := outcomeFromResponses(decoded)
require.Len(t, outcome.Replicas, 1)
assert.Equal(t, "Реплика.", outcome.Replicas[0].Text)
}
// Обрезанное вложение узнаётся отказом, а не половиной расшифровки: половина
// текста, выданная за целую, тише и хуже отказа.
func TestTruncatedPayloadIsRefused(t *testing.T) {
raw, err := encodeResponses([]*stt.StreamingResponse{response("Реплика.", 0, 500)})
require.NoError(t, err)
require.Greater(t, len(raw), 2)
_, err = decodeResponses(raw[:len(raw)-2])
assert.Error(t, err, "обрезанное вложение не разбирается молча")
}
// Пустой поток даёт пустой результат, а не отказ: «на записи нет текста» —
// законный исход распознавания.
func TestEmptyStreamGivesEmptyOutcome(t *testing.T) {
raw, err := encodeResponses(nil)
require.NoError(t, err)
decoded, err := decodeResponses(raw)
require.NoError(t, err)
assert.Empty(t, decoded)
outcome := outcomeFromResponses(decoded)
assert.Empty(t, outcome.Replicas)
assert.Empty(t, outcome.PlainText)
}
// Ответ без разбора текста реплик не даёт и разбор не роняет: провайдер шлёт по
// потоку и служебные события.
func TestResponseWithoutTextIsSkipped(t *testing.T) {
responses := []*stt.StreamingResponse{
{Event: &stt.StreamingResponse_FinalRefinement{FinalRefinement: &stt.FinalRefinement{}}},
response("Реплика.", 0, 500),
response("", 500, 600),
}
outcome := outcomeFromResponses(responses)
require.Len(t, outcome.Replicas, 1, "пустые и служебные события репликами не становятся")
assert.Equal(t, "Реплика.", outcome.PlainText)
}
// protoimplUnknown собирает байты неизвестного поля: номер поля, которого в
// нашей схеме нет, с целочисленным значением.
func protoimplUnknown(t *testing.T) []byte {
t.Helper()
// Поле 4095, тип varint, значение 7 — заведомо за пределами схемы ответа.
raw, err := proto.Marshal(&stt.StreamingResponse{})
require.NoError(t, err)
require.Empty(t, raw)
return []byte{0xF8, 0xFF, 0x3F, 0x07}
}
@@ -9,6 +9,10 @@ import (
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
// ProviderName — имя провайдера, под которым сохраняется попытка распознавания.
// По нему видно, чем считана запись, когда провайдеров станет больше одного.
const ProviderName = "yandex-speechkit"
type YandexAudioRecognizerConfig struct { type YandexAudioRecognizerConfig struct {
// s3 // s3
Region string Region string
@@ -56,36 +60,44 @@ func (s *YandexAudioRecognizerService) Close() error {
return s.sttService.Close() return s.sttService.Close()
} }
func (s *YandexAudioRecognizerService) Provider() string { return ProviderName }
func (s *YandexAudioRecognizerService) Model() string { return RecognitionModel }
// startRecognitionTimeout — сколько ждём принятия операции, когда нас уже // startRecognitionTimeout — сколько ждём принятия операции, когда нас уже
// остановили. Число меньше жёсткого предела остановки: иначе процесс убьют // остановили. Число меньше жёсткого предела остановки: иначе процесс убьют
// прежде, чем ответ дойдёт, и защита ничего не даст. // прежде, чем ответ дойдёт, и защита ничего не даст.
const startRecognitionTimeout = 10 * time.Second const startRecognitionTimeout = 10 * time.Second
func (s *YandexAudioRecognizerService) Recognize(ctx context.Context, file io.Reader, fileName string) (string, error) { // Upload кладёт аудио туда, откуда провайдер его прочитает.
//
// Заливка отменяется штатно: она дорога по времени, а повтор её бесплатен — // Отменяется штатно: заливка дорога по времени, а повтор её бесплатен — объект
// объект ложится под тем же ключом. // ложится под тем же ключом.
err := s.s3Sevice.uploadFile(ctx, file, fileName) func (s *YandexAudioRecognizerService) Upload(ctx context.Context, file io.Reader, objectKey string) (string, error) {
if err != nil { if err := s.s3Sevice.uploadFile(ctx, file, objectKey); err != nil {
return "", err return "", err
} }
return s.s3Sevice.fileUrl(objectKey), nil
}
uri := s.s3Sevice.fileUrl(fileName) // ObjectExists отвечает, лежит ли объект нужного размера.
//
// Сверка идёт по присутствию и длине, а не по отпечатку содержимого: признак
// целостности у составного объекта не равен отпечатку, и сверка хешем дала бы
// расхождение на всякой большой записи.
func (s *YandexAudioRecognizerService) ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
return s.s3Sevice.objectExists(ctx, objectKey, size)
}
// А вот принятие операции от отмены защищено. Окно короткое и дорогое: // Submit заводит операцию распознавания. Оплачивается наружу, поэтому от отмены
// SpeechKit может операцию принять и начать считать деньги, а ответ до нас // защищён: окно короткое и дорогое — SpeechKit может операцию принять и начать
// не доедет — идентификатор потеряется навсегда, и повтор оплатит ту же // считать деньги, а ответ до нас не доедет, и повтор оплатит ту же запись второй
// запись второй раз. Свой предел вызову оставлен, чтобы остановка не ждала // раз. Свой предел вызову оставлен, чтобы остановка не ждала вечно.
// вечно. func (s *YandexAudioRecognizerService) Submit(ctx context.Context, sourceURI string) (string, error) {
startCtx, cancel := protectFromCancel(ctx, startRecognitionTimeout) startCtx, cancel := protectFromCancel(ctx, startRecognitionTimeout)
defer cancel() defer cancel()
opId, err := s.sttService.recognizeFileFromS3(startCtx, uri) return s.sttService.recognizeFileFromS3(startCtx, sourceURI)
if err != nil {
return "", err
}
return opId, nil
} }
// protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения // protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения
@@ -95,11 +107,26 @@ func protectFromCancel(ctx context.Context, timeout time.Duration) (context.Cont
return context.WithTimeout(context.WithoutCancel(ctx), timeout) return context.WithTimeout(context.WithoutCancel(ctx), timeout)
} }
func (s *YandexAudioRecognizerService) GetRecognitionText(ctx context.Context, operationID string) (string, error) { // Fetch забирает готовый результат и отдаёт его доменным: реплики со временем,
return s.sttService.getRecognitionText(ctx, operationID) // плоский текст и байты ответа на хранение. Формата провайдера наружу не выходит
// ничего — ни один шаг конвейера не знает, каким потоком тот отвечает.
func (s *YandexAudioRecognizerService) Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
return s.sttService.fetchRecognition(ctx, operationID)
} }
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) { // Parse строит доменный результат из **сохранённого** ответа, не обращаясь к
// провайдеру. По нему архив пересчитывается без единого рубля.
func (s *YandexAudioRecognizerService) Parse(raw []byte) (*entity.RecognitionOutcome, error) {
responses, err := decodeResponses(raw)
if err != nil {
return nil, err
}
outcome := outcomeFromResponses(responses)
outcome.Raw = raw
return outcome, nil
}
func (s *YandexAudioRecognizerService) CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
operation, err := s.sttService.checkOperationStatus(ctx, operationID) operation, err := s.sttService.checkOperationStatus(ctx, operationID)
if err != nil { if err != nil {
return nil, err return nil, err
+32
View File
@@ -12,6 +12,7 @@ import (
"github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager" "github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go" "github.com/aws/smithy-go"
) )
@@ -89,6 +90,37 @@ func (s *yandexS3Service) uploadFile(ctx context.Context, file io.Reader, fileNa
return nil return nil
} }
// objectExists отвечает, лежит ли объект нужного размера.
//
// По нему шаг решает, повторять ли заливку: повтор её бесплатен, но дорог по
// времени на многочасовой записи. Сверка идёт по присутствию и длине, а не по
// отпечатку содержимого: признак целостности у составного объекта не равен
// отпечатку, и сверка хешем расходилась бы на всякой большой записи.
func (s *yandexS3Service) objectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
out, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(objectKey),
})
if err != nil {
var notFound *s3types.NotFound
if errors.As(err, &notFound) {
return false, nil
}
// Отказ SDK несёт полный URL объекта, а он — ключ к чужому аудио: наружу
// идёт класс отказа и только он.
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
if apiErr.ErrorCode() == "NotFound" || apiErr.ErrorCode() == "NoSuchKey" {
return false, nil
}
return false, fmt.Errorf("failed to head object in S3: %s", apiErr.ErrorCode())
}
return false, errors.New("failed to head object in S3")
}
return out.ContentLength != nil && *out.ContentLength == size, nil
}
func (s *yandexS3Service) fileUrl(fileName string) string { func (s *yandexS3Service) fileUrl(fileName string) string {
endpoint := strings.TrimRight(s.endpoint, "/") endpoint := strings.TrimRight(s.endpoint, "/")
return fmt.Sprintf("%s/%s/%s", endpoint, s.bucketName, fileName) return fmt.Sprintf("%s/%s/%s", endpoint, s.bucketName, fileName)
+112 -17
View File
@@ -2,17 +2,20 @@ package yandex
import ( import (
"context" "context"
"encoding/binary"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"strings"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"google.golang.org/protobuf/proto"
stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3" stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3"
"github.com/yandex-cloud/go-genproto/yandex/cloud/operation" "github.com/yandex-cloud/go-genproto/yandex/cloud/operation"
"git.vakhrushev.me/av/transcriber/internal/entity"
) )
const ( const (
@@ -134,9 +137,13 @@ func (s *speechKitService) recognizeFileFromS3(ctx context.Context, s3URI string
return op.Id, nil return op.Id, nil
} }
// GetRecognitionResult получает результат распознавания по ID операции // fetchRecognition забирает результат операции целиком и отдаёт его доменным,
func (s *speechKitService) getRecognitionText(ctx context.Context, operationID string) (string, error) { // вместе с сырым ответом на хранение.
// Добавляем авторизацию и folder_id в контекст //
// Ответ сохраняется потому, что **результат операции у провайдера не
// переспрашивается**: связь реплики с говорящим сервис строить пока не умеет, и
// когда научится, архив пересчитается из сохранённого без единого рубля.
func (s *speechKitService) fetchRecognition(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey) ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID) ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
@@ -146,11 +153,10 @@ func (s *speechKitService) getRecognitionText(ctx context.Context, operationID s
stream, err := s.sttClient.GetRecognition(ctx, req) stream, err := s.sttClient.GetRecognition(ctx, req)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get recognition stream: %w", err) return nil, fmt.Errorf("failed to get recognition stream: %w", err)
} }
var sb strings.Builder var responses []*stt.StreamingResponse
for { for {
resp, err := stream.Recv() resp, err := stream.Recv()
if err != nil { if err != nil {
@@ -160,19 +166,19 @@ func (s *speechKitService) getRecognitionText(ctx context.Context, operationID s
if errors.Is(err, io.EOF) { if errors.Is(err, io.EOF) {
break break
} }
return "", fmt.Errorf("failed to receive recognition response: %w", err) return nil, fmt.Errorf("failed to receive recognition response: %w", err)
}
if refinement := resp.GetFinalRefinement(); refinement != nil {
if text := refinement.GetNormalizedText(); text != nil {
for _, alt := range text.Alternatives {
sb.WriteString(alt.Text)
sb.WriteString(" ")
}
}
} }
responses = append(responses, resp)
} }
return sb.String(), nil raw, err := encodeResponses(responses)
if err != nil {
return nil, err
}
outcome := outcomeFromResponses(responses)
outcome.Raw = raw
return outcome, nil
} }
// checkOperationStatus проверяет статус операции распознавания // checkOperationStatus проверяет статус операции распознавания
@@ -190,3 +196,92 @@ func (s *speechKitService) checkOperationStatus(ctx context.Context, operationID
return op, nil return op, nil
} }
// encodeResponses укладывает ответ провайдера целиком, в том виде, в каком он
// пришёл: сообщения потока подряд, каждое со своей длиной впереди.
//
// Форма **двоичная**, а не текстовая, и это несущее решение. Текстовое
// представление собирается по нашей скомпилированной схеме и молча выбрасывает
// поля, которых в ней нет, — а провайдер добавляет их без предупреждения.
// Двоичная форма неизвестные поля переносит: они переживают запись и чтение и
// станут читаемыми, когда мы обновим схему. Ради этого архив и заводился —
// результат операции у провайдера не переспрашивается, и повторное
// распознавание стоит денег.
//
// Цена названа прямо: сохранённое не читается глазами и не разбирается ничем,
// кроме нашего же кода.
func encodeResponses(responses []*stt.StreamingResponse) ([]byte, error) {
var raw []byte
for _, resp := range responses {
encoded, err := proto.Marshal(resp)
if err != nil {
// Текст расшифровки наружу не выходит даже отказом: сообщение
// провайдера несёт её целиком.
return nil, errors.New("failed to encode provider response")
}
raw = binary.AppendUvarint(raw, uint64(len(encoded)))
raw = append(raw, encoded...)
}
return raw, nil
}
// decodeResponses читает сохранённый ответ провайдера обратно.
func decodeResponses(raw []byte) ([]*stt.StreamingResponse, error) {
var responses []*stt.StreamingResponse
for len(raw) > 0 {
size, read := binary.Uvarint(raw)
if read <= 0 || uint64(len(raw)-read) < size {
return nil, errors.New("stored provider payload is truncated")
}
raw = raw[read:]
var resp stt.StreamingResponse
if err := proto.Unmarshal(raw[:size], &resp); err != nil {
return nil, errors.New("failed to decode provider response")
}
responses = append(responses, &resp)
raw = raw[size:]
}
return responses, nil
}
// outcomeFromResponses строит доменный результат: реплики со временем и плоский
// текст. Формата провайдера отсюда наружу не выходит ничего.
//
// Говорящие не размечаются: связь реплики с разбором говорящего у провайдера не
// выяснена. Структура при этом строится из сохранённого ответа, поэтому разметка
// станет возможной без повторной оплаты.
func outcomeFromResponses(responses []*stt.StreamingResponse) *entity.RecognitionOutcome {
outcome := &entity.RecognitionOutcome{}
var plain []byte
for _, resp := range responses {
refinement := resp.GetFinalRefinement()
if refinement == nil {
continue
}
text := refinement.GetNormalizedText()
if text == nil {
continue
}
for _, alt := range text.GetAlternatives() {
if alt.GetText() == "" {
continue
}
outcome.Replicas = append(outcome.Replicas, entity.Replica{
StartMs: alt.GetStartTimeMs(),
EndMs: alt.GetEndTimeMs(),
Text: alt.GetText(),
})
if len(plain) > 0 {
plain = append(plain, ' ')
}
plain = append(plain, alt.GetText()...)
}
}
outcome.PlainText = string(plain)
return outcome
}
+7
View File
@@ -43,6 +43,13 @@ func New(dataDir string) (*pb.PocketBase, error) {
return nil, fmt.Errorf("failed to apply storage schema: %w", err) return nil, fmt.Errorf("failed to apply storage schema: %w", err)
} }
// Страж владельца вешается здесь, а не вызывающим: он защищает архив от
// удаления учётной записи, и сборка, забывшая его позвать, теряет защиту
// молча. Так это уже и было — окружение проверок его не ставило, и всё
// разграничение проверялось на приложении, где архив сносится одним
// запросом.
GuardOwnerDeletion(app)
return app, nil return app, nil
} }
+23 -33
View File
@@ -112,11 +112,15 @@ func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
return work, nil return work, nil
} }
// CreateLocal кладёт рабочую копию в хранилище. Имя задаём мы: умолчание // Create кладёт рабочую копию в хранилище. Имя задаём мы: умолчание библиотеки
// библиотеки строит его из имени, данного отправителем, а имя отправителя в // строит его из имени, данного отправителем, а имя отправителя в хранилище не
// хранилище не попадает — путь к файлу читается в журнале, и инвариант // попадает — путь к файлу читается в журнале, и инвариант приватности этого не
// приватности этого не допускает. Свой суффикс хранилище допишет само. // допускает. Свой суффикс хранилище допишет само.
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*entity.File, error) { //
// Копий у записи ровно две — принятая и приведённая, — и обе местные. Прежний
// путь заведения записи о копии во внешнем хранилище отсюда ушёл: та копия
// файлом записи не считается, а её ключ живёт в строке попытки распознавания.
func (repo *FileRepository) Create(name string, work contract.WorkFile, meta contract.FileMeta, ownerID string) (*entity.File, error) {
collection, err := findCollection(repo.app, migrations.FilesCollection) collection, err := findCollection(repo.app, migrations.FilesCollection)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -132,6 +136,13 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*e
record.Set("file", stored) record.Set("file", stored)
record.Set("location", entity.LocationLocal) record.Set("location", entity.LocationLocal)
record.Set("size", stored.Size) record.Set("size", stored.Size)
record.Set("format", meta.Format)
record.Set("duration_ms", meta.DurationMs)
// Владелец файла — владелец записи, которой файл принадлежит. Пустой значит
// «файл без владельца»: таков всякий файл записи, принятой ботом. Правило
// просмотра коллекции сужено этой колонкой, и без неё чужое аудио осталось
// бы доступным всякому вошедшему.
record.Set("owner", ownerID)
if err := repo.app.Save(record); err != nil { if err := repo.app.Save(record); err != nil {
// Отказ укладки называет имя файла — то самое, из которого строится // Отказ укладки называет имя файла — то самое, из которого строится
@@ -143,24 +154,6 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*e
return recordToFile(record), nil return recordToFile(record), nil
} }
func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.File, error) {
collection, err := findCollection(repo.app, migrations.FilesCollection)
if err != nil {
return nil, err
}
record := core.NewRecord(collection)
record.Set("location", entity.LocationS3)
record.Set("object_key", objectKey)
record.Set("size", size)
if err := repo.app.Save(record); err != nil {
return nil, fmt.Errorf("failed to store remote file record: %w", err)
}
return recordToFile(record), nil
}
func (repo *FileRepository) GetByID(id string) (*entity.File, error) { func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
record, err := repo.app.FindRecordById(migrations.FilesCollection, id) record, err := repo.app.FindRecordById(migrations.FilesCollection, id)
if err != nil { if err != nil {
@@ -256,16 +249,13 @@ func firstFileName(record *core.Record) string {
} }
func recordToFile(record *core.Record) *entity.File { func recordToFile(record *core.Record) *entity.File {
name := firstFileName(record)
if name == "" {
name = record.GetString("object_key")
}
return &entity.File{ return &entity.File{
Id: record.Id, Id: record.Id,
Location: record.GetString("location"), Location: record.GetString("location"),
FileName: name, FileName: firstFileName(record),
Size: int64(record.GetInt("size")), Size: int64(record.GetInt("size")),
CreatedAt: record.GetDateTime("created").Time(), Format: record.GetString("format"),
DurationMs: int64(record.GetInt("duration_ms")),
CreatedAt: record.GetDateTime("created").Time(),
} }
} }
@@ -1,38 +0,0 @@
package pocketbase
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Потолок размера у поля файла задан числом, а не нулём: нулём библиотека читает
// собственное умолчание в 5 МиБ, и на нём отвергалось бы всё длиннее примерно
// пяти минут — то есть штатная запись сервиса. Проверка судит запись, которая
// заведомо больше этого умолчания: обновление библиотеки, вернувшее умолчание,
// иначе прошло бы молча.
func TestCreateLocal_AcceptsRecordLargerThanLibraryDefault(t *testing.T) {
app := newTestApp(t)
repo := NewFileRepository(app)
const libraryDefault = 5 << 20
// Ровно на байт больше умолчания: проверка судит границу, а не пропускную
// способность — лишние мегабайты стоили бы секунд на каждом прогоне.
work, err := repo.Stage(".mp3", strings.NewReader(strings.Repeat("a", libraryDefault+1)))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
size, err := work.Size()
require.NoError(t, err)
require.Greater(t, size, int64(libraryDefault), "запись заведомо больше умолчания библиотеки")
file, err := repo.CreateLocal("big.mp3", work)
require.NoError(t, err, "запись длиннее умолчания библиотеки ложится в хранилище")
assert.Equal(t, size, file.Size)
assert.Greater(t, entity.MaxRecordSize, size, "объявленный потолок выше проверяемого размера")
}
@@ -1,203 +0,0 @@
package pocketbase
import (
"database/sql"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Отображение задачи в запись коллекции и обратно живёт одним местом. Прежде
// список колонок был переписан четырежды — в каждом запросе своего слоя, — и
// расхождение проявлялось как потерянное при сохранении поле.
// applyOwnedByPipeline кладёт в запись только те поля, которыми распоряжается
// конвейер. Поля, которые он не меняет никогда — куда отвечать отправителю и
// каким входом пришла запись, — не трогаются вовсе.
//
// Разрез нужен потому, что шаг держит задачу снимком с момента захвата и до
// своего сохранения, а это до восьми часов. Всё, что владелец правил в панели за
// это время, безусловная запись снимка стёрла бы молча: ни строки в журнале, ни
// отказа в панели — владелец видел бы успешное сохранение и был бы уверен, что
// правка на месте.
func applyOwnedByPipeline(record *core.Record, job *entity.TranscribeJob) {
record.Set("state", job.State)
record.Set("file", derefString(job.FileID))
record.Set("error_text", derefString(job.ErrorText))
record.Set("acquisition_id", derefString(job.AcquisitionID))
record.Set("acquire_time", dateOrEmpty(job.AcquireTime))
record.Set("delay_time", dateOrEmpty(job.DelayTime))
record.Set("attempts", job.Attempts)
record.Set("recognition_op_id", derefString(job.RecognitionOpID))
record.Set("transcription_text", derefString(job.TranscriptionText))
}
// applyToRecord кладёт задачу в запись целиком — это заведение, и спорить за
// поля здесь не с кем.
func applyToRecord(record *core.Record, job *entity.TranscribeJob) {
applyOwnedByPipeline(record, job)
record.Set("source", job.Source)
record.Set("tg_chat_id", derefInt64(job.TgChatId))
record.Set("tg_reply_message_id", derefInt(job.TgReplyMessageId))
}
func recordToJob(record *core.Record) *entity.TranscribeJob {
return &entity.TranscribeJob{
Id: record.Id,
State: record.GetString("state"),
Source: record.GetString("source"),
FileID: nilIfEmpty(record.GetString("file")),
ErrorText: nilIfEmpty(record.GetString("error_text")),
AcquisitionID: nilIfEmpty(record.GetString("acquisition_id")),
AcquireTime: timeOrNil(record.GetDateTime("acquire_time")),
DelayTime: timeOrNil(record.GetDateTime("delay_time")),
Attempts: record.GetInt("attempts"),
RecognitionOpID: nilIfEmpty(record.GetString("recognition_op_id")),
TranscriptionText: nilIfEmpty(record.GetString("transcription_text")),
TgChatId: nilIfZero64(int64(record.GetInt("tg_chat_id"))),
TgReplyMessageId: nilIfZeroInt(record.GetInt("tg_reply_message_id")),
CreatedAt: record.GetDateTime("created").Time(),
UpdatedAt: record.GetDateTime("updated").Time(),
}
}
// acquiredRow — задача, прочитанная сырым запросом захвата. Колонки читаются
// именно так, потому что запрос идёт мимо записей коллекции; связь с их
// перечнем держит константа acquireColumns и тест захвата, читающий задачу
// целиком.
type acquiredRow struct {
Id string `db:"id"`
State string `db:"state"`
Source string `db:"source"`
FileID sql.NullString `db:"file"`
ErrorText sql.NullString `db:"error_text"`
AcquisitionID sql.NullString `db:"acquisition_id"`
AcquireTime sql.NullString `db:"acquire_time"`
DelayTime sql.NullString `db:"delay_time"`
Attempts int `db:"attempts"`
RecognitionOpID sql.NullString `db:"recognition_op_id"`
TranscriptionText sql.NullString `db:"transcription_text"`
TgChatId sql.NullInt64 `db:"tg_chat_id"`
TgReplyMessageId sql.NullInt64 `db:"tg_reply_message_id"`
Created sql.NullString `db:"created"`
Updated sql.NullString `db:"updated"`
}
func (r *acquiredRow) toJob() *entity.TranscribeJob {
job := &entity.TranscribeJob{
Id: r.Id,
State: r.State,
Source: r.Source,
FileID: nullToPtr(r.FileID),
ErrorText: nullToPtr(r.ErrorText),
AcquisitionID: nullToPtr(r.AcquisitionID),
AcquireTime: parseTimeOrNil(r.AcquireTime),
DelayTime: parseTimeOrNil(r.DelayTime),
Attempts: r.Attempts,
RecognitionOpID: nullToPtr(r.RecognitionOpID),
TranscriptionText: nullToPtr(r.TranscriptionText),
}
if r.TgChatId.Valid && r.TgChatId.Int64 != 0 {
chatId := r.TgChatId.Int64
job.TgChatId = &chatId
}
if r.TgReplyMessageId.Valid && r.TgReplyMessageId.Int64 != 0 {
msgId := int(r.TgReplyMessageId.Int64)
job.TgReplyMessageId = &msgId
}
if created := parseTimeOrNil(r.Created); created != nil {
job.CreatedAt = *created
}
if updated := parseTimeOrNil(r.Updated); updated != nil {
job.UpdatedAt = *updated
}
return job
}
func derefString(v *string) string {
if v == nil {
return ""
}
return *v
}
func derefInt64(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
func derefInt(v *int) int {
if v == nil {
return 0
}
return *v
}
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
// хранилище это пустая строка, и она же значит «времени нет».
func dateOrEmpty(v *time.Time) any {
if v == nil {
return ""
}
date, err := types.ParseDateTime(*v)
if err != nil {
return ""
}
return date
}
func nilIfEmpty(v string) *string {
if v == "" {
return nil
}
return &v
}
func timeOrNil(v types.DateTime) *time.Time {
if v.IsZero() {
return nil
}
t := v.Time()
return &t
}
func nilIfZero64(v int64) *int64 {
if v == 0 {
return nil
}
return &v
}
func nilIfZeroInt(v int) *int {
if v == 0 {
return nil
}
return &v
}
func nullToPtr(v sql.NullString) *string {
if !v.Valid || v.String == "" {
return nil
}
s := v.String
return &s
}
func parseTimeOrNil(v sql.NullString) *time.Time {
if !v.Valid || v.String == "" {
return nil
}
date, err := types.ParseDateTime(v.String)
if err != nil || date.IsZero() {
return nil
}
t := date.Time()
return &t
}
@@ -0,0 +1,106 @@
package migrations
import (
"errors"
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// up202608140001 заводит владельца записи.
//
// Колонка — связь с коллекцией пользователей: хранилище само следит, чтобы
// владельцем стояла существующая учётная запись, а не строка, похожая на её
// идентификатор.
//
// Пустое значение допустимо, и это решение с названной ценой. Записи, принятые
// ботом, владельца не имеют вовсе: связи чата Telegram с учётной записью сервис
// не ведёт, её заводит отдельная задача. Обязательность для приёма по HTTP
// держит поэтому сам приём, а не схема.
//
// Каскадное удаление выключено, но одного этого мало: при выключенном каскаде
// хранилище **вынимает** идентификатор из поля связи и сохраняет запись без
// проверок, то есть архив удалённого пользователя стал бы ничьим и не достался
// бы никому. Поэтому удаление учётной записи, у которой остались задачи,
// отвергается слоем приложения — `GuardOwnerDeletion`.
func up202608140001(app core.App) error {
users, err := app.FindCollectionByNameOrId(UsersCollection)
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
jobs, err := app.FindCollectionByNameOrId(JobsCollection)
if err != nil {
return fmt.Errorf("failed to find jobs collection: %w", err)
}
jobs.Fields.Add(ownerField(users.Id))
if err := app.Save(jobs); err != nil {
return fmt.Errorf("failed to add owner to jobs: %w", err)
}
files, err := app.FindCollectionByNameOrId(FilesCollection)
if err != nil {
return fmt.Errorf("failed to find files collection: %w", err)
}
files.Fields.Add(ownerField(users.Id))
// Правило просмотра сужается владельцем. Прежнее пускало всякого узнанного:
// владельца у записи тогда не было, и сужать выборку было нечем. Без этой
// строки разграничение закрыло бы метаданные задачи и оставило открытым
// содержимое — то самое, что оно и заведено прятать: знание идентификатора
// файловой записи равнялось бы праву скачать чужое аудио.
files.ViewRule = ptr(`@request.auth.id != "" && owner = @request.auth.id`)
if err := app.Save(files); err != nil {
return fmt.Errorf("failed to narrow files by owner: %w", err)
}
return nil
}
// ownerField собирает описание колонки владельца. Обе коллекции получают
// одинаковую: разойдясь, они дали бы разное поведение у задачи и у её файла.
func ownerField(usersCollectionID string) *core.RelationField {
return &core.RelationField{
Name: "owner",
CollectionId: usersCollectionID,
MaxSelect: 1,
// Пустое значение допустимо — см. шапку шага. Умолчания у колонки нет:
// связь его не имеет по устройству, и запись не достаётся никому по
// недосмотру схемы.
Required: false,
// Удаление учётной записи не уносит её записи следом: сервис объявлен
// архивом. Что происходит вместо этого, держит `GuardOwnerDeletion`.
CascadeDelete: false,
}
}
// down202608140001 снимает колонку с обеих коллекций и возвращает правило
// просмотра файлов к тому, что стояло до шага, — «всякий узнанный».
func down202608140001(app core.App) error {
for _, name := range []string{JobsCollection, FilesCollection} {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", name, err)
}
field := collection.Fields.GetByName("owner")
if field == nil {
return errors.New("collection " + name + " has no owner field")
}
collection.Fields.RemoveById(field.GetId())
if name == FilesCollection {
collection.ViewRule = ptr(`@request.auth.id != ""`)
}
if err := app.Save(collection); err != nil {
return fmt.Errorf("failed to drop owner from %s: %w", name, err)
}
}
return nil
}
@@ -0,0 +1,347 @@
package migrations
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// up202608140002 перестраивает модель вокруг аудиозаписи.
//
// Прежняя коллекция задач уходит целиком: сервис на сервере остановлен, а
// прежние данные удалены решением владельца 2026-08-14 — переноса эта работа не
// делает, и оставленная пустая коллекция висела бы в панели вторым домом для
// того же понятия.
//
// Порядок заведения задан связями, а не вкусом: приложения ссылаются на запись,
// а запись — на них, поэтому запись заводится первой без обратных ссылок, потом
// приложения, и только потом ссылки дописываются.
//
// Правила доступа у новых коллекций остаются **незаданными**, то есть «только
// владелец панели». Содержимое записи отдаёт собственный адрес сервиса, а не
// поверхность хранилища; непустое правило открыло бы перечисление коллекции
// впрок, а норма проекта велит держать эту поверхность закрытой.
func up202608140002(app core.App) error {
users, err := app.FindCollectionByNameOrId(UsersCollection)
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
files, err := app.FindCollectionByNameOrId(FilesCollection)
if err != nil {
return fmt.Errorf("failed to find files collection: %w", err)
}
// Формат и длительность у копии: по ним видно, чем запись была, не открывая
// её. Расширение наружу выходит только приведённым к перечню известных.
files.Fields.Add(
&core.TextField{Name: "format"},
&core.NumberField{Name: "duration_ms", OnlyInt: true},
)
if err := app.Save(files); err != nil {
return fmt.Errorf("failed to extend files: %w", err)
}
topics, err := createTopics(app, users.Id)
if err != nil {
return err
}
records, err := createAudioRecords(app, users.Id, files.Id, topics.Id)
if err != nil {
return err
}
texts, err := createTexts(app, records.Id)
if err != nil {
return err
}
structures, err := createStructures(app, records.Id)
if err != nil {
return err
}
recognitions, err := createRecognitions(app, records.Id)
if err != nil {
return err
}
if err := createRecordEvents(app, records.Id); err != nil {
return err
}
// Обратные ссылки дописываются последними: раньше коллекций-целей ещё нет.
records.Fields.Add(
&core.RelationField{Name: "transcript_text", CollectionId: texts.Id, MaxSelect: 1},
&core.RelationField{Name: "literary_text", CollectionId: texts.Id, MaxSelect: 1},
&core.RelationField{Name: "structure", CollectionId: structures.Id, MaxSelect: 1},
&core.RelationField{Name: "recognition", CollectionId: recognitions.Id, MaxSelect: 1},
)
if err := app.Save(records); err != nil {
return fmt.Errorf("failed to link audio records to their appendices: %w", err)
}
jobs, err := app.FindCollectionByNameOrId(JobsCollection)
if err != nil {
return fmt.Errorf("failed to find jobs collection: %w", err)
}
if err := app.Delete(jobs); err != nil {
return fmt.Errorf("failed to drop the former jobs collection: %w", err)
}
return nil
}
// createAudioRecords заводит центральную сущность.
//
// Ссылки на файлы две и порознь: шаг конвейера больше не переставляет одну на
// свой результат, и исходник остаётся доступным после того, как запись прошла
// конвейер.
func createAudioRecords(app core.App, usersID, filesID, topicsID string) (*core.Collection, error) {
records := core.NewBaseCollection(RecordsCollection)
records.Fields.Add(
ownerField(usersID),
&core.SelectField{
Name: "source",
Values: []string{entity.SourceUnknown, entity.SourceApi, entity.SourceTelegram},
MaxSelect: 1,
Required: true,
},
// Заголовок и краткое описание читаются вместе со списком, сотней штук
// разом, и потому лежат колонками записи, а не строками текстов.
&core.TextField{Name: "title"},
&core.TextField{Name: "brief"},
// Перечень рубежей закрыт схемой: запись, заведённая в панели руками, не
// должна попасть в выборку с рубежом, которого конвейер не знает.
&core.SelectField{
Name: "state",
Values: entity.AllStates(),
MaxSelect: 1,
Required: true,
},
// Время входа в рубеж — сторож застревания. Ставится только сменой рубежа
// и возвратом записи в работу; откладывание опроса его не двигает.
&core.DateField{Name: "state_entered_at"},
// Остановка — признак, а не рубеж: `state` при ней не стирается, и снятие
// признака продолжает работу с места остановки.
&core.DateField{Name: "halted_at"},
&core.SelectField{
Name: "halt_reason",
Values: entity.AllHaltReasons(),
MaxSelect: 1,
},
&core.TextField{Name: "error_text"},
// Признак **этого** захвата: значение уникально для каждого захвата, и
// запись результата условна по нему, а не по занятости записи.
&core.TextField{Name: "acquisition_id"},
// Срок протухания захвата приезжает с рубежом и пишется числом при самом
// захвате: воркер не привязан к шагу и вывести срок из себя не может.
&core.DateField{Name: "acquire_expires_at"},
&core.DateField{Name: "delay_time"},
// Число отказов ограничивает повторы внутри шага. Время в рубеже мерит
// отдельный сторож: одно число не справлялось ни с одной из обязанностей.
&core.NumberField{Name: "attempts", OnlyInt: true, Min: ptr(0.0)},
&core.RelationField{Name: "original_file", CollectionId: filesID, MaxSelect: 1},
&core.RelationField{Name: "normalized_file", CollectionId: filesID, MaxSelect: 1},
&core.RelationField{
Name: "topics",
CollectionId: topicsID,
MaxSelect: entity.MaxTopicsPerRecord,
},
&core.NumberField{Name: "tg_chat_id", OnlyInt: true},
&core.NumberField{Name: "tg_reply_message_id", OnlyInt: true},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
// Отбор захвата идёт по рубежу, признаку остановки, паузе и сроку протухания
// захвата — индекс снимает полный перебор.
records.AddIndex("idx_audio_records_state", false, "state, halted_at", "")
if err := app.Save(records); err != nil {
return nil, fmt.Errorf("failed to create audio records: %w", err)
}
return records, nil
}
// createTexts заводит тексты записи. Пара «запись и вид» уникальна: повтор
// прерванного шага иначе завёл бы второй комплект строк, и вопрос «какой текст
// отдавать человеку» стал бы вопросом порядка записи, а не состояния.
func createTexts(app core.App, recordsID string) (*core.Collection, error) {
texts := core.NewBaseCollection(TextsCollection)
texts.Fields.Add(
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
&core.SelectField{
Name: "kind",
Values: entity.AllTextKinds(),
MaxSelect: 1,
Required: true,
},
// Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме
// зовут формат файла, и третий смысл у одного слова развёл бы по разным
// вещам вид текста и формат копии.
&core.EditorField{Name: "contents"},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
texts.AddIndex("idx_texts_record_kind", true, "record, kind", "")
if err := app.Save(texts); err != nil {
return nil, fmt.Errorf("failed to create texts: %w", err)
}
return texts, nil
}
// createStructures заводит структуру реплик. Номер версии нужен потому, что
// разбор сохранённого ответа изменится раньше, чем архив пересчитают.
func createStructures(app core.App, recordsID string) (*core.Collection, error) {
structures := core.NewBaseCollection(StructuresCollection)
structures.Fields.Add(
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
&core.NumberField{Name: "version", OnlyInt: true, Required: true},
&core.JSONField{Name: "contents", MaxSize: structureContentsMaxSize},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
structures.AddIndex("idx_structures_record_version", true, "record, version", "")
if err := app.Save(structures); err != nil {
return nil, fmt.Errorf("failed to create structures: %w", err)
}
return structures, nil
}
// createRecognitions заводит попытку распознавания у внешнего провайдера.
//
// Сырой ответ лежит **вложением**, а не колонкой: шаг опроса читает эту строку
// раз в несколько секунд, а хранилище читает запись целиком — ответ на
// многочасовую запись ехал бы в память при каждом опросе.
//
// Поле вложения помечено защищённым: сырой ответ это полный текст речи, и
// умолчание библиотеки отдавало бы его по ссылке любому, кто её знает.
func createRecognitions(app core.App, recordsID string) (*core.Collection, error) {
recognitions := core.NewBaseCollection(RecognitionsCollection)
payload := &core.FileField{Name: "payload", MaxSelect: 1, MaxSize: recognitionPayloadMaxSize}
payload.Protected = true
recognitions.Fields.Add(
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
&core.TextField{Name: "provider", Required: true},
&core.TextField{Name: "model"},
// Идентификатор операции у провайдера — самое провайдерское, что есть в
// модели, и живёт он здесь, а не колонкой записи.
&core.TextField{Name: "external_id"},
// Адрес, по которому провайдер читает аудио. Копия во внешнем хранилище
// файлом записи не считается: другой провайдер её не потребует.
&core.TextField{Name: "source_uri"},
payload,
&core.DateField{Name: "started_at"},
&core.DateField{Name: "finished_at"},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
if err := app.Save(recognitions); err != nil {
return nil, fmt.Errorf("failed to create recognitions: %w", err)
}
return recognitions, nil
}
// createRecordEvents заводит журнал событий записи.
//
// Колонка текста отказа зовётся `outcome_text`, а не `error_text`: последнее имя
// названо поимённо инвариантом проекта о секрете, и две колонки с этим именем
// сделали бы инвариант двусмысленным.
func createRecordEvents(app core.App, recordsID string) error {
events := core.NewBaseCollection(RecordEventsCollection)
events.Fields.Add(
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
&core.SelectField{
Name: "origin",
Values: entity.AllEventOrigins(),
MaxSelect: 1,
Required: true,
},
&core.TextField{Name: "step"},
&core.SelectField{
Name: "outcome",
Values: entity.AllEventOutcomes(),
MaxSelect: 1,
Required: true,
},
&core.TextField{Name: "outcome_text"},
&core.NumberField{Name: "duration_ms", OnlyInt: true},
&core.AutodateField{Name: "created", OnCreate: true},
)
events.AddIndex("idx_record_events_record", false, "record", "")
if err := app.Save(events); err != nil {
return fmt.Errorf("failed to create record events: %w", err)
}
return nil
}
// createTopics заводит словарь тем. Тема уникальна в паре «владелец и название»:
// словарь свой у каждого человека, и общий показал бы одному темы другого.
func createTopics(app core.App, usersID string) (*core.Collection, error) {
topics := core.NewBaseCollection(TopicsCollection)
topics.Fields.Add(
&core.RelationField{
Name: "owner",
CollectionId: usersID,
MaxSelect: 1,
Required: true,
},
&core.TextField{Name: "name", Required: true},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
topics.AddIndex("idx_topics_owner_name", true, "owner, name", "")
if err := app.Save(topics); err != nil {
return nil, fmt.Errorf("failed to create topics: %w", err)
}
return topics, nil
}
const (
// structureContentsMaxSize — потолок разбитой на реплики расшифровки. Число
// с запасом: шестичасовой разговор даёт порядка мегабайта текста с временем.
structureContentsMaxSize = 16 << 20
// recognitionPayloadMaxSize — потолок сохранённого ответа провайдера. Он
// многословнее самой расшифровки: несёт альтернативы, время каждого слова и
// разбор говорящих.
recognitionPayloadMaxSize = 256 << 20
)
// Поля объявляются россыпью, а не помощником, который принимал бы имя доводом:
// сверка перечня колонок со схемой читает литерал `Name:` в шагах, и имя,
// спрятанное за вызовом, она не видит — колонка выпала бы из-под правила молча.
// down202608140002 снимает новые коллекции. Прежнюю коллекцию задач он не
// восстанавливает: данных под ней не было, а пустая копия прежней схемы была бы
// вторым домом для понятия, которого больше нет.
func down202608140002(app core.App) error {
// Порядок обратный порядку заведения: приложения ссылаются на запись.
order := []string{
RecordEventsCollection,
RecognitionsCollection,
StructuresCollection,
TextsCollection,
RecordsCollection,
TopicsCollection,
}
for _, name := range order {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
continue
}
if err := app.Delete(collection); err != nil {
return fmt.Errorf("failed to drop %s: %w", name, err)
}
}
return nil
}
@@ -0,0 +1,77 @@
package migrations
import (
"errors"
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// up202608140003 запрещает пустого владельца у аудиозаписи и у её файла.
//
// Прежде пустое значение допускалось, и цену за это платили записи, принятые
// ботом: связи чата Telegram с учётной записью сервис не вёл, и владельца у них
// не было вовсе. Вход Telegram убран, заводить ничью запись стало некому, и
// обязательность переезжает из приёма в схему — туда, где её держит хранилище, а
// не договорённость. Разница не косметическая: пока обязательность жила в
// приёме, ничью запись заводили руками в панели, она уходила в конвейер, стоила
// денег на распознавание и не доставалась потом никому.
//
// Существующих строк шаг **не смотрит**, и это проверено прогоном: хранилище
// держит обязательность связи проверкой записи при сохранении, а не ограничением
// таблицы, поэтому смена признака на базе с ничьей записью проходит зелёным и
// такую запись оставляет. Искать ничьи строки надо до выкладки и запросом —
// `SELECT count(*) FROM audio_records WHERE owner = ”` и то же по `files`;
// прогон самого шага на копии этого не показывает.
//
// Оставленная ничья запись становится незакрываемой: захват идёт сырым запросом
// мимо проверки и выдаёт её воркеру, а всякое сохранение — включая то, которым
// ставится признак остановки, — отказывает. Порядок выкладки поэтому начинается
// с проверки данных, а не с прогона шага.
func up202608140003(app core.App) error {
for _, name := range []string{RecordsCollection, FilesCollection} {
if err := setOwnerRequired(app, name, true); err != nil {
return err
}
}
return nil
}
// down202608140003 возвращает колонке необязательность. Записей это не касается:
// пустых значений среди них нет, а появиться им теперь неоткуда.
func down202608140003(app core.App) error {
for _, name := range []string{RecordsCollection, FilesCollection} {
if err := setOwnerRequired(app, name, false); err != nil {
return err
}
}
return nil
}
// setOwnerRequired правит признак обязательности у колонки владельца одной
// коллекции. Колонка ищется по имени и приводится к типу связи: шаг, молча
// пропустивший чужой тип, оставил бы схему в состоянии, о котором никто не
// узнает.
func setOwnerRequired(app core.App, collectionName string, required bool) error {
collection, err := app.FindCollectionByNameOrId(collectionName)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", collectionName, err)
}
field := collection.Fields.GetByName("owner")
if field == nil {
return errors.New("collection " + collectionName + " has no owner field")
}
relation, ok := field.(*core.RelationField)
if !ok {
return errors.New("owner field of collection " + collectionName + " is not a relation")
}
relation.Required = required
if err := app.Save(collection); err != nil {
return fmt.Errorf("failed to change owner requirement in %s: %w", collectionName, err)
}
return nil
}
@@ -23,7 +23,23 @@ import (
// меняются только новым шагом схемы. // меняются только новым шагом схемы.
const ( const (
FilesCollection = "files" FilesCollection = "files"
JobsCollection = "transcribe_jobs" // JobsCollection — прежняя коллекция задач. Шаг 202608140002 её удаляет;
// имя остаётся здесь, потому что на него ссылаются прежние шаги схемы, а
// применённый шаг не переписывается.
JobsCollection = "transcribe_jobs"
// RecordsCollection — аудиозапись, центральная сущность сервиса. Имя в
// snake_case, как у соседей по схеме: одно исключение разошлось бы молча по
// константе имён, запросу захвата, правилам панели и запрету удаления.
RecordsCollection = "audio_records"
TextsCollection = "texts"
StructuresCollection = "structures"
RecognitionsCollection = "recognitions"
RecordEventsCollection = "record_events"
TopicsCollection = "topics"
// UsersCollection заводит не наш шаг, а системный шаг библиотеки. Имя стоит
// здесь потому, что на него ссылаются и шаги схемы, и проверка предъявителя
// на приёме: строковый литерал в двух местах разошёлся бы молча.
UsersCollection = "users"
) )
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его // Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
@@ -31,6 +47,9 @@ const (
func init() { func init() {
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go") pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go") pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go")
pbmigrations.Register(up202608140003, down202608140003, "202608140003_owner_required.go")
} }
func ptr[T any](v T) *T { return &v } func ptr[T any](v T) *T { return &v }
@@ -0,0 +1,93 @@
package pocketbase
import (
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// GuardOwnerDeletion отвергает удаление учётной записи, у которой остались
// аудиозаписи, их файлы либо темы её словаря.
//
// Колонка владельца — связь с выключенным каскадным удалением, и одного этого
// мало: при выключенном каскаде хранилище не удаляет ссылающуюся запись, а
// **вынимает** идентификатор из поля связи и сохраняет её без проверок. Задачи
// остались бы на месте, но стали бы ничьими, а ничья задача не достаётся по API
// никому — архив человека исчез бы молча и восстановлению не подлежал:
// прежнего владельца не остаётся нигде.
//
// Цена запрета названа прямо: владелец панели упирается в отказ, а способа
// удалить записи в сервисе пока нет вовсе — его приносит отдельная задача. До
// неё удаление учётной записи с записями невозможно, и это осознанный тупик.
//
// Слой стоит на удалении записи, а не на запросе к панели: панель ходит правами
// суперпользователя, и правило коллекции её не судит. Удаление при этом не
// только панельное — умолчание библиотеки разрешает вошедшему удалить свою
// учётную запись запросом, так что страж закрывает и публичную поверхность.
//
// Считаются **все** коллекции с владельцем, и перечень их живёт одним списком
// ниже. Файл переживает свою запись: шаг конвейера заводит его до сохранения, и
// потерянный захват оставляет файл с владельцем и без ссылки. Учётная запись, у
// которой остались одни такие файлы, без этого счёта удалялась бы штатно, а
// аудио становилось бы ничьим.
func GuardOwnerDeletion(app core.App) {
app.OnRecordDelete(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
count, err := countOwned(e.App, e.Record.Id)
if err != nil {
return err
}
if count > 0 {
// Отказ отдаётся ошибкой роутера, а не обычной: библиотека пропускает
// наружу только `*router.ApiError`, а всякую другую подменяет своим
// сообщением — «убедитесь, что запись не участвует в обязательной
// связи». Подсказка эта не просто бесполезная, а **ведущая**:
// единственная обязательная связь у задачи — файл, и владелец панели,
// поверив ей, пойдёт удалять задачи и файлы руками. То есть сделает
// ровно то необратимое, ради предотвращения чего страж и заведён.
//
// Число в отказе — не содержимое записей, а их счёт: он говорит
// владельцу панели, почему удаление не прошло, и не выносит наружу
// ничего о самих записях.
return router.NewBadRequestError(fmt.Sprintf(
"у учётной записи остались записи (%d): сервис — архив, и удаление сделало бы их ничьими",
count,
), nil)
}
return e.Next()
})
}
// ownedCollections — коллекции с колонкой владельца. Перечень живёт здесь одним
// списком, и разойтись с шагом схемы ему нельзя: пропущенная коллекция
// пропускает удаление вперёд, а наружу приезжает не наш отказ с причиной, а
// подсказка библиотеки про обязательную связь — та самая, по которой владелец
// панели пойдёт удалять записи руками.
//
// Так уже случилось однажды: `topics` завелась третьей и в списке не появилась.
var ownedCollections = []string{
migrations.RecordsCollection,
migrations.FilesCollection,
migrations.TopicsCollection,
}
// countOwned считает всё, что принадлежит учётной записи, — по всем коллекциям
// с колонкой владельца.
func countOwned(app core.App, ownerID string) (int64, error) {
var total int64
for _, collection := range ownedCollections {
count, err := app.CountRecords(collection, dbx.HashExp{"owner": ownerID})
if err != nil {
return 0, fmt.Errorf("failed to count owned records in %s: %w", collection, err)
}
total += count
}
return total, nil
}
@@ -0,0 +1,167 @@
package pocketbase
import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Страж удаления учётной записи — единственное, что стоит между владельцем
// панели и молчаливым обезличиванием чужого архива: при выключенном каскаде
// хранилище снимает ссылку и сохраняет запись без проверок.
func newAccount(t *testing.T, app core.App) *core.Record {
t.Helper()
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
require.NoError(t, err)
record := core.NewRecord(users)
record.Set("email", uuid.NewString()+"@example.test")
record.Set("verified", true)
record.Set("password", uuid.NewString())
require.NoError(t, app.Save(record))
return record
}
// newRecordOf заводит аудиозапись названного владельца.
func newRecordOf(t *testing.T, app core.App, ownerID string) *entity.AudioRecord {
t.Helper()
record := &entity.AudioRecord{
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
Source: entity.SourceApi,
OwnerID: ownerID,
}
require.NoError(t, NewAudioRecordRepository(app).Create(record))
return record
}
// Учётная запись с архивом не удаляется, и отказ называет причину — иначе
// наружу приезжает подсказка библиотеки про обязательную связь, по которой
// владелец панели пойдёт удалять записи руками.
func TestGuardOwnerDeletion(t *testing.T) {
app := newTestStorage(t)
account := newAccount(t, app)
record := newRecordOf(t, app, account.Id)
err := app.Delete(account)
require.Error(t, err, "учётная запись с архивом не удаляется")
assert.Contains(t, err.Error(), "остались записи", "отказ называет причину")
after, err := NewAudioRecordRepository(app).Get(record.Id)
require.NoError(t, err, "запись на месте")
assert.Equal(t, account.Id, after.OwnerID, "и владелец у неё прежний")
}
// Считаются все коллекции с владельцем, а не одни записи: файл переживает свою
// запись, а тема живёт в словаре человека.
func TestGuardOwnerDeletionCountsEveryOwnedCollection(t *testing.T) {
cases := map[string]func(t *testing.T, app core.App, ownerID string){
"аудиозапись": func(t *testing.T, app core.App, ownerID string) {
newRecordOf(t, app, ownerID)
},
"один файл без записи": func(t *testing.T, app core.App, ownerID string) {
repo := NewFileRepository(app)
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
_, err = repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, ownerID)
require.NoError(t, err)
},
"одна тема словаря": func(t *testing.T, app core.App, ownerID string) {
topics, err := app.FindCollectionByNameOrId(migrations.TopicsCollection)
require.NoError(t, err)
topic := core.NewRecord(topics)
topic.Set("owner", ownerID)
topic.Set("name", "личная тема")
require.NoError(t, app.Save(topic))
},
}
for name, own := range cases {
t.Run(name, func(t *testing.T) {
app := newTestStorage(t)
account := newAccount(t, app)
own(t, app, account.Id)
err := app.Delete(account)
require.Error(t, err, "учётная запись с этим добром не удаляется")
assert.Contains(t, err.Error(), "остались записи",
"отказ наш, а не подсказка библиотеки про обязательную связь")
})
}
}
// Учётная запись, за которой ничего не числится, удаляется штатно: страж
// заведён против потери архива, а не против удаления вообще.
func TestGuardOwnerDeletionLetsEmptyAccountGo(t *testing.T) {
app := newTestStorage(t)
account := newAccount(t, app)
require.NoError(t, app.Delete(account), "пустая учётная запись удаляется")
}
// Колонка владельца пустого значения не принимает и умолчания не имеет:
// ничьей записи в хранилище не бывает, и завести её нечем — ни приёмом, ни
// конвейером, ни рукой в панели.
func TestOwnerColumnRefusesEmptyValue(t *testing.T) {
app := newTestStorage(t)
for _, name := range []string{migrations.RecordsCollection, migrations.FilesCollection} {
t.Run(name, func(t *testing.T) {
collection, err := app.FindCollectionByNameOrId(name)
require.NoError(t, err)
field := collection.Fields.GetByName("owner")
require.NotNil(t, field, "колонка владельца заведена")
relation, ok := field.(*core.RelationField)
require.True(t, ok, "владелец — связь с учётной записью, а не строка")
assert.True(t, relation.Required, "пустое значение колонка не принимает")
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом")
})
}
}
// Та же норма со стороны сохранения: схема отвергает запись без владельца, а не
// только объявляет колонку обязательной.
func TestStorageRefusesRecordWithoutOwner(t *testing.T) {
app := newTestStorage(t)
record := &entity.AudioRecord{
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
Source: entity.SourceApi,
}
require.Error(t, NewAudioRecordRepository(app).Create(record),
"ничья запись в хранилище не ложится")
}
// И файл — наравне с записью: разное правило у них читалось бы как недосмотр.
func TestStorageRefusesFileWithoutOwner(t *testing.T) {
app := newTestStorage(t)
repo := NewFileRepository(app)
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
_, err = repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, "")
require.Error(t, err, "ничей файл в хранилище не ложится")
}
+66 -18
View File
@@ -4,35 +4,83 @@ import (
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/entity"
) )
// BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что // BindPanelRules подчиняет правку записи в панели тем же правилам, что и правку
// и правку из кода. // из кода.
// //
// Панель — вход в задачу наравне с конвейером, а не окно просмотра: ради правки // Панель — вход в запись наравне с конвейером, а не окно просмотра: ради правки
// она и покупалась, мёртвая задача оживляется сменой состояния. Но правка полем // она и покупалась, остановленная запись возвращается в работу снятием признака.
// идёт мимо кода, который чистит служебные поля прошлого состояния, и владелец, // Но правка полем идёт мимо кода, который чистит служебные поля, и владелец,
// «вернувший задачу в работу», получил бы задачу с прежним признаком захвата // «вернувший запись в работу», получил бы запись с прежним признаком захвата
// (захвату она не выдастся до конца срока) и с числом попыток на пределе (умрёт // (захвату она не выдастся до конца срока), с числом отказов на пределе
// от первого же отказа). Узнать об этом ему неоткуда. // (остановится от первого же отказа) и со старым временем входа в рубеж
// (остановится снова первым же захватом по пределу простоя). Узнать об этом ему
// неоткуда.
//
// Правило живёт **одним местом** — доменными `Resume` и `MoveToState`, — и хук
// зовёт именно их, а не повторяет перечень служебных полей колонками. Повтор
// перечня был бы вторым домом того же правила: новый сторож попал бы в домен и
// не попал в панель, и владелец «вернул бы запись в работу», а она снова выпала
// бы из выборки — молча.
// //
// Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное // Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное
// событие не различает, кто пишет, и срабатывало бы на каждом переходе // событие не различает, кто пишет, и срабатывало бы на каждом переходе
// конвейера: тогда задержка, поставленная шагом вместе со сменой состояния, // конвейера: тогда пауза, поставленная шагом вместе со сменой рубежа, стиралась
// стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое // бы тем же сохранением, а число отказов остановленной записи — которое
// переход хранит намеренно — приходило бы владельцу нулём. // остановка хранит намеренно — приходило бы владельцу нулём.
func BindPanelRules(app core.App) { func BindPanelRules(app core.App) {
app.OnRecordUpdateRequest(migrations.JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error { app.OnRecordUpdateRequest(migrations.RecordsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
original := e.Record.Original() original := e.Record.Original()
if original == nil || original.GetString("state") == e.Record.GetString("state") { if original == nil {
return e.Next() return e.Next()
} }
e.Record.Set("acquisition_id", "") stateChanged := original.GetString("state") != e.Record.GetString("state")
e.Record.Set("acquire_time", "") // Снятие признака остановки — то самое движение, ради которого признак и
e.Record.Set("delay_time", "") // заведён: запись возвращается в работу с сохранённого рубежа.
e.Record.Set("attempts", 0) resumed := !original.GetDateTime("halted_at").IsZero() &&
e.Record.GetDateTime("halted_at").IsZero()
return e.Next() if !stateChanged && !resumed {
return e.Next()
}
// Запись читается уже с правкой человека: рубеж здесь тот, который он
// выбрал, а признак остановки — тот, который он снял или оставил.
record := recordToAudioRecord(e.Record)
switch {
case resumed:
record.Resume()
default:
record.MoveToState(record.State)
}
applyOwnedByPipeline(e.Record, record)
if err := e.Next(); err != nil {
return err
}
if resumed {
// Перезапуск виден в журнале событий с указанием, что его сделал
// человек: иначе запись, вернувшаяся в работу, выглядела бы как
// запись, которая туда и не уходила.
//
// Строка пишется **после** сохранения: событие о правке, которая не
// прошла, соврало бы о состоянии записи. Отказ записи журнала саму
// правку не отменяет — журнал никем не читается ради решения.
event := &entity.RecordEvent{
RecordID: e.Record.Id,
Origin: entity.EventOriginHuman,
Step: "resume",
Outcome: entity.EventOutcomeResumed,
}
if err := NewRecordEventRepository(e.App).Append(event); err != nil {
e.App.Logger().Error("Failed to log record resume", "error", err, "record_id", e.Record.Id)
}
}
return nil
}) })
} }
@@ -0,0 +1,240 @@
package pocketbase
import (
"testing"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Панель — единственный сегодня путь вернуть остановленную запись в работу, и
// хук правил стоит на правке **запросом**. Модельное сохранение его не трогает,
// поэтому проверки ниже идут через запрос — иначе они зеленели бы, не касаясь
// того пути, которым владелец и ходит.
// newPanelStorage поднимает хранилище с повешенными правилами панели — так же,
// как это делает сборка сервиса. Без них проверки судили бы хранилище без
// правил, то есть не то, что работает в проде.
func newPanelStorage(t *testing.T) core.App {
t.Helper()
app := newTestStorage(t)
BindPanelRules(app)
return app
}
// updateByRequest правит запись так, как это делает панель: запросом, а не
// сохранением модели.
func updateByRequest(t *testing.T, app core.App, recordID string, body map[string]any) *core.Record {
t.Helper()
record, err := app.FindRecordById(migrations.RecordsCollection, recordID)
require.NoError(t, err)
// Запись, прочитанная из хранилища, помнит прежние значения сама — по ним
// хук и отличает смену рубежа от правки соседнего поля.
for key, value := range body {
record.Set(key, value)
}
// Событие правки запросом несёт и запрос, и коллекцию: `RequestEvent` вложен
// указателем, а по коллекции хук и отбирается — без неё он не сработает вовсе,
// и проверка зеленела бы, не коснувшись правила.
collection, err := app.FindCollectionByNameOrId(migrations.RecordsCollection)
require.NoError(t, err)
event := &core.RecordRequestEvent{RequestEvent: &core.RequestEvent{}}
event.App = app
event.Collection = collection
event.Record = record
require.NoError(t, app.OnRecordUpdateRequest(migrations.RecordsCollection).Trigger(event, func(e *core.RecordRequestEvent) error {
return e.App.Save(e.Record)
}))
after, err := app.FindRecordById(migrations.RecordsCollection, recordID)
require.NoError(t, err)
return after
}
// haltedRecord заводит остановленную запись со всеми накопленными сторожами —
// такой её видит владелец, открывая панель.
func haltedRecord(t *testing.T, app core.App) *entity.AudioRecord {
t.Helper()
record := newRecordOf(t, app, newAccount(t, app).Id)
record.MoveToState(entity.StateNormalized)
record.Attempts = 4
record.AcquisitionID = ptrOf("прежний-захват")
record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
record.DelayTime = ptrOf(clock.Now().Add(time.Hour))
record.Halt(entity.HaltReasonStepFailed, "сбой конвертации файла")
// Время входа в рубеж отодвигаем: запись простояла остановленной дольше
// предела простоя, и это ровно тот случай, ради которого сторож сбрасывается.
record.StateEnteredAt = clock.Now().Add(-24 * time.Hour)
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
return record
}
func ptrOf[T any](v T) *T { return &v } //nolint:newexpr // значение вычисляется, new(x) его не примет
// Снятие признака остановки возвращает запись в работу с сохранённого рубежа и
// сбрасывает **всех** сторожей. Без сброса времени входа в рубеж запись,
// простоявшая остановленной дольше предела, остановилась бы снова первым же
// захватом — и владелец не узнал бы об этом.
func TestPanelResumeClearsEveryGuard(t *testing.T) {
app := newPanelStorage(t)
record := haltedRecord(t, app)
after := updateByRequest(t, app, record.Id, map[string]any{"halted_at": ""})
assert.Equal(t, entity.StateNormalized, after.GetString("state"), "рубеж сохранён")
assert.True(t, after.GetDateTime("halted_at").IsZero(), "признак остановки снят")
assert.Empty(t, after.GetString("halt_reason"), "причина снята вместе с ним")
assert.Empty(t, after.GetString("error_text"), "и текст отказа")
assert.Empty(t, after.GetString("acquisition_id"), "признак прежнего захвата очищен")
assert.True(t, after.GetDateTime("acquire_expires_at").IsZero(), "срок протухания тоже")
assert.True(t, after.GetDateTime("delay_time").IsZero(), "пауза снята")
assert.Equal(t, 0, after.GetInt("attempts"), "отказы сброшены")
entered := after.GetDateTime("state_entered_at").Time()
assert.WithinDuration(t, clock.Now(), entered, time.Minute,
"время входа в рубеж поставлено заново: иначе сторож простоя остановит запись снова")
// И ближайший захват её выдаёт — то есть перезапуск действительно работает.
acquired, err := NewAudioRecordRepository(app).FindAndAcquire(entity.WorkingStages())
require.NoError(t, err, "запись вернулась в выборку")
assert.Equal(t, record.Id, acquired.ID)
}
// Перезапуск виден в журнале событий с указанием, что его сделал человек: иначе
// запись, вернувшаяся в работу, выглядела бы как запись, которая туда и не
// уходила.
func TestPanelResumeIsLogged(t *testing.T) {
app := newPanelStorage(t)
record := haltedRecord(t, app)
updateByRequest(t, app, record.Id, map[string]any{"halted_at": ""})
events, err := app.FindAllRecords(migrations.RecordEventsCollection)
require.NoError(t, err)
var human int
for _, event := range events {
if event.GetString("record") == record.Id && event.GetString("origin") == entity.EventOriginHuman {
human++
assert.Equal(t, entity.EventOutcomeResumed, event.GetString("outcome"))
}
}
assert.Equal(t, 1, human, "ровно одна строка о перезапуске человеком")
}
// Правка рубежа руками чистит служебные поля прошлого захвата так же, как
// снятие остановки: иначе владелец, «вернувший запись в работу» сменой рубежа,
// получит запись, которая не выдаётся захвату до конца прежнего срока.
func TestPanelStateEditClearsGuards(t *testing.T) {
app := newPanelStorage(t)
record := newRecordOf(t, app, newAccount(t, app).Id)
record.Attempts = 4
record.AcquisitionID = ptrOf("прежний-захват")
record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
after := updateByRequest(t, app, record.Id, map[string]any{"state": entity.StateNormalized})
assert.Equal(t, entity.StateNormalized, after.GetString("state"))
assert.Empty(t, after.GetString("acquisition_id"))
assert.Equal(t, 0, after.GetInt("attempts"))
}
// Правка соседнего поля служебных полей не трогает: хук судит смену рубежа и
// снятие остановки, а не всякое сохранение. Иначе владелец, поправивший
// заголовок, снял бы захват у работающего шага.
func TestPanelKeepsGuardsOnUnrelatedEdit(t *testing.T) {
app := newPanelStorage(t)
record := newRecordOf(t, app, newAccount(t, app).Id)
record.Attempts = 3
record.AcquisitionID = ptrOf("живой-захват")
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
after := updateByRequest(t, app, record.Id, map[string]any{"title": "Разговор с бабушкой"})
assert.Equal(t, "Разговор с бабушкой", after.GetString("title"))
assert.Equal(t, "живой-захват", after.GetString("acquisition_id"), "захват работающего шага не снят")
assert.Equal(t, 3, after.GetInt("attempts"), "отказы не сброшены")
}
// Захват отдаёт идентификатор и признак **этого** захвата, а срок протухания
// приезжает с рубежом: воркер не привязан к шагу и вывести срок из себя не
// может.
func TestAcquireCarriesStageDeadline(t *testing.T) {
app := newTestStorage(t)
repo := NewAudioRecordRepository(app)
record := newRecordOf(t, app, newAccount(t, app).Id)
acquired, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
require.Equal(t, record.Id, acquired.ID)
require.NotEmpty(t, acquired.Holder)
stored, err := app.FindRecordById(migrations.RecordsCollection, record.Id)
require.NoError(t, err)
assert.Equal(t, acquired.Holder, stored.GetString("acquisition_id"))
stage, ok := entity.StageByName(entity.StateUploaded)
require.True(t, ok)
expected := clock.Now().Add(stage.AcquireTimeout)
assert.WithinDuration(t, expected, stored.GetDateTime("acquire_expires_at").Time(), time.Minute,
"срок протухания приехал с рубежа записи")
}
// Одна запись достаётся ровно одному захвату: на этом стоит инвариант «Принятая
// запись не теряется молча».
func TestAcquireHandsRecordToExactlyOne(t *testing.T) {
app := newTestStorage(t)
repo := NewAudioRecordRepository(app)
newRecordOf(t, app, newAccount(t, app).Id)
first, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err, "первому запись досталась")
require.NotEmpty(t, first.Holder)
for range 2 {
_, err = repo.FindAndAcquire(entity.WorkingStages())
require.Error(t, err, "остальным — признак «работы нет»")
}
}
// Протухший захват возвращает запись в работу, и признак нового захвата
// отличается от прежнего: условие записи результата сверяет именно значение.
func TestRottenAcquisitionIsHandedOutAgain(t *testing.T) {
app := newTestStorage(t)
repo := NewAudioRecordRepository(app)
record := newRecordOf(t, app, newAccount(t, app).Id)
first, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
stored, err := app.FindRecordById(migrations.RecordsCollection, record.Id)
require.NoError(t, err)
stored.Set("acquire_expires_at", types.NowDateTime().Add(-time.Hour))
require.NoError(t, app.Save(stored))
second, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err, "протухший захват не мешает выдать запись следующему")
assert.Equal(t, record.Id, second.ID)
assert.NotEqual(t, first.Holder, second.Holder, "признак нового захвата отличается от прежнего")
}
@@ -0,0 +1,161 @@
package pocketbase
import (
"errors"
"fmt"
"io"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/filesystem"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
type RecognitionRepository struct {
app core.App
}
func NewRecognitionRepository(app core.App) *RecognitionRepository {
return &RecognitionRepository{app: app}
}
// Create заводит строку попытки **до** обращения к провайдеру.
//
// Порядок здесь несущий: окно между ответом провайдера и записью идентификатора
// операции — то место, где теряется оплаченное. Заведённая заранее строка даёт
// повторному шагу, чем проверить сделанное прежде, чем платить второй раз.
func (repo *RecognitionRepository) Create(r *entity.Recognition) error {
collection, err := findCollection(repo.app, migrations.RecognitionsCollection)
if err != nil {
return err
}
started := clock.Now()
record := core.NewRecord(collection)
record.Set("record", r.RecordID)
record.Set("provider", r.Provider)
record.Set("model", r.Model)
record.Set("external_id", r.ExternalID)
record.Set("source_uri", r.SourceURI)
record.Set("started_at", dateOrEmpty(&started))
if err := repo.app.Save(record); err != nil {
return fmt.Errorf("failed to create recognition attempt for record %s: %w", r.RecordID, err)
}
r.Id = record.Id
r.StartedAt = &started
return nil
}
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
// последнему повторный шаг узнаёт, что за эту запись уже заплачено, и второй раз
// наружу не платит.
func (repo *RecognitionRepository) Submitted(id, sourceURI, externalID string) error {
record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id)
if err != nil {
return fmt.Errorf("failed to find recognition attempt %s: %w", id, err)
}
record.Set("source_uri", sourceURI)
record.Set("external_id", externalID)
if err := repo.app.Save(record); err != nil {
return fmt.Errorf("failed to store operation id of attempt %s: %w", id, err)
}
return nil
}
// Finish кладёт сырой ответ провайдера вложением и отмечает завершение.
//
// Вложением, а не колонкой: шаг опроса читает эту строку раз в несколько секунд,
// а хранилище читает запись целиком — ответ на многочасовую запись ехал бы в
// память при каждом опросе. Хранится он потому, что результат операции у
// провайдера не переспрашивается.
func (repo *RecognitionRepository) Finish(id string, raw []byte) error {
record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id)
if err != nil {
return fmt.Errorf("failed to find recognition attempt %s: %w", id, err)
}
if len(raw) > 0 {
// Имя вложения задаём мы: умолчание хранилища строит его из имени
// исходного файла, а имя, данное отправителем, в хранилище не попадает.
payload, err := filesystem.NewFileFromBytes(raw, id+".payload")
if err != nil {
return fmt.Errorf("failed to prepare provider payload of attempt %s", id)
}
record.Set("payload", payload)
}
finished := clock.Now()
record.Set("finished_at", dateOrEmpty(&finished))
if err := repo.app.Save(record); err != nil {
// Отказ хранилища несёт имя файла вложения целиком, а оно — последняя
// часть ссылки: цепочка `%w` уехала бы в журнал вместе с ним.
return fmt.Errorf("failed to store provider payload of attempt %s", id)
}
return nil
}
func (repo *RecognitionRepository) GetByID(id string) (*entity.Recognition, error) {
record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
}
return &entity.Recognition{
Id: record.Id,
RecordID: record.GetString("record"),
Provider: record.GetString("provider"),
Model: record.GetString("model"),
ExternalID: record.GetString("external_id"),
SourceURI: record.GetString("source_uri"),
StartedAt: timeOrNil(record.GetDateTime("started_at")),
FinishedAt: timeOrNil(record.GetDateTime("finished_at")),
}, nil
}
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда ответ
// нужен: шаг опроса читает строку попытки без него.
func (repo *RecognitionRepository) ReadRaw(id string) ([]byte, error) {
record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to find recognition attempt %s: %w", id, err)
}
names := record.GetStringSlice("payload")
if len(names) == 0 {
return nil, fmt.Errorf("recognition attempt %s has no stored payload", id)
}
fsys, err := repo.app.NewFilesystem()
if err != nil {
return nil, fmt.Errorf("failed to open storage filesystem: %w", err)
}
reader, err := fsys.GetReader(record.BaseFilesPath() + "/" + names[0])
if err != nil {
// Отказ хранилища несёт имя вложения целиком, а имя — последняя часть
// ссылки на скачивание: наружу идёт идентификатор попытки, и только он.
return nil, errors.Join(
fmt.Errorf("failed to read stored payload of attempt %s", id),
fsys.Close(),
)
}
raw, readErr := io.ReadAll(reader)
closeErr := errors.Join(reader.Close(), fsys.Close())
if readErr != nil {
return nil, errors.Join(
fmt.Errorf("failed to read stored payload of attempt %s", id),
closeErr,
)
}
if closeErr != nil {
return nil, closeErr
}
return raw, nil
}
@@ -0,0 +1,50 @@
package pocketbase
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
type RecordEventRepository struct {
app core.App
}
func NewRecordEventRepository(app core.App) *RecordEventRepository {
return &RecordEventRepository{app: app}
}
// Append пишет строку журнала событий записи.
//
// Журнал пишется на смену рубежа, на остановку и на снятие остановки, а не на
// каждое откладывание опроса: часовая запись дала бы сотни строк ни о чём. Ни
// один шаг конвейера его не читает, чтобы решить, что делать дальше: решение
// принимается по рубежу записи, и второй источник решения разошёлся бы с первым
// молча.
//
// Содержимое записи сюда не попадает — инвариант приватности действует здесь
// наравне с журналом сервиса.
func (repo *RecordEventRepository) Append(event *entity.RecordEvent) error {
collection, err := findCollection(repo.app, migrations.RecordEventsCollection)
if err != nil {
return err
}
record := core.NewRecord(collection)
record.Set("record", event.RecordID)
record.Set("origin", event.Origin)
record.Set("step", event.Step)
record.Set("outcome", event.Outcome)
record.Set("outcome_text", event.OutcomeText)
record.Set("duration_ms", event.DurationMs)
if err := repo.app.Save(record); err != nil {
return fmt.Errorf("failed to append event of record %s: %w", event.RecordID, err)
}
event.Id = record.Id
return nil
}
@@ -0,0 +1,119 @@
package pocketbase
import (
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Отображение аудиозаписи в запись коллекции и обратно живёт одним местом.
//
// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет
// колонки поимённо, а возвращает идентификатор и признак своего захвата.
// Инвариант проекта о колонках очереди этим съёживается и перестаёт расти с
// моделью — иначе каждая новая колонка записи попадала бы под него.
// applyOwnedByPipeline кладёт в запись только те поля, которыми распоряжается
// конвейер. Поля, которые он не меняет никогда — владелец, вход, заголовок,
// краткое описание, темы и адресат ответа, — не трогаются вовсе.
//
// Разрез нужен потому, что шаг держит запись снимком с момента захвата и до
// своего сохранения, а это часы. Всё, что владелец правил в панели за это время,
// безусловная запись снимка стёрла бы молча: ни строки в журнале, ни отказа в
// панели — владелец видел бы успешное сохранение и был бы уверен, что правка на
// месте.
func applyOwnedByPipeline(record *core.Record, r *entity.AudioRecord) {
record.Set("state", r.State)
record.Set("state_entered_at", dateOrEmpty(&r.StateEnteredAt))
record.Set("halted_at", dateOrEmpty(r.HaltedAt))
record.Set("halt_reason", derefString(r.HaltReason))
record.Set("error_text", derefString(r.ErrorText))
record.Set("acquisition_id", derefString(r.AcquisitionID))
record.Set("acquire_expires_at", dateOrEmpty(r.AcquireExpiresAt))
record.Set("delay_time", dateOrEmpty(r.DelayTime))
record.Set("attempts", r.Attempts)
record.Set("original_file", derefString(r.OriginalFileID))
record.Set("normalized_file", derefString(r.NormalizedFileID))
record.Set("transcript_text", derefString(r.TranscriptTextID))
record.Set("literary_text", derefString(r.LiteraryTextID))
record.Set("structure", derefString(r.StructureID))
record.Set("recognition", derefString(r.RecognitionID))
}
// applyToRecord кладёт запись целиком — это заведение, и спорить за поля здесь
// не с кем.
func applyToRecord(record *core.Record, r *entity.AudioRecord) {
applyOwnedByPipeline(record, r)
// Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага,
// записанный поверх, стёр бы его молча.
record.Set("owner", r.OwnerID)
record.Set("source", r.Source)
record.Set("title", derefString(r.Title))
record.Set("brief", derefString(r.Brief))
}
func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
return &entity.AudioRecord{
Id: record.Id,
OwnerID: record.GetString("owner"),
Source: record.GetString("source"),
Title: nilIfEmpty(record.GetString("title")),
Brief: nilIfEmpty(record.GetString("brief")),
State: record.GetString("state"),
StateEnteredAt: record.GetDateTime("state_entered_at").Time(),
HaltedAt: timeOrNil(record.GetDateTime("halted_at")),
HaltReason: nilIfEmpty(record.GetString("halt_reason")),
ErrorText: nilIfEmpty(record.GetString("error_text")),
AcquisitionID: nilIfEmpty(record.GetString("acquisition_id")),
AcquireExpiresAt: timeOrNil(record.GetDateTime("acquire_expires_at")),
DelayTime: timeOrNil(record.GetDateTime("delay_time")),
Attempts: record.GetInt("attempts"),
OriginalFileID: nilIfEmpty(record.GetString("original_file")),
NormalizedFileID: nilIfEmpty(record.GetString("normalized_file")),
TranscriptTextID: nilIfEmpty(record.GetString("transcript_text")),
LiteraryTextID: nilIfEmpty(record.GetString("literary_text")),
StructureID: nilIfEmpty(record.GetString("structure")),
RecognitionID: nilIfEmpty(record.GetString("recognition")),
CreatedAt: record.GetDateTime("created").Time(),
UpdatedAt: record.GetDateTime("updated").Time(),
}
}
func derefString(v *string) string {
if v == nil {
return ""
}
return *v
}
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
// хранилище это пустая строка, и она же значит «времени нет».
func dateOrEmpty(v *time.Time) any {
if v == nil || v.IsZero() {
return ""
}
date, err := types.ParseDateTime(*v)
if err != nil {
return ""
}
return date
}
func nilIfEmpty(v string) *string {
if v == "" {
return nil
}
return &v
}
func timeOrNil(v types.DateTime) *time.Time {
if v.IsZero() {
return nil
}
t := v.Time()
return &t
}
@@ -0,0 +1,236 @@
package pocketbase
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/clock"
)
type AudioRecordRepository struct {
app core.App
}
func NewAudioRecordRepository(app core.App) *AudioRecordRepository {
return &AudioRecordRepository{app: app}
}
func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error {
collection, err := findCollection(repo.app, migrations.RecordsCollection)
if err != nil {
return err
}
record := core.NewRecord(collection)
if r.Id != "" {
record.Id = r.Id
}
applyToRecord(record, r)
if err := repo.app.Save(record); err != nil {
return fmt.Errorf("failed to insert audio record: %w", err)
}
r.Id = record.Id
r.CreatedAt = record.GetDateTime("created").Time()
r.UpdatedAt = record.GetDateTime("updated").Time()
return nil
}
// Save сохраняет запись, захват которой держит holder. Проверка и запись идут
// одной транзакцией: шаг, потерявший запись за время работы, получает
// LostAcquisitionError и результата не пишет.
//
// Сверяется **значение** признака захвата, а не занятость записи. Захват,
// перевыданный другому — по протуханию срока или после того, как человек снял
// признак остановки в панели, — обязан обратить запись первого в отказ; условие
// по непустоте признака пропустило бы обоих, и два шага записали бы в одну
// запись по очереди, портя её результат.
func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error {
return repo.app.RunInTransaction(func(txApp core.App) error {
record, err := txApp.FindRecordById(migrations.RecordsCollection, r.Id)
if err != nil {
return fmt.Errorf("failed to find audio record: %w", err)
}
if holder != "" && record.GetString("acquisition_id") != holder {
return &contract.LostAcquisitionError{JobID: r.Id}
}
// Кладём только то, чем распоряжается конвейер: правку владельца в
// панели снимок шага стирать не должен.
applyOwnedByPipeline(record, r)
if err := txApp.Save(record); err != nil {
return fmt.Errorf("failed to update audio record: %w", err)
}
r.UpdatedAt = record.GetDateTime("updated").Time()
return nil
})
}
// GetByID отдаёт запись, только если её владелец — ownerID.
//
// Чужая запись, запись без владельца и несуществующая дают одну и ту же ошибку:
// по разнице ответов иначе перебирается список заведённых записей, а
// идентификатор записи и есть то, что разграничение прячет.
//
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем. Правило это
// не стало избыточным с обязательностью колонки: схема запрещает **заводить**
// ничью запись, а здесь запрещено **спрашивать** ничьим именем — иначе
// вызывающий без учётной записи получил бы выборку вместо отказа.
func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) {
if ownerID == "" {
return nil, &contract.JobNotFoundError{Message: "record not found"}
}
record, err := repo.find(id)
if err != nil {
return nil, err
}
if record.GetString("owner") != ownerID {
return nil, &contract.JobNotFoundError{Message: "record not found"}
}
return recordToAudioRecord(record), nil
}
// Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья выборка
// владельцем не сужается.
func (repo *AudioRecordRepository) Get(id string) (*entity.AudioRecord, error) {
record, err := repo.find(id)
if err != nil {
return nil, err
}
return recordToAudioRecord(record), nil
}
func (repo *AudioRecordRepository) find(id string) (*core.Record, error) {
record, err := repo.app.FindRecordById(migrations.RecordsCollection, id)
if err != nil {
// «Такой записи нет» переводится в доменную ошибку **здесь**, у
// источника, как велит конвенция об ошибках. Иначе три исхода, которые
// разграничение обязано сделать неразличимыми, разъезжаются: чужая и
// ничья записи дают доменную ошибку, а несуществующая — отказ базы,
// неотличимый от настоящей аварии хранилища.
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{Message: "record not found"}
}
return nil, fmt.Errorf("failed to get audio record: %w", err)
}
return record, nil
}
// FindAndAcquire забирает пригодную к работе запись одним неделимым шагом:
// выбор подходящей и пометка её захваченной идут вместе.
//
// Возвращается **идентификатор и признак этого захвата**, а не перечень колонок.
// Колонки шаг читает обычным чтением: иначе всякая новая колонка записи попадала
// бы под инвариант проекта о колонках очереди, а забытая приезжала бы нулевой, и
// первое же сохранение писало бы этот ноль поверх сохранённого значения.
//
// Срок протухания захвата приезжает **с рубежом**, а не с воркером: воркер не
// привязан к шагу и не знает заранее, что вытянет. Перечень рубежей и их сроков
// приходит одним дескриптором — перечислять их порознь нельзя: рубеж, забытый в
// отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
// проекта не пишется в журнал и не считается в метрику.
//
// Запрос идёт сырым, мимо записей коллекции: `app.DB()` направляет всё, кроме
// выборок, в пул с единственным соединением, и захваты выстраиваются в очередь.
// Хуки коллекции на нём не срабатывают, поэтому время изменения проставляет сам
// запрос.
//
// Все времена кладутся и сравниваются тем же видом, каким хранилище пишет свои
// `created`/`updated`: сравнение строк побайтово, и вид, разошедшийся хоть
// разделителем, обратил бы условие срока в постоянную истину или постоянную
// ложь — молча.
func (repo *AudioRecordRepository) FindAndAcquire(stages []entity.Stage) (*contract.AcquiredRecord, error) {
if len(stages) == 0 {
return nil, &contract.JobNotFoundError{Message: "no working stages declared"}
}
// Метка времени берётся единой точкой, а не `types.NowDateTime()`: обёртка
// хранилища читает часы сама, и запрет линтера её не видит — новая метка в
// этом запросе обошла бы единую точку молча.
now, err := types.ParseDateTime(clock.Now())
if err != nil {
return nil, fmt.Errorf("failed to parse current time: %w", err)
}
holder := uuid.NewString()
params := dbx.Params{
"holder": holder,
"now": now.String(),
}
// Срок протухания у каждого рубежа свой, поэтому он выбирается по рубежу
// самой записи прямо в запросе: воркер, ещё не знающий, что вытянет,
// подставить его не может.
var expiry strings.Builder
expiry.WriteString("CASE state")
var states []string
for i, stage := range stages {
stateKey := fmt.Sprintf("state%d", i)
expiryKey := fmt.Sprintf("expiry%d", i)
deadline, err := types.ParseDateTime(clock.Now().Add(stage.AcquireTimeout))
if err != nil {
return nil, fmt.Errorf("failed to parse acquire deadline: %w", err)
}
fmt.Fprintf(&expiry, " WHEN {:%s} THEN {:%s}", stateKey, expiryKey)
params[stateKey] = stage.Name
params[expiryKey] = deadline.String()
states = append(states, "{:"+stateKey+"}")
}
expiry.WriteString(" END")
table := "{{" + migrations.RecordsCollection + "}}"
query := repo.app.DB().NewQuery(`
UPDATE ` + table + `
SET acquisition_id = {:holder},
acquire_expires_at = ` + expiry.String() + `,
attempts = attempts + 1,
updated = {:now}
WHERE id = (
SELECT id FROM ` + table + `
WHERE state IN (` + strings.Join(states, ", ") + `)
AND (halted_at = '' OR halted_at IS NULL)
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
AND (acquisition_id = '' OR acquisition_id IS NULL
OR acquire_expires_at = '' OR acquire_expires_at IS NULL
OR acquire_expires_at < {:now})
ORDER BY created, id
LIMIT 1
)
RETURNING id`)
query.Bind(params)
var row struct {
Id string `db:"id"`
}
if err := query.One(&row); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{Message: "no record is ready for work"}
}
return nil, fmt.Errorf("failed to acquire an audio record: %w", err)
}
return &contract.AcquiredRecord{ID: row.Id, Holder: holder}, nil
}
@@ -0,0 +1,110 @@
package pocketbase
import (
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// newTestStorage поднимает хранилище на пустом каталоге и накатывает схему —
// тем же путём, каким это делает сервис при старте.
func newTestStorage(t *testing.T) core.App {
t.Helper()
app, err := New(t.TempDir())
require.NoError(t, err)
t.Cleanup(func() {
if err := app.ResetBootstrapState(); err != nil {
t.Logf("не удалось закрыть хранилище: %v", err)
}
})
return app
}
// Критерий приёмки 10. Содержимое записи закрыто во всех коллекциях, куда оно
// переехало.
//
// Прежде содержимое лежало одной колонкой задачи, и закрывала его одна норма про
// файл записи. Теперь оно живёт в шести коллекциях, и реализация, следующая
// только прежней норме, завела бы поле вложения с умолчанием библиотеки: ссылка
// на сырой ответ провайдера — а это полный текст речи — отдавала бы его любому,
// кто её знает, без сессии.
func TestRecordContentIsClosedEverywhere(t *testing.T) {
app := newTestStorage(t)
// Правило просмотра остаётся незаданным, то есть «только владелец панели».
// Содержимое отдаёт собственный адрес сервиса, а не поверхность хранилища;
// непустое правило открыло бы перечисление коллекции впрок.
for _, name := range []string{
migrations.RecordsCollection,
migrations.TextsCollection,
migrations.StructuresCollection,
migrations.RecognitionsCollection,
migrations.RecordEventsCollection,
migrations.TopicsCollection,
} {
collection, err := app.FindCollectionByNameOrId(name)
require.NoError(t, err, "коллекция %s заведена шагом схемы", name)
assert.Nil(t, collection.ListRule, "перечисление %s закрыто", name)
assert.Nil(t, collection.ViewRule, "чтение %s закрыто", name)
assert.Nil(t, collection.CreateRule, "заведение записи в %s закрыто", name)
assert.Nil(t, collection.UpdateRule, "правка %s закрыта", name)
assert.Nil(t, collection.DeleteRule, "удаление из %s закрыто", name)
}
// А поле вложения помечено защищённым: без пометки ссылка открывает
// содержимое любому, кто её знает, и знание ссылки становится правом.
recognitions, err := app.FindCollectionByNameOrId(migrations.RecognitionsCollection)
require.NoError(t, err)
field := recognitions.Fields.GetByName("payload")
require.NotNil(t, field, "поле сохранённого ответа заведено")
file, ok := field.(*core.FileField)
require.True(t, ok, "сохранённый ответ лежит вложением, а не колонкой")
assert.True(t, file.Protected, "поле вложения защищено")
}
// Прежняя коллекция задач уходит вместе с моделью: данных под ней не было, а
// пустая копия висела бы в панели вторым домом для понятия, которого больше нет.
func TestFormerJobsCollectionIsGone(t *testing.T) {
app := newTestStorage(t)
_, err := app.FindCollectionByNameOrId(migrations.JobsCollection)
assert.Error(t, err, "прежней коллекции задач не осталось")
}
// Пара «запись и вид» уникальна: повтор прерванного шага не заводит второго
// комплекта строк, и вопрос «какой текст отдавать человеку» не становится
// вопросом порядка записи.
func TestAppendicesAreUniquePerRecord(t *testing.T) {
app := newTestStorage(t)
indexes := map[string][]string{
migrations.TextsCollection: {"idx_texts_record_kind"},
migrations.StructuresCollection: {"idx_structures_record_version"},
migrations.TopicsCollection: {"idx_topics_owner_name"},
}
for name, expected := range indexes {
collection, err := app.FindCollectionByNameOrId(name)
require.NoError(t, err)
for _, index := range expected {
var found bool
for _, declared := range collection.Indexes {
if strings.Contains(declared, index) && strings.Contains(declared, "UNIQUE") {
found = true
}
}
assert.Truef(t, found, "у %s есть уникальный индекс %s", name, index)
}
}
}
@@ -0,0 +1,170 @@
package pocketbase
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
type TextRepository struct {
app core.App
}
func NewTextRepository(app core.App) *TextRepository {
return &TextRepository{app: app}
}
// Put кладёт текст записи, заменяя прежний того же вида.
//
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
// человеку» стал бы вопросом порядка записи, а не состояния.
//
// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита
// архива. Повторный опрос той же операции — обычное дело: держатель захвата
// умер, сохранение рубежа отказало, человек снял остановку в панели. Провайдер
// при этом вправе ответить пустым потоком, отказом это не считается, и
// безусловная замена стирала бы сохранённую расшифровку живого человека без
// следа и без возврата. Та же защита стоит у сырого ответа провайдера
// (`RecognitionRepository.Finish`), и разное правило у двух хранителей одного
// результата читалось бы как недосмотр.
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
collection, err := findCollection(repo.app, migrations.TextsCollection)
if err != nil {
return nil, err
}
record, err := repo.app.FindFirstRecordByFilter(
migrations.TextsCollection,
"record = {:record} && kind = {:kind}",
dbx.Params{"record": recordID, "kind": kind},
)
switch {
case err == nil:
// Строка есть — заменяем содержимое.
case errors.Is(err, sql.ErrNoRows):
record = core.NewRecord(collection)
record.Set("record", recordID)
record.Set("kind", kind)
default:
// Отказ хранилища «строкой нет» не является, и подменять его вставкой
// нельзя: она упрётся в уникальный индекс, и наверх уедет жалоба на
// запись вместо правды о недоступной базе.
return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err)
}
// Прежнее непустое содержимое пустым не заменяется: строка остаётся как
// есть, и вызывающий получает её обратно.
if contents == "" && record.GetString("contents") != "" {
return textFromRecord(record), nil
}
record.Set("contents", contents)
if err := repo.app.Save(record); err != nil {
// Текст расшифровки наружу не выходит даже отказом: цепочка `%w` от
// хранилища несёт значение поля.
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
}
return textFromRecord(record), nil
}
func (repo *TextRepository) GetByID(id string) (*entity.Text, error) {
record, err := repo.app.FindRecordById(migrations.TextsCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get text %s: %w", id, err)
}
return textFromRecord(record), nil
}
func textFromRecord(record *core.Record) *entity.Text {
return &entity.Text{
Id: record.Id,
RecordID: record.GetString("record"),
Kind: record.GetString("kind"),
Contents: record.GetString("contents"),
}
}
type StructureRepository struct {
app core.App
}
func NewStructureRepository(app core.App) *StructureRepository {
return &StructureRepository{app: app}
}
// Put кладёт структуру реплик, заменяя прежнюю той же версии разбора. Довод тот
// же, что и у текста: повтор шага не должен заводить второй строки.
func (repo *StructureRepository) Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error) {
collection, err := findCollection(repo.app, migrations.StructuresCollection)
if err != nil {
return nil, err
}
contents, err := json.Marshal(replicas)
if err != nil {
return nil, fmt.Errorf("failed to encode structure of record %s", recordID)
}
record, err := repo.app.FindFirstRecordByFilter(
migrations.StructuresCollection,
"record = {:record} && version = {:version}",
dbx.Params{"record": recordID, "version": version},
)
switch {
case err == nil:
// Строка есть — заменяем содержимое. Пустой перечень реплик поверх
// непустого не кладётся по тому же доводу, что и у текста: повторный
// опрос с пустым ответом провайдера стирал бы разбор живой записи.
if len(replicas) == 0 && len(record.GetString("contents")) > len("[]") {
return repo.GetByID(record.Id)
}
case errors.Is(err, sql.ErrNoRows):
record = core.NewRecord(collection)
record.Set("record", recordID)
record.Set("version", version)
default:
return nil, fmt.Errorf("failed to look up structure of record %s: %w", recordID, err)
}
record.Set("contents", string(contents))
if err := repo.app.Save(record); err != nil {
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
}
return &entity.Structure{
Id: record.Id,
RecordID: recordID,
Version: version,
Replicas: replicas,
}, nil
}
func (repo *StructureRepository) GetByID(id string) (*entity.Structure, error) {
record, err := repo.app.FindRecordById(migrations.StructuresCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get structure %s: %w", id, err)
}
var replicas []entity.Replica
raw := record.GetString("contents")
if raw != "" {
if err := json.Unmarshal([]byte(raw), &replicas); err != nil {
return nil, fmt.Errorf("failed to decode structure %s", id)
}
}
return &entity.Structure{
Id: record.Id,
RecordID: record.GetString("record"),
Version: record.GetInt("version"),
Replicas: replicas,
}, nil
}
@@ -1,157 +0,0 @@
package pocketbase
import (
"database/sql"
"errors"
"fmt"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/clock"
)
type TranscriptJobRepository struct {
app core.App
}
func NewTranscriptJobRepository(app core.App) *TranscriptJobRepository {
return &TranscriptJobRepository{app: app}
}
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
collection, err := findCollection(repo.app, migrations.JobsCollection)
if err != nil {
return err
}
record := core.NewRecord(collection)
if job.Id != "" {
record.Id = job.Id
}
applyToRecord(record, job)
if err := repo.app.Save(record); err != nil {
return fmt.Errorf("failed to insert transcribe job: %w", err)
}
job.Id = record.Id
job.CreatedAt = record.GetDateTime("created").Time()
job.UpdatedAt = record.GetDateTime("updated").Time()
return nil
}
// Save сохраняет задачу, захват которой держит holder. Проверка и запись идут
// одной транзакцией: шаг, потерявший задачу за время работы, получает
// LostAcquisitionError и результата не пишет.
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder string) error {
err := repo.app.RunInTransaction(func(txApp core.App) error {
record, err := txApp.FindRecordById(migrations.JobsCollection, job.Id)
if err != nil {
return fmt.Errorf("failed to find transcribe job: %w", err)
}
if holder != "" && record.GetString("acquisition_id") != holder {
return &contract.LostAcquisitionError{JobID: job.Id}
}
// Кладём только то, чем распоряжается конвейер: правку владельца в
// панели снимок шага стирать не должен.
applyOwnedByPipeline(record, job)
if err := txApp.Save(record); err != nil {
return fmt.Errorf("failed to update transcribe job: %w", err)
}
job.UpdatedAt = record.GetDateTime("updated").Time()
return nil
})
if err != nil {
return err
}
return nil
}
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
record, err := repo.app.FindRecordById(migrations.JobsCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
}
return recordToJob(record), nil
}
// Колонки, которые читает захват. Список нужен запросу дословно: `RETURNING *`
// отдал бы и порядок, зависящий от схемы.
const acquireColumns = `id, state, source, file, error_text, acquisition_id, ` +
`acquire_time, delay_time, attempts, recognition_op_id, transcription_text, ` +
`tg_chat_id, tg_reply_message_id, created, updated`
// FindAndAcquire забирает задачу одним неделимым шагом: выбор подходящей и
// пометка её захваченной идут вместе, и захваченная возвращается тем же
// запросом. Двум вызывающим, пришедшим за одним состоянием, запись достаётся
// одному — на этом стоит инвариант «Принятая запись не теряется молча».
//
// Запрос идёт сырым, мимо записей коллекции: `app.DB()` направляет всё, кроме
// выборок, в пул с единственным соединением, и захваты выстраиваются в очередь.
// Хуки коллекции на нём не срабатывают, поэтому время изменения проставляет сам
// запрос.
//
// Все времена кладутся и сравниваются тем же видом, каким хранилище пишет свои
// `created`/`updated`: сравнение строк побайтово, и вид, разошедшийся хоть
// разделителем, обратил бы условие срока в постоянную истину или постоянную
// ложь — молча.
func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) {
// Метка времени берётся единой точкой, а не `types.NowDateTime()`: обёртка
// хранилища читает часы сама, и запрет линтера её не видит — новая метка в
// этом запросе обошла бы единую точку молча.
now, err := types.ParseDateTime(clock.Now())
if err != nil {
return nil, fmt.Errorf("failed to parse current time: %w", err)
}
query := repo.app.DB().NewQuery(`
UPDATE {{` + migrations.JobsCollection + `}}
SET acquisition_id = {:acquisition_id},
acquire_time = {:now},
attempts = attempts + 1,
updated = {:now}
WHERE id = (
SELECT id FROM {{` + migrations.JobsCollection + `}}
WHERE state = {:state}
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
AND (acquisition_id = '' OR acquisition_id IS NULL OR acquire_time < {:rotting})
ORDER BY created, id
LIMIT 1
)
RETURNING ` + acquireColumns)
rotting, err := types.ParseDateTime(rottingTime)
if err != nil {
return nil, fmt.Errorf("failed to parse rotting time: %w", err)
}
query.Bind(dbx.Params{
"acquisition_id": acquisitionId,
"now": now.String(),
"state": state,
"rotting": rotting.String(),
})
var row acquiredRow
if err := query.One(&row); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{State: state, Message: "appropriate job not found"}
}
return nil, fmt.Errorf("failed to acquire job with state %s: %w", state, err)
}
return row.toJob(), nil
}
@@ -1,412 +0,0 @@
package pocketbase
import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// newTestApp поднимает хранилище на пустом каталоге и накатывает схему — тем же
// путём, каким это делает сервис при старте.
func newTestApp(t *testing.T) core.App {
t.Helper()
app, err := New(t.TempDir())
require.NoError(t, err)
t.Cleanup(func() {
if err := app.ResetBootstrapState(); err != nil {
t.Logf("не удалось закрыть хранилище: %v", err)
}
})
return app
}
// newFile заводит запись о файле: ссылка на неё у задачи обязательна схемой.
func newFile(t *testing.T, app core.App) *entity.File {
t.Helper()
repo := NewFileRepository(app)
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
file, err := repo.CreateLocal("sample.mp3", work)
require.NoError(t, err)
return file
}
func newJob(t *testing.T, repo *TranscriptJobRepository, state string) *entity.TranscribeJob {
t.Helper()
file := newFile(t, repo.app)
job := &entity.TranscribeJob{State: state, Source: entity.SourceApi, FileID: &file.Id}
require.NoError(t, repo.Create(job))
return job
}
// Захват неделим: выбор подходящей задачи и пометка её захваченной идут вместе.
// Двум вызывающим, пришедшим за одним состоянием разом, запись достаётся
// одному — на этом стоит инвариант «Принятая запись не теряется молча».
func TestFindAndAcquire_OnlyOneOfThreeGetsTheJob(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
const racers = 3
var (
wg sync.WaitGroup
mu sync.Mutex
got []*entity.TranscribeJob
notFound int
)
start := make(chan struct{})
for i := 0; i < racers; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
<-start
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
mu.Lock()
defer mu.Unlock()
if err != nil {
var missing *contract.JobNotFoundError
if assert.ErrorAs(t, err, &missing) {
notFound++
}
return
}
got = append(got, acquired)
}(i)
}
close(start)
wg.Wait()
require.Len(t, got, 1, "запись получает ровно один из трёх захватов")
assert.Equal(t, job.Id, got[0].Id)
assert.Equal(t, racers-1, notFound, "остальные получают признак «работы нет»")
}
// Захваченная задача второй раз не выдаётся, пока срок захвата не истёк.
func TestFindAndAcquire_AcquiredJobIsNotHandedOutAgain(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
newJob(t, repo, entity.StateCreated)
first, err := repo.FindAndAcquire(entity.StateCreated, "first", time.Now().Add(-time.Hour))
require.NoError(t, err)
require.NotNil(t, first)
_, err = repo.FindAndAcquire(entity.StateCreated, "second", time.Now().Add(-time.Hour))
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing, "захваченная задача второму не выдаётся")
}
// Захват протухает, и задача достаётся снова. Время захвата кладётся **не**
// нашим кодом, а тем же путём, что и `created`: проверка, кладущая его своим
// форматом, была бы зелена и тогда, когда сравнение вида сломано.
func TestFindAndAcquire_RottenAcquisitionIsHandedOutAgain(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
_, err := repo.FindAndAcquire(entity.StateCreated, "first", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Задним числом — записью коллекции, то есть тем же слоем, который пишет
// собственные времена хранилища.
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("acquire_time", types.NowDateTime().Add(-2*time.Hour))
require.NoError(t, app.Save(record))
again, err := repo.FindAndAcquire(entity.StateCreated, "second", time.Now().Add(-time.Hour))
require.NoError(t, err, "протухший захват не мешает выдать задачу следующему")
assert.Equal(t, job.Id, again.Id)
}
// Пауза держит задачу от выдачи, пока не кончится.
func TestFindAndAcquire_DelayedJobIsNotHandedOut(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
delay := time.Now().Add(time.Hour)
job.DelayTime = &delay
require.NoError(t, repo.Save(job, ""))
_, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing, "задача не выдаётся, пока пауза не кончилась")
}
// Число попыток растёт при каждом захвате: только так попытка засчитывается и
// задаче, брошенной вместе с процессом.
func TestFindAndAcquire_AttemptsGrowOnEveryAcquisition(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
newJob(t, repo, entity.StateCreated)
for expected := 1; expected <= 3; expected++ {
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
require.NoError(t, err)
assert.Equal(t, expected, acquired.Attempts)
}
}
// Захват отдаёт задачу целиком, а не только её ключ: сырой запрос идёт мимо
// записей коллекции, и расхождение перечня колонок иначе проявилось бы как
// потерянное поле.
func TestFindAndAcquire_ReturnsWholeJob(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
chatId := int64(4242)
replyId := 17
opId := "operation-id"
text := "расшифровка"
file := newFile(t, app)
job := &entity.TranscribeJob{
State: entity.StateTranscribe,
Source: entity.SourceTelegram,
FileID: &file.Id,
TgChatId: &chatId,
TgReplyMessageId: &replyId,
RecognitionOpID: &opId,
TranscriptionText: &text,
}
require.NoError(t, repo.Create(job))
acquired, err := repo.FindAndAcquire(entity.StateTranscribe, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
assert.Equal(t, job.Id, acquired.Id)
assert.Equal(t, entity.StateTranscribe, acquired.State)
assert.Equal(t, entity.SourceTelegram, acquired.Source)
require.NotNil(t, acquired.TgChatId)
assert.Equal(t, chatId, *acquired.TgChatId)
require.NotNil(t, acquired.TgReplyMessageId)
assert.Equal(t, replyId, *acquired.TgReplyMessageId)
require.NotNil(t, acquired.RecognitionOpID)
assert.Equal(t, opId, *acquired.RecognitionOpID)
require.NotNil(t, acquired.TranscriptionText)
assert.Equal(t, text, *acquired.TranscriptionText)
assert.False(t, acquired.CreatedAt.IsZero(), "время заведения доехало")
}
// Шаг, потерявший захват за время работы, результата не пишет: иначе два
// воркера пишут в одну задачу по очереди, а отправитель получает два ответа.
func TestSave_RefusesWriteFromLostAcquisition(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
newJob(t, repo, entity.StateCreated)
mine, err := repo.FindAndAcquire(entity.StateCreated, "mine", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Задача досталась другому, пока шаг работал.
record, err := app.FindRecordById(migrations.JobsCollection, mine.Id)
require.NoError(t, err)
record.Set("acquisition_id", "someone-else")
require.NoError(t, app.Save(record))
mine.MoveToState(entity.StateConverted)
err = repo.Save(mine, "mine")
var lost *contract.LostAcquisitionError
require.ErrorAs(t, err, &lost)
// И состояние не поехало.
after, err := repo.GetByID(mine.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateCreated, after.State)
}
// Пустой держатель значит «задача не захватывалась» — так её сохраняет приём.
func TestSave_WithoutHolderWritesAnyway(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
job.MoveToState(entity.StateConverted)
require.NoError(t, repo.Save(job, ""))
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateConverted, after.State)
}
// Правка состояния **запросом** — то есть из панели — чистит служебные поля
// прошлого состояния: те же, что чистит переход из кода. Иначе владелец,
// вернувший мёртвую задачу в работу, получил бы задачу, которая не выдаётся
// захвату и умирает от первого же отказа, и не узнал бы об этом.
func TestPanelRules_StateChangeByRequestClearsAcquisition(t *testing.T) {
app := newTestApp(t)
BindPanelRules(app)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
require.NotNil(t, acquired.AcquisitionID)
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("attempts", 5)
record.Set("state", entity.StateDead)
require.NoError(t, app.Save(record))
// Владелец возвращает задачу в работу правкой состояния в панели — то есть
// запросом к записи, а не сохранением из кода.
patchRecord(t, app, job.Id, `{"state":"`+entity.StateCreated+`"}`)
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Nil(t, after.AcquisitionID, "признак захвата снят")
assert.Nil(t, after.AcquireTime, "время захвата снято")
assert.Nil(t, after.DelayTime, "пауза снята")
assert.Equal(t, 0, after.Attempts, "число попыток обнулено")
// И ближайший захват задачу выдаёт.
again, err := repo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(-time.Hour))
require.NoError(t, err)
assert.Equal(t, job.Id, again.Id)
}
// Обратная сторона того же правила, и она дороже: правила панели MUST не
// трогать записи, которые правит сам конвейер. Модельный хук их не различал, и
// пауза, поставленная шагом вместе со сменой состояния, стиралась тем же
// сохранением, а число попыток мёртвой задачи приходило владельцу нулём.
func TestPanelRules_DoNotTouchPipelineWrites(t *testing.T) {
app := newTestApp(t)
BindPanelRules(app)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateConverted)
acquired, err := repo.FindAndAcquire(entity.StateConverted, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Шаг ставит задержку опроса вместе со сменой состояния.
delay := time.Now().Add(10 * time.Second)
acquired.MoveToStateAndDelay(entity.StateTranscribe, &delay)
require.NoError(t, repo.Save(acquired, "holder"))
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
require.NotNil(t, after.DelayTime, "задержка, поставленная шагом, пережила сохранение")
// Переход в «мертва» хранит число попыток намеренно: по нему владелец видит,
// сколько раз мы пробовали.
after.Attempts = 6
after.Die("attempts exhausted: 6")
require.NoError(t, repo.Save(after, ""))
dead, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDead, dead.State)
assert.Equal(t, 6, dead.Attempts, "число попыток мёртвой задачи сохранено")
}
// patchRecord правит запись тем же путём, каким её правит панель: запросом к
// API от имени владельца.
func patchRecord(t *testing.T, app core.App, recordID, body string) {
t.Helper()
superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
require.NoError(t, err)
owner := core.NewRecord(superusers)
owner.Set("email", "owner@example.com")
owner.Set("password", "ownerpassword123")
require.NoError(t, app.Save(owner))
token, err := owner.NewStaticAuthToken(time.Hour)
require.NoError(t, err)
router, err := apis.NewRouter(app)
require.NoError(t, err)
mux, err := router.BuildMux()
require.NoError(t, err)
req := httptest.NewRequest(
http.MethodPatch,
"/api/collections/"+migrations.JobsCollection+"/records/"+recordID,
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", token)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "правка записи владельцем: %s", w.Body.String())
}
// Правка владельца в панели переживает сохранение шага. Шаг держит задачу
// снимком с момента захвата и до своего сохранения — до восьми часов, — и
// безусловная запись снимка стёрла бы правку молча: ни строки в журнале, ни
// отказа в панели.
func TestSave_KeepsOwnerEditMadeWhileStepHeldTheJob(t *testing.T) {
app := newTestApp(t)
BindPanelRules(app)
repo := NewTranscriptJobRepository(app)
file := newFile(t, app)
chatId := int64(111)
job := &entity.TranscribeJob{
State: entity.StateCreated,
Source: entity.SourceTelegram,
FileID: &file.Id,
TgChatId: &chatId,
}
require.NoError(t, repo.Create(job))
// Шаг захватил задачу и работает.
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Владелец правит в панели поле, которого конвейер не касается.
patchRecord(t, app, job.Id, `{"tg_chat_id":999999}`)
// Шаг доработал и сохраняет свой снимок.
acquired.MoveToState(entity.StateConverted)
require.NoError(t, repo.Save(acquired, "holder"))
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateConverted, after.State, "шаг свой результат записал")
require.NotNil(t, after.TgChatId)
assert.Equal(t, int64(999999), *after.TgChatId, "правка владельца пережила сохранение шага")
}
-27
View File
@@ -1,27 +0,0 @@
package telegram
import (
"git.vakhrushev.me/av/transcriber/internal/contract"
)
// AbsentMessageSender подставляется вместо отправителя Telegram, когда вход
// выключен признаком `telegram.enabled` либо Telegram оказался недоступен, и
// клиента заводить не из чего. Он ничего не отправляет и на всякий ответ отдаёт
// `contract.ErrDeliveryChannelDown`.
//
// Заглушка, а не пустой отправитель: необязательная зависимость, доехавшая до
// ядра нулём, роняет процесс на первой же задаче из Telegram, а проверка на
// месте употребления завела бы в ядре знание о том, как собран сервис.
//
// Молчит он намеренно. Записать недоставку заглушке нечем: контракт отправки
// несёт текст, чат и сообщение для ответа, а идентификатора задачи в нём нет.
// Пишет поэтому шаг конвейера, который задачу знает.
type AbsentMessageSender struct{}
func NewAbsentMessageSender() *AbsentMessageSender {
return &AbsentMessageSender{}
}
func (s *AbsentMessageSender) Send(_ string, _ int64, _ *int) error {
return contract.ErrDeliveryChannelDown
}
-37
View File
@@ -1,37 +0,0 @@
package telegram
import (
"log/slog"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/contract"
)
// Заглушка отдаёт «канал не поднят» и молчит: записать недоставку ей нечем —
// идентификатора задачи контракт отправки не несёт, и пишет её шаг конвейера.
func TestAbsentSenderReportsChannelDown(t *testing.T) {
sender := NewAbsentMessageSender()
err := sender.Send("расшифровка записи", 100, nil)
require.ErrorIs(t, err, contract.ErrDeliveryChannelDown)
}
// Непустой годный токен по-прежнему даёт настоящего отправителя: прежний путь
// сохранён, и меняется только то, что клиента теперь отдают готовым.
func TestSenderIsBuiltFromLiveBot(t *testing.T) {
bot, _ := newProbeBot(t, func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte(getMeResponse)); err != nil {
t.Errorf("подставной Telegram не смог ответить: %v", err)
}
})
sender := NewTelegramMessageSender(bot, slog.New(slog.DiscardHandler))
require.NotNil(t, sender)
assert.Same(t, bot, sender.bot, "отправитель говорит с тем же клиентом, что и транспорт")
}
-134
View File
@@ -1,134 +0,0 @@
package telegram
import (
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// ErrEmptyToken — ключ доступа пуст при включённом входе, то есть **ошибка
// настройки**: старт роняется. Отдельным значением, чтобы отличаться от
// недоступности Telegram, у которой исход обратный — подъём без бота.
//
// Отказ от входа Telegram этим значением больше не выражается: намерение
// объявляет признак включения `telegram.enabled`, и выключенный вход отсеивается
// до всякого обращения сюда. Пустой ключ ловит проверка настроек ещё раньше,
// поэтому сюда он доходит только в обход проверки.
var ErrEmptyToken = errors.New("telegram bot token is empty")
// NewBot заводит клиента Bot API — и это **единая точка**, через которую с
// библиотекой разговаривают оба пакета: адаптер отправки и транспорт бота.
//
// Точка нужна ради инварианта «секрет не покидает конфиг». Токен живёт в пути
// каждого обращения к Bot API (`https://api.telegram.org/bot<TOKEN>/getFile`),
// а `http.Client` кладёт адрес запроса в `*url.Error` целиком. Библиотека
// отдаёт этот отказ вызывающему как есть, поэтому чистка на месте употребления
// закрывает ровно один вызов из пяти: остаются `getFile`, `sendMessage`,
// `getMe` из конструктора и длинный опрос. Здесь закрыты все.
func NewBot(token string, logger *slog.Logger) (*tgbotapi.BotAPI, error) {
return newBot(token, tgbotapi.APIEndpoint, logger)
}
// newBot принимает адрес отдельно — иначе проверка утечки токена ходила бы за
// подтверждением в живой Telegram, а боевым токеном запускаться запрещено.
func newBot(token, endpoint string, logger *slog.Logger) (*tgbotapi.BotAPI, error) {
if token == "" {
return nil, ErrEmptyToken
}
// Длинный опрос живёт внутри библиотеки и печатает свой отказ пакетным
// логгером в stderr (`GetUpdatesChan`), минуя и наш `slog`, и чистку выше.
// Это самый частый путь: опрос идёт непрерывно, а скачивание — только когда
// кто-то прислал запись. Логгер пакетный, поэтому и подменяется один раз.
if err := tgbotapi.SetLogger(&redactingLogger{token: token, logger: logger}); err != nil {
return nil, fmt.Errorf("failed to set telegram logger: %w", err)
}
// Сборка ходит за `getMe` и стоит на пути старта — раньше HTTP-сервера,
// панели и воркеров. Без срока ожидания молчащий Telegram (соединение
// принято, ответа нет) вешал бы весь подъём бессрочно: порт не слушается,
// проба здоровья не отвечает, а в журнале ни строки.
probe := &safeClient{inner: &http.Client{Timeout: ProbeTimeout}}
// Отказ конструктора чистится здесь, а не клиентом: адрес собирается
// строкой с токеном внутри, и `http.NewRequest` падает на его разборе
// **до** обращения к клиенту — то есть мимо `safeClient`. Токен с
// управляющим символом или неверной `%`-последовательностью иначе уезжает
// в журнал целиком: перенос строки в конце значения ловится так же.
bot, err := tgbotapi.NewBotAPIWithClient(token, endpoint, probe)
if err != nil {
return nil, WithoutURL(err)
}
// Дальше живёт длинный опрос, и срок ему не нужен: он ждёт обновлений
// столько, сколько задано настройкой, и клиент со сроком рвал бы его.
bot.Client = &safeClient{inner: &http.Client{}}
return bot, nil
}
// ProbeTimeout — сколько ждём Telegram при сборке клиента. Число выбрано
// решением, а не замером: одно обращение за `getMe` укладывается в доли
// секунды, а десять секунд — потолок, после которого Telegram считается
// недоступным и сервис поднимается без него.
const ProbeTimeout = 10 * time.Second
// safeClient — клиент, чей отказ не несёт адреса. Библиотека объявляет
// зависимость интерфейсом `HTTPClient` и возвращает наш отказ вызывающему
// нетронутым, поэтому чистка отсюда доходит до каждого вызова Bot API.
type safeClient struct {
inner *http.Client
}
func (c *safeClient) Do(req *http.Request) (*http.Response, error) {
resp, err := c.inner.Do(req)
if err != nil {
return nil, WithoutURL(err)
}
return resp, nil
}
// WithoutURL снимает с отказа адрес запроса, сохраняя причину. Стандартный
// клиент кладёт в `*url.Error` полный URL, а в ссылке Telegram стоит токен
// бота: без этой чистки первый же сбой сети печатает секрет в журнал.
// Причина остаётся и узнаётся `errors.Is` по-прежнему.
func WithoutURL(err error) error {
var urlErr *url.Error
if errors.As(err, &urlErr) {
return urlErr.Err
}
return err
}
// redactingLogger отдаёт сообщения библиотеки нашему журналу, вычеркнув токен.
// Здесь чистится текст, а не ошибка: библиотека печатает уже отформатированную
// строку, и разбирать в ней `*url.Error` нечего. Замена точная — токен известен.
type redactingLogger struct {
token string
logger *slog.Logger
}
const redactedToken = "«токен»"
func (l *redactingLogger) Println(v ...any) {
l.write(strings.TrimSuffix(fmt.Sprintln(v...), "\n"))
}
func (l *redactingLogger) Printf(format string, v ...any) {
l.write(fmt.Sprintf(format, v...))
}
// write пишет на WARN: это сбой фонового цикла со штатным повтором, а не
// событие, требующее разбора (docs/conventions/logging.md, «Уровень —
// это адресат»). Сообщение нейтрально: тем же логгером библиотека печатает и
// отладку, если её включить, а разделить их она не даёт.
func (l *redactingLogger) write(message string) {
l.logger.Warn("Telegram library log",
"message", strings.ReplaceAll(message, l.token, redactedToken))
}
-172
View File
@@ -1,172 +0,0 @@
package telegram
import (
"bytes"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Токен виден в пути каждого обращения к Bot API, а `http.Client` кладёт путь в
// `*url.Error` целиком. Проверки ниже судят по тексту: секрет не должен
// встречаться ни в отказе, ни в строке журнала. Утечка необратима — утёкший
// токен отзывают руками (CLAUDE.md, «Инварианты», critical).
const probeToken = "7654321:AAHsecretBOTtokenVALUE"
// getMeResponse — ответ, которым подставной Telegram пускает конструктор
// дальше: `NewBotAPIWithClient` ходит за `getMe` прежде, чем отдать клиента.
const getMeResponse = `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"probe","username":"probe_bot"}}`
func newProbeBot(t *testing.T, handler http.HandlerFunc) (*tgbotapi.BotAPI, *httptest.Server) {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
bot, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
require.NoError(t, err)
return bot, server
}
// Отказ транспорта на любом вызове Bot API не несёт токена: чистка стоит на
// границе клиента, а не у места употребления, поэтому закрыты все вызовы разом.
func TestBotAPIFailureDoesNotCarryToken(t *testing.T) {
bot, server := newProbeBot(t, func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte(getMeResponse)); err != nil {
t.Errorf("подставной Telegram не смог ответить: %v", err)
}
})
// Собеседник исчез — так выглядит обрыв сети, DNS-сбой и недоступность
// api.telegram.org.
server.Close()
t.Run("getFile", func(t *testing.T) {
_, err := bot.GetFile(tgbotapi.FileConfig{FileID: "any"})
require.Error(t, err)
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ: %v", err)
})
t.Run("sendMessage", func(t *testing.T) {
_, err := bot.Send(tgbotapi.NewMessage(1, "текст"))
require.Error(t, err)
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ: %v", err)
})
}
// Токен, ломающий разбор адреса, — второй путь отказа конструктора, и до
// недавнего он был открыт: `http.NewRequest` падает раньше обращения к клиенту,
// то есть мимо чистки на его границе. Так выглядит перенос строки, приехавший
// с секретом из шаблона выкладки, и невычищенная `%`-последовательность.
func TestBotConstructionFailureOnUnparsableTokenDoesNotCarryToken(t *testing.T) {
broken := map[string]string{
"перенос строки": probeToken + "\n",
"негодная escape-пара": "7654321:AAH%zzSECRETtokenVALUE",
}
for name, token := range broken {
t.Run(name, func(t *testing.T) {
_, err := newBot(token, tgbotapi.APIEndpoint, slog.New(slog.DiscardHandler))
require.Error(t, err)
assert.NotContains(t, err.Error(), token, "токен уехал в отказ: %v", err)
assert.NotContains(t, err.Error(), "api.telegram.org", "адрес остался в отказе: %v", err)
})
}
}
// Отказ конструктора несёт тот же путь: `NewBotAPIWithClient` ходит за `getMe`,
// и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду.
func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
server.Close()
_, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
require.Error(t, err)
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ конструктора: %v", err)
}
// Длинный опрос печатает свои отказы пакетным логгером самой библиотеки, минуя
// наш `slog`. Логгер подменён — значит, и эта строка идёт через вычистку.
func TestLibraryLoggerRedactsToken(t *testing.T) {
journal := &bytes.Buffer{}
logger := slog.New(slog.NewTextHandler(journal, nil))
redacting := &redactingLogger{token: probeToken, logger: logger}
redacting.Println(errors.New(`Post "https://api.telegram.org/bot` + probeToken + `/getUpdates": dial tcp: refused`))
redacting.Printf("Failed to get updates from %s", "https://api.telegram.org/bot"+probeToken+"/getUpdates")
written := journal.String()
assert.NotContains(t, written, probeToken, "токен уехал в журнал: %s", written)
assert.Equal(t, 2, strings.Count(written, redactedToken), "вместо токена стоит пометка")
assert.Contains(t, written, "dial tcp", "причина отказа осталась")
}
// Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу.
// Обратное тоже нормируется: отказ негодного токена не должен читаться как
// отказ от входа, иначе сборка при старте подставит заглушку там, где нужен
// отказ, и молча потеряет бота.
func TestEmptyTokenIsRecognizedByValue(t *testing.T) {
_, err := NewBot("", slog.New(slog.DiscardHandler))
require.ErrorIs(t, err, ErrEmptyToken)
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
server.Close()
_, err = newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
require.Error(t, err)
require.NotErrorIs(t, err, ErrEmptyToken)
}
// WithoutURL снимает адрес, но не причину: `errors.Is` по цепочке продолжает
// работать, иначе чистка стоила бы узнаваемости отказа.
func TestWithoutURLKeepsCause(t *testing.T) {
cause := errors.New("dial tcp: connection refused")
wrapped := &url.Error{Op: "Post", URL: "https://api.telegram.org/bot" + probeToken + "/getMe", Err: cause}
cleaned := WithoutURL(wrapped)
assert.NotContains(t, cleaned.Error(), probeToken)
require.ErrorIs(t, cleaned, cause)
assert.Equal(t, cause, WithoutURL(cause), "отказ без адреса не трогают")
}
// Стык, которого не сторожил никто: подмена пакетного логгера держится одной
// строкой в `NewBot`, а снятие этой строки не роняло ни одной проверки. Оракул
// косвенный по необходимости — библиотека не отдаёт установленный логгер
// обратно, — поэтому он смотрит на исход: её собственная строка обязана
// оказаться в нашем журнале.
func TestLibraryLoggerIsActuallyInstalled(t *testing.T) {
journal := &bytes.Buffer{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte(getMeResponse)); err != nil {
t.Errorf("подставной Telegram не смог ответить: %v", err)
}
}))
t.Cleanup(server.Close)
bot, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.NewTextHandler(journal, nil)))
require.NoError(t, err)
// Отладку библиотека печатает тем же логгером, что и отказы: включаем её,
// чтобы строка появилась без обрыва сети.
bot.Debug = true
_, err = bot.GetMe()
require.NoError(t, err)
assert.Contains(t, journal.String(), "Telegram library log",
"строка библиотеки прошла мимо нашего журнала: логгер не подменён")
}
-66
View File
@@ -1,66 +0,0 @@
package telegram
import (
"log/slog"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
const (
TextLengthLimit = 4000
)
type TelegramMessageSender struct {
bot *tgbotapi.BotAPI
logger *slog.Logger
}
// NewTelegramMessageSender принимает готового клиента, а не токен. Клиента
// заводит сборка при старте — одного на отправителя и на транспорт бота: пока
// его строили здесь и там порознь, два пути одного старта разошлись в том,
// терпеть ли негодный токен, и согласовывать их приходилось руками.
func NewTelegramMessageSender(bot *tgbotapi.BotAPI, logger *slog.Logger) *TelegramMessageSender {
return &TelegramMessageSender{
bot: bot,
logger: logger,
}
}
func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error {
// If message is short enough, send it directly
if len([]rune(text)) <= TextLengthLimit {
return s.sendSingleMessage(text, chatId, replyToMessageId)
}
// Split long message into parts
parts := s.splitMessageByWords(text, TextLengthLimit)
// Send each part
for i, part := range parts {
var replyId *int
// Only use replyToMessageId for the first part
if i == 0 {
replyId = replyToMessageId
}
err := s.sendSingleMessage(part, chatId, replyId)
if err != nil {
return err
}
}
return nil
}
// sendSingleMessage sends a single message
func (s *TelegramMessageSender) sendSingleMessage(text string, chatId int64, replyToMessageId *int) error {
resultMsg := tgbotapi.NewMessage(chatId, text)
if replyToMessageId != nil {
resultMsg.ReplyToMessageID = *replyToMessageId
}
_, err := s.bot.Send(resultMsg)
if err != nil {
s.logger.Error("Failed to send message to tg bot", "error", err)
return err
}
return nil
}
-62
View File
@@ -1,62 +0,0 @@
package telegram
// splitMessageByWords splits a message into parts of maxLen UTF-8 characters
// splitting by words to avoid cutting words in the middle
func (s *TelegramMessageSender) splitMessageByWords(text string, maxLen int) []string {
var parts []string
// If text is already short enough, return as is
if len([]rune(text)) <= maxLen {
return []string{text}
}
runes := []rune(text)
for len(runes) > 0 {
// Determine the end position for this part
end := len(runes)
if end > maxLen {
end = maxLen
}
// Try to find a good split point (word boundary)
splitPoint := end
for i := end - 1; i > end-20 && i > 0; i-- { // Look back up to 20 characters
// Check if this is a good split point (after a space)
if runes[i] == ' ' {
splitPoint = i + 1 // Include the space in the previous part
break
}
}
// If we couldn't find a good split point, just split at maxLen
if splitPoint == end && end == maxLen {
// Check if we're in the middle of a word
if end < len(runes) && runes[end] != ' ' && runes[end-1] != ' ' {
// Try to find a split point going forward
for i := end; i < len(runes) && i < end+20; i++ {
if runes[i] == ' ' {
splitPoint = i
break
}
}
}
}
// If still no good split point, use the original end
if splitPoint > len(runes) {
splitPoint = len(runes)
}
// Add this part
parts = append(parts, string(runes[:splitPoint]))
// Move to the next part
if splitPoint >= len(runes) {
break
}
runes = runes[splitPoint:]
}
return parts
}
-125
View File
@@ -1,125 +0,0 @@
package telegram
import (
"testing"
)
func TestTelegramMessageSender_splitMessageByWords(t *testing.T) {
sender := &TelegramMessageSender{}
tests := []struct {
name string
text string
maxLen int
expected []string
}{
{
name: "Short text should return as is",
text: "Привет мир",
maxLen: 25,
expected: []string{
"Привет мир",
},
},
{
name: "Text exactly at limit",
text: "Это тестовый текст который ровно соответствует лимиту",
maxLen: 35,
expected: []string{
"Это тестовый текст который ровно ",
"соответствует ",
"лимиту",
},
},
{
name: "Text with word boundaries",
text: "Это очень длинный текст для проверки работы функции разделения сообщения",
maxLen: 25,
expected: []string{
"Это очень длинный текст ",
"для проверки работы ",
"функции разделения ",
"сообщения",
},
},
{
name: "Text with long words",
text: "Этот текст содержит оченьдлинноеслово которое не должно быть разбито",
maxLen: 20,
expected: []string{
"Этот текст содержит ",
"оченьдлинноеслово ",
"которое не должно ",
"быть ",
"разбито",
},
},
{
name: "Text with multiple spaces",
text: "Этот текст имеет много пробелов",
maxLen: 20,
expected: []string{
"Этот текст ",
"имеет много ",
"пробелов",
},
},
{
name: "Text with Russian characters and punctuation",
text: "Привет! Как дела? Это тестовая строка для проверки работы функции.",
maxLen: 25,
expected: []string{
"Привет! Как дела? Это ",
"тестовая строка для ",
"проверки работы ",
"функции.",
},
},
{
name: "Single word longer than maxLen",
text: "Некотороедлинноеслово",
maxLen: 10,
expected: []string{
"Некотороед",
"линноеслов",
"о",
},
},
{
name: "Text with mixed Russian and English",
text: "Привет Hello мир World текст для проверки",
maxLen: 20,
expected: []string{
"Привет Hello мир ",
"World текст для ",
"проверки",
},
},
{
name: "Text with special characters",
text: "Тест с символами: @#$%^&*()_+-=[]{}|;':\",./<>?",
maxLen: 25,
expected: []string{
"Тест с символами: ",
"@#$%^&*()_+-=[]{}|;':\",./",
"<>?",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sender.splitMessageByWords(tt.text, tt.maxLen)
if len(result) != len(tt.expected) {
t.Errorf("splitMessageByWords() length = %d, want %d", len(result), len(tt.expected))
return
}
for i, expectedPart := range tt.expected {
if result[i] != expectedPart {
t.Errorf("splitMessageByWords() part %d = %q, want %q", i, result[i], expectedPart)
}
}
})
}
}
+155 -131
View File
@@ -28,7 +28,7 @@ const (
) )
// Ядро — `internal/service`: оно знает только интерфейсы `internal/contract`, а // Ядро — `internal/service`: оно знает только интерфейсы `internal/contract`, а
// ffmpeg, Yandex, Telegram и хранилище подставляются в `main.go` // ffmpeg, Yandex и хранилище подставляются в `main.go`
// (docs/architecture.md, «Принципы»). // (docs/architecture.md, «Принципы»).
const core = "internal/service" const core = "internal/service"
@@ -36,7 +36,6 @@ const core = "internal/service"
// одном из них: иначе второй начинает зависеть от первого и тащит его целиком. // одном из них: иначе второй начинает зависеть от первого и тащит его целиком.
var transports = map[string]bool{ var transports = map[string]bool{
"internal/controller/http": true, "internal/controller/http": true,
"internal/controller/tg": true,
"internal/controller/worker": true, "internal/controller/worker": true,
} }
@@ -174,186 +173,211 @@ func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) {
} }
} }
// Колонки очереди правятся в четырёх местах пакета хранилища плюс шаг схемы, и // Перечень колонок аудиозаписи компилятор не видит: их пишет `applyOwnedByPipeline`,
// компилятор видит два из них (инвариант CLAUDE.md, «Инварианты», major). // читает `recordToAudioRecord`, и заводит шаг схемы. Колонка, забытая в паре
// Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата // «пишем — читаем», теряется молча: запись, прочитанная не тем путём, приезжает
// нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения // с нулевым полем, и первое же сохранение пишет этот ноль поверх значения.
// поле теряется только у задачи, попавшей к воркеру.
// //
// Правила ниже закрывают все четыре места плюс шаг схемы: перечень запроса, // Мест стало **два** вместо прежних четырёх: захват больше не перечисляет
// структуру захвата, запись коллекции (`applyToRecord`/`recordToJob`) и перенос // колонки поимённо, а возвращает идентификатор и признак своего захвата. Правила
// поля в задачу (`toJob`). Литерал колонки ищется **в телах** нужных функций, а // ниже держат оставшуюся пару плюс шаг схемы.
// не в файле: файл держит и структуру с тегами `db:"…"`, и по ней условие
// выполнялось бы само собой.
const ( const (
repoPkg = "internal/adapter/repo/pocketbase" repoPkg = "internal/adapter/repo/pocketbase"
acquireFile = repoPkg + "/transcript_job_repo.go" mappingFile = repoPkg + "/record_mapping.go"
mappingFile = repoPkg + "/job_mapping.go"
migrationsPath = repoPkg + "/migrations" migrationsPath = repoPkg + "/migrations"
stageFile = "internal/entity/stage.go"
stateFile = "internal/entity/audio_record.go"
serviceFile = "internal/service/transcribe.go"
) )
// Колонки, которые заводит и заполняет само хранилище: перечня запроса они // Колонки, которые заводит и заполняет само хранилище: нашего кода они не
// касаются, а нашего кода — нет. // касаются.
var storageOwned = map[string]bool{"id": true, "created": true, "updated": true} var storageOwned = map[string]bool{"id": true, "created": true, "updated": true}
func TestПереченьЗахватаСовпадаетСоСтруктурой(t *testing.T) { func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) {
query := acquireColumnNames(t) written := writtenColumns(t)
row := rowColumnNames(t) read := readColumns(t)
for _, col := range query { for col := range written {
if !row[col] { if storageOwned[col] {
continue
}
if !read[col] {
t.Errorf( t.Errorf(
"колонка %q есть в acquireColumns, но не в acquiredRow: из захвата "+ "колонку %q пишет отображение записи, но recordToAudioRecord её не "+
"она приедет нулевой, и первый Save затрёт сохранённое значение", "читает: запись приедет из хранилища без этого поля",
col, col,
) )
} }
delete(row, col)
} }
for col := range row { for col := range read {
t.Errorf( if storageOwned[col] {
"колонка %q есть в acquiredRow, но не в acquireColumns: запрос её не "+ continue
"читает, и поле остаётся нулевым", }
col, if !written[col] {
) t.Errorf(
"колонку %q читает recordToAudioRecord, но её не пишет ни "+
"applyOwnedByPipeline, ни applyToRecord: поле не сохранится",
col,
)
}
} }
} }
func TestКолонкиЗахватаЗаведеныШагомСхемы(t *testing.T) { func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) {
declared := schemaFieldNames(t) declared := schemaFieldNames(t)
for _, col := range acquireColumnNames(t) { for col := range writtenColumns(t) {
if col == "id" { if storageOwned[col] {
continue // ключ заводит само хранилище, шаг схемы его не объявляет continue
} }
if !declared[col] { if !declared[col] {
t.Errorf( t.Errorf(
"колонка %q читается захватом, но ни один шаг схемы её не заводит: "+ "колонка %q пишется отображением записи, но ни один шаг схемы её не "+
"запрос отвалится на живой базе", "заводит: сохранение отвалится на живой базе",
col, col,
) )
} }
} }
} }
// Четвёртое место — путь через запись коллекции: `applyToRecord` пишет колонку, // Рубеж объявлен одним дескриптором, но шаг под него пишется в другом месте, и
// `recordToJob` читает. Ищется литерал **в телах этих функций**, а не в файле: // связь между ними компилятор не видит. Рубеж, оставшийся без шага, из работы не
// в файле лежит и структура захвата со своими тегами `db:"…"`, и по ней условие // выходит: воркер его захватит, шага не найдёт и остановит запись — а рубеж,
// выполнялось бы само собой — правило было бы зелёным всегда. // забытый в дескрипторе, не выдаётся захвату вовсе, и пустой прогон по
func TestКолонкиЗахватаЧитаютсяИЧерезЗапись(t *testing.T) { // инварианту проекта не пишется в журнал и не считается в метрику.
write := funcBody(t, mappingFile, "func applyOwnedByPipeline(") + func TestУКаждогоРабочегоРубежаЕстьШаг(t *testing.T) {
body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(")
for _, stage := range workingStageIdents(t) {
if !strings.Contains(body, "entity."+stage) {
t.Errorf(
"рубеж entity.%s объявлен рабочим в дескрипторе, но шага под него нет "+
"в таблице stepFor: запись с этим рубежом остановится, не начав работы",
stage,
)
}
}
}
// Обратное направление того же правила: шаг, написанный под рубеж, которого в
// дескрипторе нет, недостижим — захват такую запись не выдаст никогда.
func TestШагиОбъявленыРубежамиДескриптора(t *testing.T) {
body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(")
declared := map[string]bool{}
for _, stage := range stageIdents(t) {
declared[stage] = true
}
re := regexp.MustCompile(`case entity\.(\w+):`)
for _, m := range re.FindAllStringSubmatch(body, -1) {
if !declared[m[1]] {
t.Errorf(
"в таблице stepFor есть ветка для entity.%s, но такого рубежа нет в "+
"дескрипторе: запись с этим рубежом захвату не выдаётся",
m[1],
)
}
}
}
// writtenColumns — колонки, которые пишет отображение записи в хранилище.
func writtenColumns(t *testing.T) map[string]bool {
t.Helper()
body := funcBody(t, mappingFile, "func applyOwnedByPipeline(") +
funcBody(t, mappingFile, "func applyToRecord(") funcBody(t, mappingFile, "func applyToRecord(")
read := funcBody(t, mappingFile, "func recordToJob(")
for _, col := range acquireColumnNames(t) {
if storageOwned[col] {
continue // эти колонки заводит и заполняет само хранилище
}
if !strings.Contains(write, `"`+col+`"`) {
t.Errorf(
"колонку %q читает захват, но её не пишет ни applyOwnedByPipeline, "+
"ни applyToRecord: путь через запись коллекции её потеряет",
col,
)
}
if !strings.Contains(read, `"`+col+`"`) {
t.Errorf(
"колонку %q читает захват, но recordToJob её не читает: задача, "+
"прочитанная не захватом, приедет без этого поля",
col,
)
}
}
}
// Пятое условие того же инварианта: колонка, доехавшая до структуры захвата,
// обязана попасть в задачу. `toJob` обращается к **полям**, а не к литералам,
// поэтому сверяются имена полей, а не имена колонок: поле, забытое здесь,
// приезжает из захвата прочитанным и теряется на последнем шаге.
func TestПоляСтруктурыЗахватаДоезжаютДоЗадачи(t *testing.T) {
body := funcBody(t, mappingFile, "func (r *acquiredRow) toJob()")
for _, field := range rowFieldNames(t) {
if !strings.Contains(body, "r."+field) {
t.Errorf(
"поле %s структуры захвата не читается в toJob: колонка приедет из "+
"запроса, но в задачу не попадёт",
field,
)
}
}
}
// --- Чтение исходников ------------------------------------------------------
// acquireColumnNames достаёт имена колонок из константы `acquireColumns`. Она
// склеена из строковых литералов, поэтому берётся текстом, а не разбором типов:
// значение константы известно на месте.
func acquireColumnNames(t *testing.T) []string {
t.Helper()
body := readFile(t, acquireFile)
const marker = "const acquireColumns = "
start := strings.Index(body, marker)
if start < 0 {
t.Fatalf("в %s нет константы acquireColumns: правило потеряло предмет", acquireFile)
}
tail := body[start+len(marker):]
end := strings.Index(tail, "`\n")
if end < 0 {
t.Fatalf("не нашёл конец константы acquireColumns в %s", acquireFile)
}
var cols []string
for _, chunk := range strings.Split(strings.NewReplacer("`", "", "+", "", "\n", "", "\t", "").Replace(tail[:end]), ",") {
if col := strings.TrimSpace(chunk); col != "" {
cols = append(cols, col)
}
}
if len(cols) == 0 {
t.Fatalf("перечень acquireColumns прочитан пустым: правило потеряло предмет")
}
return cols
}
// rowColumnNames достаёт колонки из тегов `db:"…"` структуры `acquiredRow`.
func rowColumnNames(t *testing.T) map[string]bool {
t.Helper()
out := map[string]bool{} out := map[string]bool{}
for _, m := range regexp.MustCompile("`db:\"([^\"]+)\"`").FindAllStringSubmatch(rowStruct(t), -1) { for _, m := range regexp.MustCompile(`record\.Set\("([^"]+)"`).FindAllStringSubmatch(body, -1) {
out[m[1]] = true out[m[1]] = true
} }
if len(out) == 0 { if len(out) == 0 {
t.Fatalf("у acquiredRow не прочитан ни один тег db: правило потеряло предмет") t.Fatalf("отображение записи не пишет ни одной колонки: правило потеряло предмет")
} }
return out return out
} }
// rowFieldNames достаёт имена полей структуры `acquiredRow` — те, к которым // readColumns — колонки, которые читает обратное отображение.
// обращается `toJob`. func readColumns(t *testing.T) map[string]bool {
func rowFieldNames(t *testing.T) []string {
t.Helper() t.Helper()
var out []string body := funcBody(t, mappingFile, "func recordToAudioRecord(")
for _, m := range regexp.MustCompile(`(?m)^\t([A-Z]\w*)\s`).FindAllStringSubmatch(rowStruct(t), -1) { out := map[string]bool{}
out = append(out, m[1]) for _, m := range regexp.MustCompile(`\.Get\w+\("([^"]+)"\)`).FindAllStringSubmatch(body, -1) {
out[m[1]] = true
} }
if len(out) == 0 { if len(out) == 0 {
t.Fatalf("у acquiredRow не прочитано ни одно поле: правило потеряло предмет") t.Fatalf("recordToAudioRecord не читает ни одной колонки: правило потеряло предмет")
} }
return out return out
} }
// rowStruct — текст объявления структуры `acquiredRow`. // stageIdents — имена констант рубежей, перечисленных дескриптором.
func rowStruct(t *testing.T) string { func stageIdents(t *testing.T) []string {
t.Helper() t.Helper()
body := readFile(t, mappingFile) out, _ := stageDescriptor(t)
start := strings.Index(body, "type acquiredRow struct {") return out
}
// workingStageIdents — то же, но без конечного рубежа: из него запись в работу
// не берут.
func workingStageIdents(t *testing.T) []string {
t.Helper()
_, working := stageDescriptor(t)
return working
}
func stageDescriptor(t *testing.T) (all []string, working []string) {
t.Helper()
body := readFile(t, stageFile)
const marker = "var stages = []Stage{"
start := strings.Index(body, marker)
if start < 0 { if start < 0 {
t.Fatalf("в %s нет структуры acquiredRow: правило потеряло предмет", mappingFile) t.Fatalf("в %s нет дескриптора рубежей: правило потеряло предмет", stageFile)
} }
end := strings.Index(body[start:], "\n}") end := strings.Index(body[start:], "\n}")
if end < 0 { if end < 0 {
t.Fatalf("не нашёл конец структуры acquiredRow в %s", mappingFile) t.Fatalf("не нашёл конец дескриптора рубежей в %s", stageFile)
} }
return body[start : start+end]
declared := declaredStates(t)
re := regexp.MustCompile(`\{Name: (\w+)[^}]*\}`)
for _, m := range re.FindAllStringSubmatch(body[start:start+end], -1) {
if !declared[m[1]] {
t.Errorf(
"дескриптор называет рубеж %s, которого нет среди объявленных состояний "+
"в %s: перечень схемы разошёлся бы с ним молча",
m[1], stateFile,
)
continue
}
all = append(all, m[1])
if !strings.Contains(m[0], "Terminal: true") {
working = append(working, m[1])
}
}
if len(all) == 0 {
t.Fatalf("дескриптор рубежей прочитан пустым: правило потеряло предмет")
}
if len(working) == 0 {
t.Fatalf("в дескрипторе нет ни одного рабочего рубежа: правило потеряло предмет")
}
return all, working
} }
// declaredStates — константы рубежей, объявленные доменом.
func declaredStates(t *testing.T) map[string]bool {
t.Helper()
out := map[string]bool{}
re := regexp.MustCompile(`(?m)^\t(State\w+)\s*=\s*"`)
for _, m := range re.FindAllStringSubmatch(readFile(t, stateFile), -1) {
out[m[1]] = true
}
if len(out) == 0 {
t.Fatalf("в %s не объявлено ни одного рубежа: правило потеряло предмет", stateFile)
}
return out
}
// --- Чтение исходников ------------------------------------------------------
// funcBody — текст тела функции от её заголовка до закрывающей скобки в первой // funcBody — текст тела функции от её заголовка до закрывающей скобки в первой
// позиции строки. Пропавший заголовок — отказ, а не пустое тело: правило, // позиции строки. Пропавший заголовок — отказ, а не пустое тело: правило,
// потерявшее предмет, обязано краснеть, а не зеленеть. // потерявшее предмет, обязано краснеть, а не зеленеть.
+54 -47
View File
@@ -7,23 +7,62 @@ import (
"os" "os"
"sort" "sort"
"strings" "strings"
"time"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
"git.vakhrushev.me/av/transcriber/internal/entity"
) )
type Config struct { type Config struct {
Server ServerConfig `toml:"server"` Server ServerConfig `toml:"server"`
Storage StorageConfig `toml:"storage"` Storage StorageConfig `toml:"storage"`
Pipeline PipelineConfig `toml:"pipeline"`
Yandex YandexConfig `toml:"yandex"` Yandex YandexConfig `toml:"yandex"`
Telegram TelegramConfig `toml:"telegram"`
Auth AuthConfig `toml:"auth"` Auth AuthConfig `toml:"auth"`
} }
// PipelineConfig — настройки конвейера расшифровки.
type PipelineConfig struct {
// Workers — число одинаковых воркеров. Ноль — законное значение: сервис
// поднимается, записи принимаются и не двигаются. Это режим, а не поломка.
Workers int `toml:"workers"`
// OwnWorkLimitMinutes — предел простоя записи там, где работу делаем мы
// сами. Сторож ловит **зависание**, а не долгую работу: живой шаг
// наблюдается по самому процессу, а остановка обратима — снятие признака
// возвращает запись на её рубеж.
OwnWorkLimitMinutes int `toml:"own_work_limit_minutes"`
// ForeignWorkLimitMinutes — предел простоя там, где ждём чужую операцию.
// Сколько идёт распознавание долгой записи, никто не мерил, поэтому ошибка
// идёт в сторону долгого: ложная остановка хуже поздней.
ForeignWorkLimitMinutes int `toml:"foreign_work_limit_minutes"`
}
// StuckLimits переводит настройки в пределы простоя.
func (c PipelineConfig) StuckLimits() entity.StuckLimits {
return entity.StuckLimits{
Own: time.Duration(c.OwnWorkLimitMinutes) * time.Minute,
Foreign: time.Duration(c.ForeignWorkLimitMinutes) * time.Minute,
}
}
// Validate проверяет числа конвейера. Отрицательное число воркеров — ошибка
// настройки, а не режим: ноль объявлен законным значением, и отличать его от
// опечатки обязан старт.
func (c PipelineConfig) Validate() error {
if c.Workers < 0 {
return errors.New("pipeline: число воркеров не может быть отрицательным")
}
if c.OwnWorkLimitMinutes <= 0 || c.ForeignWorkLimitMinutes <= 0 {
return errors.New("pipeline: пределы простоя задаются положительным числом минут")
}
return nil
}
type ServerConfig struct { type ServerConfig struct {
Port int `toml:"port"` Port int `toml:"port"`
ShutdownTimeout int `toml:"shutdown_timeout"` ShutdownTimeout int `toml:"shutdown_timeout"`
ForceShutdownTimeout int `toml:"force_shutdown_timeout"` ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
UsersWhiteList []string `toml:"users_while_list"`
} }
// StorageConfig — единственный каталог данных: под ним лежат и база, и файлы // StorageConfig — единственный каталог данных: под ним лежат и база, и файлы
@@ -42,33 +81,6 @@ type YandexConfig struct {
ObjStorageEndpoint string `toml:"object_storage_endpoint"` ObjStorageEndpoint string `toml:"object_storage_endpoint"`
} }
// TelegramConfig — вход Telegram. Признак включения объявляет намерение
// владельца, `BotToken` означает только доступ. Пока два значения жили в одном
// поле, пустой токен читался разом как «вход выключен» и как «ключ не доехал»,
// и сервис поднимался без бота в обоих случаях.
type TelegramConfig struct {
// Enabled — умолчания у него нет **намеренно**, и потому его нет в
// `defaultConfig()`: умолчание было бы угаданным намерением, а признак
// заведён затем, чтобы намерение объявляли. Отсутствие ключа в файле ловит
// `LoadConfig` — нулевое значение `bool` режима не выбирает.
Enabled bool `toml:"enabled"`
BotToken string `toml:"bot_token"`
UpdateTimeout int `toml:"update_timeout"`
}
// Validate проверяет ключ доступа против объявленного намерения. Пустой ключ
// при включённом входе — ошибка настройки: бот по нему не появится, а тихий
// подъём без бота оставил бы отправителей без ответов.
//
// Названо имя ключа, а не значение: значение `bot_token` в журнал попасть не
// должно.
func (c TelegramConfig) Validate() error {
if c.Enabled && c.BotToken == "" {
return errors.New("telegram: не заполнен ключ bot_token при enabled = true")
}
return nil
}
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор // AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции // клиента и секрет приезжают сюда и приводятся к настройкам коллекции
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и // пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
@@ -143,6 +155,15 @@ func defaultConfig() *Config {
Storage: StorageConfig{ Storage: StorageConfig{
DataDir: "data", DataDir: "data",
}, },
Pipeline: PipelineConfig{
Workers: 3,
// Час на свою работу и сутки на чужую. Час меньше времени, которое
// многочасовая запись занимает на приведении, и это принято
// сознательно: сторож ловит зависание, живой шаг наблюдается по
// самому процессу, а остановка обратима.
OwnWorkLimitMinutes: 60,
ForeignWorkLimitMinutes: 24 * 60,
},
Yandex: YandexConfig{ Yandex: YandexConfig{
FolderID: "", FolderID: "",
SpeechKitAPIKey: "", SpeechKitAPIKey: "",
@@ -152,11 +173,6 @@ func defaultConfig() *Config {
ObjStorageRegion: "ru-central1", ObjStorageRegion: "ru-central1",
ObjStorageEndpoint: "https://storage.yandexcloud.net/", ObjStorageEndpoint: "https://storage.yandexcloud.net/",
}, },
// Умолчания у `Enabled` здесь нет намеренно — причина у поля.
Telegram: TelegramConfig{
BotToken: "",
UpdateTimeout: 10,
},
Auth: AuthConfig{ Auth: AuthConfig{
SecureCookie: true, SecureCookie: true,
}, },
@@ -173,19 +189,10 @@ func LoadConfig(path string) (*Config, error) {
config := defaultConfig() config := defaultConfig()
// Load configuration from file // Load configuration from file
meta, err := toml.DecodeFile(path, &config) if _, err := toml.DecodeFile(path, &config); err != nil {
if err != nil {
return nil, decodeError(path, err) return nil, decodeError(path, err)
} }
// Признак включения входа Telegram обязателен: умолчания у него нет, и
// отличить «не задан» от «задан ложным» умеет только разбор — нулевое
// значение `bool` в структуре у обоих одинаковое. Отсюда и `meta`: наружу
// она не отдаётся, приговор выносится здесь.
if !meta.IsDefined("telegram", "enabled") {
return nil, errors.New("telegram: не задан ключ enabled; он объявляет, нужен ли сервису вход Telegram")
}
return config, nil return config, nil
} }
+78 -104
View File
@@ -1,11 +1,11 @@
package config package config
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
) )
// Проверка входа — единственная страховка от того, чтобы сервис поднялся с // Проверка входа — единственная страховка от того, чтобы сервис поднялся с
@@ -99,68 +99,6 @@ func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) {
} }
} }
// Признак включения объявляет намерение, ключ доступа означает только доступ.
// Пока эти два значения жили в одном поле, пустой токен читался разом как
// «вход выключен» и как «ключ не доехал».
func TestTelegramConfigValidateAcceptsEnabledWithToken(t *testing.T) {
cfg := TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"}
if err := cfg.Validate(); err != nil {
t.Fatalf("включённый вход с ключом отвергнут: %v", err)
}
}
func TestTelegramConfigValidateRejectsEnabledWithoutToken(t *testing.T) {
cfg := TelegramConfig{Enabled: true, BotToken: ""}
err := cfg.Validate()
if err == nil {
t.Fatal("включённый вход без ключа доступа пропущен")
}
if !strings.Contains(err.Error(), "bot_token") {
t.Fatalf("имя ключа не названо: %v", err)
}
}
// Выключенный вход на ключ доступа не смотрит вовсе: пустой ключ при нём —
// обычное состояние локального прогона, а не ошибка настройки.
func TestTelegramConfigValidateIgnoresTokenWhenDisabled(t *testing.T) {
cfg := TelegramConfig{Enabled: false, BotToken: ""}
if err := cfg.Validate(); err != nil {
t.Fatalf("выключенный вход без ключа отвергнут: %v", err)
}
}
// Половина требования «сообщение не несёт значения ключа» на этой проверке
// **вакуумна**, и честнее это назвать, чем изображать сторожа.
//
// `Validate()` отказывает ровно на пустом ключе — значения, которым можно
// проговориться, на этом пути не существует. Прежняя редакция сторожа искала
// подстроку, которой в сообщении нет ни при каком входе, и потому не могла
// упасть вовсе: правка на `%q` от токена оставила бы её зелёной. В проекте это
// третий пойманный случай проверки, не способной упасть.
//
// Настоящий сторож той же нормы живёт там, где непустой ключ в отказ попасть
// действительно может, — `TestLoadConfigMalformedSecretLineHidesValue` и
// `TestLoadConfigMalformedBeforeAnyKeyHidesValue`. Здесь проверяется то, что
// проверяемо: заполненный ключ проходит, пустой отвергается с именем ключа.
func TestTelegramConfigValidateNamesKeyWithoutValue(t *testing.T) {
filled := TelegramConfig{Enabled: true, BotToken: "123456:AAHfake-secret-token-value"}
if err := filled.Validate(); err != nil {
t.Fatalf("включённый вход с заполненным ключом отвергнут: %v", err)
}
err := TelegramConfig{Enabled: true, BotToken: ""}.Validate()
if err == nil {
t.Fatal("включённый вход без ключа доступа пропущен")
}
if !strings.Contains(err.Error(), "bot_token") {
t.Fatalf("имя ключа не названо: %v", err)
}
}
func writeConfig(t *testing.T, body string) string { func writeConfig(t *testing.T, body string) string {
t.Helper() t.Helper()
@@ -172,49 +110,17 @@ func writeConfig(t *testing.T, body string) string {
} }
const validConfigBody = ` const validConfigBody = `
[telegram] [storage]
enabled = false data_dir = "data"
bot_token = ""
` `
// Признак обязателен: файл без него негоден. Умолчание было бы угаданным
// намерением, а отличить «не задан» от «задан ложным» умеет только разбор —
// нулевое значение bool у обоих одинаковое.
func TestLoadConfigRejectsMissingTelegramEnabled(t *testing.T) {
path := writeConfig(t, "[telegram]\nbot_token = \"123456:AA-fake\"\n")
_, err := LoadConfig(path)
if err == nil {
t.Fatal("файл без признака включения принят")
}
if !strings.Contains(err.Error(), "enabled") {
t.Fatalf("имя недостающего ключа не названо: %v", err)
}
}
func TestLoadConfigReadsBothValuesOfTelegramEnabled(t *testing.T) {
for _, enabled := range []bool{true, false} {
t.Run(fmt.Sprintf("%t", enabled), func(t *testing.T) {
body := fmt.Sprintf("[telegram]\nenabled = %t\nbot_token = \"123456:AA-fake\"\n", enabled)
cfg, err := LoadConfig(writeConfig(t, body))
if err != nil {
t.Fatalf("годный файл отвергнут: %v", err)
}
if cfg.Telegram.Enabled != enabled {
t.Fatalf("признак доехал как %t, а в файле %t", cfg.Telegram.Enabled, enabled)
}
})
}
}
// Инвариант «секрет не покидает конфиг»: текст отказа разбора собирает чужая // Инвариант «секрет не покидает конфиг»: текст отказа разбора собирает чужая
// библиотека из разбираемого куска файла, и оборванная строка ключа доступа // библиотека из разбираемого куска файла, и оборванная строка ключа доступа
// уехала бы в журнал вместе со значением. Отсюда собственное сообщение. // уехала бы в журнал вместе со значением. Отсюда собственное сообщение.
func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) { func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) {
const secret = "123456:AAHfake-secret-token-value" const secret = "AQVNfake-secret-api-key-value"
// Кавычка не закрыта: разбор оборвётся на значении. // Кавычка не закрыта: разбор оборвётся на значении.
path := writeConfig(t, "[telegram]\nenabled = true\nbot_token = \""+secret+"\n") path := writeConfig(t, "[yandex]\nfolder_id = \"b1g\"\nspeech_kit_api_key = \""+secret+"\n")
_, err := LoadConfig(path) _, err := LoadConfig(path)
if err == nil { if err == nil {
@@ -222,7 +128,7 @@ func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) {
} }
message := err.Error() message := err.Error()
for _, part := range []string{secret, "AAHfake", "secret-token-value", "123456"} { for _, part := range []string{secret, "AQVNfake", "secret-api-key-value"} {
if strings.Contains(message, part) { if strings.Contains(message, part) {
t.Fatalf("значение ключа доступа уехало в отказ: %v", err) t.Fatalf("значение ключа доступа уехало в отказ: %v", err)
} }
@@ -231,7 +137,7 @@ func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) {
if !strings.Contains(message, "строке 3") { if !strings.Contains(message, "строке 3") {
t.Fatalf("номер строки не назван, чинить нечего: %v", err) t.Fatalf("номер строки не назван, чинить нечего: %v", err)
} }
if !strings.Contains(message, "bot_token") { if !strings.Contains(message, "speech_kit_api_key") {
t.Fatalf("ключ не назван, чинить нечего: %v", err) t.Fatalf("ключ не назван, чинить нечего: %v", err)
} }
} }
@@ -255,9 +161,9 @@ func TestLoadConfigTypeMismatchKeepsDiagnostics(t *testing.T) {
// уязвимая: именно в ней будущая правка легче всего протащит текст библиотеки // уязвимая: именно в ней будущая правка легче всего протащит текст библиотеки
// обратно. // обратно.
func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) { func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) {
const secret = "123456:AAHsecret-token-value" const secret = "AQVNsecret-api-key-value"
// У секции не закрыта скобка: разбор оборвётся, не назвав ни одного ключа. // У секции не закрыта скобка: разбор оборвётся, не назвав ни одного ключа.
path := writeConfig(t, "[telegram\nenabled = true\nbot_token = \""+secret+"\"\n") path := writeConfig(t, "[yandex\nfolder_id = \"b1g\"\nspeech_kit_api_key = \""+secret+"\"\n")
_, err := LoadConfig(path) _, err := LoadConfig(path)
if err == nil { if err == nil {
@@ -265,7 +171,7 @@ func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) {
} }
message := err.Error() message := err.Error()
for _, part := range []string{secret, "AAHsecret", "secret-token-value"} { for _, part := range []string{secret, "AQVNsecret", "secret-api-key-value"} {
if strings.Contains(message, part) { if strings.Contains(message, part) {
t.Fatalf("значение ключа доступа уехало в отказ: %v", err) t.Fatalf("значение ключа доступа уехало в отказ: %v", err)
} }
@@ -274,3 +180,71 @@ func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) {
t.Fatalf("место отказа не названо, чинить нечего: %v", err) t.Fatalf("место отказа не названо, чинить нечего: %v", err)
} }
} }
// Числа конвейера приезжают из файла, а не выдумываются кодом.
func TestLoadConfigReadsPipelineSettings(t *testing.T) {
path := writeConfig(t, `
[pipeline]
workers = 7
own_work_limit_minutes = 90
foreign_work_limit_minutes = 720
[storage]
data_dir = "data"
`)
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("конфиг не прочитан: %v", err)
}
if cfg.Pipeline.Workers != 7 {
t.Errorf("число воркеров не прочитано: %d", cfg.Pipeline.Workers)
}
limits := cfg.Pipeline.StuckLimits()
if limits.Own != 90*time.Minute {
t.Errorf("предел своей работы не прочитан: %v", limits.Own)
}
if limits.Foreign != 720*time.Minute {
t.Errorf("предел чужой работы не прочитан: %v", limits.Foreign)
}
}
// Умолчания есть у всех трёх чисел: файл без секции конвейера годен, и сервис
// поднимается с рабочими значениями.
func TestPipelineSettingsHaveDefaults(t *testing.T) {
path := writeConfig(t, "[storage]\ndata_dir = \"data\"\n")
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("конфиг не прочитан: %v", err)
}
if cfg.Pipeline.Workers <= 0 {
t.Errorf("умолчание числа воркеров негодно: %d", cfg.Pipeline.Workers)
}
if err := cfg.Pipeline.Validate(); err != nil {
t.Errorf("умолчания не проходят собственную проверку: %v", err)
}
}
// Ноль воркеров — объявленный режим, а отрицательное число и нулевой предел —
// опечатка: подниматься с ней значит остановить всякую запись первым же
// захватом.
func TestPipelineValidateSeparatesModeFromTypo(t *testing.T) {
valid := PipelineConfig{Workers: 0, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 1440}
if err := valid.Validate(); err != nil {
t.Errorf("ноль воркеров объявлен законным значением: %v", err)
}
for name, cfg := range map[string]PipelineConfig{
"отрицательное число воркеров": {Workers: -1, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 1440},
"нулевой предел своей работы": {Workers: 1, OwnWorkLimitMinutes: 0, ForeignWorkLimitMinutes: 1440},
"нулевой предел чужой работы": {Workers: 1, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 0},
} {
if err := cfg.Validate(); err == nil {
t.Errorf("%s принято за режим", name)
}
}
}
+32 -7
View File
@@ -24,12 +24,37 @@ type AudioFileConverter interface {
Convert(ctx context.Context, src, dest string) error Convert(ctx context.Context, src, dest string) error
} }
// AudioRecognizer — внешний распознаватель речи.
//
// Заливка и отправка операции разделены: это два обращения с разной ценой
// повтора. Повтор заливки бесплатен и кладёт объект под тем же ключом; повтор
// отправки оплачивается наружу, и шаг обязан проверить сделанное прежде, чем
// платить второй раз.
//
// Результат отдаётся **доменным** — реплики со временем, плоский текст и байты
// ответа на хранение, — а не сырым форматом провайдера: разбор потока это
// обязанность адаптера, и ни один шаг конвейера не знает, каким потоком и какими
// полями провайдер отвечает.
type AudioRecognizer interface { type AudioRecognizer interface {
Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error) // Provider — имя провайдера, под которым сохраняется попытка.
GetRecognitionText(ctx context.Context, operationID string) (string, error) Provider() string
CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) // Model — имя модели распознавания.
} Model() string
// Upload кладёт аудио туда, откуда провайдер его прочитает, и отдаёт адрес.
type TelegramMessageSender interface { // Повтор кладёт объект под тем же ключом и оплаты не стоит.
Send(text string, chatId int64, replyToMessageId *int) error Upload(ctx context.Context, file io.Reader, objectKey string) (sourceURI string, err error)
// ObjectExists отвечает, лежит ли объект нужного размера. По нему шаг решает,
// повторять ли заливку; сверка содержимого хешем ненадёжна — признак
// целостности у составного объекта не равен отпечатку содержимого.
ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error)
// Submit заводит операцию распознавания по адресу аудио. Оплачивается
// наружу.
Submit(ctx context.Context, sourceURI string) (operationID string, err error)
// CheckStatus опрашивает операцию.
CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error)
// Fetch забирает готовый результат и отдаёт его доменным.
Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error)
// Parse строит доменный результат из **сохранённого** ответа провайдера, не
// обращаясь к нему. По нему архив пересчитывается без единого рубля.
Parse(raw []byte) (*entity.RecognitionOutcome, error)
} }
+7 -11
View File
@@ -5,14 +5,10 @@ import (
"fmt" "fmt"
) )
// ErrDeliveryChannelDown — канал, которым отвечают отправителю, не поднят. // ErrOwnerRequired — приём по HTTP дошёл до заведения задачи, а владельца ему не
// Отдаётся отправителем-заглушкой, которого получает ядро, когда вход не // назвали. Значение сентинельное: нести отказу нечего, а имя учётной записи в
// настроен. // него не кладётся никогда.
// var ErrOwnerRequired = errors.New("owner is required to accept a record")
// Значение сентинельное, а не тип: соседям по ряду есть что нести — состояние,
// идентификатор задачи, — а этому нечего. Заглушка не знает ни задачи, ни чата,
// и запись о недоставке делает шаг, у которого задача под рукой.
var ErrDeliveryChannelDown = errors.New("delivery channel is down")
type JobNotFoundError struct { type JobNotFoundError struct {
State string State string
@@ -24,9 +20,9 @@ func (e *JobNotFoundError) Error() string {
} }
// LostAcquisitionError — захват задачи за время работы шага достался другому. // LostAcquisitionError — захват задачи за время работы шага достался другому.
// Шаг, получивший его, завершается без записи результата и без ответа // Шаг, получивший его, завершается без записи результата: иначе два воркера
// отправителю: иначе два воркера пишут в одну задачу по очереди, а отправитель // пишут в одну запись по очереди, портя её результат, и счётчик отказов
// получает два ответа на одну запись. // сбрасывает тот, кто уже не владелец.
type LostAcquisitionError struct { type LostAcquisitionError struct {
JobID string JobID string
} }
+92 -15
View File
@@ -2,7 +2,6 @@ package contract
import ( import (
"io" "io"
"time"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
@@ -23,6 +22,14 @@ type WorkFile interface {
Close() error Close() error
} }
// FileMeta — что известно о копии сверх её содержимого.
type FileMeta struct {
// Format — расширение без точки, в нижнем регистре.
Format string
// DurationMs — длительность, если её удалось прочитать.
DurationMs int64
}
type FileRepository interface { type FileRepository interface {
// Stage принимает содержимое потоком в рабочую копию с заданным // Stage принимает содержимое потоком в рабочую копию с заданным
// расширением: по нему внешняя программа выбирает разбор. В память запись // расширением: по нему внешняя программа выбирает разбор. В память запись
@@ -33,26 +40,96 @@ type FileRepository interface {
StageEmpty(ext string) (WorkFile, error) StageEmpty(ext string) (WorkFile, error)
// Localize выдаёт рабочую копию хранимого файла. // Localize выдаёт рабочую копию хранимого файла.
Localize(fileID string) (WorkFile, error) Localize(fileID string) (WorkFile, error)
// CreateLocal кладёт рабочую копию в хранилище под именем name и заводит // Create кладёт рабочую копию в хранилище под именем name и заводит запись о
// запись о файле. Имя задаёт сервис: умолчание хранилища, строящее его из // файле. Имя задаёт сервис: умолчание хранилища, строящее его из имени
// имени отправителя, не применяется. // отправителя, не применяется.
CreateLocal(name string, work WorkFile) (*entity.File, error) //
// CreateRemote заводит запись о копии, лежащей во внешнем хранилище. // ownerID — владелец записи, которой файл принадлежит, и он обязателен:
CreateRemote(objectKey string, size int64) (*entity.File, error) // колонка владельца пустого значения не принимает, пустой отвергается
// схемой. Владелец лежит своей колонкой, а не выводится через запись: файл
// переживает свою запись — шаг заводит его до сохранения, и потерянный
// захват оставляет файл с владельцем и без ссылки.
Create(name string, work WorkFile, meta FileMeta, ownerID string) (*entity.File, error)
GetByID(id string) (*entity.File, error) GetByID(id string) (*entity.File, error)
// Open отдаёт содержимое хранимого файла потоком. // Open отдаёт содержимое хранимого файла потоком.
Open(fileID string) (io.ReadCloser, error) Open(fileID string) (io.ReadCloser, error)
} }
type TranscriptJobRepository interface { // AcquiredRecord — то, что отдаёт захват: идентификатор записи и признак
Create(job *entity.TranscribeJob) error // **этого** захвата.
// Save сохраняет задачу, захват которой держит holder. Захват, доставшийся //
// Перечня колонок здесь нет намеренно. Захват, возвращавший колонки поимённо,
// требовал править их в четырёх местах сразу, и забытая колонка приезжала
// нулевой, а первое же сохранение писало этот ноль поверх значения. Колонки шаг
// читает обычным чтением.
type AcquiredRecord struct {
ID string
// Holder — значение, уникальное для каждого захвата. Запись результата
// условна по нему, а не по занятости записи: захват, перевыданный другому по
// протуханию срока или после снятия остановки человеком, обязан обратить
// запись первого в отказ.
Holder string
}
type AudioRecordRepository interface {
Create(record *entity.AudioRecord) error
// Save сохраняет запись, захват которой держит holder. Захват, доставшийся
// за время работы другому, даёт LostAcquisitionError и запись не проводит. // за время работы другому, даёт LostAcquisitionError и запись не проводит.
// Пустой holder снимает эту условность и в конвейере не употребляется: все // Пустой holder снимает эту условность и в конвейере не употребляется: все
// его шаги получают признак захвата от FindAndAcquire. // его шаги получают признак захвата от FindAndAcquire.
Save(job *entity.TranscribeJob, holder string) error Save(record *entity.AudioRecord, holder string) error
GetByID(id string) (*entity.TranscribeJob, error) // GetByID отдаёт запись, только если её владелец — ownerID. Чужая запись,
// FindAndAcquire забирает задачу одним неделимым шагом и увеличивает число // ничья и несуществующая дают одну и ту же ошибку: по разнице ответов иначе
// её попыток. Работы в состоянии нет — JobNotFoundError. // перебирается список заведённых записей.
FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) //
// Владелец здесь обязателен, и пустой ownerID не совпадает ни с чем —
// включая записи без владельца. Правило записано со стороны спрашивающего:
// обязательность, которую держит одна лишь подпись метода, пустую строку
// пропускает.
GetByID(id, ownerID string) (*entity.AudioRecord, error)
// Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья
// выборка владельцем не сужается.
Get(id string) (*entity.AudioRecord, error)
// FindAndAcquire забирает пригодную к работе запись одним неделимым шагом и
// увеличивает число её отказов. Отбор идёт по рабочим рубежам, паузе, сроку
// протухания захвата и отсутствию признака остановки; срок протухания
// приезжает с рубежом и пишется в саму запись.
//
// Работы нет — JobNotFoundError.
FindAndAcquire(stages []entity.Stage) (*AcquiredRecord, error)
}
// TextRepository — тексты записи. Пара «запись и вид» уникальна: повтор
// прерванного шага не заводит второй строки.
type TextRepository interface {
Put(recordID, kind, contents string) (*entity.Text, error)
GetByID(id string) (*entity.Text, error)
}
// StructureRepository — структура реплик записи. Пара «запись и версия разбора»
// уникальна по той же причине.
type StructureRepository interface {
Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error)
GetByID(id string) (*entity.Structure, error)
}
// RecognitionRepository — попытка распознавания у внешнего провайдера.
type RecognitionRepository interface {
// Create заводит строку попытки **до** обращения к провайдеру: окно между
// его ответом и записью идентификатора — то место, где теряется оплаченное.
Create(recognition *entity.Recognition) error
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
// последнему повторный шаг узнаёт, что за эту запись уже заплачено.
Submitted(id, sourceURI, externalID string) error
// Finish отмечает завершение операции и кладёт сырой ответ вложением.
Finish(id string, raw []byte) error
GetByID(id string) (*entity.Recognition, error)
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда
// ответ нужен: шаг опроса читает строку попытки без него.
ReadRaw(id string) ([]byte, error)
}
// RecordEventRepository — журнал событий записи.
type RecordEventRepository interface {
Append(event *entity.RecordEvent) error
} }
+2 -2
View File
@@ -38,7 +38,7 @@ func TestApiRequiresSession(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, files) assert.Empty(t, files)
jobs, err := env.app.FindAllRecords(migrations.JobsCollection) jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, jobs) assert.Empty(t, jobs)
}) })
@@ -64,7 +64,7 @@ func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio"))) env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
require.Equal(t, http.StatusCreated, created.Code) require.Equal(t, http.StatusCreated, created.Code)
jobs, err := env.app.FindAllRecords(migrations.JobsCollection) jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, jobs, 1) require.Len(t, jobs, 1)
+1 -1
View File
@@ -188,7 +188,7 @@ func TestLoginCreatesAccountAndSession(t *testing.T) {
r, err := apis.NewRouter(env.app) r, err := apis.NewRouter(env.app)
require.NoError(t, err) require.NoError(t, err)
NewTranscribeHandler(pbrepo.NewTranscriptJobRepository(env.app), nil, nil).Register(r) NewTranscribeHandler(pbrepo.NewAudioRecordRepository(env.app), pbrepo.NewTextRepository(env.app), nil, nil).Register(r)
checkMux, err := r.BuildMux() checkMux, err := r.BuildMux()
require.NoError(t, err) require.NoError(t, err)
checkMux.ServeHTTP(checkResponse, check) checkMux.ServeHTTP(checkResponse, check)
+201
View File
@@ -0,0 +1,201 @@
package http
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Проверки разграничения записей по владельцу. Все идут через собранный роутер:
// сужение живёт в хранилище, но судится по тому, что видит отправитель.
// newSecondAccount заводит вторую учётную запись с собственной сессией.
// Постоянный адрес почты первой занят, и повторное сохранение отвергается —
// адрес здесь свой.
func newSecondAccount(t *testing.T, app core.App) (*core.Record, string) {
t.Helper()
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
record := core.NewRecord(users)
record.Set("email", "stranger@example.com")
record.Set("verified", true)
record.SetRandomPassword()
require.NoError(t, app.Save(record))
token, err := record.NewAuthToken()
require.NoError(t, err)
return record, token
}
// withSessionHeader предъявляет сессию заголовком. Собственная поверхность
// хранилища читается только так: слой, перекладывающий куку в заголовок, на неё
// намеренно не наведён — часть её защищена ровно тем, что браузер заголовка сам
// не шлёт.
func withSessionHeader(req *http.Request, session string) *http.Request {
req.Header.Set("Authorization", session)
return req
}
// serveAs шлёт запрос от имени названной сессии, а не сессии окружения.
func serveAs(env *testEnv, session string, w http.ResponseWriter, req *http.Request) {
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: session})
env.mux.ServeHTTP(w, req)
}
// Чужая задача неотличима от несуществующей: тот же код и то же тело. Разница
// ответов обратила бы опрос в перебор — по ней считывается, какие задачи
// заведены.
func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
job := jobWithFile(t, env)
_, stranger := newSecondAccount(t, env.app)
foreign := httptest.NewRecorder()
serveAs(env, stranger, foreign, httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody))
unknown := httptest.NewRecorder()
serveAs(env, stranger, unknown, httptest.NewRequest("GET", "/api/status/unknown0000000000", http.NoBody))
require.Equal(t, http.StatusNotFound, foreign.Code, "чужая задача не отдаётся")
assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестного идентификатора")
assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же")
// Ни состояния, ни текста расшифровки в теле нет.
assert.NotContains(t, foreign.Body.String(), entity.StateUploaded)
assert.NotContains(t, foreign.Body.String(), "transcription_text")
}
// Владельцем принятой записи становится предъявитель сессии — и у задачи, и у
// её файла.
func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
w := httptest.NewRecorder()
env.serve(w, createMultipartRequest(t, "sample.mp3", []byte("запись")))
require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.JobID)
require.NoError(t, err)
assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель")
fileRecord, err := env.app.FindRecordById("files", record.GetString("original_file"))
require.NoError(t, err)
assert.Equal(t, env.account.Id, fileRecord.GetString("owner"), "владелец файла — он же")
}
// Владельца не задают запросом: своё значение в форме на результат не влияет.
func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
_, stranger := newSecondAccount(t, env.app)
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
query := req.URL.Query()
query.Set("owner", stranger)
req.URL.RawQuery = query.Encode()
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.JobID)
require.NoError(t, err)
assert.Equal(t, env.account.Id, record.GetString("owner"))
}
// Предъявитель, чья сессия не даёт учётной записи пользователя, получает отказ
// до чтения тела. Владелец панели — именно такой: узнан он узнан, а записи в
// коллекции пользователей у него нет, и владельцем записи он стать не может.
//
// Отказ **до** укладки обязателен: позже пришлось бы убирать уже сохранённый
// файл, а уборки файлов сервис не умеет вовсе.
func TestCreateTranscribeJob_SuperuserSessionRejected(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
superusers, err := env.app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
require.NoError(t, err)
admin := core.NewRecord(superusers)
admin.Set("email", "owner@example.com")
admin.SetRandomPassword()
require.NoError(t, env.app.Save(admin))
token, err := admin.NewAuthToken()
require.NoError(t, err)
w := httptest.NewRecorder()
serveAs(env, token, w, createMultipartRequest(t, "sample.mp3", []byte("запись")))
assert.Equal(t, http.StatusForbidden, w.Code, "узнан, но не запись коллекции пользователей")
assert.Equal(t, 0, countJobs(t, env), "задачи не заведено")
assert.Equal(t, 0, countFiles(t, env), "и файла тоже")
}
// Чужой файл не отдаётся по ссылке, а свой отдаётся. Проверяется именно переход
// по ссылке: токен файла хранилище выдаёт на предъявителя, а не на файл, и отказ
// наступает на скачивании, где правило просмотра судит владельца. Проверка,
// написанная на выдачу токена, зеленела бы, не касаясь пути, по которому аудио и
// уходит.
func TestFileDownload_NarrowedByOwner(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
job := jobWithFile(t, env)
_, stranger := newSecondAccount(t, env.app)
record, err := env.app.FindRecordById("files", *job.OriginalFileID)
require.NoError(t, err)
require.Equal(t, env.account.Id, record.GetString("owner"))
link := "/api/files/files/" + record.Id + "/" + record.GetString("file")
mine := httptest.NewRecorder()
env.mux.ServeHTTP(mine, withSessionHeader(
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, env.session), http.NoBody), env.session))
foreign := httptest.NewRecorder()
env.mux.ServeHTTP(foreign, withSessionHeader(
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, stranger), http.NoBody), stranger))
require.Equal(t, http.StatusOK, mine.Code, "свой файл отдаётся")
assert.Equal(t, "запись", mine.Body.String(), "и отдаётся содержимым")
assert.NotEqual(t, http.StatusOK, foreign.Code, "чужой файл не отдаётся")
assert.NotContains(t, foreign.Body.String(), "запись", "содержимого в отказе нет")
}
// fileToken берёт у хранилища токен файла для названной сессии. Токен выдаётся
// на предъявителя: о файле хранилище при выдаче не спрашивает.
func fileToken(t *testing.T, env *testEnv, session string) string {
t.Helper()
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, withSessionHeader(
httptest.NewRequest("POST", "/api/files/token", http.NoBody), session))
require.Equal(t, http.StatusOK, w.Code, "токен файла выдаётся всякому вошедшему")
var body struct {
Token string `json:"token"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.NotEmpty(t, body.Token)
return body.Token
}
+160
View File
@@ -0,0 +1,160 @@
package http
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/pocketbase/pocketbase/apis"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Ответ об одной записи — то, ради чего эндпойнт и существует; ниже судятся его
// ветки: готовый текст, остановленная запись и отказ хранилища на чтении текста.
// statusOf спрашивает состояние записи от имени её владельца.
func statusOf(t *testing.T, env *testEnv, recordID string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
env.serve(w, httptest.NewRequest("GET", "/api/status/"+recordID, http.NoBody))
return w
}
// Готовая расшифровка доезжает до отправителя полем `transcription_text`, и
// уходит в него **сырая** расшифровка: видов текста больше одного, и отдача
// «последнего записанного» сделала бы ответ функцией порядка записи.
func TestGetTranscribeJobStatus_ReturnsTranscript(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
texts := pbrepo.NewTextRepository(env.app)
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
require.NoError(t, err)
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
require.NoError(t, err)
record.TranscriptTextID = &transcript.Id
record.LiteraryTextID = &literary.Id
record.MoveToState(entity.StateDone)
require.NoError(t, env.handler.recordRepo.Save(record, ""))
w := statusOf(t, env, record.Id)
require.Equal(t, http.StatusOK, w.Code)
var response GetTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
assert.Equal(t, entity.StateDone, response.State)
require.NotNil(t, response.TranscriptionText, "готовый текст доехал до отправителя")
assert.Equal(t, "сырая расшифровка", *response.TranscriptionText)
assert.NotContains(t, w.Body.String(), "вычитанный текст",
"вычитанный текст этим полем не подменяется: значение поля не должно меняться от того, успел ли необязательный шаг")
}
// Остановленная запись отдаёт рубеж, на котором встала, и признак остановки
// отдельным полем: отказ перестал быть состоянием, и без признака такая запись
// выглядела бы обычной, стоящей на своём рубеже.
func TestGetTranscribeJobStatus_HaltedIsVisible(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
record.MoveToState(entity.StateNormalized)
record.Halt(entity.HaltReasonStepFailed, "сбой конвертации файла")
require.NoError(t, env.handler.recordRepo.Save(record, ""))
w := statusOf(t, env, record.Id)
require.Equal(t, http.StatusOK, w.Code)
var response GetTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
assert.Equal(t, entity.StateNormalized, response.State, "рубеж тот, на котором запись встала")
assert.True(t, response.Halted, "признак остановки виден отправителю")
assert.NotContains(t, w.Body.String(), "сбой конвертации файла",
"машинный текст отказа принадлежит журналу владельца, а не ответу отправителю")
}
// Пока запись не дошла до текста, поля нет вовсе: пустая строка на его месте
// читается как «расшифровка пуста».
func TestGetTranscribeJobStatus_RunningRecordHasNoHaltedFlag(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
w := statusOf(t, env, record.Id)
require.Equal(t, http.StatusOK, w.Code)
var response GetTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
assert.Equal(t, entity.StateUploaded, response.State)
assert.False(t, response.Halted, "запись в работе остановленной не значится")
assert.Nil(t, response.TranscriptionText)
}
// failingTextRepo отказывает на чтении текста — так выглядит недоступное
// хранилище. Битую ссылку схема завести не даёт (связь проверяется при
// сохранении), и это её защита, а не пробел: остаётся отказ самого чтения.
type failingTextRepo struct{}
func (r *failingTextRepo) Put(string, string, string) (*entity.Text, error) {
return nil, errors.New("не зовётся этой проверкой")
}
func (r *failingTextRepo) GetByID(string) (*entity.Text, error) {
return nil, errors.New("хранилище недоступно")
}
// Отказ чтения текста — это отказ хранилища, а не «записи нет». Отправителю он
// приходит своим кодом, и владелец сервиса узнаёт об аварии из журнала — иначе
// она читалась бы отправителю как «вашей записи не существует», а владельцем не
// замечалась бы вовсе.
func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
texts := pbrepo.NewTextRepository(env.app)
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
require.NoError(t, err)
record.TranscriptTextID = &transcript.Id
require.NoError(t, env.handler.recordRepo.Save(record, ""))
// Обработчик пересобирается с отказывающим хранилищем текстов: остальная
// цепочка та же, что и в проде.
journal := &journalBuffer{}
handler := NewTranscribeHandler(
env.handler.recordRepo,
&failingTextRepo{},
env.handler.trsService,
slog.New(slog.NewTextHandler(journal, nil)),
)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
req := httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code,
"отказ хранилища не выдаётся за отсутствие записи")
assert.NotContains(t, w.Body.String(), "хранилище недоступно", "внутренности наружу не выходят")
assert.Contains(t, journal.String(), "Failed to read transcript",
"владелец сервиса узнаёт об аварии из журнала")
}
+80 -21
View File
@@ -2,6 +2,7 @@ package http
import ( import (
"context" "context"
"errors"
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
@@ -10,22 +11,29 @@ import (
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router" "github.com/pocketbase/pocketbase/tools/router"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/internal/service"
) )
type TranscribeHandler struct { type TranscribeHandler struct {
jobRepo contract.TranscriptJobRepository recordRepo contract.AudioRecordRepository
textRepo contract.TextRepository
trsService *service.TranscribeService trsService *service.TranscribeService
logger *slog.Logger logger *slog.Logger
} }
func NewTranscribeHandler(jobRepo contract.TranscriptJobRepository, trsService *service.TranscribeService, logger *slog.Logger) *TranscribeHandler { func NewTranscribeHandler(
recordRepo contract.AudioRecordRepository,
textRepo contract.TextRepository,
trsService *service.TranscribeService,
logger *slog.Logger,
) *TranscribeHandler {
if logger == nil { if logger == nil {
logger = slog.Default() logger = slog.Default()
} }
return &TranscribeHandler{jobRepo: jobRepo, trsService: trsService, logger: logger} return &TranscribeHandler{recordRepo: recordRepo, textRepo: textRepo, trsService: trsService, logger: logger}
} }
type CreateTranscribeJobResponse struct { type CreateTranscribeJobResponse struct {
@@ -33,16 +41,27 @@ type CreateTranscribeJobResponse struct {
State string `json:"status"` State string `json:"status"`
} }
// GetTranscribeJobResponse — ответ об одной записи.
//
// Имена полей нормативны и остались прежними: контракт HTTP API объявлен
// проектом необратимым, и переименование поля ломает внешнюю программу молча.
// Изменились **значения** поля состояния — рубеж теперь называет достигнутое, — и
// это объявленная ломка.
//
// Поле `halted` новое: отказ перестал быть состоянием, и без него остановленная
// запись выглядела бы как обычная, стоящая на своём рубеже. Машинный текст
// отказа в ответ не идёт: он принадлежит журналу владельца сервиса.
type GetTranscribeJobResponse struct { type GetTranscribeJobResponse struct {
JobID string `json:"job_id"` JobID string `json:"job_id"`
State string `json:"status"` State string `json:"status"`
Halted bool `json:"halted"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
TranscriptionText *string `json:"transcription_text,omitempty"` TranscriptionText *string `json:"transcription_text,omitempty"`
} }
// Register вешает маршруты сервиса на роутер хранилища. Порт у сервиса и у // Register вешает маршруты сервиса на роутер хранилища. Порт у сервиса и у
// панели один, поэтому и роутер один; имена полей ответа и коды при переезде // панели один, поэтому и роутер один; имена полей ответа и коды при переезде
// сохранены — публичный контракт API объявлен необратимым. // сохранены — публичный контракт HTTP API объявлен необратимым.
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) { func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
api := r.Group("/api") api := r.Group("/api")
@@ -51,7 +70,13 @@ func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
// него не подпадает, часть её защищена ровно тем, что браузер заголовка сам // него не подпадает, часть её защищена ровно тем, что браузер заголовка сам
// не шлёт. // не шлёт.
api.Bind(SessionFromCookie()) api.Bind(SessionFromCookie())
api.Bind(apis.RequireAuth()) // Коллекция названа поимённо, а не оставлена умолчанию. Без имени проверка
// пускает всякую учётную запись хранилища, включая владельца панели, — а
// записи в коллекции пользователей у него нет, и владельцем записи он стать
// не может. Отказ такому предъявителю обязан наступить здесь, до чтения
// тела: позже пришлось бы убирать уже уложенный файл, а уборки файлов
// сервис не умеет вовсе.
api.Bind(apis.RequireAuth(migrations.UsersCollection))
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись // Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
// раньше обработчика, без строки в журнале приёма. Приём размеру не судья, // раньше обработчика, без строки в журнале приёма. Приём размеру не судья,
@@ -72,14 +97,18 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
} }
}() }()
// Запись доехала целиком, поэтому задача заводится независимо от того, // Запись доехала целиком, поэтому она заводится независимо от того, дождётся
// дождётся ли отправитель ответа: на контексте запроса приём терял бы // ли отправитель ответа: на контексте запроса приём терял бы полностью
// полностью загруженную запись от одного обрыва соединения, а забрать // загруженную запись от одного обрыва соединения, а забрать результат он
// результат он может и позже — по `GET /status/{id}`. Значения контекста // может и позже — по `GET /status/{id}`. Значения контекста (журнал запроса,
// (журнал запроса, сессия) при этом сохраняются, теряется только отмена. // сессия) при этом сохраняются, теряется только отмена.
ctx := context.WithoutCancel(e.Request.Context()) ctx := context.WithoutCancel(e.Request.Context())
job, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename) // Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
// чужое имя. Проверка предъявителя стоит слоем выше, поэтому здесь `e.Auth`
// уже есть и принадлежит коллекции пользователей.
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
if err != nil { if err != nil {
// Второй раз отказ не логируем: приём назван конвенцией логирующей // Второй раз отказ не логируем: приём назван конвенцией логирующей
// границей и уже написал о нём. Транспорт переводит ошибку в ответ. // границей и уже написал о нём. Транспорт переводит ошибку в ответ.
@@ -88,23 +117,53 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
// Возвращаем успешный ответ // Возвращаем успешный ответ
return e.JSON(http.StatusCreated, CreateTranscribeJobResponse{ return e.JSON(http.StatusCreated, CreateTranscribeJobResponse{
JobID: job.Id, JobID: record.Id,
State: job.State, State: record.State,
}) })
} }
func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error { func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
jobID := e.Request.PathValue("id") recordID := e.Request.PathValue("id")
job, err := h.jobRepo.GetByID(jobID) // Чужая запись, запись без владельца и несуществующая отвечают одним и тем
// же: хранилище отдаёт на все три ту же ошибку, а транспорт — тот же код и
// то же тело. Различать их наружу нельзя — по разнице ответов перебирается
// список заведённых записей.
record, err := h.recordRepo.GetByID(recordID, e.Auth.Id)
if err != nil { if err != nil {
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
// приходит отправителю как «вашей записи нет», а владелец сервиса об
// аварии не узнаёт ниоткуда.
var notFound *contract.JobNotFoundError
if !errors.As(err, &notFound) {
h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
}
return e.JSON(http.StatusNotFound, map[string]string{"error": "Job not found"}) return e.JSON(http.StatusNotFound, map[string]string{"error": "Job not found"})
} }
return e.JSON(http.StatusOK, GetTranscribeJobResponse{ response := GetTranscribeJobResponse{
JobID: job.Id, JobID: record.Id,
State: job.State, State: record.State,
CreatedAt: job.CreatedAt, Halted: record.IsHalted(),
TranscriptionText: job.TranscriptionText, CreatedAt: record.CreatedAt,
}) }
// Вид текста называется **явно**: видов у записи больше одного, и отдача
// «последнего записанного» сделала бы ответ функцией порядка записи, а не
// состояния записи. В это поле уходит сырая расшифровка, и только она.
if record.TranscriptTextID != nil {
text, err := h.textRepo.GetByID(*record.TranscriptTextID)
if err != nil {
h.logger.Error("Failed to read transcript", "error", err, "record_id", recordID)
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to read transcription"})
}
if text.Contents != "" {
contents := text.Contents
response.TranscriptionText = &contents
}
}
return e.JSON(http.StatusOK, response)
} }
+44 -33
View File
@@ -14,6 +14,7 @@ import (
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
@@ -24,6 +25,7 @@ import (
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/internal/service"
@@ -58,13 +60,6 @@ type stubConverter struct{}
func (c *stubConverter) Convert(context.Context, string, string) error { return nil } func (c *stubConverter) Convert(context.Context, string, string) error { return nil }
// TestTgSender: приём по HTTP в Telegram не отвечает, но сервису отправитель нужен.
type TestTgSender struct{}
func (s *TestTgSender) Send(msg string, chatId int64, replyMsgId *int) error {
return nil
}
// readableMetaViewer — источник метаданных, который читает любую запись. // readableMetaViewer — источник метаданных, который читает любую запись.
func readableMetaViewer() *stubMetaViewer { func readableMetaViewer() *stubMetaViewer {
return &stubMetaViewer{seconds: 42} return &stubMetaViewer{seconds: 42}
@@ -167,8 +162,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
pbrepo.BindPanelRules(app) pbrepo.BindPanelRules(app)
fileRepo := pbrepo.NewFileRepository(app) recordRepo := pbrepo.NewAudioRecordRepository(app)
jobRepo := pbrepo.NewTranscriptJobRepository(app) textRepo := pbrepo.NewTextRepository(app)
repos := service.Repositories{
Records: recordRepo,
Files: pbrepo.NewFileRepository(app),
Texts: textRepo,
Structures: pbrepo.NewStructureRepository(app),
Recognitions: pbrepo.NewRecognitionRepository(app),
Events: pbrepo.NewRecordEventRepository(app),
}
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на // Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки // имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
@@ -178,16 +181,15 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
logger := slog.New(slog.NewTextHandler(journal, nil)) logger := slog.New(slog.NewTextHandler(journal, nil))
trsService := service.NewTranscribeService( trsService := service.NewTranscribeService(
jobRepo, repos,
fileRepo,
metaviewer, metaviewer,
&stubConverter{}, &stubConverter{},
&recognizer.MemoryAudioRecognizer{}, &recognizer.MemoryAudioRecognizer{},
&TestTgSender{}, entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour},
logger, logger,
) )
handler := NewTranscribeHandler(jobRepo, trsService, logger) handler := NewTranscribeHandler(recordRepo, textRepo, trsService, logger)
// Роутер собирается тем же способом, что и боевой: маршруты вешает сам // Роутер собирается тем же способом, что и боевой: маршруты вешает сам
// обработчик, и проверка судит ту же цепочку, что и прод. // обработчик, и проверка судит ту же цепочку, что и прод.
@@ -256,16 +258,16 @@ func countFiles(t *testing.T, env *testEnv) int {
return len(records) return len(records)
} }
// countJobs считает заведённые задачи расшифровки. // countJobs считает заведённые аудиозаписи.
func countJobs(t *testing.T, env *testEnv) int { func countJobs(t *testing.T, env *testEnv) int {
records, err := env.app.FindAllRecords(migrations.JobsCollection) records, err := env.app.FindAllRecords(migrations.RecordsCollection)
require.NoError(t, err) require.NoError(t, err)
return len(records) return len(records)
} }
// jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна // jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна
// схемой, потому что без неё задача не пройдёт ни одного шага. // схемой, потому что без неё задача не пройдёт ни одного шага.
func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob { func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord {
t.Helper() t.Helper()
repo := pbrepo.NewFileRepository(env.app) repo := pbrepo.NewFileRepository(env.app)
@@ -273,12 +275,21 @@ func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob {
require.NoError(t, err) require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }() defer func() { require.NoError(t, work.Close()) }()
file, err := repo.CreateLocal("sample.mp3", work) // Владелец — учётная запись проверки: задача, пришедшая из веба, без
// владельца больше не заводится, и фикстура без него описывала бы состояние,
// которого в проде не бывает.
file, err := repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, env.account.Id)
require.NoError(t, err) require.NoError(t, err)
job := &entity.TranscribeJob{State: entity.StateCreated, Source: entity.SourceApi, FileID: &file.Id} record := &entity.AudioRecord{
require.NoError(t, env.handler.jobRepo.Create(job)) State: entity.StateUploaded,
return job StateEnteredAt: clock.Now(),
Source: entity.SourceApi,
OwnerID: env.account.Id,
OriginalFileID: &file.Id,
}
require.NoError(t, env.handler.recordRepo.Create(record))
return record
} }
// storedContent читает содержимое файла из хранилища. // storedContent читает содержимое файла из хранилища.
@@ -319,21 +330,21 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, response.JobID) assert.NotEmpty(t, response.JobID)
assert.Equal(t, entity.StateCreated, response.State) assert.Equal(t, entity.StateUploaded, response.State)
// Задача действительно заведена, а не только названа в ответе: иначе // Задача действительно заведена, а не только названа в ответе: иначе
// отправитель получит идентификатор записи, которой не будет никогда. // отправитель получит идентификатор записи, которой не будет никогда.
require.Equal(t, 1, countJobs(t, env)) require.Equal(t, 1, countJobs(t, env))
job, err := env.handler.jobRepo.GetByID(response.JobID) job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, entity.StateCreated, job.State) assert.Equal(t, entity.StateUploaded, job.State)
require.NotNil(t, job.FileID) require.NotNil(t, job.OriginalFileID)
assert.NotEmpty(t, *job.FileID) assert.NotEmpty(t, *job.OriginalFileID)
// Содержимое лежит в хранилище одним файлом и целиком. // Содержимое лежит в хранилище одним файлом и целиком.
require.Equal(t, 1, countFiles(t, env)) require.Equal(t, 1, countFiles(t, env))
assert.Equal(t, content, storedContent(t, env, *job.FileID)) assert.Equal(t, content, storedContent(t, env, *job.OriginalFileID))
} }
func TestCreateTranscribeJob_NoFile(t *testing.T) { func TestCreateTranscribeJob_NoFile(t *testing.T) {
@@ -396,7 +407,7 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, response.JobID) assert.NotEmpty(t, response.JobID)
assert.Equal(t, entity.StateCreated, response.State) assert.Equal(t, entity.StateUploaded, response.State)
} }
func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) { func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
@@ -505,7 +516,7 @@ const senderNameMarker = "SENDERNAMELEAKMARKER7Q2"
// долг `docs/conventions/logging.md`: `msg` обязан стать короткой категорией. // долг `docs/conventions/logging.md`: `msg` обязан стать короткой категорией.
// Когда долг закроют, правка будет здесь и одна. // Когда долг закроют, правка будет здесь и одна.
const ( const (
msgIntake = "Creating transcribe job" msgIntake = "Creating audio record"
// Отказ пишет доменная граница — приём, — а не транспорт: конвенция просит // Отказ пишет доменная граница — приём, — а не транспорт: конвенция просит
// логировать ошибку один раз, и повторная запись транспорта снята. // логировать ошибку один раз, и повторная запись транспорта снята.
msgIntakeErr = "Failed to get file info" msgIntakeErr = "Failed to get file info"
@@ -595,15 +606,15 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
var response CreateTranscribeJobResponse var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
job, err := env.handler.jobRepo.GetByID(response.JobID) job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, job.FileID) require.NotNil(t, job.OriginalFileID)
// Отбор по идентификатору **этого** прогона: иначе утверждение прошло бы по // Отбор по идентификатору **этого** прогона: иначе утверждение прошло бы по
// строке, оставленной соседней проверкой, и прослеживаемость числилась бы // строке, оставленной соседней проверкой, и прослеживаемость числилась бы
// сохранённой при пустом журнале. // сохранённой при пустом журнале.
journal := env.journal.String() journal := env.journal.String()
assert.Contains(t, journal, *job.FileID, "по журналу видно, какой файл заведён") assert.Contains(t, journal, *job.OriginalFileID, "по журналу видно, какой файл заведён")
assert.Contains(t, journal, ".mp3", "расширение принятой записи в журнале остаётся") assert.Contains(t, journal, ".mp3", "расширение принятой записи в журнале остаётся")
// Разделитель ключа и значения задаёт обработчик: сегодня текстовый, по // Разделитель ключа и значения задаёт обработчик: сегодня текстовый, по
@@ -690,7 +701,7 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) {
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
assert.Equal(t, job.Id, response.JobID) assert.Equal(t, job.Id, response.JobID)
assert.Equal(t, entity.StateCreated, response.State) assert.Equal(t, entity.StateUploaded, response.State)
assert.NotZero(t, response.CreatedAt) assert.NotZero(t, response.CreatedAt)
} }
@@ -753,7 +764,7 @@ func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String()) require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())
jobs, err := env.app.FindAllRecords(migrations.JobsCollection) jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя") assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя")
} }
-111
View File
@@ -1,111 +0,0 @@
package tg
import (
"io"
"log/slog"
"net/http"
"strings"
"testing"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// probeClient подменяет клиента бота и запоминает, кого спрашивали. Через него
// проверяется стык: скачивание обязано идти клиентом бота, а не общим
// `http.DefaultClient` — чистку отказа от адреса с токеном несёт именно клиент
// (`internal/adapter/telegram`). Подмена на общий клиент правил гейта не
// нарушает, поэтому сторожить стык может только проверка.
type probeClient struct {
seen []string
download func(w *probeResponse)
}
type probeResponse struct {
status int
body string
}
func (c *probeClient) Do(req *http.Request) (*http.Response, error) {
c.seen = append(c.seen, req.URL.Path)
switch {
case strings.Contains(req.URL.Path, "/getMe"):
return jsonResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"probe_bot"}}`), nil
case strings.Contains(req.URL.Path, "/getFile"):
return jsonResponse(`{"ok":true,"result":{"file_id":"x","file_path":"voice/file_1.ogg"}}`), nil
}
answer := &probeResponse{status: http.StatusOK, body: "аудио"}
if c.download != nil {
c.download(answer)
}
return &http.Response{
StatusCode: answer.status,
Body: io.NopCloser(strings.NewReader(answer.body)),
Header: make(http.Header),
}, nil
}
func jsonResponse(body string) *http.Response {
header := make(http.Header)
header.Set("Content-Type", "application/json")
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: header,
}
}
func newProbeController(t *testing.T, client *probeClient) *TelegramController {
t.Helper()
// Клиент подставной, поэтому адрес значения не имеет — важно лишь, что
// библиотека соберёт из него разбираемый URL.
bot, err := tgbotapi.NewBotAPIWithClient(
"7654321:AAHsecretBOTtokenVALUE",
"http://telegram.probe/bot%s/%s",
client,
)
require.NoError(t, err)
return &TelegramController{
bot: bot,
logger: slog.New(slog.DiscardHandler),
}
}
// Скачивание идёт клиентом бота: иначе отказ пойдёт мимо чистки и унесёт токен.
func TestDownloadGoesThroughBotClient(t *testing.T) {
client := &probeClient{}
controller := newProbeController(t, client)
body, name, err := controller.downloadAudioFile(t.Context(), "file-id")
require.NoError(t, err)
t.Cleanup(func() {
if err := body.Close(); err != nil {
t.Errorf("не удалось закрыть тело: %v", err)
}
})
assert.Equal(t, "voice/file_1.ogg", name)
require.Len(t, client.seen, 3, "клиент бота видел все обращения: getMe, getFile и скачивание")
assert.Contains(t, client.seen[2], "voice/file_1.ogg", "скачивание ушло мимо клиента бота")
}
// Отказ выдачи файла — это не запись: тело такого ответа не должно доехать до
// хранилища и умереть на `ffprobe`, уведя диагностику к чужой причине.
func TestDownloadRejectsNonOKStatus(t *testing.T) {
client := &probeClient{download: func(w *probeResponse) {
w.status = http.StatusUnauthorized
w.body = `{"ok":false,"error_code":401,"description":"Unauthorized"}`
}}
controller := newProbeController(t, client)
body, _, err := controller.downloadAudioFile(t.Context(), "file-id")
require.Error(t, err)
assert.Nil(t, body, "тело отказа наружу не отдают")
assert.Contains(t, err.Error(), "401", "код ответа назван — по нему видно, что отказал Telegram")
}
-330
View File
@@ -1,330 +0,0 @@
package tg
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"slices"
"strings"
// Транспорт знает адаптер Telegram ровно ради единой точки чистки отказа:
// второй экземпляр той же функции здесь был бы вторым способом делать одно
// и то же, а секрет в журнале — необратим. Направление «транспорт не знает
// адаптера» правилом не держится и уже нарушено HTTP-поверхностью
// (docs/conventions/go-linters.md, «Что остаётся прозой»).
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/service"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type TelegramController struct {
// deps
transcribeService *service.TranscribeService
jobRepo contract.TranscriptJobRepository
logger *slog.Logger
// params
bot *tgbotapi.BotAPI
userWhiteList []string
updateTimeout int
}
type TelegramConfig struct {
UpdateTimeout int
UserWhiteList []string
}
// NewTelegramController принимает готового клиента, а не токен: клиента заводит
// единая точка `internal/adapter/telegram`, и только её отказ не несёт секрета.
// Токен сюда не приезжает вовсе — значит, и утечь отсюда ему неоткуда.
func NewTelegramController(
config TelegramConfig,
bot *tgbotapi.BotAPI,
transcribeService *service.TranscribeService,
jobRepo contract.TranscriptJobRepository,
logger *slog.Logger,
) (*TelegramController, error) {
if bot == nil {
return nil, errors.New("telegram bot is not created")
}
controller := &TelegramController{
bot: bot,
transcribeService: transcribeService,
jobRepo: jobRepo,
logger: logger,
updateTimeout: config.UpdateTimeout,
userWhiteList: config.UserWhiteList,
}
return controller, nil
}
// Start принимает контекст жизни процесса и отдаёт его каждому обработчику:
// скачивание записи и разбор её метаданных — работа с внешним собеседником, и
// остановка сервиса обязана до неё доходить. Приём обновлений контекстом не
// правится: его прекращает Stop.
func (c *TelegramController) Start(ctx context.Context) {
c.logger.Info("Telegram bot started", "username", c.bot.Self.UserName)
u := tgbotapi.NewUpdate(0)
u.Timeout = c.updateTimeout
updates := c.bot.GetUpdatesChan(u)
for update := range updates {
if update.Message == nil { // ignore any non-Message updates
continue
}
author := update.Message.From.String()
c.logger.Info("New incoming message", "author", author)
if !slices.Contains(c.userWhiteList, author) {
c.logger.Info("User is not in white list, reject", "author", author)
c.handleForbiddenUser(update.Message)
continue
}
// Handle commands
if update.Message.IsCommand() {
// Extract the command from the Message
switch update.Message.Command() {
case "start":
c.handleStartCommand(update.Message)
case "help":
c.handleHelpCommand(update.Message)
}
continue
}
// Handle audio messages and files
if update.Message.Audio != nil {
c.handleAudioMessage(ctx, update.Message)
} else if update.Message.Voice != nil {
c.handleVoiceMessage(ctx, update.Message)
} else if update.Message.Document != nil {
c.handleDocumentMessage(ctx, update.Message)
}
}
}
func (c *TelegramController) Stop() {
c.bot.StopReceivingUpdates()
}
func (c *TelegramController) send(chattable tgbotapi.Chattable) (tgbotapi.Message, error) {
msg, err := c.bot.Send(chattable)
if err != nil {
c.logger.Error("Failed to send message to tg bot", "error", err)
}
return msg, err
}
func (c *TelegramController) handleStartCommand(message *tgbotapi.Message) {
msg := tgbotapi.NewMessage(message.Chat.ID, "Привет! Я бот для расшифровки аудиосообщений. Отправь мне голосовое сообщение или аудиофайл, и я пришлю тебе текст.")
msg.ReplyToMessageID = message.MessageID
c.send(msg)
}
func (c *TelegramController) handleForbiddenUser(message *tgbotapi.Message) {
msg := tgbotapi.NewMessage(message.Chat.ID, "Извини, тебе нельзя пользоваться этим ботом. Обратись к владельцу бота.")
msg.ReplyToMessageID = message.MessageID
c.send(msg)
}
func (c *TelegramController) handleHelpCommand(message *tgbotapi.Message) {
helpText := `Я бот для расшифровки аудиосообщений и аудиофайлов.
Просто отправь мне:
- Голосовое сообщение
- Аудиофайл (mp3, wav, ogg и др.)
Я пришлю тебе текст расшифровки.
Команды:
/start - Начало работы с ботом
/help - Показать эту справку`
msg := tgbotapi.NewMessage(message.Chat.ID, helpText)
msg.ReplyToMessageID = message.MessageID
c.send(msg)
}
func (c *TelegramController) handleAudioMessage(ctx context.Context, message *tgbotapi.Message) {
// Отправляем сообщение о начале обработки
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю аудиофайл...")
progressMsg.ReplyToMessageID = message.MessageID
sentProgressMsg, err := c.send(progressMsg)
if err != nil {
c.logger.Error("Failed to send progress message", "error", err)
return
}
// Скачиваем файл
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Audio.FileID)
if err != nil {
c.logger.Error("Failed to download audio file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
c.send(errorMsg)
return
}
defer fileReader.Close()
// Обрабатываем файл
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
if err != nil {
c.logger.Error("Failed to create transcribe job", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
c.send(errorMsg)
return
}
// Отправляем сообщение об успешном создании задачи
successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", job.Id))
successMsg.ReplyToMessageID = message.MessageID
c.send(successMsg)
}
func (c *TelegramController) handleVoiceMessage(ctx context.Context, message *tgbotapi.Message) {
// Отправляем сообщение о начале обработки
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю голосовое сообщение...")
progressMsg.ReplyToMessageID = message.MessageID
sentProgressMsg, err := c.send(progressMsg)
if err != nil {
c.logger.Error("Failed to send progress message", "error", err)
return
}
// Скачиваем файл
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Voice.FileID)
if err != nil {
c.logger.Error("Failed to download voice file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании голосового сообщения. Попробуйте еще раз.")
c.send(errorMsg)
return
}
defer fileReader.Close()
// Обрабатываем файл
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
if err != nil {
c.logger.Error("Failed to create transcribe job", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
c.send(errorMsg)
return
}
// Отправляем сообщение об успешном создании задачи
successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", job.Id))
successMsg.ReplyToMessageID = message.MessageID
c.send(successMsg)
}
func (c *TelegramController) handleDocumentMessage(ctx context.Context, message *tgbotapi.Message) {
// Проверяем, является ли документ аудиофайлом
if !c.isAudioDocument(message.Document) {
return
}
// Отправляем сообщение о начале обработки
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю аудиофайл...")
progressMsg.ReplyToMessageID = message.MessageID
sentProgressMsg, err := c.send(progressMsg)
if err != nil {
c.logger.Error("Failed to send progress message", "error", err)
return
}
// Скачиваем файл
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Document.FileID)
if err != nil {
c.logger.Error("Failed to download document file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
c.send(errorMsg)
return
}
defer fileReader.Close()
// Обрабатываем файл
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
if err != nil {
c.logger.Error("Failed to create transcribe job", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
c.send(errorMsg)
return
}
// Отправляем сообщение об успешном создании задачи
successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", job.Id))
successMsg.ReplyToMessageID = message.MessageID
c.send(successMsg)
}
func (c *TelegramController) downloadAudioFile(ctx context.Context, fileID string) (io.ReadCloser, string, error) {
// Получаем информацию о файле
file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
if err != nil {
return nil, "", fmt.Errorf("failed to get file info: %w", err)
}
// Скачиваем файл. Запрос заводится с контекстом: скачивание шестичасовой
// записи иначе продолжается и после остановки сервиса, а ссылка на файл
// несёт токен бота — держать её живой дольше нужного незачем.
//
// Клиент берётся у бота, а не `http.DefaultClient`: у бота он свой, и его
// отказ уже не несёт адреса (`internal/adapter/telegram`, единая точка).
fileURL := file.Link(c.bot.Token)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, "", fmt.Errorf("failed to build download request: %w", telegram.WithoutURL(err))
}
resp, err := c.bot.Client.Do(request)
if err != nil {
return nil, "", fmt.Errorf("failed to download file: %w", err)
}
// Отказ выдачи файла — это не запись. Без проверки телом «записи» станет
// JSON вида `{"ok":false,…}`: он доедет до хранилища, ляжет рабочей копией
// и умрёт на `ffprobe`, а отправитель получит жалобу на свой файл вместо
// правды о протухшей ссылке.
if resp.StatusCode != http.StatusOK {
if err := resp.Body.Close(); err != nil {
c.logger.Error("Failed to close download response", "error", err)
}
return nil, "", fmt.Errorf("failed to download file: unexpected status %d", resp.StatusCode)
}
// Получаем имя файла из URL
fileName := file.FilePath
if fileName == "" {
fileName = "audio.ogg"
}
return resp.Body, fileName, nil
}
func (c *TelegramController) isAudioDocument(document *tgbotapi.Document) bool {
// Проверяем MIME-тип документа
if document.MimeType != "" {
return strings.HasPrefix(document.MimeType, "audio/") || strings.HasPrefix(document.MimeType, "video/")
}
// Проверяем расширение файла
audioExtensions := []string{".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac", ".wma"}
filename := document.FileName
for _, ext := range audioExtensions {
if len(filename) >= len(ext) && strings.ToLower(filename[len(filename)-len(ext):]) == ext {
return true
}
}
return false
}
+129
View File
@@ -0,0 +1,129 @@
package worker
import (
"context"
"log/slog"
"sync/atomic"
"testing"
"time"
)
// Пул — предмет этой работы: воркеров стало сколько угодно одинаковых вместо
// трёх именованных. Проверки ниже судят саму обвязку — подъём, остановку и
// нулевой размер, — а не шаг, который она крутит: шаг судят проверки конвейера.
// countingStep считает свои вызовы и отпускает проверку, когда их набралось
// достаточно.
func countingStep(t *testing.T, enough int64) (func(context.Context) error, <-chan struct{}, *atomic.Int64) {
t.Helper()
var calls atomic.Int64
done := make(chan struct{})
var closed atomic.Bool
return func(context.Context) error {
if calls.Add(1) >= enough && closed.CompareAndSwap(false, true) {
close(done)
}
return nil
}, done, &calls
}
// Пул поднимает столько воркеров, сколько ему назвали, и все они крутят шаг.
func TestPoolRunsEveryWorker(t *testing.T) {
const size = 4
step, done, calls := countingStep(t, size)
pool := NewPool(size, step, slog.New(slog.DiscardHandler))
for _, w := range pool.workers {
w.interval = time.Millisecond
}
if pool.Size() != size {
t.Fatalf("в пуле %d воркеров вместо %d", pool.Size(), size)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
finished := make(chan struct{})
go func() {
pool.Start(ctx)
close(finished)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatalf("шаг позвали %d раз вместо %d: не все воркеры поднялись", calls.Load(), size)
}
cancel()
select {
case <-finished:
case <-time.After(5 * time.Second):
t.Fatal("пул не дождался остановки воркеров: горутина осталась висеть")
}
}
// Нулевой пул — законный режим, а не поломка: сервис поднимается, записи
// принимаются и не двигаются. Проверка судит именно это: шаг не зовётся ни разу,
// а подъём не блокируется.
func TestZeroPoolRunsNothingAndReturns(t *testing.T) {
var calls atomic.Int64
pool := NewPool(0, func(context.Context) error {
calls.Add(1)
return nil
}, slog.New(slog.DiscardHandler))
if pool.Size() != 0 {
t.Fatalf("пустой пул завёл %d воркеров", pool.Size())
}
finished := make(chan struct{})
go func() {
pool.Start(context.Background())
close(finished)
}()
select {
case <-finished:
case <-time.After(5 * time.Second):
t.Fatal("пустой пул не вернул управление: подъём сервиса заблокирован")
}
if got := calls.Load(); got != 0 {
t.Errorf("шаг позвали %d раз при нулевом пуле", got)
}
}
// Отменённый контекст останавливает **всех** воркеров пула: забытая горутина не
// падает и не пишет, а держит процесс и продолжает опрашивать базу после
// остановки сервиса.
func TestPoolStopsEveryWorkerOnCancel(t *testing.T) {
const size = 3
step, done, _ := countingStep(t, size)
pool := NewPool(size, step, slog.New(slog.DiscardHandler))
for _, w := range pool.workers {
w.interval = time.Millisecond
}
ctx, cancel := context.WithCancel(context.Background())
finished := make(chan struct{})
go func() {
pool.Start(ctx)
close(finished)
}()
<-done
cancel()
select {
case <-finished:
case <-time.After(5 * time.Second):
t.Fatal("пул не остановился по отмене контекста")
}
}
+61 -14
View File
@@ -3,26 +3,25 @@ package worker
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"strconv" "sync"
"time" "time"
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/metrics"
) )
// Worker представляет базовый интерфейс для всех воркеров
type Worker interface {
Start(ctx context.Context)
Name() string
}
// pollInterval — пауза между прогонами шага. Полем, а не константой по месту: // pollInterval — пауза между прогонами шага. Полем, а не константой по месту:
// проверке нужен второй прогон, чтобы остановить воркер **после** того, как он // проверке нужен второй прогон, чтобы остановить воркер **после** того, как он
// рассудил об исходе первого. Отменять контекст изнутри шага она не может — // рассудил об исходе первого. Отменять контекст изнутри шага она не может —
// отменённый контекст теперь и значит «нас остановили». // отменённый контекст теперь и значит «нас остановили».
const pollInterval = time.Second const pollInterval = time.Second
// CallbackWorker крутит один и тот же шаг, опрашивая очередь.
//
// Специализации у него нет: шаг сам берёт любую пригодную к работе запись и
// выбирает работу по её рубежу. Раньше воркеров было три именованных, по одному
// на состояние, и каждый новый рубеж требовал четвёртого.
type CallbackWorker struct { type CallbackWorker struct {
name string name string
// Шаг принимает контекст воркера: остановка обязана доходить до чужой // Шаг принимает контекст воркера: остановка обязана доходить до чужой
@@ -64,15 +63,12 @@ func (w *CallbackWorker) Start(ctx context.Context) {
// первой же обёртки `%w`, которая в проекте — умолчание. // первой же обёртки `%w`, которая в проекте — умолчание.
var noop *contract.NoopJobError var noop *contract.NoopJobError
isNoop := errors.As(err, &noop) isNoop := errors.As(err, &noop)
// Остановка — не отказ шага: контекст отменили мы сами. Считать её // Остановка — не отказ шага: контекст отменили мы сами. Писать
// в метрику и писать владельцу «Worker error» значит красить каждую // владельцу «Worker error» значит красить каждую выкладку как
// выкладку как поломку — по тому же доводу, по которому не считается // поломку — по тому же доводу, по которому не считается
// `NoopJobError`. Судит контекст, а не текст ошибки: убитый процесс // `NoopJobError`. Судит контекст, а не текст ошибки: убитый процесс
// отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет. // отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
stopped := err != nil && !isNoop && ctx.Err() != nil stopped := err != nil && !isNoop && ctx.Err() != nil
if !isNoop && !stopped {
metrics.WorkerJobCounter.WithLabelValues(w.Name(), strconv.FormatBool(err != nil)).Inc()
}
if err != nil && !isNoop && !stopped { if err != nil && !isNoop && !stopped {
w.logger.Error("Worker error", "worker", w.Name(), "error", err) w.logger.Error("Worker error", "worker", w.Name(), "error", err)
} }
@@ -80,6 +76,9 @@ func (w *CallbackWorker) Start(ctx context.Context) {
w.logger.Info("Worker step interrupted by shutdown", "worker", w.Name()) w.logger.Info("Worker step interrupted by shutdown", "worker", w.Name())
} }
// Счётчик работы растит сам шаг: только он знает рубеж, с которого
// взята запись, а воркер к рубежу больше не привязан.
// Ждем перед следующей итерацией // Ждем перед следующей итерацией
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -91,3 +90,51 @@ func (w *CallbackWorker) Start(ctx context.Context) {
} }
} }
} }
// Pool — пул одинаковых воркеров конвейера.
//
// Число задаётся настройкой, и ноль — законное значение: сервис поднимается,
// записи принимаются и не двигаются. Это режим, а не поломка: он нужен местному
// запуску и выкладке, где конвейер надо остановить, не роняя приём.
type Pool struct {
workers []*CallbackWorker
logger *slog.Logger
}
// NewPool собирает пул из size одинаковых воркеров, крутящих один и тот же шаг.
func NewPool(size int, step func(ctx context.Context) error, logger *slog.Logger) *Pool {
if logger == nil {
logger = slog.Default()
}
workers := make([]*CallbackWorker, 0, size)
for i := range size {
workers = append(workers, NewCallbackWorker(fmt.Sprintf("pipeline_worker_%d", i+1), step, logger))
}
return &Pool{workers: workers, logger: logger}
}
// Size — сколько воркеров в пуле.
func (p *Pool) Size() int { return len(p.workers) }
// Start поднимает всех воркеров пула и ждёт их остановки.
func (p *Pool) Start(ctx context.Context) {
if len(p.workers) == 0 {
// Молчать нельзя: пустой пул неотличим от поломки, а объявленный режим
// обязан быть назван.
p.logger.Info("Pipeline workers are disabled by configuration")
return
}
var wg sync.WaitGroup
for _, w := range p.workers {
wg.Add(1)
go func(worker *CallbackWorker) {
defer wg.Done()
worker.Start(ctx)
p.logger.Info("Worker stopped gracefully", "worker", worker.Name())
}(w)
}
wg.Wait()
}
+14 -75
View File
@@ -11,14 +11,17 @@ import (
"time" "time"
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"github.com/prometheus/client_golang/prometheus"
) )
// Проверки этого файла судят одну развилку воркера: пустой прогон против // Проверки этого файла судят одну развилку воркера: пустой прогон против
// отказа. Инвариант проекта — «NoopJobError не ошибка» — стоит ровно на ней, а // отказа. Инвариант проекта — «NoopJobError не ошибка» — стоит ровно на ней, а
// цена срабатывания отложенная: три воркера опрашивают базу раз в секунду, и // цена срабатывания отложенная: воркеры опрашивают базу раз в секунду, и пустой
// пустой прогон, принятый за отказ, даёт три записи в секунду и столько же // прогон, принятый за отказ, даёт запись в секунду с каждого и столько же
// засчитанных сбоев, которых не было. // засчитанных сбоев, которых не было.
//
// Счёт работы здесь не судится: он переехал в шаг конвейера вместе с меткой
// рубежа. Воркер к рубежу не привязан и назвать его не может, а метка,
// выведенная из имени потока, перестала что-либо значить с появлением пула.
// journalBuffer собирает журнал прогона. Пишут в него из горутины воркера, а // journalBuffer собирает журнал прогона. Пишут в него из горутины воркера, а
// читает проверка — отсюда мьютекс. // читает проверка — отсюда мьютекс.
@@ -113,39 +116,6 @@ func runRecords(journal string) string {
return strings.Join(kept, "\n") return strings.Join(kept, "\n")
} }
// jobCount читает счётчик работы воркера из общего реестра процесса. Судит
// реестр, а не переменную пакета: метка, потерянная в точке употребления,
// переменную не ломает, а на странице метрик видна.
func jobCount(t *testing.T, worker, errLabel string) float64 {
t.Helper()
families, err := prometheus.DefaultGatherer.Gather()
if err != nil {
t.Fatalf("не удалось собрать метрики: %v", err)
}
for _, mf := range families {
if mf.GetName() != "transcriber_worker_job_count" {
continue
}
for _, m := range mf.GetMetric() {
var gotWorker, gotErr string
for _, label := range m.GetLabel() {
switch label.GetName() {
case "name":
gotWorker = label.GetValue()
case "error":
gotErr = label.GetValue()
}
}
if gotWorker == worker && gotErr == errLabel {
return m.GetCounter().GetValue()
}
}
}
return 0
}
// Обёртка `%w` объявлена конвенцией проекта умолчанием, и до этой задачи первая // Обёртка `%w` объявлена конвенцией проекта умолчанием, и до этой задачи первая
// же обёртка на пути сломала бы распознавание молча. Оракул держит именно // же обёртка на пути сломала бы распознавание молча. Оракул держит именно
// обёрнутое значение: на голом признак узнавался и приведением типа, то есть // обёрнутое значение: на голом признак узнавался и приведением типа, то есть
@@ -153,11 +123,8 @@ func jobCount(t *testing.T, worker, errLabel string) float64 {
func TestWrappedNoopIsNotAFailure(t *testing.T) { func TestWrappedNoopIsNotAFailure(t *testing.T) {
const name = "wrapped_noop_worker" const name = "wrapped_noop_worker"
before := jobCount(t, name, "false")
beforeErr := jobCount(t, name, "true")
journal := runOnce(t, name, func(context.Context) error { journal := runOnce(t, name, func(context.Context) error {
return fmt.Errorf("find and acquire job: %w", &contract.NoopJobError{State: "created"}) return fmt.Errorf("find and acquire record: %w", &contract.NoopJobError{State: "uploaded"})
}) })
// Записи о старте и остановке воркера законны и к прогону не относятся — // Записи о старте и остановке воркера законны и к прогону не относятся —
@@ -165,21 +132,13 @@ func TestWrappedNoopIsNotAFailure(t *testing.T) {
if got := runRecords(journal); got != "" { if got := runRecords(journal); got != "" {
t.Errorf("пустой прогон попал в журнал: %q", got) t.Errorf("пустой прогон попал в журнал: %q", got)
} }
if got := jobCount(t, name, "false"); got != before {
t.Errorf("счётчик успешных прогонов вырос на пустом прогоне: было %v, стало %v", before, got)
}
if got := jobCount(t, name, "true"); got != beforeErr {
t.Errorf("пустой прогон засчитан отказом: было %v, стало %v", beforeErr, got)
}
} }
// Без этой проверки оракул был бы зелен и на коде, который не считает отказом // Без этой проверки оракул был бы зелен и на коде, который не пишет об отказе
// вообще ничего. // вообще ничего. Счёт отказа судит проверка шага: метку рубежа знает он.
func TestFailureIsLoggedAndCounted(t *testing.T) { func TestFailureIsLogged(t *testing.T) {
const name = "failing_worker" const name = "failing_worker"
before := jobCount(t, name, "true")
journal := runOnce(t, name, func(context.Context) error { journal := runOnce(t, name, func(context.Context) error {
return errors.New("database is gone") return errors.New("database is gone")
}) })
@@ -187,42 +146,28 @@ func TestFailureIsLoggedAndCounted(t *testing.T) {
if !strings.Contains(journal, "database is gone") { if !strings.Contains(journal, "database is gone") {
t.Errorf("отказ не виден владельцу: журнал %q", journal) t.Errorf("отказ не виден владельцу: журнал %q", journal)
} }
if got := jobCount(t, name, "true"); got != before+1 {
t.Errorf("отказ не засчитан: было %v, стало %v", before, got)
}
} }
// Счёт успешных прогонов — знаменатель доли отказов. Реализация, снявшая его, // Успешный прогон отказом не записывается.
// проходит обе проверки выше, а владелец теряет способность отличить «три func TestSuccessIsNotLoggedAsFailure(t *testing.T) {
// прогона в секунду, все отказали» от «три отказа среди тысячи прогонов».
func TestSuccessIsCounted(t *testing.T) {
const name = "successful_worker" const name = "successful_worker"
before := jobCount(t, name, "false")
journal := runOnce(t, name, func(context.Context) error { journal := runOnce(t, name, func(context.Context) error {
return nil return nil
}) })
if got := jobCount(t, name, "false"); got != before+1 {
t.Errorf("успешный прогон не засчитан: было %v, стало %v", before, got)
}
if strings.Contains(journal, "Worker error") { if strings.Contains(journal, "Worker error") {
t.Errorf("успешный прогон записан отказом: журнал %q", journal) t.Errorf("успешный прогон записан отказом: журнал %q", journal)
} }
} }
// Остановка сервиса — не отказ шага: контекст отменили мы сами. Без этой // Остановка сервиса — не отказ шага: контекст отменили мы сами. Без этой
// развилки каждая выкладка красит журнал владельца отказами и накручивает // развилки каждая выкладка красит журнал владельца отказами, — тот же довод, по
// счётчик сбоев, которых не было, — тот же довод, по которому не считается // которому не пишется `NoopJobError`. Судит контекст, а не текст ошибки: убитый по контексту
// `NoopJobError`. Судит контекст, а не текст ошибки: убитый по контексту
// процесс отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет. // процесс отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
func TestShutdownIsNotAFailure(t *testing.T) { func TestShutdownIsNotAFailure(t *testing.T) {
const name = "stopped_worker" const name = "stopped_worker"
beforeErr := jobCount(t, name, "true")
beforeOk := jobCount(t, name, "false")
journal := &journalBuffer{} journal := &journalBuffer{}
logger := slog.New(slog.NewTextHandler(journal, nil)) logger := slog.New(slog.NewTextHandler(journal, nil))
@@ -251,10 +196,4 @@ func TestShutdownIsNotAFailure(t *testing.T) {
if got := journal.String(); strings.Contains(got, "Worker error") { if got := journal.String(); strings.Contains(got, "Worker error") {
t.Errorf("остановка записана отказом: журнал %q", got) t.Errorf("остановка записана отказом: журнал %q", got)
} }
if got := jobCount(t, name, "true"); got != beforeErr {
t.Errorf("остановка засчитана отказом: было %v, стало %v", beforeErr, got)
}
if got := jobCount(t, name, "false"); got != beforeOk {
t.Errorf("остановка засчитана успешным прогоном: было %v, стало %v", beforeOk, got)
}
} }
+197
View File
@@ -0,0 +1,197 @@
package entity
import (
"time"
"git.vakhrushev.me/av/transcriber/internal/clock"
)
// Рубежи конвейера. Рубеж называет **достигнутое**, а не предстоящее: по нему
// видно, что с записью уже сделано, и потому остановленная запись продолжает с
// места остановки, а не с начала.
//
// Конечный рубеж зовётся `done`: доставка ответа отправителю в конвейер не
// входит, и слово описывает пройденный конвейер, а не полученный человеком
// текст.
const (
StateUploaded = "uploaded"
StateNormalized = "normalized"
StateSubmitted = "submitted"
StateTranscribed = "transcribed"
StateDone = "done"
)
// Причины остановки. Прежние состояния `failed` и `dead` схлопнуты сюда: обе
// восстанавливаются одинаково — снятием признака, — и различие между ними
// перестало быть структурным.
const (
// HaltReasonStepFailed — шаг рассудил об этой записи окончательно.
HaltReasonStepFailed = "step_failed"
// HaltReasonAttempts — мы повторяли и перестали.
HaltReasonAttempts = "attempts_exhausted"
// HaltReasonStuck — запись простояла в рубеже дольше предела.
HaltReasonStuck = "stuck"
)
const (
SourceUnknown = "unknown"
SourceApi = "api"
// SourceTelegram — историческое значение. Вход Telegram убран, новых записей
// с этим источником не появляется, а константа остаётся: на неё ссылается
// применённый шаг схемы `202608140002`, а применённый шаг не переписывается.
SourceTelegram = "telegram"
)
// AudioRecord — аудиозапись, центральная сущность сервиса.
//
// Приложения к ней — файлы, тексты, структура реплик, темы, журнал событий и
// попытка распознавания — живут своими строками и адресуются ссылками. Поля
// очереди соседствуют с доменом, но не с содержимым: расшифровка лежит строкой
// `texts`, и чтение очереди её не тянет.
type AudioRecord struct {
Id string
// OwnerID — учётная запись, от имени которой запись принята. Обязателен:
// колонка владельца пустого значения не принимает, и ничьей записи в
// хранилище не бывает. Назначается один раз, при приёме, и конвейером не
// меняется.
OwnerID string
Source string
// Title и Brief читаются вместе со списком, сотней штук разом, и потому
// лежат колонками записи, а не строками `texts`.
Title *string
Brief *string
State string
// StateEnteredAt ставится только сменой рубежа и возвратом записи в работу.
// Откладывание опроса его не двигает — иначе застревание в чужой операции
// не наступало бы никогда.
StateEnteredAt time.Time
// Остановка — признак, а не рубеж: `State` при ней не стирается.
HaltedAt *time.Time
HaltReason *string
ErrorText *string
// AcquisitionID — признак **этого** захвата, значение уникальное для каждого.
// Запись результата условна по нему, а не по занятости записи: захват,
// перевыданный другому — по протуханию срока или после снятия остановки
// человеком, — обязан обратить запись первого в отказ.
AcquisitionID *string
AcquireExpiresAt *time.Time
DelayTime *time.Time
// Attempts считает **отказы** и ограничивает повторы внутри шага. Время в
// рубеже мерит StateEnteredAt: одно число не справлялось ни с одной из двух
// обязанностей.
Attempts int
// Ссылки на файлы живут порознь и не переставляются: исходник остаётся
// доступным после того, как запись прошла конвейер.
OriginalFileID *string
NormalizedFileID *string
StructureID *string
TranscriptTextID *string
LiteraryTextID *string
RecognitionID *string
CreatedAt time.Time
UpdatedAt time.Time
}
// AllStates — закрытый перечень рубежей для схемы хранилища.
func AllStates() []string {
out := make([]string, 0, len(stages))
for _, s := range stages {
out = append(out, s.Name)
}
return out
}
// AllHaltReasons — закрытый перечень причин остановки для схемы хранилища.
func AllHaltReasons() []string {
return []string{HaltReasonStepFailed, HaltReasonAttempts, HaltReasonStuck}
}
// MoveToState двигает запись на новый рубеж и чистит служебные поля прошлого.
//
// Время входа в рубеж ставится заново: с этой минуты идёт отсчёт застревания.
// Число отказов обнуляется — шаг, дошедший до перехода, завершился без отказа, а
// отказы считают именно отказавшие: иначе запись, прошедшая конвейер целиком,
// накопила бы их поштучно и остановилась бы здоровой.
func (r *AudioRecord) MoveToState(state string) {
now := clock.Now()
r.State = state
r.StateEnteredAt = now
r.DelayTime = nil
r.AcquisitionID = nil
r.AcquireExpiresAt = nil
r.Attempts = 0
r.UpdatedAt = now
}
// Postpone откладывает работу над записью: ставит паузу и снимает захват.
//
// Переходом это не является и потому не трогает ни рубеж, ни время входа в
// него. Число отказов обнуляется по прежнему доводу — ожидание чужой операции
// отказом не является.
//
// Прежде шаг опроса звал переход с **тем же** состоянием, и мнимость этого
// перехода обнуляла сторожа. Без разделения время входа в рубеж сбрасывалось бы
// на каждом опросе и повторило бы ровно тот промах, ради которого заводится.
func (r *AudioRecord) Postpone(until time.Time) {
r.DelayTime = &until
r.AcquisitionID = nil
r.AcquireExpiresAt = nil
r.Attempts = 0
r.UpdatedAt = clock.Now()
}
// RetryAfter освобождает отказавшую запись для повтора: захват снимается, пауза
// ставится, а число отказов сохраняется — по нему растёт пауза и наступает
// предел.
func (r *AudioRecord) RetryAfter(delay time.Time) {
r.AcquisitionID = nil
r.AcquireExpiresAt = nil
r.DelayTime = &delay
r.UpdatedAt = clock.Now()
}
// Halt останавливает запись признаком, сохраняя достигнутый рубеж.
//
// Число отказов сохраняется: по нему видно, сколько раз пробовали. Захват
// снимается — остановленная запись всё равно не выдаётся, а оставленный признак
// захвата помешал бы первому же захвату после снятия остановки.
func (r *AudioRecord) Halt(reason, errText string) {
now := clock.Now()
r.HaltedAt = &now
r.HaltReason = &reason
r.ErrorText = &errText
r.AcquisitionID = nil
r.AcquireExpiresAt = nil
r.DelayTime = nil
r.UpdatedAt = now
}
// Resume возвращает остановленную запись в работу с сохранённого рубежа.
//
// Сбрасываются все три сторожа. Время входа в рубеж — тоже, и это не
// избыточность: запись, простоявшая остановленной дольше предела, иначе
// останавливалась бы снова первым же захватом, и перезапуск не работал бы вовсе.
func (r *AudioRecord) Resume() {
now := clock.Now()
r.HaltedAt = nil
r.HaltReason = nil
r.ErrorText = nil
r.Attempts = 0
r.DelayTime = nil
r.AcquisitionID = nil
r.AcquireExpiresAt = nil
r.StateEnteredAt = now
r.UpdatedAt = now
}
// IsHalted — стоит ли на записи признак остановки.
func (r *AudioRecord) IsHalted() bool {
return r.HaltedAt != nil
}
+16 -9
View File
@@ -21,16 +21,23 @@ const (
// дойдёт до обработчика — без строки в журнале приёма. // дойдёт до обработчика — без строки в журнале приёма.
const MaxRecordSize int64 = 8 << 30 // 8 ГиБ const MaxRecordSize int64 = 8 << 30 // 8 ГиБ
// File — одна физическая копия: исходник, результат конвертации и копия во // File — одна физическая копия записи. Их ровно две: принятая и приведённая к
// внешнем хранилище — три разные записи. // рабочему формату. Копия во внешнем хранилище файлом записи не считается — она
// существует только потому, что провайдер читает аудио по адресу, и её ключ
// живёт в строке попытки распознавания.
type File struct { type File struct {
Id string Id string
Location string Location string
// FileName — имя, под которым файл лежит: у местной копии это имя, заданное // FileName — имя, под которым файл лежит: имя задаёт сервис. Своего суффикса
// сервисом, у внешней — ключ объекта. Своего суффикса хранилище к заданному // хранилище к заданному имени не дописывает: суффикс появляется только у
// имени не дописывает: суффикс появляется только у имён, которые оно строит // имён, которые оно строит само из имени отправителя, а это умолчание не
// само из имени отправителя, а это умолчание не применяется. // применяется.
FileName string FileName string
Size int64 Size int64
CreatedAt time.Time // Format — расширение без точки, приведённое к нижнему регистру. Наружу оно
// выходит только через метку метрики, приведённую к перечню известных.
Format string
// DurationMs — длительность записи, если её удалось прочитать.
DurationMs int64
CreatedAt time.Time
} }
-94
View File
@@ -1,94 +0,0 @@
package entity
import (
"time"
"git.vakhrushev.me/av/transcriber/internal/clock"
)
type TranscribeJob struct {
Id string
State string
Source string
FileID *string
ErrorText *string
AcquisitionID *string
AcquireTime *time.Time
DelayTime *time.Time
Attempts int // Число попыток: растёт при захвате, обнуляется на шаге без отказа
RecognitionOpID *string // ID операции распознавания в Yandex Cloud
TranscriptionText *string // Результат распознавания
TgChatId *int64 // Telegram: в какой чат отправить результат распознавания
TgReplyMessageId *int // Telegram: с каким сообщением связать результат распознавания
CreatedAt time.Time
UpdatedAt time.Time
}
const (
StateCreated = "created"
StateConverted = "converted"
StateTranscribe = "transcribe"
StateDone = "done"
StateFailed = "failed"
// StateDead — задача, которую мы повторяли и перестали. От `failed` она
// отличается тем, чей это приговор: в `failed` задачу переводит шаг,
// рассудивший об этой записи окончательно, а сюда она уходит без такого
// суждения. Ни один шаг конвейера в неё не переводит сам.
StateDead = "dead"
)
const (
SourceUnknown = "unknown"
SourceApi = "api"
SourceTelegram = "telegram"
)
// Переводит задачу в новое состояние, при этом очищает все
// служебные поля предыдущего состояния, как-то время задержки, информацию о воркере и тд
func (j *TranscribeJob) MoveToState(state string) {
j.State = state
j.DelayTime = nil
j.AcquisitionID = nil
j.AcquireTime = nil
// Шаг, дошедший до перехода, завершился без отказа, а попытки считают
// именно отказавшие: иначе задача, прошедшая конвейер целиком, накопила бы
// их поштучно и умерла бы здоровой.
j.Attempts = 0
j.UpdatedAt = clock.Now()
}
func (j *TranscribeJob) MoveToStateAndDelay(state string, delay *time.Time) {
j.MoveToState(state)
j.DelayTime = delay
j.UpdatedAt = clock.Now()
}
func (j *TranscribeJob) Done(transcriptionText string) {
j.MoveToState(StateDone)
j.TranscriptionText = &transcriptionText
}
func (j *TranscribeJob) Fail(errText string) {
j.MoveToState(StateFailed)
j.ErrorText = &errText
}
// RetryAfter освобождает отказавшую задачу для повтора: захват снимается,
// пауза ставится, а число попыток сохраняется — по нему растёт пауза и
// наступает предел.
func (j *TranscribeJob) RetryAfter(delay time.Time) {
j.AcquisitionID = nil
j.AcquireTime = nil
j.DelayTime = &delay
j.UpdatedAt = clock.Now()
}
// Die переводит задачу, исчерпавшую попытки, в состояние «мертва». Число
// попыток при этом сохраняется: по нему видно, сколько раз мы пробовали, а
// возвращает задачу в работу владелец правкой состояния.
func (j *TranscribeJob) Die(errText string) {
attempts := j.Attempts
j.MoveToState(StateDead)
j.Attempts = attempts
j.ErrorText = &errText
}
+44 -2
View File
@@ -1,6 +1,8 @@
package entity package entity
// RecognitionStatus представляет статус операции транскрипции import "time"
// RecognitionStatus представляет статус операции распознавания у провайдера.
type RecognitionStatus int type RecognitionStatus int
const ( const (
@@ -26,7 +28,7 @@ func (s RecognitionStatus) String() string {
} }
} }
// RecognitionResult представляет результат операции транскрипции // RecognitionResult представляет исход опроса операции распознавания.
type RecognitionResult struct { type RecognitionResult struct {
Status RecognitionStatus Status RecognitionStatus
Error string // Текст ошибки (заполняется при StatusFailed) Error string // Текст ошибки (заполняется при StatusFailed)
@@ -76,3 +78,43 @@ func (r *RecognitionResult) GetError() string {
} }
return "" return ""
} }
// Recognition — попытка распознавания у внешнего провайдера.
//
// Всё провайдерское живёт здесь, а не колонками записи: идентификатор операции —
// самое провайдерское, что есть в модели, а копия аудио во внешнем хранилище
// существует только потому, что сегодняшний провайдер читает запись по адресу.
// Другой провайдер её не потребует, и смена провайдера не трогает доменную
// сущность вовсе.
//
// Сырой ответ хранится вложением, а не колонкой этой строки: шаг опроса читает
// её раз в несколько секунд, а хранилище читает запись целиком — ответ на
// многочасовую запись ехал бы в память при каждом опросе. Хранится он потому,
// что результат операции у провайдера не переспрашивается.
type Recognition struct {
Id string
RecordID string
Provider string
Model string
// ExternalID — идентификатор операции у провайдера. Заводится **до**
// обращения к нему: окно между ответом провайдера и записью идентификатора —
// то место, где теряется оплаченное.
ExternalID string
// SourceURI — адрес, по которому провайдер читает аудио.
SourceURI string
StartedAt *time.Time
FinishedAt *time.Time
}
// RecognitionOutcome — доменный результат распознавания, каким его отдаёт
// адаптер. Формата провайдера здесь нет: разбор потока — обязанность адаптера, и
// ни один шаг конвейера не знает, каким потоком и какими полями провайдер
// отвечает.
type RecognitionOutcome struct {
// Replicas — реплики со временем от начала записи.
Replicas []Replica
// PlainText — плоский текст расшифровки.
PlainText string
// Raw — ответ провайдера целиком, как он пришёл, на хранение вложением.
Raw []byte
}
+50
View File
@@ -0,0 +1,50 @@
package entity
// Источник события журнала записи.
const (
// EventOriginPipeline — событие произвёл шаг конвейера.
EventOriginPipeline = "pipeline"
// EventOriginHuman — событие произвёл человек: перезапуск виден в журнале с
// указанием, кто его сделал.
EventOriginHuman = "human"
)
// Исход события.
const (
EventOutcomeDone = "done"
EventOutcomeFailed = "failed"
EventOutcomeHalted = "halted"
EventOutcomeResumed = "resumed"
)
// AllEventOrigins — закрытый перечень источников для схемы хранилища.
func AllEventOrigins() []string {
return []string{EventOriginPipeline, EventOriginHuman}
}
// AllEventOutcomes — закрытый перечень исходов для схемы хранилища.
func AllEventOutcomes() []string {
return []string{EventOutcomeDone, EventOutcomeFailed, EventOutcomeHalted, EventOutcomeResumed}
}
// RecordEvent — строка журнала событий одной записи.
//
// Пишется на смену рубежа, на остановку и на снятие остановки — не на каждое
// откладывание опроса: часовая запись дала бы сотни строк ни о чём. Ни один шаг
// конвейера этот журнал не читает, чтобы решить, что делать дальше: решение
// принимается по рубежу записи, и второй источник решения разошёлся бы с первым
// молча.
//
// Содержимое записи сюда не попадает — инвариант приватности действует здесь
// наравне с журналом сервиса. Поле текста отказа зовётся `OutcomeText`, а не
// `error_text`: последнее имя названо поимённо инвариантом о секрете, и две
// колонки с этим именем сделали бы инвариант двусмысленным.
type RecordEvent struct {
Id string
RecordID string
Origin string
Step string
Outcome string
OutcomeText string
DurationMs int64
}
+22
View File
@@ -0,0 +1,22 @@
package entity
// Состояния прежней модели. **Частью модели они не являются** и в перечень
// рубежей не входят: цепочку рубежей объявляет `stage.go`, а закрытый перечень
// для схемы — `AllStates()`.
//
// Живут они здесь по одной причине: шаг схемы `202608110001_init.go` заводил
// прежнюю коллекцию задач этими значениями, а **применённый шаг схемы не
// переписывается** — хранилище считает применённое по имени файла, и правка
// сделала бы его другим шагом под прежним именем. Шаг ссылается на эти
// константы, значит они обязаны существовать, пока существует он.
//
// Коллекцию, которую он заводил, удаляет шаг `202608140002`. Ни один живой путь
// сервиса этих значений не читает и не пишет; `StateDone` в этом списке нет —
// то же слово осталось именем конечного рубежа новой модели.
const (
StateCreated = "created"
StateConverted = "converted"
StateTranscribe = "transcribe"
StateFailed = "failed"
StateDead = "dead"
)
+97
View File
@@ -0,0 +1,97 @@
package entity
import "time"
// Work — чью работу ждёт запись, стоя на рубеже. От этого зависит предел
// простоя: своя работа мерится одним числом, ожидание чужой операции — другим.
// Граница проходит по исполнителю, а не по рубежу: число на каждый рубеж
// назвало бы разными вещи, различающиеся только им.
type Work int
const (
// WorkOwn — работу делаем мы сами.
WorkOwn Work = iota
// WorkForeign — ждём операцию внешнего сервиса.
WorkForeign
)
// Stage — объявление рубежа одним местом.
//
// Из этого перечня выводятся все потребители: выбор следующего шага, отбор
// захвата, срок протухания захвата и предел простоя. Перечислять рубежи порознь
// в каждом потребителе нельзя: рубеж, забытый в отборе захвата, не выдаётся ни
// одному воркеру никогда, а пустой прогон по инварианту проекта не пишется в
// журнал и не считается в метрику — запись встала бы без единого следа.
type Stage struct {
Name string
// Work — чью работу ждём, стоя на этом рубеже.
Work Work
// AcquireTimeout — срок протухания захвата. Едет с рубежом, а не с воркером:
// воркер не привязан к шагу и не знает заранее, что вытянет.
AcquireTimeout time.Duration
// Terminal — рубеж, из которого запись в работу не берут. Такой рубеж не
// подпадает и под предел простоя: стоять в нём запись будет вечно по
// построению.
Terminal bool
}
// Сроки захвата. Каждый не меньше того, что его шаг может занять на самом
// длинном допустимом входе: расчётный потолок записи — шесть часов, и приведение
// такой записи идёт дольше часа по построению.
const (
normalizeAcquireTimeout = 8 * time.Hour
submitAcquireTimeout = 8 * time.Hour
pollAcquireTimeout = time.Hour
finishAcquireTimeout = time.Hour
)
// stages — цепочка рубежей в порядке прохождения.
var stages = []Stage{
{Name: StateUploaded, Work: WorkOwn, AcquireTimeout: normalizeAcquireTimeout},
{Name: StateNormalized, Work: WorkOwn, AcquireTimeout: submitAcquireTimeout},
{Name: StateSubmitted, Work: WorkForeign, AcquireTimeout: pollAcquireTimeout},
{Name: StateTranscribed, Work: WorkOwn, AcquireTimeout: finishAcquireTimeout},
{Name: StateDone, Terminal: true},
}
// WorkingStages — рубежи, с которых запись берут в работу.
func WorkingStages() []Stage {
out := make([]Stage, 0, len(stages))
for _, s := range stages {
if !s.Terminal {
out = append(out, s)
}
}
return out
}
// StageByName находит рубеж по имени. Второе значение ложно у рубежа, которого
// в цепочке нет: запись с таким рубежом до шага не доходит.
func StageByName(name string) (Stage, bool) {
for _, s := range stages {
if s.Name == name {
return s, true
}
}
return Stage{}, false
}
// StuckLimits — пределы простоя, приходящие из настроек.
type StuckLimits struct {
// Own — предел на своей работе.
Own time.Duration
// Foreign — предел на ожидании чужой операции.
Foreign time.Duration
}
// Limit — предел простоя для этого рубежа. У конечного рубежа предела нет:
// запись стоит в нём вечно по построению.
func (s Stage) Limit(limits StuckLimits) (time.Duration, bool) {
if s.Terminal {
return 0, false
}
if s.Work == WorkForeign {
return limits.Foreign, true
}
return limits.Own, true
}
+28
View File
@@ -0,0 +1,28 @@
package entity
// StructureVersion — версия вида структуры реплик. Разбор сохранённого ответа
// провайдера изменится раньше, чем архив пересчитают, и по номеру видно, какой
// разбор её построил.
const StructureVersion = 1
// Replica — одна реплика с временем от начала записи.
//
// Говорящий сегодня не размечается: связь реплики с разбором говорящего у
// провайдера не выяснена. Поле заведено, потому что структура строится из
// сохранённого ответа и пересчитается без повторной оплаты, когда связь
// выяснится.
type Replica struct {
StartMs int64 `json:"start_ms"`
EndMs int64 `json:"end_ms"`
Speaker string `json:"speaker,omitempty"`
Text string `json:"text"`
}
// Structure — реплики записи со временем. Лежит своей строкой, пара «запись и
// версия разбора» уникальна.
type Structure struct {
Id string
RecordID string
Version int
Replicas []Replica
}
+28
View File
@@ -0,0 +1,28 @@
package entity
// Виды текста записи. Расшифровка и вычитанный текст читаются по открытию одной
// записи и лежат строками `texts`, а не колонками: колонкой на каждый вид схема
// росла бы с каждым новым видом, а необратимый шаг схемы платится за каждую
// такую колонку отдельно.
const (
// TextKindTranscript — сырая расшифровка, как её отдал распознаватель.
TextKindTranscript = "transcript"
// TextKindLiterary — вычитанный текст. Его считает отдельная задача; здесь
// заведено только место, куда он ляжет.
TextKindLiterary = "literary"
)
// AllTextKinds — закрытый перечень видов текста для схемы хранилища.
func AllTextKinds() []string {
return []string{TextKindTranscript, TextKindLiterary}
}
// Text — один вид текста одной записи. Пара «запись и вид» уникальна: повтор
// прерванного шага иначе завёл бы второй комплект строк, и вопрос «какой текст
// отдавать человеку» стал бы вопросом порядка записи, а не состояния.
type Text struct {
Id string
RecordID string
Kind string
Contents string
}
+22
View File
@@ -0,0 +1,22 @@
package entity
// MaxTopicsPerRecord — потолок числа тем у одной записи. Без него часовой
// разговор даёт два десятка тем, и словарь распухает за неделю; это же число
// уезжает в запрос к языковой модели.
const MaxTopicsPerRecord = 5
// Topic — тема из словаря одного человека. Пара «владелец и название»
// уникальна: словарь тем свой у каждого.
//
// Коллекцией, а не набором строк в записи, потому что перечень тем человека
// нужен целиком перед каждым обращением к модели, а собрать его из наборов строк
// можно только перебором всех его записей.
//
// Ни один шаг этой работы тем не пишет и не читает: место заведено вперёд, чтобы
// задача, считающая темы языковой моделью, не платила вторым необратимым шагом
// схемы.
type Topic struct {
Id string
OwnerID string
Name string
}
+5 -7
View File
@@ -10,13 +10,11 @@ const OtherFormatLabel = "other"
// knownFormats — закрытый перечень расширений, которые допускаются меткой. // knownFormats — закрытый перечень расширений, которые допускаются меткой.
// //
// Состав: пути, которые выдаёт Telegram (голосовое приходит как // Состав: форматы, доезжающие приёмом по HTTP, плюс собственное умолчание
// `voice/file_N.oga`, кружок — с `.mp4`), плюс форматы, доезжающие приёмом по // сервиса на случай имени без расширения. Значения `oga` и `mp4` достались от
// HTTP, плюс собственное умолчание сервиса на случай имени без расширения. // убранного входа Telegram — голосовое приходило оттуда как `voice/file_N.oga`,
// Списку, по которому бот отбирает **документы** (`isAudioDocument`), перечень // кружок с `.mp4`, — и остаются: перечень сужает **метку**, а не приём, и
// намеренно не равен: тот судит по типу содержимого и своим списком пользуется // выброшенное из него значение уронило бы прежние записи в `other`.
// лишь когда типа нет, а сюда попадает и то, что приходит другими путями.
// Сведение двух списков в один уронило бы основной вход сервиса в `other`.
var knownFormats = map[string]struct{}{ var knownFormats = map[string]struct{}{
"mp3": {}, "mp3": {},
"wav": {}, "wav": {},
+12 -16
View File
@@ -6,12 +6,17 @@ import (
) )
var ( var (
// Работа конвейера с разрезом по **рубежу**, с которого взята запись, а не по
// имени потока. Воркеры одинаковы, и имя потока перестало что-либо значить;
// а счётчик отказов — единственный сигнал, по которому владелец сервиса
// замечает поломку, и без разреза по шагу «падает приведение» и «падает
// распознавание» стали бы неразличимы.
WorkerJobCounter = promauto.NewCounterVec( WorkerJobCounter = promauto.NewCounterVec(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "transcriber_worker_job_count", Name: "transcriber_worker_job_count",
Help: "Count of jobs handled by each worker", Help: "Count of pipeline steps by the stage a record was taken from",
}, },
[]string{"name", "error"}, []string{"stage", "error"},
) )
// Размер принятых на обработку файлов (в байтах) // Размер принятых на обработку файлов (в байтах)
@@ -44,9 +49,11 @@ var (
[]string{"source_format", "target_format", "error"}, []string{"source_format", "target_format", "error"},
) )
// Поднят ли вход приёма. Единственный канал наблюдения, автоматизированный // Поднят ли вход приёма. Метка ставится только тому входу, который у сервиса
// у владельца: потерянный вход иначе виден только строкой журнала при // есть; вход остался один, и метки убранного здесь не появляется — ноль рядом
// старте, а проба здоровья отвечает «ok» и без него. // с ним читался бы как поломка, а вечная единица — как исправность того, чего
// нет. Различать поднятый и неподнятый признак снова станет, когда входов
// снова станет больше одного.
IntakeUpGauge = promauto.NewGaugeVec( IntakeUpGauge = promauto.NewGaugeVec(
prometheus.GaugeOpts{ prometheus.GaugeOpts{
Name: "transcriber_intake_up", Name: "transcriber_intake_up",
@@ -55,17 +62,6 @@ var (
[]string{"channel"}, []string{"channel"},
) )
// Ответы, которые не удалось доставить отправителю. Работа при этом
// сделана, шаг отказа не объявляет, и без счётчика недоставка видна только
// в журнале — до его ротации.
UndeliveredReplyCounter = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "transcriber_undelivered_reply_count",
Help: "Count of replies that could not be delivered to the sender",
},
[]string{"reason"},
)
// Размер файла после конвертации (в байтах) // Размер файла после конвертации (в байтах)
OutputFileSizeHistogram = promauto.NewHistogramVec( OutputFileSizeHistogram = promauto.NewHistogramVec(
prometheus.HistogramOpts{ prometheus.HistogramOpts{
+33 -29
View File
@@ -5,67 +5,71 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"testing" "testing"
"time"
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
// Путь признака «работы нет» состоит из двух звеньев: репозиторий рождает // Путь признака «работы нет» состоит из двух звеньев: репозиторий рождает
// «подходящей задачи не нашлось», сервис переводит это в «работы нет», и уже // «пригодной записи не нашлось», сервис переводит это в «работы нет», и уже его
// его читает воркер. Проверки воркера подменяют работу целиком и второе звено // читает воркер. Проверки воркера подменяют работу целиком и второе звено не
// не видят — без этого файла правку в сервисе принимал бы только линтер, а он // видят — без этого файла правку в сервисе принимал бы только линтер, а он судит
// судит форму записи, а не то, узнаётся ли признак на самом деле. // форму записи, а не то, узнаётся ли признак на самом деле.
// stubJobRepo отдаёт заданную ошибку на запрос задачи. Прочих методов запроса // stubRecordRepo отдаёт заданную ошибку на запрос записи. Прочих методов
// задачи проверки этого файла не зовут. // проверки этого файла не зовут.
type stubJobRepo struct { type stubRecordRepo struct {
err error err error
} }
func (r *stubJobRepo) Create(*entity.TranscribeJob) error { return nil } func (r *stubRecordRepo) Create(*entity.AudioRecord) error { return nil }
func (r *stubJobRepo) Save(*entity.TranscribeJob, string) error { return nil } func (r *stubRecordRepo) Save(*entity.AudioRecord, string) error { return nil }
func (r *stubJobRepo) GetByID(string) (*entity.TranscribeJob, error) { func (r *stubRecordRepo) GetByID(string, string) (*entity.AudioRecord, error) {
return nil, errors.New("не зовётся этими проверками") return nil, errors.New("не зовётся этими проверками")
} }
func (r *stubJobRepo) FindAndAcquire(string, string, time.Time) (*entity.TranscribeJob, error) { func (r *stubRecordRepo) Get(string) (*entity.AudioRecord, error) {
return nil, errors.New("не зовётся этими проверками")
}
func (r *stubRecordRepo) FindAndAcquire([]entity.Stage) (*contract.AcquiredRecord, error) {
return nil, r.err return nil, r.err
} }
func serviceWithRepo(repo contract.TranscriptJobRepository) *TranscribeService { func serviceWithRepo(repo contract.AudioRecordRepository) *TranscribeService {
logger := slog.New(slog.DiscardHandler) return NewTranscribeService(
return NewTranscribeService(repo, nil, nil, nil, nil, nil, logger) Repositories{Records: repo},
nil, nil, nil,
entity.StuckLimits{},
slog.New(slog.DiscardHandler),
)
} }
// Репозиторий вправе добавить своему отказу пояснение — соседние ветки того же // Репозиторий вправе добавить своему отказу пояснение — соседние ветки того же
// метода уже оборачивают ошибки `%w` подряд. Пока признак узнавался приведением // метода уже оборачивают ошибки `%w` подряд. Пока признак узнавался приведением
// типа, первая такая обёртка превратила бы пустой прогон в отказ: воркер начал // типа, первая такая обёртка превратила бы пустой прогон в отказ: воркер начал
// бы писать в журнал раз в секунду на каждом из трёх воркеров. // бы писать в журнал раз в секунду на каждом воркере пула.
func TestFindJobTranslatesWrappedNotFoundToNoop(t *testing.T) { func TestAcquireTranslatesWrappedNotFoundToNoop(t *testing.T) {
svc := serviceWithRepo(&stubJobRepo{ svc := serviceWithRepo(&stubRecordRepo{
err: fmt.Errorf("find and acquire job: %w", err: fmt.Errorf("find and acquire record: %w",
&contract.JobNotFoundError{State: "created", Message: "appropriate job not found"}), &contract.JobNotFoundError{Message: "no record is ready for work"}),
}) })
_, _, err := svc.findJob("created", time.Minute) _, _, err := svc.acquire()
var noop *contract.NoopJobError var noop *contract.NoopJobError
if !errors.As(err, &noop) { if !errors.As(err, &noop) {
t.Fatalf("обёрнутое «задачи нет» не переведено в пустой прогон: получено %v", err) t.Fatalf("обёрнутое «работы нет» не переведено в пустой прогон: получено %v", err)
}
if noop.State != "created" {
t.Errorf("состояние потеряно при переводе: %q", noop.State)
} }
} }
// Оборотная сторона: настоящий отказ хранилища пустым прогоном считаться не // Оборотная сторона: настоящий отказ хранилища пустым прогоном считаться не
// должен, иначе задача молча крутилась бы в цикле без единой записи. // должен, иначе запись молча крутилась бы в цикле без единой записи в журнале.
func TestFindJobKeepsRealFailure(t *testing.T) { func TestAcquireKeepsRealFailure(t *testing.T) {
svc := serviceWithRepo(&stubJobRepo{err: errors.New("database is gone")}) svc := serviceWithRepo(&stubRecordRepo{err: errors.New("database is gone")})
_, _, err := svc.findJob("created", time.Minute) _, _, err := svc.acquire()
var noop *contract.NoopJobError var noop *contract.NoopJobError
if errors.As(err, &noop) { if errors.As(err, &noop) {
+83
View File
@@ -0,0 +1,83 @@
package service
import (
"context"
"os"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Счёт работы конвейера живёт в шаге, а не в воркере: только шаг знает рубеж, с
// которого взята запись, а воркеры пула одинаковы и именем ничего не говорят.
// stageCount читает счётчик работы из общего реестра процесса. Судит реестр, а
// не переменную пакета: метка, потерянная в точке употребления, переменную не
// ломает, а на странице метрик видна.
func stageCount(t *testing.T, stage, errLabel string) float64 {
t.Helper()
families, err := prometheus.DefaultGatherer.Gather()
require.NoError(t, err)
for _, mf := range families {
if mf.GetName() != "transcriber_worker_job_count" {
continue
}
for _, m := range mf.GetMetric() {
var gotStage, gotErr string
for _, label := range m.GetLabel() {
switch label.GetName() {
case "stage":
gotStage = label.GetValue()
case "error":
gotErr = label.GetValue()
}
}
if gotStage == stage && gotErr == errLabel {
return m.GetCounter().GetValue()
}
}
}
return 0
}
// Критерий приёмки 11. Отказ виден с разрезом по шагу: счётчик отказов —
// единственный сигнал, по которому владелец сервиса замечает поломку, и без
// метки рубежа «падает приведение» и «падает распознавание» стали бы
// неразличимы.
func TestFailureIsCountedWithStageLabel(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
beforeOk := stageCount(t, entity.StateUploaded, "false")
record := newRecord(t, env)
require.NoError(t, env.service.RunStep(t.Context()))
require.Equal(t, entity.StateNormalized, readRecord(t, env, record.Id).State)
assert.InDelta(t, beforeOk+1, stageCount(t, entity.StateUploaded, "false"), 0,
"успешный шаг засчитан с меткой своего рубежа")
// Теперь тот же рубеж, но с отказом.
failing := newPipelineEnv(t, &okMetaViewer{}, &failingStepConverter{})
beforeErr := stageCount(t, entity.StateUploaded, "true")
newRecord(t, failing)
require.Error(t, failing.service.RunStep(t.Context()))
assert.InDelta(t, beforeErr+1, stageCount(t, entity.StateUploaded, "true"), 0,
"отказ засчитан с меткой того же рубежа")
}
// failingStepConverter отказывает **отказом шага**, а не приговором записи:
// приговор счётчик отказов не растит, потому что шаг при нём отрабатывает.
type failingStepConverter struct{}
func (c *failingStepConverter) Convert(_ context.Context, _, dest string) error {
// Файл результата не создаётся вовсе — шаг отказывает на измерении копии.
return os.Remove(dest)
}
+122
View File
@@ -0,0 +1,122 @@
package service
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Выборка воркера владельцем не сужается: владелец решает, кому запись
// показывать, а не кому её считать. Сужение поставило бы записи одних людей в
// зависимость от того, кто первым завёл учётную запись.
func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
first, err := env.service.CreateJobFromApi(t.Context(),
strings.NewReader("первая"), "one.mp3", newOwner(t, env.app))
require.NoError(t, err)
second, err := env.service.CreateJobFromApi(t.Context(),
strings.NewReader("вторая"), "two.mp3", newOwner(t, env.app))
require.NoError(t, err)
// Третья — ещё одного владельца: воркер не сужается ни одним из них.
third := newRecord(t, env)
// Захваченная запись остаётся за держателем, и следующий вызов берёт
// следующую, а не ту же самую.
taken := map[string]bool{}
for range 3 {
acquired, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err, "воркер берёт записи подряд, владельцем не сужаясь")
taken[acquired.ID] = true
}
assert.True(t, taken[first.Id], "запись первого владельца досталась воркеру")
assert.True(t, taken[second.Id], "и второго")
assert.True(t, taken[third.Id], "и третьего")
_, err = env.recordRepo.FindAndAcquire(entity.WorkingStages())
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing, "больше пригодных к работе записей нет")
}
// Захват отдаёт идентификатор и признак своего захвата — не перечень колонок.
// Колонки шаг читает сам: иначе всякая новая колонка записи попадала бы под
// инвариант проекта о колонках очереди.
func TestAcquireReturnsIdentifierAndHolder(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
owner := newOwner(t, env.app)
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
require.NoError(t, err)
acquired, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
assert.Equal(t, record.Id, acquired.ID, "захват назвал запись")
assert.NotEmpty(t, acquired.Holder, "и признак своего захвата")
// Колонки читаются отдельным чтением, и владелец среди них.
read := readRecord(t, env, record.Id)
assert.Equal(t, owner, read.OwnerID)
assert.Equal(t, acquired.Holder, *read.AcquisitionID, "признак захвата записан в саму запись")
require.NotNil(t, read.AcquireExpiresAt, "срок протухания приехал с рубежом")
}
// Шаг конвейера владельца не затирает: сохранение кладёт только то, чем
// распоряжается конвейер, и владельца среди этого нет.
func TestPipelineStepKeepsOwner(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
owner := newOwner(t, env.app)
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
require.NoError(t, err)
// Отказ приведения — приговор записи: шаг останавливает её и сохраняет. Это
// сохранение владельца тронуть не должно.
require.NoError(t, env.service.RunStep(t.Context()))
after := readRecord(t, env, record.Id)
require.True(t, after.IsHalted(), "шаг записал свой приговор")
assert.Equal(t, owner, after.OwnerID, "владелец пережил шаг")
}
// Приём без владельца записи не заводит. Отказ стоит здесь раньше схемы: он
// отвечает понятной ошибкой до того, как запись ляжет в хранилище, а схема
// отказала бы уже после укладки файла.
func TestCreateJobFromApiRequiresOwner(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", "")
require.ErrorIs(t, err, contract.ErrOwnerRequired)
}
// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
// ответов иначе перебирается список заведённых записей.
func TestGetByIDHidesForeignRecords(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
owner := newOwner(t, env.app)
stranger := newOwner(t, env.app)
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
require.NoError(t, err)
var missing *contract.JobNotFoundError
_, err = env.recordRepo.GetByID(record.Id, stranger)
require.ErrorAs(t, err, &missing, "чужая запись неотличима от несуществующей")
_, err = env.recordRepo.GetByID(record.Id, "")
require.ErrorAs(t, err, &missing, "пустой владелец не совпадает ни с чем")
own, err := env.recordRepo.GetByID(record.Id, owner)
require.NoError(t, err, "своя запись отдаётся")
assert.Equal(t, record.Id, own.Id)
}
+591 -161
View File
@@ -8,9 +8,11 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
"github.com/google/uuid"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types" "github.com/pocketbase/pocketbase/tools/types"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -23,10 +25,22 @@ import (
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
// Проверки конвейера идут против настоящего хранилища: захват, число попыток и // Проверки конвейера идут против настоящего хранилища: захват, число отказов и
// переход в «мертва» держатся на запросе, и подставной репозиторий проверял бы // остановка держатся на запросе, и подставной репозиторий проверял бы
// собственную заглушку, а не то, что делает база. // собственную заглушку, а не то, что делает база.
// okConverter переливает исходник в результат — так это выглядит у настоящего
// приведения к рабочему формату.
type okConverter struct{}
func (c *okConverter) Convert(_ context.Context, src, dest string) error {
content, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dest, content, 0o600)
}
// failingConverter отказывает на каждой попытке. // failingConverter отказывает на каждой попытке.
type failingConverter struct{} type failingConverter struct{}
@@ -46,26 +60,43 @@ func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInf
return nil, errors.New("запись не читается") return nil, errors.New("запись не читается")
} }
// recordingSender запоминает, что и куда отправлено.
type recordingSender struct {
messages []string
}
func (s *recordingSender) Send(text string, chatId int64, replyMsgId *int) error {
s.messages = append(s.messages, text)
return nil
}
type pipelineEnv struct { type pipelineEnv struct {
app core.App app core.App
service *TranscribeService service *TranscribeService
jobRepo *pbrepo.TranscriptJobRepository repos Repositories
fileRepo *pbrepo.FileRepository recordRepo *pbrepo.AudioRecordRepository
sender *recordingSender fileRepo *pbrepo.FileRepository
} }
// testLimits — пределы простоя проверок. Числа боевые; проверка застревания
// двигает не их, а время входа записи в рубеж: сторож меряет именно его.
var testLimits = entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour}
func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter contract.AudioFileConverter) *pipelineEnv { func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter contract.AudioFileConverter) *pipelineEnv {
t.Helper() t.Helper()
return newPipelineEnvWith(t, metaviewer, converter, &recognizer.MemoryAudioRecognizer{})
}
func newPipelineEnvWith(
t *testing.T,
metaviewer contract.AudioMetaViewer,
converter contract.AudioFileConverter,
rec contract.AudioRecognizer,
) *pipelineEnv {
t.Helper()
return newPipelineEnvWithLogger(t, metaviewer, converter, rec, slog.New(slog.DiscardHandler))
}
// newPipelineEnvWithLogger — то же с подменённым журналом: проверке, судящей
// строку журнала, нужен свой, а не общий.
func newPipelineEnvWithLogger(
t *testing.T,
metaviewer contract.AudioMetaViewer,
converter contract.AudioFileConverter,
rec contract.AudioRecognizer,
logger *slog.Logger,
) *pipelineEnv {
t.Helper()
app, err := pbrepo.New(t.TempDir()) app, err := pbrepo.New(t.TempDir())
require.NoError(t, err) require.NoError(t, err)
@@ -80,158 +111,437 @@ func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter
// нет. // нет.
pbrepo.BindPanelRules(app) pbrepo.BindPanelRules(app)
jobRepo := pbrepo.NewTranscriptJobRepository(app) recordRepo := pbrepo.NewAudioRecordRepository(app)
fileRepo := pbrepo.NewFileRepository(app) fileRepo := pbrepo.NewFileRepository(app)
sender := &recordingSender{} repos := Repositories{
Records: recordRepo,
Files: fileRepo,
Texts: pbrepo.NewTextRepository(app),
Structures: pbrepo.NewStructureRepository(app),
Recognitions: pbrepo.NewRecognitionRepository(app),
Events: pbrepo.NewRecordEventRepository(app),
}
svc := NewTranscribeService(repos, metaviewer, converter, rec, testLimits, logger)
svc := NewTranscribeService( return &pipelineEnv{
jobRepo, app: app,
fileRepo, service: svc,
metaviewer, repos: repos,
converter, recordRepo: recordRepo,
&recognizer.MemoryAudioRecognizer{}, fileRepo: fileRepo,
sender, }
slog.New(slog.DiscardHandler),
)
return &pipelineEnv{app: app, service: svc, jobRepo: jobRepo, fileRepo: fileRepo, sender: sender}
} }
// newTelegramJob заводит задачу с записью — так, как её завёл бы приём. // newRecord заводит запись — так, как её заводит приём по HTTP: от имени
func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob { // вошедшего, потому что ничьей записи в хранилище не бывает.
func newRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord {
t.Helper() t.Helper()
chatId := int64(100) record, err := env.service.CreateJobFromApi(
job, err := env.service.CreateJobFromTelegram(t.Context(), strings.NewReader("запись"), "voice.ogg", chatId, 1) t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app))
require.NoError(t, err) require.NoError(t, err)
return job return record
} }
// clearDelay снимает паузу, чтобы следующий прогон взял задачу сразу: проверка // clearDelay снимает паузу, чтобы следующий прогон взял запись сразу.
// судит счётчик попыток, а не то, умеет ли она ждать. func clearDelay(t *testing.T, env *pipelineEnv, recordID string) {
func clearDelay(t *testing.T, env *pipelineEnv, jobID string) {
t.Helper() t.Helper()
record, err := env.app.FindRecordById(migrations.JobsCollection, jobID) record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
require.NoError(t, err) require.NoError(t, err)
record.Set("delay_time", "") record.Set("delay_time", "")
require.NoError(t, env.app.Save(record)) require.NoError(t, env.app.Save(record))
} }
// rotAcquisition отодвигает время захвата так, чтобы он протух: так это // enteredStateAt отодвигает время входа записи в рубеж: так это выглядит, когда
// выглядит, когда шаг оборвался вместе с процессом. // запись простояла в нём дольше предела.
func rotAcquisition(t *testing.T, env *pipelineEnv, jobID string) { func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time.Time) {
t.Helper() t.Helper()
record, err := env.app.FindRecordById(migrations.JobsCollection, jobID) record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
require.NoError(t, err) require.NoError(t, err)
record.Set("acquire_time", types.NowDateTime().Add(-24*time.Hour)) record.Set("state_entered_at", types.DateTime{}.Add(0))
stamp, err := types.ParseDateTime(moment)
require.NoError(t, err)
record.Set("state_entered_at", stamp)
require.NoError(t, env.app.Save(record)) require.NoError(t, env.app.Save(record))
} }
// Задача, падающая на каждой попытке, уходит в «мертва»: из выборки исчезает, // setAttempts ставит записи число отказов: так она выглядит, когда шаг отказал
// видна отбором по состоянию, а отправитель узнаёт о неудаче. Инвариант // столько раз подряд, что предел исчерпан.
// «Принятая запись не теряется молча» допускает два исхода, и молчаливая смерть func setAttempts(t *testing.T, env *pipelineEnv, recordID string, attempts int) {
// не подходит ни под один. t.Helper()
func TestJobDiesAfterAttemptLimit(t *testing.T) {
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
require.NoError(t, err)
record.Set("attempts", attempts)
require.NoError(t, env.app.Save(record))
}
// drain крутит конвейер, пока он двигает записи. Паузы опроса снимаются: они
// проверяются отдельно, а здесь мешают дойти до конца.
func drain(t *testing.T, env *pipelineEnv, recordID string) {
t.Helper()
for range 20 {
clearDelay(t, env, recordID)
err := env.service.RunStep(t.Context())
var noop *contract.NoopJobError
if errors.As(err, &noop) {
return
}
require.NoError(t, err)
after := readRecord(t, env, recordID)
if after.State == entity.StateDone || after.IsHalted() {
return
}
}
t.Fatal("конвейер не дошёл до исхода за отведённое число прогонов")
}
// readRecord читает запись мимо сужения владельцем.
//
// Читающий метод сервиса отдаёт запись только её владельцу, а проверки конвейера
// смотрят записи, принятые ботом: владельца у таких нет вовсе. Проверке нужно не
// право, а состояние записи после шага.
func readRecord(t *testing.T, env *pipelineEnv, id string) *entity.AudioRecord {
t.Helper()
record, err := env.recordRepo.Get(id)
require.NoError(t, err)
return record
}
// Запись, отказывающая на каждой попытке, останавливается признаком: из выборки
// исчезает, рубеж сохраняет, а отправитель узнаёт о неудаче. Инвариант «Принятая
// запись не теряется молча» допускает два исхода, и молчаливая остановка не
// подходит ни под один.
func TestRecordHaltsAfterAttemptLimit(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := newTelegramJob(t, env) record := newRecord(t, env)
// Отказ конвертации переводит задачу в `failed` сразу, поэтому предел // Захват без выполнения шага — так это выглядит при гибели процесса: отказа
// попыток проверяем на шаге, который отказывает *не* приговором: подменяем // шаг объявить не успевает, а попытка засчитана.
// его отказом источника метаданных внутри самого шага конвертации нельзя, и for range maxAttempts {
// вместо этого гоняем захват без выполнения шага — так же, как это выглядит _, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
// при гибели процесса.
for i := 0; i < maxAttempts; i++ {
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
require.NoError(t, err) require.NoError(t, err)
expireAcquisition(t, env, record.Id)
} }
rotAcquisition(t, env, job.Id)
// Следующий захват видит перебор и хоронит задачу. err := env.service.RunStep(t.Context())
err := env.service.FindAndRunConversionJob(t.Context())
var noop *contract.NoopJobError var noop *contract.NoopJobError
require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся") require.ErrorAs(t, err, &noop, "остановленная запись шагу не отдаётся")
after, err := env.jobRepo.GetByID(job.Id) after := readRecord(t, env, record.Id)
require.NoError(t, err) assert.True(t, after.IsHalted(), "запись остановлена признаком")
assert.Equal(t, entity.StateDead, after.State, "задача видна отбором по состоянию") require.NotNil(t, after.HaltReason)
assert.Greater(t, after.Attempts, maxAttempts, "число попыток сохранено") assert.Equal(t, entity.HaltReasonAttempts, *after.HaltReason)
assert.Equal(t, entity.StateUploaded, after.State, "рубеж пережил остановку")
require.Len(t, env.sender.messages, 1, "отправитель узнал о неудаче") assert.Greater(t, after.Attempts, maxAttempts, "число отказов сохранено")
assert.Contains(t, env.sender.messages[0], "попытки исчерпаны")
// И из выборки она исчезла. // И из выборки она исчезла.
_, err = env.jobRepo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(time.Hour)) _, err = env.recordRepo.FindAndAcquire(entity.WorkingStages())
var missing *contract.JobNotFoundError var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing) assert.ErrorAs(t, err, &missing)
} }
// Мёртвая задача возвращается в работу правкой состояния. // expireAcquisition отодвигает срок протухания захвата в прошлое: так это
func TestDeadJobReturnsAfterStateEdit(t *testing.T) { // выглядит, когда шаг оборвался вместе с процессом.
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) func expireAcquisition(t *testing.T, env *pipelineEnv, recordID string) {
t.Helper()
job := newTelegramJob(t, env) record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
require.NoError(t, err)
record.Set("acquire_expires_at", types.NowDateTime().Add(-time.Hour))
require.NoError(t, env.app.Save(record))
}
for i := 0; i < maxAttempts; i++ { // Критерий приёмки 1. Остановленная на шаге запись перезапускается снятием
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour)) // признака и продолжает с того рубежа, где стояла, — следующим идёт отправка на
// распознавание, а не повторное приведение.
func TestHaltedRecordResumesFromItsStage(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newRecord(t, env)
// Доводим до рубежа приведения и останавливаем на нём.
require.NoError(t, env.service.RunStep(t.Context()))
after := readRecord(t, env, record.Id)
require.Equal(t, entity.StateNormalized, after.State)
after.Halt(entity.HaltReasonStepFailed, "проверочная остановка")
require.NoError(t, env.recordRepo.Save(after, ""))
// Остановленная запись захвату не выдаётся.
_, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
var missing *contract.JobNotFoundError
require.ErrorAs(t, err, &missing, "остановленная запись из выборки исчезла")
// Снятие признака возвращает её в работу с сохранённого рубежа.
halted := readRecord(t, env, record.Id)
halted.Resume()
require.NoError(t, env.recordRepo.Save(halted, ""))
resumed := readRecord(t, env, record.Id)
assert.Equal(t, entity.StateNormalized, resumed.State, "рубеж сохранён")
assert.Equal(t, 0, resumed.Attempts, "отказы сброшены")
// Следующим идёт отправка на распознавание, а не повторное приведение.
require.NoError(t, env.service.RunStep(t.Context()))
next := readRecord(t, env, record.Id)
assert.Equal(t, entity.StateSubmitted, next.State, "продолжили с места остановки")
assert.NotNil(t, next.RecognitionID, "отправка состоялась")
}
// Критерий приёмки 2. У прошедшей конвейер записи ссылки на исходник и на
// приведённую копию ведут на разные существующие файлы: прежде ссылка была одна,
// и каждый шаг переставлял её на свой результат.
func TestBothFileLinksSurvivePipeline(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newRecord(t, env)
drain(t, env, record.Id)
after := readRecord(t, env, record.Id)
require.Equal(t, entity.StateDone, after.State)
require.NotNil(t, after.OriginalFileID, "ссылка на принятую копию заполнена")
require.NotNil(t, after.NormalizedFileID, "ссылка на приведённую копию заполнена")
assert.NotEqual(t, *after.OriginalFileID, *after.NormalizedFileID, "копии разные")
for _, id := range []string{*after.OriginalFileID, *after.NormalizedFileID} {
reader, err := env.fileRepo.Open(id)
require.NoError(t, err, "обе копии открываются")
content, err := io.ReadAll(reader)
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, content)
require.NoError(t, reader.Close())
} }
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("state", entity.StateCreated)
require.NoError(t, env.app.Save(record))
again, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(time.Hour))
require.NoError(t, err, "снятое состояние возвращает задачу в работу")
assert.Equal(t, job.Id, again.Id)
} }
// Отказ шага не оставляет задачу захваченной до конца срока: захват снимается, // Критерий приёмки 3. Поведение записи не зависит от числа воркеров: при одном
// и задача ждёт нарастающую паузу. Иначе повтор наступал бы через восемь часов. // и при нескольких она доходит до конечного рубежа.
func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) { func TestOutcomeDoesNotDependOnWorkerCount(t *testing.T) {
// Источник метаданных отказывает — это отказ шага, а не приговор записи. for _, workers := range []int{1, 4} {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newRecord(t, env)
job := newTelegramJob(t, env) for range 20 {
clearDelay(t, env, record.Id)
// Ссылку переставляем на запись без содержимого: шаг отказывает на получении var wg sync.WaitGroup
// рабочей копии — то есть отказом, а не приговором записи. for range workers {
empty, err := env.fileRepo.CreateRemote("object-key", 1) wg.Add(1)
require.NoError(t, err) go func() {
defer wg.Done()
// Исход прогона здесь не судится: воркеров несколько, и всем,
// кроме одного, работы не достаётся. Судится состояние записи.
if err := env.service.RunStep(t.Context()); err != nil {
t.Log(err)
}
}()
}
wg.Wait()
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id) if readRecord(t, env, record.Id).State == entity.StateDone {
require.NoError(t, err) break
record.Set("file", empty.Id) }
require.NoError(t, env.app.Save(record)) }
// Первый отказ. after := readRecord(t, env, record.Id)
require.Error(t, env.service.FindAndRunConversionJob(t.Context())) assert.Equalf(t, entity.StateDone, after.State, "запись дошла до конца при %d воркерах", workers)
assert.Falsef(t, after.IsHalted(), "запись не остановлена при %d воркерах", workers)
after, err := env.jobRepo.GetByID(job.Id) }
require.NoError(t, err)
require.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору")
require.NotNil(t, after.DelayTime, "пауза поставлена")
firstDelay := time.Until(*after.DelayTime)
// Второй отказ — с той же задачи, пауза снята вручную.
clearDelay(t, env, job.Id)
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
after, err = env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
require.NotNil(t, after.DelayTime)
secondDelay := time.Until(*after.DelayTime)
assert.Greater(t, secondDelay, firstDelay, "вторая пауза длиннее первой")
} }
// Пауза растёт с числом попыток и упирается в потолок. // Тот же критерий, вторая половина: нулевое число воркеров — законное значение.
// Записи принимаются и не двигаются, и это режим, а не поломка.
func TestZeroWorkersLeaveRecordUntouched(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newRecord(t, env)
// Пул нулевого размера ни одного прогона не делает — потому запись остаётся
// там, где её оставил приём.
after := readRecord(t, env, record.Id)
assert.Equal(t, entity.StateUploaded, after.State, "запись принята и стоит на первом рубеже")
assert.False(t, after.IsHalted(), "и не потеряна")
assert.Equal(t, record.Id, after.Id)
}
// Критерий приёмки 5, первая половина. Откладывание опроса не двигает время
// входа в рубеж и не обнуляет отсчёт застревания: иначе запись, чью чужую
// операцию опрашивают раз в несколько секунд, не достигла бы предела никогда.
func TestPostponeKeepsStuckCountdown(t *testing.T) {
entered := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
record := &entity.AudioRecord{State: entity.StateSubmitted, StateEnteredAt: entered}
for range 100 {
record.Postpone(time.Now().Add(5 * time.Second))
}
assert.Equal(t, entered, record.StateEnteredAt, "сотня откладываний не двигает отсчёт")
assert.Equal(t, entity.StateSubmitted, record.State, "и рубежа не трогает")
assert.Nil(t, record.AcquisitionID, "захват при этом снят")
assert.NotNil(t, record.DelayTime, "а пауза поставлена")
}
// Критерий приёмки 5, вторая половина. Запись, простоявшая в рубеже дольше
// предела, останавливается признаком с причиной «застряла».
func TestStuckRecordIsHalted(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newRecord(t, env)
enteredStateAt(t, env, record.Id, time.Now().Add(-2*testLimits.Own))
err := env.service.RunStep(t.Context())
var noop *contract.NoopJobError
require.ErrorAs(t, err, &noop, "застрявшая запись шагу не отдаётся")
after := readRecord(t, env, record.Id)
require.True(t, after.IsHalted())
require.NotNil(t, after.HaltReason)
assert.Equal(t, entity.HaltReasonStuck, *after.HaltReason, "причина названа")
assert.Equal(t, entity.StateUploaded, after.State, "рубеж сохранён")
}
// Конечный рубеж под предел простоя не подпадает: стоять в нём запись будет
// вечно по построению, и сторож остановил бы всякую доведённую запись.
func TestDoneRecordIsNeverStuck(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newRecord(t, env)
drain(t, env, record.Id)
enteredStateAt(t, env, record.Id, time.Now().Add(-10*24*time.Hour))
err := env.service.RunStep(t.Context())
var noop *contract.NoopJobError
require.ErrorAs(t, err, &noop, "доведённая запись в работу не берётся")
after := readRecord(t, env, record.Id)
assert.Equal(t, entity.StateDone, after.State)
assert.False(t, after.IsHalted(), "признака остановки у доведённой записи нет")
}
// Критерий приёмки 6. Держатель захвата отличим значением, а не занятостью
// записи: шаг, чей захват достался другому, результата не пишет и отправителю
// ничего не шлёт.
func TestOnlyHolderWritesResult(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newRecord(t, env)
first, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
require.Equal(t, record.Id, first.ID)
// Человек снял признак остановки — панель чистит признак захвата, — и запись
// достаётся другому воркеру.
expireAcquisition(t, env, record.Id)
second, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
require.NotEqual(t, first.Holder, second.Holder, "признак нового захвата отличается")
// Первый доходит до записи результата и получает отказ.
stale := readRecord(t, env, record.Id)
stale.MoveToState(entity.StateNormalized)
err = env.recordRepo.Save(stale, first.Holder)
var lost *contract.LostAcquisitionError
require.ErrorAs(t, err, &lost, "потерявший захват не пишет")
after := readRecord(t, env, record.Id)
assert.Equal(t, entity.StateUploaded, after.State, "рубеж не сдвинут потерявшим захват")
}
// Критерий приёмки 7. Всякий способ вывести запись из работы оставляет причину
// остановки: причин больше одной, и обязанность у них общая. Отправитель узнаёт
// исход опросом готовности, а владелец сервиса — журналом событий записи.
func TestEveryHaltReasonRecordsItsCause(t *testing.T) {
reasons := []struct {
name string
halt func(t *testing.T, env *pipelineEnv, recordID string)
}{
{
name: "приговор шага",
halt: func(t *testing.T, env *pipelineEnv, recordID string) {
require.NoError(t, env.service.RunStep(t.Context()))
},
},
{
name: "застревание",
halt: func(t *testing.T, env *pipelineEnv, recordID string) {
enteredStateAt(t, env, recordID, time.Now().Add(-2*testLimits.Own))
var noop *contract.NoopJobError
require.ErrorAs(t, env.service.RunStep(t.Context()), &noop)
},
},
{
name: "исчерпанные отказы",
halt: func(t *testing.T, env *pipelineEnv, recordID string) {
setAttempts(t, env, recordID, maxAttempts+1)
var noop *contract.NoopJobError
require.ErrorAs(t, env.service.RunStep(t.Context()), &noop)
},
},
}
for _, reason := range reasons {
t.Run(reason.name, func(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
record := newRecord(t, env)
reason.halt(t, env, record.Id)
after := readRecord(t, env, record.Id)
require.True(t, after.IsHalted(), "запись остановлена")
// Признак остановки и причина ставятся одним движением, поэтому
// вторым утверждением берётся **журнал событий**: он пишется
// отдельной строкой, отдельным сохранением, и упасть может сам по
// себе. Прежде эту роль играл счёт ответов отправителю; ответы ушли
// вместе с входом Telegram, и без замены проверка вывелась бы из
// собственной предыдущей строки.
events := recordEvents(t, env, record.Id)
require.Len(t, events, 1, "остановка оставила строку журнала событий")
outcome := events[0].GetString("outcome")
assert.Contains(t,
[]string{entity.EventOutcomeHalted, entity.EventOutcomeFailed}, outcome,
"строка журнала называет исход остановкой")
assert.NotEmpty(t, events[0].GetString("outcome_text"), "и несёт причину")
})
}
}
// Критерий приёмки 8. Повтор шага не создаёт второго приложения: пара «запись и
// вид» уникальна, и вопрос «какой текст отдавать человеку» не становится
// вопросом порядка записи.
func TestRepeatedStoreKeepsSingleText(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newRecord(t, env)
first, err := env.repos.Texts.Put(record.Id, entity.TextKindTranscript, "первый разбор")
require.NoError(t, err)
second, err := env.repos.Texts.Put(record.Id, entity.TextKindTranscript, "второй разбор")
require.NoError(t, err)
assert.Equal(t, first.Id, second.Id, "строка та же, а не вторая")
stored, err := env.repos.Texts.GetByID(first.Id)
require.NoError(t, err)
assert.Equal(t, "второй разбор", stored.Contents)
count, err := env.app.CountRecords(migrations.TextsCollection)
require.NoError(t, err)
assert.Equal(t, int64(1), count, "второго комплекта строк не завелось")
}
// Пауза растёт с числом отказов и упирается в потолок.
func TestRetryDelayGrowsAndCaps(t *testing.T) { func TestRetryDelayGrowsAndCaps(t *testing.T) {
assert.Equal(t, retryDelayBase, retryDelay(1)) assert.Equal(t, retryDelayBase, retryDelay(1))
assert.Equal(t, 2*retryDelayBase, retryDelay(2)) assert.Equal(t, 2*retryDelayBase, retryDelay(2))
@@ -240,6 +550,44 @@ func TestRetryDelayGrowsAndCaps(t *testing.T) {
assert.Equal(t, retryDelayBase, retryDelay(0), "нулевая попытка не даёт нулевой паузы") assert.Equal(t, retryDelayBase, retryDelay(0), "нулевая попытка не даёт нулевой паузы")
} }
// Отказ шага не оставляет запись захваченной до конца срока: захват снимается,
// и запись ждёт нарастающую паузу. Иначе повтор наступал бы через часы.
func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
record := newRecord(t, env)
// Ссылка переставляется на запись о файле без содержимого: шаг отказывает на
// получении рабочей копии — то есть отказом, а не приговором записи.
files, err := env.app.FindCollectionByNameOrId(migrations.FilesCollection)
require.NoError(t, err)
empty := core.NewRecord(files)
empty.Set("location", entity.LocationLocal)
empty.Set("size", 1)
// Владелец обязателен и у файла: схема ничьих не принимает.
empty.Set("owner", record.OwnerID)
require.NoError(t, env.app.Save(empty))
stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id)
require.NoError(t, err)
stored.Set("original_file", empty.Id)
require.NoError(t, env.app.Save(stored))
require.Error(t, env.service.RunStep(t.Context()))
after := readRecord(t, env, record.Id)
require.Nil(t, after.AcquisitionID, "захват снят: запись пригодна к повтору")
require.NotNil(t, after.DelayTime, "пауза поставлена")
firstDelay := time.Until(*after.DelayTime)
clearDelay(t, env, record.Id)
require.Error(t, env.service.RunStep(t.Context()))
after = readRecord(t, env, record.Id)
require.NotNil(t, after.DelayTime)
assert.Greater(t, time.Until(*after.DelayTime), firstDelay, "вторая пауза длиннее первой")
}
// Рабочая копия убирается на любом исходе, включая отказ. Забытая копия — это // Рабочая копия убирается на любом исходе, включая отказ. Забытая копия — это
// шестичасовая запись во временном каталоге, и узнать о ней неоткуда. // шестичасовая запись во временном каталоге, и узнать о ней неоткуда.
func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) { func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
@@ -248,7 +596,7 @@ func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{})
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3") _, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env.app))
require.Error(t, err, "отказ источника метаданных роняет приём") require.Error(t, err, "отказ источника метаданных роняет приём")
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*")) leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
@@ -264,7 +612,7 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3") _, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env.app))
require.NoError(t, err) require.NoError(t, err)
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*")) leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
@@ -272,24 +620,43 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
assert.Empty(t, leftovers, "рабочей копии после успеха не остаётся") assert.Empty(t, leftovers, "рабочей копии после успеха не остаётся")
} }
// Задача не остаётся ссылающейся на файл, которого нет: ссылка переставляется // Уборка проверяется и на шаге приведения: репозиторий даёт единственный способ
// только после того, как запись о новом файле существует. // убрать копию, но зовёт его шаг. Копий здесь две — исходник и результат.
func TestJobNeverPointsToMissingFile(t *testing.T) { func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := newTelegramJob(t, env) newRecord(t, env)
// Конвертация отказывает — задача уходит в `failed`, но ссылка остаётся на leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
// исходную запись, а не на несозданный результат.
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, entity.StateFailed, after.State) require.Empty(t, leftovers, "приём убрал свою рабочую копию")
require.NotNil(t, after.FileID)
file, err := env.fileRepo.GetByID(*after.FileID) require.NoError(t, env.service.RunStep(t.Context()))
require.NoError(t, err, "ссылка задачи ведёт на существующую запись о файле")
leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
require.NoError(t, err)
assert.Empty(t, leftovers, "ни исходной копии, ни копии под результат не осталось")
}
// Запись не остаётся ссылающейся на файл, которого нет: ссылка ставится только
// после того, как запись о файле существует.
func TestRecordNeverPointsToMissingFile(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
record := newRecord(t, env)
require.NoError(t, env.service.RunStep(t.Context()))
after := readRecord(t, env, record.Id)
assert.True(t, after.IsHalted(), "приговор шага остановил запись")
require.NotNil(t, after.OriginalFileID)
assert.Nil(t, after.NormalizedFileID, "ссылки на несозданный результат не появилось")
file, err := env.fileRepo.GetByID(*after.OriginalFileID)
require.NoError(t, err, "ссылка ведёт на существующую запись о файле")
assert.NotEmpty(t, file.FileName) assert.NotEmpty(t, file.FileName)
} }
@@ -299,11 +666,11 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
content := strings.Repeat("запись ", 1000) content := strings.Repeat("запись ", 1000)
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3") record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env.app))
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, job.FileID) require.NotNil(t, record.OriginalFileID)
reader, err := env.fileRepo.Open(*job.FileID) reader, err := env.fileRepo.Open(*record.OriginalFileID)
require.NoError(t, err) require.NoError(t, err)
defer reader.Close() defer reader.Close()
@@ -311,22 +678,22 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, content, string(stored)) assert.Equal(t, content, string(stored))
// И длина в учёте совпадает с длиной принятого. file, err := env.fileRepo.GetByID(*record.OriginalFileID)
file, err := env.fileRepo.GetByID(*job.FileID)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, int64(len(content)), file.Size) assert.Equal(t, int64(len(content)), file.Size)
assert.Equal(t, "mp3", file.Format, "формат копии записан")
} }
// Рабочая копия хранимого файла отдаётся именем на диске — так её получают // Рабочая копия хранимого файла отдаётся именем на диске — так её получают шаги,
// шаги, отдающие файл внешней программе. // отдающие файл внешней программе.
func TestLocalizeGivesReadableCopy(t *testing.T) { func TestLocalizeGivesReadableCopy(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3") record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env.app))
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, job.FileID) require.NotNil(t, record.OriginalFileID)
work, err := env.fileRepo.Localize(*job.FileID) work, err := env.fileRepo.Localize(*record.OriginalFileID)
require.NoError(t, err) require.NoError(t, err)
content, err := os.ReadFile(work.Path()) content, err := os.ReadFile(work.Path())
@@ -338,27 +705,90 @@ func TestLocalizeGivesReadableCopy(t *testing.T) {
assert.True(t, os.IsNotExist(err), "закрытая копия убрана") assert.True(t, os.IsNotExist(err), "закрытая копия убрана")
} }
// Уборка рабочей копии проверяется и на шаге конвертации: репозиторий даёт // newOwner заводит учётную запись и отдаёт её идентификатор.
// единственный способ убрать копию, но зовёт его шаг, и норма держится //
// проверкой, а не построением. Копий здесь две — исходник и результат. // Владелец — связь с коллекцией пользователей, и хранилище проверяет, что такая
func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) { // запись есть: выдуманный идентификатор запись завести не даст.
tempDir := t.TempDir() func newOwner(t *testing.T, app core.App) string {
t.Setenv("TMPDIR", tempDir) t.Helper()
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
require.NoError(t, err)
record := core.NewRecord(users)
record.Set("email", uuid.NewString()+"@example.test")
record.Set("verified", true)
record.Set("password", uuid.NewString())
require.NoError(t, app.Save(record))
return record.Id
}
// Остановка приговором шага засчитывается **отказом**, а не успехом, и пишет в
// журнал записи одну строку, а не две.
//
// Пока исход шага выводился из «шаг не вернул ошибку», остановленная запись
// получала событием `done` вслед за `halted` и растила счётчик успехов — то есть
// единственный канал владельца молчал ровно там, где запись встала.
func TestHaltIsCountedAsFailureAndLoggedOnce(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
newTelegramJob(t, env) beforeOk := stageCount(t, entity.StateUploaded, "false")
beforeErr := stageCount(t, entity.StateUploaded, "true")
// Приём уже отработал — убеждаемся, что за собой он прибрал, иначе остаток record := newRecord(t, env)
// от него зачёлся бы шагу конвертации. require.NoError(t, env.service.RunStep(t.Context()))
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
require.NoError(t, err)
require.Empty(t, leftovers, "приём убрал свою рабочую копию")
// Конвертация отказывает — задача уходит в `failed`, копии убраны. after := readRecord(t, env, record.Id)
require.NoError(t, env.service.FindAndRunConversionJob(t.Context())) require.True(t, after.IsHalted(), "приговор шага остановил запись")
leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*")) assert.InDelta(t, beforeErr+1, stageCount(t, entity.StateUploaded, "true"), 0,
"остановка засчитана отказом")
assert.InDelta(t, beforeOk, stageCount(t, entity.StateUploaded, "false"), 0,
"и успехом не засчитана")
events := recordEvents(t, env, record.Id)
require.Len(t, events, 1, "одна строка журнала, а не две")
assert.Equal(t, entity.EventOutcomeFailed, events[0].GetString("outcome"),
"исход назван приговором, а не сделанной работой")
}
// Откладывание опроса в журнал событий не пишется: часовое ожидание чужой
// операции дало бы там сотни строк ни о чём, и журнал, заведённый для человека,
// стал бы нечитаемым ровно у долгой записи.
func TestPostponeWritesNoEvent(t *testing.T) {
rec := &countingRecognizer{}
rec.inProgress.Store(5)
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
record := newRecord(t, env)
require.NoError(t, env.service.RunStep(t.Context())) // приведение
require.NoError(t, env.service.RunStep(t.Context())) // отправка
require.Equal(t, entity.StateSubmitted, readRecord(t, env, record.Id).State)
before := len(recordEvents(t, env, record.Id))
for range 5 {
clearDelay(t, env, record.Id)
require.NoError(t, env.service.RunStep(t.Context()))
}
assert.Len(t, recordEvents(t, env, record.Id), before,
"пять откладываний не оставили в журнале ни строки")
}
// recordEvents читает журнал событий одной записи в порядке заведения.
func recordEvents(t *testing.T, env *pipelineEnv, recordID string) []*core.Record {
t.Helper()
all, err := env.app.FindAllRecords(migrations.RecordEventsCollection)
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, leftovers, "ни исходной копии, ни копии под результат не осталось")
var own []*core.Record
for _, event := range all {
if event.GetString("record") == recordID {
own = append(own, event)
}
}
return own
} }
+219 -203
View File
@@ -1,268 +1,284 @@
package service package service
import ( import (
"bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"io" "io"
"strings" "log/slog"
"sync/atomic"
"testing" "testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
// Шаги распознавания переписаны переездом на новое хранилище целиком: они берут // countingRecognizer считает обращения наружу: за них платят по факту, и повтор
// содержимое по записи, заводят запись о копии во внешнем хранилище и пишут // оплаченного шага — самый дорогой класс дефекта в этом сервисе.
// результат условием по держателю захвата. Подставной распознаватель проекта type countingRecognizer struct {
// умеет только «завершено с фиксированным текстом», поэтому ветки ожидания, uploads atomic.Int64
// отказа операции и пустого текста изобразить нечем — для них нужен управляемый submits atomic.Int64
// двойник. fetches atomic.Int64
parses atomic.Int64
// scriptedRecognizer отдаёт заданный исход проверки операции и заданный текст. objectHere atomic.Bool
type scriptedRecognizer struct { inProgress atomic.Int64
result *entity.RecognitionResult
text string
recognizeErr error
recognizeCalls int
lastObjectKey string
} }
func (r *scriptedRecognizer) Recognize(_ context.Context, file io.Reader, fileName string) (string, error) { func (r *countingRecognizer) Provider() string { return "counting" }
r.recognizeCalls++
r.lastObjectKey = fileName func (r *countingRecognizer) Model() string { return "counting" }
if r.recognizeErr != nil {
return "", r.recognizeErr func (r *countingRecognizer) Upload(_ context.Context, _ io.Reader, objectKey string) (string, error) {
r.uploads.Add(1)
r.objectHere.Store(true)
return "counting://" + objectKey, nil
}
func (r *countingRecognizer) ObjectExists(context.Context, string, int64) (bool, error) {
return r.objectHere.Load(), nil
}
func (r *countingRecognizer) SourceURI(objectKey string) string {
return "counting://" + objectKey
}
func (r *countingRecognizer) Submit(context.Context, string) (string, error) {
r.submits.Add(1)
return uuid.NewString(), nil
}
func (r *countingRecognizer) CheckStatus(context.Context, string) (*entity.RecognitionResult, error) {
if r.inProgress.Load() > 0 {
r.inProgress.Add(-1)
return entity.NewInProgressResult(), nil
} }
// Содержимое обязано быть читаемым: шаг отдаёт его наружу потоком. return entity.NewCompletedResult(), nil
if _, err := io.Copy(io.Discard, file); err != nil { }
return "", err
func (r *countingRecognizer) Fetch(context.Context, string) (*entity.RecognitionOutcome, error) {
r.fetches.Add(1)
return r.Parse(countingPayload(t0Replicas))
}
func (r *countingRecognizer) Parse(raw []byte) (*entity.RecognitionOutcome, error) {
r.parses.Add(1)
var replicas []entity.Replica
if err := json.Unmarshal(raw, &replicas); err != nil {
return nil, errors.New("не разобрать сохранённый ответ")
} }
return "operation-id", nil
var plain []byte
for _, replica := range replicas {
if len(plain) > 0 {
plain = append(plain, ' ')
}
plain = append(plain, replica.Text...)
}
return &entity.RecognitionOutcome{Replicas: replicas, PlainText: string(plain), Raw: raw}, nil
} }
func (r *scriptedRecognizer) GetRecognitionText(context.Context, string) (string, error) { var t0Replicas = []entity.Replica{
return r.text, nil {StartMs: 0, EndMs: 900, Text: "Первая реплика."},
{StartMs: 900, EndMs: 1800, Text: "Вторая реплика."},
} }
func (r *scriptedRecognizer) CheckRecognitionStatus(context.Context, string) (*entity.RecognitionResult, error) { func countingPayload(replicas []entity.Replica) []byte {
return r.result, nil raw, err := json.Marshal(replicas)
if err != nil {
panic(err)
}
return raw
} }
// convertedJob доводит задачу до состояния, с которого работает шаг // Критерий приёмки 4. Структура реплик строится из сохранённого ответа
// распознавания: запись принята и сконвертирована. // провайдера без единого обращения к нему: результат операции не
func convertedJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob { // переспрашивается, и архив пересчитывается из сохранённого без рубля.
t.Helper() func TestStructureIsBuiltFromStoredPayload(t *testing.T) {
rec := &countingRecognizer{}
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
job := newTelegramJob(t, env) record := newRecord(t, env)
drain(t, env, record.Id)
acquired, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "setup", time.Now().Add(-time.Hour)) after := readRecord(t, env, record.Id)
require.Equal(t, entity.StateDone, after.State)
require.NotNil(t, after.RecognitionID)
// Сохранённый ответ на месте и читается целиком.
raw, err := env.repos.Recognitions.ReadRaw(*after.RecognitionID)
require.NoError(t, err) require.NoError(t, err)
acquired.MoveToState(entity.StateConverted) require.NotEmpty(t, raw, "сырой ответ провайдера сохранён")
require.NoError(t, env.jobRepo.Save(acquired, "setup"))
return job // А теперь — построение структуры из сохранённого, без обращений наружу.
} fetchesBefore := rec.fetches.Load()
submitsBefore := rec.submits.Load()
// withRecognizer пересобирает сервис с управляемым распознавателем поверх того outcome, err := env.service.recognizer.Parse(raw)
// же хранилища.
func withRecognizer(env *pipelineEnv, rec contract.AudioRecognizer) *TranscribeService {
return NewTranscribeService(
env.jobRepo,
env.fileRepo,
&okMetaViewer{},
&failingConverter{},
rec,
env.sender,
env.service.logger,
)
}
// Шаг распознавания отдаёт содержимое наружу, заводит запись о копии во внешнем
// хранилище и переставляет на неё ссылку задачи — только после того, как запись
// о копии существует.
func TestTranscribeJobHandsRecordOverAndMovesOn(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := convertedJob(t, env)
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeJob(t.Context()))
assert.Equal(t, 1, rec.recognizeCalls, "содержимое отдано распознавателю")
assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван")
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, entity.StateTranscribe, after.State) require.Len(t, outcome.Replicas, len(t0Replicas), "структура собрана")
require.NotNil(t, after.RecognitionOpID) assert.Equal(t, t0Replicas[0].Text, outcome.Replicas[0].Text)
assert.Equal(t, "operation-id", *after.RecognitionOpID) assert.Equal(t, t0Replicas[0].StartMs, outcome.Replicas[0].StartMs, "время реплики сохранено")
require.NotNil(t, after.DelayTime, "задержка перед первой проверкой поставлена")
// Ссылка задачи ведёт на существующую запись о копии, а не на несозданную. assert.Equal(t, fetchesBefore, rec.fetches.Load(), "к провайдеру за результатом не ходили")
require.NotNil(t, after.FileID) assert.Equal(t, submitsBefore, rec.submits.Load(), "и новой операции не заводили")
copyRecord, err := env.fileRepo.GetByID(*after.FileID)
// Структура записи собрана из того же ответа и лежит своей строкой.
require.NotNil(t, after.StructureID)
structure, err := env.repos.Structures.GetByID(*after.StructureID)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, entity.LocationS3, copyRecord.Location) assert.Equal(t, entity.StructureVersion, structure.Version)
require.Len(t, structure.Replicas, len(t0Replicas))
assert.Equal(t, t0Replicas[1].Text, structure.Replicas[1].Text)
} }
// Отказ распознавателя не двигает задачу: она остаётся пригодной к повтору. // Шаг с внешней оплатой проверяет сделанное: объект нужного размера на месте —
func TestTranscribeJobKeepsJobRetryableOnRecognizerFailure(t *testing.T) { // заливку не повторяем; операция заведена — не платим второй раз.
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) func TestPaidWorkIsNotRepeated(t *testing.T) {
job := convertedJob(t, env) rec := &countingRecognizer{}
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
rec := &scriptedRecognizer{recognizeErr: errors.New("распознаватель недоступен")} record := newRecord(t, env)
svc := withRecognizer(env, rec)
require.Error(t, svc.FindAndRunTranscribeJob(t.Context())) // Приведение.
require.NoError(t, env.service.RunStep(t.Context()))
require.Equal(t, entity.StateNormalized, readRecord(t, env, record.Id).State)
after, err := env.jobRepo.GetByID(job.Id) // Отправка.
require.NoError(t, err) require.NoError(t, env.service.RunStep(t.Context()))
assert.Equal(t, entity.StateConverted, after.State, "задача осталась на своём шаге") require.Equal(t, int64(1), rec.uploads.Load(), "залили один раз")
assert.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору") require.Equal(t, int64(1), rec.submits.Load(), "и заплатили один раз")
assert.NotNil(t, after.DelayTime, "пауза перед повтором поставлена")
assert.Empty(t, env.sender.messages, "отправителю про повторимый отказ не пишут") // Возвращаем запись на рубеж отправки — так это выглядит, когда шаг оборвался
// после оплаты, а захват протух.
after := readRecord(t, env, record.Id)
after.MoveToState(entity.StateNormalized)
require.NoError(t, env.recordRepo.Save(after, ""))
require.NoError(t, env.service.RunStep(t.Context()))
assert.Equal(t, int64(1), rec.uploads.Load(), "объект на месте — заливка не повторилась")
assert.Equal(t, int64(1), rec.submits.Load(), "операция заведена — второй раз не платим")
} }
// transcribingJob доводит задачу до состояния ожидания операции. // Ожидание чужой операции откладывается своей задержкой, отказов не тратит и
func transcribingJob(t *testing.T, env *pipelineEnv, rec contract.AudioRecognizer) *entity.TranscribeJob { // рубежа не двигает.
t.Helper() func TestPollingPostponesWithoutSpendingAttempts(t *testing.T) {
rec := &countingRecognizer{}
rec.inProgress.Store(3)
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
job := convertedJob(t, env) record := newRecord(t, env)
require.NoError(t, withRecognizer(env, rec).FindAndRunTranscribeJob(t.Context()))
clearDelay(t, env, job.Id) require.NoError(t, env.service.RunStep(t.Context())) // приведение
return job require.NoError(t, env.service.RunStep(t.Context())) // отправка
} require.Equal(t, entity.StateSubmitted, readRecord(t, env, record.Id).State)
// Ожидание чужой операции попытку не тратит и опрос не учащает: шаг отработал entered := readRecord(t, env, record.Id).StateEnteredAt
// без отказа, и задержка у него своя, числом.
func TestCheckJobWaitsWithoutSpendingAttempts(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
svc := withRecognizer(env, rec) var delays []int64
for range 3 {
clearDelay(t, env, record.Id)
require.NoError(t, env.service.RunStep(t.Context()))
for i := 0; i < 3; i++ { after := readRecord(t, env, record.Id)
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context())) require.Equal(t, entity.StateSubmitted, after.State, "рубеж не сдвинут откладыванием")
assert.Equal(t, 0, after.Attempts, "ожидание чужой операции отказа не тратит")
assert.WithinDuration(t, entered, after.StateEnteredAt, 0, "и отсчёт застревания не двигает")
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateTranscribe, after.State)
assert.Equal(t, 0, after.Attempts, "ожидание операции попытку не тратит")
require.NotNil(t, after.DelayTime) require.NotNil(t, after.DelayTime)
assert.InDelta(t, nextCheckDelay.Seconds(), time.Until(*after.DelayTime).Seconds(), 2, delays = append(delays, after.DelayTime.Unix())
"задержка опроса не выродилась в наименьшую паузу повтора")
clearDelay(t, env, job.Id)
} }
assert.Len(t, delays, 3, "все три прогона отложили работу")
} }
// Отказ операции распознавания — приговор записи: задача уходит в `failed`, а // emptyRecognizer отдаёт готовую операцию с пустым результатом: так выглядит
// отправитель узнаёт причину человеческим текстом. // оплаченное распознавание, из которого ничего не вышло.
func TestCheckJobFailsJobAndTellsSender(t *testing.T) { type emptyRecognizer struct {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) countingRecognizer
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
rec.result = entity.NewFailedResult("операция отклонена")
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateFailed, after.State)
require.Len(t, env.sender.messages, 1, "отправитель узнал об отказе")
assert.Contains(t, env.sender.messages[0], "сбой при распознавании файла")
assert.NotContains(t, env.sender.messages[0], "операция отклонена",
"машинная причина отправителю не идёт")
} }
// Готовая операция завершает задачу и отдаёт текст отправителю ровно один раз. func (r *emptyRecognizer) Fetch(context.Context, string) (*entity.RecognitionOutcome, error) {
func TestCheckJobCompletesAndAnswersOnce(t *testing.T) { r.fetches.Add(1)
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) return &entity.RecognitionOutcome{}, nil
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
rec.result = entity.NewCompletedResult()
rec.text = "расшифровка записи"
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDone, after.State)
require.NotNil(t, after.TranscriptionText)
assert.Equal(t, "расшифровка записи", *after.TranscriptionText)
require.Len(t, env.sender.messages, 1, "отправитель получил ровно один ответ")
assert.Equal(t, "расшифровка записи", env.sender.messages[0])
// И задача из выборки исчезла: второй ответ отправителю неоткуда взяться.
_, err = env.jobRepo.FindAndAcquire(entity.StateTranscribe, "next", time.Now().Add(-time.Hour))
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing)
} }
// Пустая расшифровка — не отказ: задача завершается, а отправителю уходит // Пустой результат виден владельцу сервиса записью журнала «может стать
// объяснение вместо пустого сообщения. // проблемой». Прежде этот случай был виден ответом отправителю — «на записи нет
func TestCheckJobCompletesEmptyTextWithExplanation(t *testing.T) { // текста», — и вместе с убранной доставкой он исчез бы вовсе: запись доходит до
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) // конца молча и от успешной не отличается. Текста в записи нет по инварианту
rec := &scriptedRecognizer{result: entity.NewInProgressResult()} // приватности: пустой ему взяться неоткуда, а проверка судит уровень и
job := transcribingJob(t, env, rec) // идентификатор.
func TestEmptyRecognitionIsNamedInJournal(t *testing.T) {
journal := &bytes.Buffer{}
rec.result = entity.NewCompletedResult() env := newPipelineEnvWithLogger(t, &okMetaViewer{}, &okConverter{}, &emptyRecognizer{},
rec.text = "" slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})))
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context())) record := newRecord(t, env)
drain(t, env, record.Id)
after, err := env.jobRepo.GetByID(job.Id) written := journal.String()
require.NoError(t, err) assert.Contains(t, written, "Recognition returned empty text", "пустой результат назван")
assert.Equal(t, entity.StateDone, after.State) assert.Contains(t, written, "level=WARN", "уровень — «может стать проблемой»")
assert.Contains(t, written, record.Id, "запись названа идентификатором")
require.Len(t, env.sender.messages, 1)
assert.Contains(t, strings.ToLower(env.sender.messages[0]), "нет текста")
} }
// Шаг, потерявший захват за время работы, результата не пишет и отправителю не // exhaustibleRecognizer отдаёт полный результат один раз, а на всяком следующем
// отвечает: иначе два воркера пишут в одну задачу, а отправитель получает два // обращении — пустой: так выглядит провайдер, чей поток закрылся на первом же
// ответа на одну запись. // ответе. Отказом это не считается ни у него, ни у нас.
func TestCheckJobWritesNothingWhenAcquisitionLost(t *testing.T) { type exhaustibleRecognizer struct {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) countingRecognizer
rec := &scriptedRecognizer{result: entity.NewInProgressResult()} served atomic.Bool
job := transcribingJob(t, env, rec) }
rec.result = entity.NewCompletedResult() func (r *exhaustibleRecognizer) Fetch(context.Context, string) (*entity.RecognitionOutcome, error) {
rec.text = "расшифровка записи" r.fetches.Add(1)
if r.served.Swap(true) {
// Захват задачи достался другому, пока шаг работал. return &entity.RecognitionOutcome{}, nil
acquired, err := env.jobRepo.FindAndAcquire(entity.StateTranscribe, "mine", time.Now().Add(-time.Hour)) }
require.NoError(t, err) return r.Parse(countingPayload(t0Replicas))
}
// Повторный опрос той же операции — обычное дело: держатель захвата умер,
// сохранение рубежа отказало, человек снял остановку в панели. Пустой ответ
// провайдера при этом не должен стирать уже сохранённую расшифровку: сервис
// объявлен архивом, а восстановления у стёртого текста нет.
func TestEmptySecondAnswerKeepsArchivedText(t *testing.T) {
rec := &exhaustibleRecognizer{}
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
record := newRecord(t, env)
drain(t, env, record.Id)
stored := readRecord(t, env, record.Id)
require.NotNil(t, stored.TranscriptTextID, "расшифровка сохранена первым ответом")
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id) before, err := env.repos.Texts.GetByID(*stored.TranscriptTextID)
require.NoError(t, err) require.NoError(t, err)
record.Set("acquisition_id", "someone-else") require.NotEmpty(t, before.Contents)
require.NoError(t, env.app.Save(record))
svc := withRecognizer(env, rec) // Второй опрос той же записи: рубеж возвращается на отправленный, и шаг
err = svc.checkTranscribeJob(t.Context(), acquired, "mine") // забирает результат заново — теперь пустой.
back := readRecord(t, env, record.Id)
back.MoveToState(entity.StateSubmitted)
require.NoError(t, env.recordRepo.Save(back, ""))
clearDelay(t, env, record.Id)
require.NoError(t, env.service.RunStep(t.Context()))
var lost *contract.LostAcquisitionError // Проверка обязана дойти до второго ответа: иначе она зеленела бы, ничего не
require.ErrorAs(t, err, &lost) // проверив, — а класс «проверка, не способная упасть» в этом проекте ловили
// уже трижды.
require.EqualValues(t, 2, rec.fetches.Load(), "результат забирали дважды")
after, err := env.jobRepo.GetByID(job.Id) after, err := env.repos.Texts.GetByID(*stored.TranscriptTextID)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, entity.StateTranscribe, after.State, "результат не записан") assert.Equal(t, before.Contents, after.Contents,
assert.Empty(t, env.sender.messages, "отправителю ничего не отправлено") "пустой ответ провайдера не стирает сохранённую расшифровку")
} }
+22 -28
View File
@@ -8,7 +8,6 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
@@ -28,43 +27,42 @@ func (c *killedConverter) Convert(ctx context.Context, _, _ string) error {
return errors.New("ffmpeg conversion failed: signal: killed") return errors.New("ffmpeg conversion failed: signal: killed")
} }
// Остановка сервиса посреди конвертации не выносит записи приговора: задача // Критерий приёмки 9. Остановка сервиса посреди приведения не выносит записи
// остаётся пригодной к повтору, попытку не тратит и отправителю о сбое, // приговора и **не тратит отказа**: запись не виновата в том, что нас
// которого не было, не сообщает. Прежде любой отказ `Convert` уводил задачу в // перезапустили, и несколько выкладок подряд иначе останавливают здоровую
// терминальное `failed`, откуда её возвращает только владелец правкой в панели. // многочасовую запись с приговором «попытки исчерпаны».
func TestShutdownDuringConversionKeepsJobRetryable(t *testing.T) { func TestShutdownDuringConversionKeepsRecordRetryable(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
converter := &killedConverter{cancel: cancel} converter := &killedConverter{cancel: cancel}
env := newPipelineEnv(t, &okMetaViewer{}, converter) env := newPipelineEnv(t, &okMetaViewer{}, converter)
job := newTelegramJob(t, env) record := newRecord(t, env)
err := env.service.FindAndRunConversionJob(ctx) err := env.service.RunStep(ctx)
require.Error(t, err, "шаг обязан сообщить об обрыве наверх") require.Error(t, err, "шаг обязан сообщить об обрыве наверх")
require.ErrorIs(t, err, context.Canceled, "обрыв узнаётся по смыслу, а не по тексту") require.ErrorIs(t, err, context.Canceled, "обрыв узнаётся по смыслу, а не по тексту")
after, err := env.jobRepo.GetByID(job.Id) after := readRecord(t, env, record.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateCreated, after.State, "задача осталась на повтор, а не похоронена") assert.Equal(t, entity.StateUploaded, after.State, "запись осталась на повтор")
assert.Nil(t, after.AcquisitionID, "захват снят: задачу возьмёт следующий прогон") assert.False(t, after.IsHalted(), "приговора не выносили")
assert.Equal(t, 0, after.Attempts, "остановка попытки не тратит") assert.Nil(t, after.AcquisitionID, "захват снят: запись возьмёт следующий прогон")
assert.Nil(t, after.ErrorText, "приговора не выносили") assert.Equal(t, 0, after.Attempts, "остановка отказа не тратит")
assert.Empty(t, env.sender.messages, "отправителю о несуществующем сбое не сообщают") assert.Nil(t, after.ErrorText)
} }
// Задача, которую шаг не успел взять, потому что нас уже остановили, остаётся // Запись, которую шаг не успел взять, потому что нас уже остановили, остаётся
// нетронутой: захват не случился, попытка не потрачена. // нетронутой: захват не случился, отказ не потрачен.
func TestShutdownBeforeStepLeavesJobUntouched(t *testing.T) { func TestShutdownBeforeStepLeavesRecordUntouched(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := newTelegramJob(t, env) record := newRecord(t, env)
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
cancel() cancel()
err := env.service.FindAndRunConversionJob(ctx) err := env.service.RunStep(ctx)
// Исход «шаг не сделал ничего» — это `NoopJobError`: воркер не пишет о нём // Исход «шаг не сделал ничего» — это `NoopJobError`: воркер не пишет о нём
// владельцу и не считает его в метрику. // владельцу и не считает его в метрику.
@@ -72,12 +70,8 @@ func TestShutdownBeforeStepLeavesJobUntouched(t *testing.T) {
var noop *contract.NoopJobError var noop *contract.NoopJobError
require.ErrorAs(t, err, &noop) require.ErrorAs(t, err, &noop)
after, err := env.jobRepo.GetByID(job.Id) after := readRecord(t, env, record.Id)
require.NoError(t, err) assert.Equal(t, entity.StateUploaded, after.State)
assert.Equal(t, entity.StateCreated, after.State) assert.Equal(t, 0, after.Attempts, "захвата не было — отказу взяться неоткуда")
assert.Equal(t, 0, after.Attempts, "захвата не было — попытке взяться неоткуда") assert.Nil(t, after.AcquisitionID)
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
assert.Empty(t, record.GetString("acquisition_id"))
} }
File diff suppressed because it is too large Load Diff
-140
View File
@@ -1,140 +0,0 @@
package service
import (
"bytes"
"log/slog"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Ответ отправителю уходит после того, как достигнутое состояние сохранено.
// Значит, недоставка не может быть отказом шага: объявленный отказ засчитался
// бы воркеру сбоем, лёг бы владельцу записью отказа и переписал бы служебные
// поля завершённой задачи. Причин недоставки две, исход у них общий.
// downSender изображает неподнятый канал доставки: так ведёт себя заглушка,
// которую ядро получает вместо отправителя Telegram.
type downSender struct {
calls int
}
func (s *downSender) Send(string, int64, *int) error {
s.calls++
return contract.ErrDeliveryChannelDown
}
// journalEnv пересобирает сервис с названным отправителем и своим журналом:
// утверждения судят и состояние задачи, и то, что увидел владелец.
func journalEnv(
t *testing.T,
env *pipelineEnv,
rec contract.AudioRecognizer,
sender contract.TelegramMessageSender,
) (*TranscribeService, *bytes.Buffer) {
t.Helper()
journal := &bytes.Buffer{}
svc := NewTranscribeService(
env.jobRepo,
env.fileRepo,
&okMetaViewer{},
&failingConverter{},
rec,
sender,
slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})),
)
return svc, journal
}
// Канал не поднят: задача доводится до конца, шаг отказа не объявляет, а
// владелец узнаёт о недоставке из журнала.
func TestUndeliveredOnDownChannelKeepsJobDone(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
rec.result = entity.NewCompletedResult()
rec.text = "расшифровка записи"
sender := &downSender{}
svc, journal := journalEnv(t, env, rec, sender)
// Шаг завершается без отказа — именно это воркер считает в свой счётчик.
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
assert.Equal(t, 1, sender.calls, "ответ до отправителя доехал")
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDone, after.State, "задача осталась в достигнутом состоянии")
require.NotNil(t, after.TranscriptionText)
assert.Equal(t, "расшифровка записи", *after.TranscriptionText, "расшифровка сохранена")
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
written := journal.String()
assert.Contains(t, written, "Reply was not delivered", "недоставка названа")
assert.Contains(t, written, job.Id, "запись несёт идентификатор задачи")
assert.Contains(t, written, "level=WARN", "объявленный режим — «может стать проблемой»")
assert.NotContains(t, written, "расшифровка записи", "текста расшифровки в журнале нет")
}
// Адресат у задачи не назван: исход тот же. Прежде эта ветка объявляла отказ
// шага на уже завершённой работе.
func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
// Задача из Telegram, у которой чат не назван: такую отдаёт правка в панели.
// Колонка чистится мимо захвата — иначе setup унёс бы задачу у шага.
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("tg_chat_id", nil)
require.NoError(t, env.app.Save(record))
rec.result = entity.NewCompletedResult()
rec.text = "расшифровка записи"
sender := &downSender{}
svc, journal := journalEnv(t, env, rec, sender)
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
assert.Equal(t, 0, sender.calls, "до отправителя дело не дошло: адресата нет")
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDone, after.State)
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
written := journal.String()
assert.Contains(t, written, "Reply was not delivered")
assert.Contains(t, written, job.Id)
assert.Contains(t, written, "chat is not specified", "причина названа")
assert.Contains(t, written, "level=ERROR",
"порча записи громче штатного «бот не настроен»: иначе сигнал утонет")
}
// Запись, принятая по HTTP, до отправителя не доходит вовсе: недоставки нет, и
// записи о ней в журнале быть не должно — иначе журнал владельца заполнят
// строки о задачах основного входа.
func TestApiJobDoesNotReachSenderAndLogsNothing(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "voice.ogg")
require.NoError(t, err)
sender := &downSender{}
svc, journal := journalEnv(t, env, &scriptedRecognizer{}, sender)
require.NoError(t, svc.send(job, "расшифровка записи"))
assert.Equal(t, 0, sender.calls, "отправителя не звали")
assert.NotContains(t, journal.String(), "Reply was not delivered", "недоставки не было")
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"testing"
"github.com/stretchr/testify/assert"
)
// Имя файла в хранилище в журнал не идёт: оно последняя часть ссылки
// `/api/files/...`, и строка журнала вместе с идентификатором записи собрала бы
// ссылку целиком. Инвариант проекта, critical.
func TestJournalRouteHidesStoredFileName(t *testing.T) {
cases := []struct {
name string
path string
want string
}{
{
name: "ссылка на файл теряет имя",
path: "/api/files/files/abc123def456ghi/9f1c-3b2a.mp3",
want: "/api/files/files/abc123def456ghi/<имя>",
},
{
name: "маршрут остаётся различимым",
path: "/api/files/files/abc123def456ghi/запись.ogg",
want: "/api/files/files/abc123def456ghi/<имя>",
},
{
name: "прочие пути не трогаются",
path: "/api/status/abc123def456ghi",
want: "/api/status/abc123def456ghi",
},
{
name: "приём не трогается",
path: "/api/audio",
want: "/api/audio",
},
{
name: "сам префикс без имени не портится",
path: "/api/files/",
want: "/api/files/",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, journalRoute(c.path))
})
}
}
// Отдельно и прямо: имени в готовой строке нет. Проверка судит результат, а не
// устройство — переписанная реализация обязана остаться зелёной.
func TestJournalRouteDropsNameEntirely(t *testing.T) {
const stored = "0f7b8dd3-d1cc-424c.mp3"
route := journalRoute("/api/files/files/rec0000000000000/" + stored)
assert.NotContains(t, route, stored, "имя файла в хранилище не доезжает до журнала")
assert.Contains(t, route, "rec0000000000000", "идентификатор записи остаётся: по нему прослеживается путь")
}
+58 -75
View File
@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strings"
"sync" "sync"
"syscall" "syscall"
"time" "time"
@@ -19,7 +20,6 @@ import (
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/config" "git.vakhrushev.me/av/transcriber/internal/config"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http" httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
"git.vakhrushev.me/av/transcriber/internal/controller/worker" "git.vakhrushev.me/av/transcriber/internal/controller/worker"
"git.vakhrushev.me/av/transcriber/internal/metrics" "git.vakhrushev.me/av/transcriber/internal/metrics"
"git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/internal/service"
@@ -59,11 +59,11 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// Включённый вход без ключа доступа — ошибка настройки, а не режим: бот по // Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а
// пустому ключу не появится, а тихий подъём без него оставил бы отправителей // отрицательное число и нулевой предел простоя — опечатка, и подниматься с
// без ответов. // ней значит остановить всякую запись первым же захватом.
if err := cfg.Telegram.Validate(); err != nil { if err := cfg.Pipeline.Validate(); err != nil {
logger.Error("Unable to start with incomplete telegram settings", "error", err) logger.Error("Unable to start with incorrect pipeline settings", "error", err)
os.Exit(1) os.Exit(1)
} }
@@ -89,19 +89,20 @@ func main() {
pbrepo.BindPanelRules(storage) pbrepo.BindPanelRules(storage)
// Создаем репозитории // Создаем репозитории
fileRepo := pbrepo.NewFileRepository(storage) recordRepo := pbrepo.NewAudioRecordRepository(storage)
jobRepo := pbrepo.NewTranscriptJobRepository(storage) repos := service.Repositories{
Records: recordRepo,
Files: pbrepo.NewFileRepository(storage),
Texts: pbrepo.NewTextRepository(storage),
Structures: pbrepo.NewStructureRepository(storage),
Recognitions: pbrepo.NewRecognitionRepository(storage),
Events: pbrepo.NewRecordEventRepository(storage),
}
// Создаем адаптеры // Создаем адаптеры
metaviewer := ffmpegmv.NewFfmpegMetaViewer() metaviewer := ffmpegmv.NewFfmpegMetaViewer()
converter := ffmpegconv.NewFfmpegConverter() converter := ffmpegconv.NewFfmpegConverter()
tgBot, tgSender, err := buildTelegram(cfg.Telegram, logger)
if err != nil {
logger.Error("Failed to create Telegram bot", "error", err)
os.Exit(1)
}
recognizer, err := yandex.NewYandexAudioRecognizerService(yandex.YandexAudioRecognizerConfig{ recognizer, err := yandex.NewYandexAudioRecognizerService(yandex.YandexAudioRecognizerConfig{
Region: cfg.Yandex.ObjStorageRegion, Region: cfg.Yandex.ObjStorageRegion,
AccessKey: cfg.Yandex.ObjStorageAccessKey, AccessKey: cfg.Yandex.ObjStorageAccessKey,
@@ -127,12 +128,11 @@ func main() {
// Создаем сервисы // Создаем сервисы
transcribeService := service.NewTranscribeService( transcribeService := service.NewTranscribeService(
jobRepo, repos,
fileRepo,
metaviewer, metaviewer,
converter, converter,
recognizer, recognizer,
tgSender, cfg.Pipeline.StuckLimits(),
logger, logger,
) )
@@ -143,61 +143,23 @@ func main() {
// Создаем WaitGroup для ожидания завершения всех воркеров // Создаем WaitGroup для ожидания завершения всех воркеров
var wg sync.WaitGroup var wg sync.WaitGroup
tgConfig := tgcontroller.TelegramConfig{ // Пул одинаковых воркеров: специализации у них нет, шаг выбирается по рубежу
UpdateTimeout: cfg.Telegram.UpdateTimeout, // самой записи. Число приходит настройкой, ноль — законное значение.
UserWhiteList: cfg.Server.UsersWhiteList, pool := worker.NewPool(cfg.Pipeline.Workers, transcribeService.RunStep, logger)
} wg.Add(1)
go func() {
defer wg.Done()
pool.Start(ctx)
}()
// Транспорт поднимается только там, где есть клиент: о том, что бота нет, // Вход у сервиса один — приём по HTTP, — и метка ставится только ему. Метки
// сказано выше единственной записью, и вторая здесь была бы записью о том // убранного входа Telegram здесь нет намеренно: ноль читался бы как поломка,
// же факте. // а признак существует ради того дня, когда входов снова станет больше.
var tgController *tgcontroller.TelegramController
if tgBot != nil {
tgController, err = tgcontroller.NewTelegramController(tgConfig, tgBot, transcribeService, jobRepo, logger)
if err != nil {
logger.Error("Failed to create Telegram controller", "error", err)
os.Exit(1)
}
// Запускаем Telegram бот в отдельной горутине
wg.Add(1)
go func() {
defer wg.Done()
logger.Info("Starting Telegram bot")
tgController.Start(ctx)
logger.Info("Telegram bot stopped gracefully")
}()
}
// Создаем воркеры
conversionWorker := worker.NewCallbackWorker("conversion_worker", transcribeService.FindAndRunConversionJob, logger)
transcribeWorker := worker.NewCallbackWorker("transcribe_worker", transcribeService.FindAndRunTranscribeJob, logger)
checkWorker := worker.NewCallbackWorker("check_worker", transcribeService.FindAndRunTranscribeCheckJob, logger)
workers := []worker.Worker{
conversionWorker,
transcribeWorker,
checkWorker,
}
// Запускаем воркеры в отдельных горутинах
for _, w := range workers {
wg.Add(1)
go func(worker worker.Worker) {
defer wg.Done()
worker.Start(ctx)
logger.Info("Worker stopped gracefully", "worker", worker.Name())
}(w)
}
// Вход по HTTP поднимается всегда: он основной, и отдельного разреза у него
// нет. Признак ставится рядом с признаком Telegram, чтобы владелец судил об
// обоих входах одним отбором.
metrics.IntakeUpGauge.WithLabelValues("http").Set(1) metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом, // Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
// и второму серверу на нём взяться неоткуда. // и второму серверу на нём взяться неоткуда.
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger) transcribeHandler := httpcontroller.NewTranscribeHandler(recordRepo, repos.Texts, transcribeService, logger)
authHandler := httpcontroller.NewAuthHandler(storage, httpcontroller.AuthHandlerConfig{ authHandler := httpcontroller.NewAuthHandler(storage, httpcontroller.AuthHandlerConfig{
AuthURL: cfg.Auth.AuthURL, AuthURL: cfg.Auth.AuthURL,
RedirectURL: cfg.Auth.RedirectURL, RedirectURL: cfg.Auth.RedirectURL,
@@ -231,7 +193,7 @@ func main() {
logger.Log(e.Request.Context(), level, "Incoming request", logger.Log(e.Request.Context(), level, "Incoming request",
"http.method", e.Request.Method, "http.method", e.Request.Method,
"http.route", e.Request.URL.Path, "http.route", journalRoute(e.Request.URL.Path),
"http.status_code", e.Status(), "http.status_code", e.Status(),
"duration_ms", time.Since(start).Milliseconds(), "duration_ms", time.Since(start).Milliseconds(),
"transport", "http") "transport", "http")
@@ -290,8 +252,7 @@ func main() {
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
logger.Info("Transcriber service started with background workers") logger.Info("Transcriber service started", "pipeline_workers", pool.Size())
logger.Info("Workers: ConversionWorker, TranscribeWorker, CheckWorker")
logger.Info("Press Ctrl+C to stop...") logger.Info("Press Ctrl+C to stop...")
// Ждем сигнал завершения либо отказ сервера // Ждем сигнал завершения либо отказ сервера
@@ -302,11 +263,6 @@ func main() {
logger.Error("HTTP server stopped unexpectedly, shutting down") logger.Error("HTTP server stopped unexpectedly, shutting down")
} }
if tgController != nil {
logger.Info("Shutting down Telegram bot...")
tgController.Stop()
}
// Создаем контекст с таймаутом для graceful shutdown HTTP сервера // Создаем контекст с таймаутом для graceful shutdown HTTP сервера
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
defer shutdownCancel() defer shutdownCancel()
@@ -344,3 +300,30 @@ func main() {
logger.Info("Transcriber service stopped") logger.Info("Transcriber service stopped")
} }
// filesPathPrefix — начало пути, которым хранилище отдаёт файл записи. Последний
// сегмент такого пути и есть имя файла в хранилище.
const filesPathPrefix = "/api/files/"
// journalRoute готовит путь запроса к записи в журнал.
//
// Инвариант проекта запрещает имени файла в хранилище попадать в журнал: имя —
// последняя часть ссылки `/api/files/...`, и строка журнала вместе с
// идентификатором записи собирала бы ссылку целиком. Слой журнала пишет путь
// всякого запроса, поэтому имя срезается здесь — иначе оно уезжало бы в
// собранные логи при каждом скачивании записи.
//
// Срезается только имя: маршрут остаётся различимым, и наблюдаемость от этого не
// теряется.
func journalRoute(path string) string {
if !strings.HasPrefix(path, filesPathPrefix) {
return path
}
cut := strings.LastIndex(path, "/")
if cut < len(filesPathPrefix) {
return path
}
return path[:cut+1] + "<имя>"
}

Some files were not shown because too many files have changed in this diff Show More