внутренняя модель перестроена вокруг аудиозаписи

- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
av
2026-08-14 20:20:33 +03:00
parent d079f03350
commit 1576d06735
84 changed files with 8973 additions and 2865 deletions
+24 -9
View File
@@ -72,15 +72,30 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
записи. **critical**
- **Колонки очереди правятся в четырёх местах** пакета хранилища —
`applyToRecord`, `recordToJob`, константа `acquireColumns` и структура
`acquiredRow` с её `toJob`, — плюс шаг схемы. Компилятор видит два из них.
Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата
нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения:
поле теряется **только у задачи, попавшей к воркеру**. **major**
- **Результат пишет только держатель захвата.** Шаг, чей захват за время работы
достался другому, завершается без записи и без ответа отправителю. Иначе два
воркера пишут в одну задачу по очереди, а отправитель получает два ответа.
- **Колонки записи правятся в двух местах** пакета хранилища —
`applyOwnedByPipeline` вместе с `applyToRecord` и `recordToAudioRecord`, —
плюс шаг схемы. Компилятор не видит ни одного: колонка, забытая в одном из
них, теряется молча — запись сохранится без поля либо приедет с нулевым.
Мест было четыре, пока захват перечислял колонки поимённо; теперь он
возвращает идентификатор и признак своего захвата, и перечень перестал расти
с моделью. Сверку держат правила `internal/archrules`. **major**
- **Рубеж объявляется одним дескриптором** — `internal/entity/stage.go`. Из него
выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя;
перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в
отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
ниже не пишется в журнал и не считается в метрику: запись встанет без единого
следа. Сверку держат правила `internal/archrules`. **major**
- **Результат пишет только держатель захвата, и держатель узнаётся значением.**
Признак захвата уникален для каждого захвата, и запись результата условна по
нему, а не по занятости записи. Шаг, чей захват за время работы достался
другому — по протуханию срока или после того, как человек снял признак
остановки в панели, — завершается без записи и без ответа отправителю. Условие
по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись
по очереди, а отправитель получал бы два ответа. **major**
- **Остановленная запись сообщает отправителю, какой бы ни была причина.**
Причин три — приговор шага, исчерпанные отказы, застревание. Остановленная
запись захвату не выдаётся, значит исход «пригодна к повтору» исключён.
Обязанность, записанная у одной причины, у остальных читалась бы как снятая.
**major**
## Команды
+27
View File
@@ -9,6 +9,33 @@ force_shutdown_timeout = 20
[storage]
data_dir = "data"
# Конвейер расшифровки.
[pipeline]
# Число рабочих потоков. Специализации у них нет: каждый берёт любую пригодную к
# работе запись и выбирает шаг по её рубежу.
#
# Ноль — законное значение, а не поломка: сервис поднимается, записи
# принимаются и не двигаются. Годится местному запуску и выкладке, где конвейер
# надо остановить, не роняя приём.
workers = 3
# Предел простоя записи там, где работу делаем мы сами, в минутах.
#
# Сторож ловит **зависание**, а не долгую работу: пока шаг идёт, запись занята
# захватом, и живой процесс наблюдается сам по себе. Час меньше времени, которое
# многочасовая запись занимает на приведении, и это принято сознательно
# (решение владельца 2026-08-14): цена ложной остановки — одно движение
# владельца, потому что остановка обратима и рубежа не стирает.
own_work_limit_minutes = 60
# Предел простоя там, где ждём операцию внешнего сервиса, в минутах.
#
# Сколько идёт распознавание долгой записи, никто не мерил, поэтому ошибаемся в
# сторону долгого: ложная остановка хуже поздней. Откладывание опроса этот
# отсчёт не двигает — иначе зависшая у провайдера операция опрашивалась бы
# вечно.
foreign_work_limit_minutes = 1440
# Yandex Cloud Configuration
[yandex]
# ID папки в Yandex Cloud (получить в консоли Yandex Cloud)
@@ -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`.
+3
View File
@@ -35,6 +35,9 @@
| Дата | Запись | Статус |
| --- | --- | --- |
| 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) | |
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | |
+49 -23
View File
@@ -32,6 +32,11 @@
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
2026-08-12;
- [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания
у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется
целиком и вложением, как из сохранённого строится структура реплик без
повторной оплаты и почему разбор формата провайдера не доходит до конвейера.
Задача `record-centric-model` 2026-08-14;
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
@@ -78,22 +83,22 @@
| --- | --- | --- |
| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу |
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
| Воркеры | `internal/controller/worker` | Крутят по одному шагу конвейера, опрашивая базу |
| Сервис расшифровки | `internal/service` | Конвейер: приём, конвертация, распознавание, отдача результата |
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
| Конвертер и метаданные | `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`, `panel.go` | Панель хранилища; правила правки задачи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: цепочка переходов состояний -->
Конвейер: `created``converted``transcribe``done` либо `failed`. Каждый
переход двигает свой воркер, и каждый опрашивает базу раз в секунду. Что
делает задача, исчерпавшая попытки, нормирует
[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
«мертва»».
Цепочка рубежей — `uploaded``normalized``submitted``transcribed`
`done`; рубеж называет достигнутое, а не предстоящее, и нормирует его
[pipeline](../openspec/specs/pipeline/spec.md), «Рубеж записи называет
достигнутое». Отказ рубежом не является: он ставит признак остановки, а рубеж
сохраняется — там же, «Остановка записи — признак, а не рубеж». Шаг выбирается
по рубежу одним местом, воркеры к шагам не привязаны, а их число приходит
настройкой.
## Внешние границы и форматы
@@ -136,6 +141,17 @@
поделят один длинный опрос и часть ответов до людей не дойдёт.
Ревью кода воспроизвело порядок на прежней версии, живой прогон — на нынешней.
- **Откат образа через шаг схемы `202608140002` не работает и не говорит об
этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только
те шаги, которые знает сам бинарь: прежний образ шагов новее не видит,
поднимается **без единой ошибки** и отвечает зелёной пробой здоровья — после
чего всякое обращение к очереди отказывает «коллекции нет». Проверено прогоном
двух бинарей на одном каталоге данных.
Значит штатное средство владельца на инциденте — «вернём прошлый образ» — с
этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа;
обратного шага схемы нет и не планируется. Порог перехода назван прямо: до
выкладки `record-centric-model` откат образа работает, после — нет.
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
наружу — [database.md](database.md), «Настройки с числовым значением»:
@@ -145,17 +161,23 @@
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
| --- | --- | --- | --- | --- |
| Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняют только ошибки настройки — ответ «такого бота нет» и включённый вход с пустым ключом доступа. Норму держит [intake](../openspec/specs/intake/spec.md), «Признак включения решает, поднимается ли вход Telegram» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись завершается заглушкой «на записи нет текста» |
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла». Остановка сервиса — исход другой: процесс убивают контекстом, и задача остаётся на повтор, не тратя попытки | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
или по сообщению об ошибке. Владелец — по метрике
`transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера.
Отдельного оповещения нет.
`transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
«падает приведение» отличается от «падает распознавание». Плюс логи
контейнера. Отдельного оповещения нет.
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
на смену рубежа, на остановку и на снятие остановки; читает его человек в
панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет.
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
воркеры опрашивают базу вхолостую с паузой из
[database.md](database.md), «Настройки с числовым значением».
@@ -164,12 +186,15 @@
| Что | Где |
| --- | --- |
| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа |
| Правка задачи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` — один запрос с `RETURNING` |
| Приём аудио и заведение записи | `TranscribeService.createRecord` — через него идут оба входа |
| Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
| Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
| Выбор шага по рубежу | `TranscribeService.stepFor` — таблица, а не привязка к воркеру |
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
| Переход записи на рубеж | `entity.AudioRecord.MoveToState` — чистит служебные поля прошлого рубежа и ставит время входа |
| Откладывание работы | `entity.AudioRecord.Postpone` — ставит паузу и снимает захват, рубежа не трогая |
| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; ответ отправителю — `TranscribeService.halt`, одно место на все причины |
| Разбор конфигурации | `internal/config.LoadConfig` |
| Чтение времени | `internal/clock``Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
@@ -243,7 +268,8 @@
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
конвертер этот случай не проверялся.
- **Очередь.** Модель очереди сделана задачей `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`. Не решено, отказываться ли от холостого опроса: он
даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их
паузы, а не замер
+6 -4
View File
@@ -49,10 +49,12 @@
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код.
*Расхождение:* перечень состояний задачи закрыт схемой (`SelectField`), а не
кодом — ради панели владельца: правка руками не должна заводить состояние,
которого конвейер не знает. Цена названа: шестое состояние потребует нового
шага схемы.
*Расхождение:* перечни, по которым панель владельца правит запись руками,
закрыты схемой (`SelectField`), а не кодом: правка руками не должна заводить
значение, которого сервис не знает. Закрыты рубеж записи, причина её
остановки, вид текста, источник и исход события журнала. Цена названа: новое
значение любого из них потребует нового шага схемы, а применённый шаг не
переписывается. Прочие перечни остаются обычным `TEXT`.
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
+2 -1
View File
@@ -91,7 +91,8 @@
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` |
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
| Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | `internal/archrules` → правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда |
| Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком |
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
### Отмена и внешний собеседник
+10 -10
View File
@@ -28,22 +28,22 @@ OpenSpec.
`jq` без регулярных выражений.
```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":"telegram","duration_seconds":137}
```
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
## Сообщение
- `msg` — короткая константа в нижнем регистре: `job accepted`,
- `msg` — короткая константа в нижнем регистре: `record accepted`,
`recognition done`, `conversion failed`. Данные — в атрибутах:
`log.Info("job accepted", "job_id", id, "source", "telegram")`.
`log.Info("record accepted", "record_id", id, "source", "telegram")`.
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
`recognize: done`. Подсистему выносим в поле `capability`, не в текст.
- **Смена состояния задачи — единая категория `state transition`** с полями
`from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь
жизненный цикл собирался одним отбором:
`jq 'select(.msg=="state transition" and .job_id=="…")'`. Физический эффект
`jq 'select(.msg=="state transition" and .record_id=="…")'`. Физический эффект
сверх перехода — отдельная запись своей категории (`file converted`,
`text delivered`), она запись перехода не подменяет.
@@ -100,13 +100,13 @@ OpenSpec.
| Когда добавляем | Поля |
| --- | --- |
| на входящий HTTP-запрос | `transport` (`http`, `telegram`), `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` |
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
*Расхождение:* в коде встречаются `job_id`, `file_id`, `operation_id`,
*Расхождение:* в коде встречаются `record_id`, `file_id`, `operation_id`,
`worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и
пересечён с этим лишь частично.
@@ -120,16 +120,16 @@ OpenSpec.
чтобы ключ дописывался на каждую запись сам:
```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 логируем как атрибут, а не как текст сообщения:
`log.Error("conversion failed", "error", err, "job_id", id)`. Ключ — `error`.
`log.Error("conversion failed", "error", err, "record_id", id)`. Ключ — `error`.
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст
@@ -277,5 +277,5 @@ Bot API, поэтому чистка на месте употребления з
## Анализ
- Повседневно — `jq`: `jq 'select(.job_id=="…")' app.jsonl`.
- Повседневно — `jq`: `jq 'select(.record_id=="…")' app.jsonl`.
- Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.
+184 -52
View File
@@ -38,85 +38,191 @@ CGO сборке не нужен.
### `files`
Один файл на одну физическую копию: исходник, результат конвертации и копия в
Object Storage — каждая своей записью.
Одна запись на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не
считается — она существует только потому, что провайдер распознавания читает
аудио по адресу, и её ключ живёт в строке попытки распознавания.
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `file` | file | Сам файл; пусто у копии в Object Storage |
| `file` | file | Сам файл |
| `owner` | relation → `users` | Владелец файла; пусто у файлов записи, принятой ботом |
| `location` | select | `local` или `s3` |
| `object_key` | TEXT | Ключ объекта; пусто у местной копии |
| `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется |
| `size` | INTEGER | Размер в байтах |
| `format` | TEXT | Расширение без точки, в нижнем регистре |
| `duration_ms` | INTEGER | Длительность, если её удалось прочитать |
| `created`, `updated` | DATETIME | Проставляет хранилище |
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
capability, и третий смысл развёл бы одно слово по разным вещам.
### `transcribe_jobs`
### `audio_records`
Задача расшифровки и она же очередь.
Аудиозапись — центральная сущность сервиса. Домен, поля очереди и ссылки на
приложения лежат здесь; содержимое — по ссылкам, отдельными строками.
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `owner` | relation → `users` | Владелец записи; пусто у записей, принятых ботом |
| `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой |
| `source` | select | `api`, `telegram`, `unknown` |
| `file` | relation → `files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
| `delay_time` | DATETIME | Не брать задачу раньше этого времени |
| `acquisition_id` | TEXT | Кто захватил задачу |
| `acquire_time` | DATETIME | Когда захватил; по нему считается протухание |
| `attempts` | INTEGER ≥ 0 | Число попыток: растёт при захвате, обнуляется на шаге без отказа |
| `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud |
| `transcription_text` | editor | Результат распознавания |
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
| `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой |
| `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания |
| `halted_at` | DATETIME | Признак остановки; рубеж при ней не стирается |
| `halt_reason` | select | `step_failed`, `attempts_exhausted`, `stuck` |
| `error_text` | TEXT | Текст ошибки, машинный |
| `acquisition_id` | TEXT | Признак **этого** захвата, уникальный для каждого |
| `acquire_expires_at` | DATETIME | Срок протухания захвата; приезжает с рубежом |
| `delay_time` | DATETIME | Не брать запись раньше этого времени |
| `attempts` | INTEGER ≥ 0 | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании |
| `original_file` | relation → `files` | Принятая копия |
| `normalized_file` | relation → `files` | Копия, приведённая к рабочему формату |
| `transcript_text`, `literary_text` | relation → `texts` | Тексты записи |
| `structure` | relation → `structures` | Структура реплик |
| `recognition` | relation → `recognitions` | Попытка распознавания |
| `topics` | relation → `topics`, до 5 | Темы записи |
| `tg_chat_id` | INTEGER | Куда отправить результат |
| `tg_reply_message_id` | INTEGER | С каким сообщением связать |
| `created`, `updated` | DATETIME | Проставляет хранилище |
Индекс один — по `state`: выборка воркера идёт по нему, паузе и сроку захвата.
Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ
этот один.
Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним,
паузе и сроку протухания.
**Состояния `failed` и `dead` — разные приговоры**, и чей это приговор, нормирует
[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
«мертва»». Схеме принадлежит только закрытость перечня: шестое состояние
потребует нового шага.
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
хранилище, и принятого человеком файла не найти было ничем.
**Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead`
схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием
признака, — и различие между ними перестало быть структурным. Рубеж при
остановке сохраняется, поэтому запись продолжает с места остановки.
**Сторожей двое.** `attempts` ограничивает повторы внутри шага,
`state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не
справлялось ни с одной.
### `texts`
| Поле | Тип | Что |
| --- | --- | --- |
| `record` | relation → `audio_records` | Чья это расшифровка |
| `kind` | select | `transcript` или `literary` |
| `contents` | editor | Сам текст |
Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки.
Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат
файла.
### `structures`
| Поле | Тип | Что |
| --- | --- | --- |
| `record` | relation → `audio_records` | Чья это структура |
| `version` | INTEGER | Версия вида разбора |
| `contents` | JSON | Реплики со временем |
Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор
сохранённого ответа изменится раньше, чем архив пересчитают.
### `recognitions`
Попытка распознавания у внешнего провайдера — всё, что зависит от него.
| Поле | Тип | Что |
| --- | --- | --- |
| `record` | relation → `audio_records` | Чья это попытка |
| `provider`, `model` | TEXT | Кем и какой моделью считано |
| `external_id` | TEXT | Идентификатор операции у провайдера |
| `source_uri` | TEXT | Адрес, по которому провайдер читает аудио |
| `payload` | file, **защищённое** | Сырой ответ провайдера целиком |
| `started_at`, `finished_at` | DATETIME | Границы операции |
**Сырой ответ лежит вложением, а не колонкой.** Шаг опроса читает эту строку раз
в несколько секунд, а хранилище читает запись целиком: ответ на многочасовую
запись ехал бы в память при каждом опросе. Хранится он потому, что результат
операции у провайдера не переспрашивается.
Поле вложения помечено защищённым: сырой ответ — это полный текст речи, и
умолчание библиотеки отдавало бы его по ссылке любому, кто её знает.
### `record_events`
| Поле | Тип | Что |
| --- | --- | --- |
| `record` | relation → `audio_records` | Чьё это событие |
| `origin` | select | `pipeline` или `human` |
| `step` | TEXT | Имя шага |
| `outcome` | select | `done`, `failed`, `halted`, `resumed` |
| `outcome_text` | TEXT | Причина, если она есть |
| `duration_ms` | INTEGER | Сколько шаг занял |
Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо
поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант
двусмысленным.
Журнал пишется на смену рубежа, на остановку и на снятие остановки — не на
каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить,
что делать дальше.
### `topics`
| Поле | Тип | Что |
| --- | --- | --- |
| `owner` | relation → `users` | Чей это словарь |
| `name` | TEXT | Название темы |
Пара «владелец и название» уникальна: словарь тем свой у каждого человека.
Коллекцией, а не набором строк в записи, потому что перечень тем нужен целиком
перед каждым обращением к языковой модели. Ни один шаг сегодняшнего сервиса тем
не пишет и не читает — место заведено вперёд, чтобы задача, считающая темы, не
платила вторым необратимым шагом схемы.
### Чего в схеме больше нет
Коллекция `transcribe_jobs` удалена шагом `202608140002`. Данных под ней не было:
сервис на сервере остановлен, а прежние записи удалены решением владельца
2026-08-14 — переноса это изменение не делало. Оставленная пустая коллекция
висела бы в панели вторым домом для понятия, которого больше нет.
**Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в
обеих таблицах. Пустое значение допустимо, и это решение с ценой: записи,
принятые ботом, владельца не имеют вовсе, потому что связи чата Telegram с
учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит
поэтому сам приём, а не схема.
учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит поэтому
сам приём, а не схема.
Выборка по владельцу сужает **чтение задачи**: чужая, ничья и несуществующая
Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер
обрабатывает записи всех. Тот же шаг сужает правило просмотра коллекции
`files` владельцем: прежнее правило пускало всякого вошедшего, и знание
идентификатора файловой записи равнялось праву скачать чужое аудио.
обрабатывает записи всех. Тот же шаг сузил правило просмотра коллекции `files`
владельцем: прежнее правило пускало всякого вошедшего, и знание идентификатора
файловой записи равнялось праву скачать чужое аудио.
**Учётная запись с задачами не удаляется.** Каскадное удаление у связи выключено,
**Учётная запись с записями не удаляется.** Каскадное удаление у связи выключено,
но одного этого мало: при выключенном каскаде хранилище снимает ссылку и
сохраняет запись без проверок — задачи остались бы, но стали бы ничьими, а ничья
задача не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`,
сохраняет запись без проверок — записи остались бы, но стали бы ничьими, а ничья
запись не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`,
а не правило коллекции: панель ходит правами суперпользователя, и правило её не
судит. Цена названа прямо — владелец панели упирается в отказ, а удаления
записей в сервисе пока нет вовсе.
судит. Считаются все коллекции с колонкой владельца — `audio_records`, `files` и
`topics`, — и перечень живёт одним местом: пропущенная коллекция пропускает
удаление вперёд, а наружу приезжает подсказка библиотеки про обязательную связь
вместо нашего отказа с причиной.
**Правила доступа задач пусты**, то есть перечислять и читать их может только
владелец панели. Проверено прогоном: анонимный запрос к
`/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`,
`/api/settings` и `/api/crons``401`.
**Правила доступа новых коллекций пусты**, то есть перечислять и читать их может
только владелец панели. Содержимое записи отдаёт собственный адрес сервиса, а не
поверхность хранилища; непустое правило открыло бы перечисление коллекции впрок.
Проверено прогоном: анонимный запрос к `/api/collections/*/records` отвечает
`403`, к `/api/logs`, `/api/backups`, `/api/settings` и `/api/crons``401`.
## Представление данных
Чем физически лежит запись и что происходит при чтении и записи.
- **Расшифровка лежит целиком в поле `transcription_text`** одной строкой.
Запись длиной в час даёт десятки килобайт в одной ячейке; читается она
целиком при каждом чтении задачи и при каждом захвате.
- **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват
её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а
колонки шаг читает отдельным чтением. Прежде расшифровка стояла колонкой той
же строки и читалась при каждом опросе очереди.
- **Аудио лежит в раскладке хранилища:**
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
@@ -135,19 +241,28 @@ capability, и третий смысл развёл бы одно слово п
Без этого сужения закрытие API обходится двумя запросами — завести себе
запись и войти паролем. Продление сессии закрыто слоем в приложении, а не
настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
- **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции.
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
невоспроизводим.
невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку протухания
захвата и отсутствию признака остановки; срок протухания выбирается по рубежу
самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, подставить
его не может.
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому,
что условие проверяется тем же запросом, что и сам захват.
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`,
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы.
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его
серьёзность (critical/major) — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
- **Список колонок задан двумя местами** — `applyOwnedByPipeline` вместе с
`applyToRecord` и `recordToAudioRecord`, — плюс шагом схемы. Мест было четыре,
пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и
перечень перестал расти с моделью. Правило правки и его серьёзность —
инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты»; сверку держат правила
`internal/archrules`.
- **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`.
Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя:
рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон
по инварианту проекта не пишется в журнал и не считается в метрику.
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
@@ -157,14 +272,22 @@ capability, и третий смысл развёл бы одно слово п
| Настройка | Значение | Где | Откуда число |
| --- | --- | --- | --- |
| Предел попыток | 5 | `service/transcribe.go` | обычное умолчание, не замер |
| Пауза перед повтором | `2^(попытка−1)` с, потолок 5 минут | там же | то же |
| Срок захвата, конвертация | 8 часов | там же | потолок записи 6 часов плюс запас |
| Срок захвата, распознавание | 8 часов | там же | то же |
| Срок захвата, проверка операции | 1 час | там же | опрос идёт секунды |
| Задержка перед первой проверкой операции | 10 секунд | там же | как было |
| Предел отказов | 5 | `service/transcribe.go` | обычное умолчание, не замер |
| Пауза перед повтором | `2^(отказ1)` с, потолок 5 минут | там же | то же |
| Срок захвата, приведение | 8 часов | `entity/stage.go` | потолок записи 6 часов плюс запас |
| Срок захвата, отправка на распознавание | 8 часов | там же | то же |
| Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды |
| Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды |
| Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение |
| Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже |
| Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого |
| Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая |
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
| Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих |
| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем |
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
| Задержка между проверками операции | 5 секунд | там же | как было |
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` | как было |
| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
@@ -177,6 +300,15 @@ capability, и третий смысл развёл бы одно слово п
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
протухания ещё не истёк. Значит для держателя, погибшего жёстко — контейнер убит
по нехватке памяти или `docker kill`, — запись невидима сторожу до истечения
**срока захвата** её рубежа, то есть восьми часов у приведения и отправки.
Замерено прогоном: до истечения срока повторный захват записи не выдаёт, и
остановка «застряла» наступает только после него. Мягкая остановка сюда не
подпадает: она снимает захват сама.
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
+13 -8
View File
@@ -69,8 +69,8 @@
**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы —
подпакетом `migrations`):
- список колонок совпадает во всех четырёх местах — `applyToRecord`,
`recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант
- список колонок совпадает в обоих местах — `applyOwnedByPipeline` вместе с
`applyToRecord` и `recordToAudioRecord` — и в шаге схемы (инвариант
[CLAUDE.md](../CLAUDE.md), «Инварианты»);
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
держатель захвата;
@@ -109,11 +109,15 @@
приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей
`errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать
её как известную нельзя.
- **«Захват задачи не в транзакции — гонка двух воркеров».** По построению её
нет: три воркера читают три разных состояния, и одну строку они не делят.
Механика захвата и её слабые места — [database.md](database.md),
«Представление данных». Находка становится настоящей ровно тогда, когда
появится второй экземпляр процесса или второй воркер на то же состояние.
- **«Захват записи не в транзакции — гонка двух воркеров».** ~~По построению её
нет: три воркера читают три разных состояния, и одну строку они не делят.~~
**Отменено 2026-08-14 задачей `record-centric-model`:** построение снято. Пул
одинаковых воркеров конкурирует за один и тот же набор записей, и второй
воркер на тот же рубеж теперь есть всегда, когда их больше одного. Находка о
гонке захвата стала настоящей и выбрасывается только по существу — механика
захвата и её слабые места в [database.md](database.md), «Представление
данных». Строка оставлена отменённой, а не удалена: прогон, помнящий прежнюю
редакцию, иначе выбросил бы настоящую находку как известную.
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
Новой находкой это не считается, пока не измерен рост.
@@ -165,7 +169,8 @@
первые две описаны частично. Поведение прочих узлов, включая
приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда
ещё — самый большой.
- `conventions`: новая колонка правится во всех четырёх местах репозитория
- `conventions`: новая колонка правится в обоих местах репозитория, а новый
рубеж — одним дескриптором
(CLAUDE.md, «Инварианты»).
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
+36 -7
View File
@@ -112,7 +112,17 @@ Telegram отправителю.
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
каталогов, но это единственное, что стоит между входом и именем файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет.
расширением. Бакет один на все записи, префикса по пользователю нет. С
2026-08-14 копия там файлом записи не считается: она существует лишь потому,
что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
распознавания.
- **Вторая раскладка файла на диске** появилась 2026-08-14 вместе с сохранённым
ответом провайдера: `data/storage/<recognitions>/<попытка>/<имя>.payload`. Имя
задаёт сервис, как и у аудио. Содержимое там — **полный текст речи**, а не
метаданные, поэтому поле помечено защищённым, правило просмотра коллекции
оставлено пустым, и ссылка на вложение подпадает под тот же запрет, что и
ссылка на аудио: в журнал она не пишется. Проверено прогоном: без сессии, с
чужим и со своим токеном файла ссылка отвечает «не найдено».
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
можно только с коротким токеном файла, который выдаётся по сессии, и запрос
@@ -121,12 +131,14 @@ Telegram отправителю.
остаётся: **имя файла в хранилище в журнал не пишется**
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное,
- **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное,
что защищает `GET /api/status/:id`.
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
есть доступны они только владельцу панели; коды, снятые прогоном, —
есть доступны они только владельцу панели, — и коллекции, заведённые
2026-08-14, тоже: содержимое записи отдаёт собственный адрес сервиса, а не
поверхность хранилища. Коды, снятые прогоном, —
[database.md](database.md), «Коллекции», норма —
[storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только
то, что заказано».
@@ -210,7 +222,13 @@ Telegram отправителю.
## Что чувствительнее чего
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
это самое чувствительное, что здесь есть.
это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
колонкой, а шестью коллекциями: сама запись (заголовок и краткое описание),
`texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
`recognitions` (**сырой ответ провайдера вложением — полный текст речи**),
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается
наравне с записью — норму держит спека `storage`.
2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним.
3. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
Утечка оплачивается деньгами и доступом к бакету.
@@ -336,6 +354,17 @@ Telegram отправителю.
вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить
бессрочно, и тем же днём заведена задача `delete-record`: своя запись
убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста.
Пока она не сделана, единственный способ убрать запись — руками в базе и в
каталоге на сервере. Учёт расхода удалению не подлежит по решению человека:
деньги потрачены, а строки потребления текста не содержат.
Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
строки потребления текста не содержат.
**Руками запись сегодня не удаляется, и прежняя строка об этом была неверна.**
Проверено прогоном 2026-08-14: содержимое живёт в коллекциях, перечисленных
выше («Что чувствительнее чего»), связи приложений с записью обязательны и
каскада не имеют, поэтому удаление самой
строки записи отвергается хранилищем, а удаление её файлов проходит молча.
Владелец, выполнивший прежнюю процедуру, стирает аудио и **оставляет полный
текст речи** — расшифровку, разбивку по репликам и сырой ответ провайдера
файлом на диске. Порядок, которым запись убирается на самом деле: сперва
строки приложений — журнал событий, попытка распознавания вместе с её
вложением, структура, тексты, — потом сама запись, потом её файлы. До
`delete-record` это единственный способ, и он ручной целиком.
+1 -1
View File
@@ -19,6 +19,7 @@ require (
github.com/stretchr/testify v1.10.0
github.com/yandex-cloud/go-genproto v0.17.0
google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
)
require (
@@ -73,7 +74,6 @@ require (
golang.org/x/text v0.40.0 // 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/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
+64 -7
View File
@@ -2,22 +2,79 @@ package recognizer
import (
"context"
"encoding/json"
"errors"
"io"
"git.vakhrushev.me/av/transcriber/internal/entity"
"github.com/google/uuid"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// MemoryAudioRecognizer — подставной распознаватель для местного запуска и
// проверок. Прогон на реальных ключах ради проверки кода запрещён: распознавание
// и хранение в Object Storage оплачиваются по факту.
//
// Сырой ответ он отдаёт своего вида, но настоящего: тем же путём, что и живой
// адаптер, — сохранённые байты разбираются обратно в реплики, и структура
// строится без единого обращения наружу.
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
}
func (r *MemoryAudioRecognizer) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
return "Foo bar, Baz.", nil
}
func (r *MemoryAudioRecognizer) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
func (r *MemoryAudioRecognizer) CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
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"
)
// ProviderName — имя провайдера, под которым сохраняется попытка распознавания.
// По нему видно, чем считана запись, когда провайдеров станет больше одного.
const ProviderName = "yandex-speechkit"
type YandexAudioRecognizerConfig struct {
// s3
Region string
@@ -56,36 +60,44 @@ func (s *YandexAudioRecognizerService) Close() error {
return s.sttService.Close()
}
func (s *YandexAudioRecognizerService) Provider() string { return ProviderName }
func (s *YandexAudioRecognizerService) Model() string { return RecognitionModel }
// startRecognitionTimeout — сколько ждём принятия операции, когда нас уже
// остановили. Число меньше жёсткого предела остановки: иначе процесс убьют
// прежде, чем ответ дойдёт, и защита ничего не даст.
const startRecognitionTimeout = 10 * time.Second
func (s *YandexAudioRecognizerService) Recognize(ctx context.Context, file io.Reader, fileName string) (string, error) {
// Заливка отменяется штатно: она дорога по времени, а повтор её бесплатен —
// объект ложится под тем же ключом.
err := s.s3Sevice.uploadFile(ctx, file, fileName)
if err != nil {
// Upload кладёт аудио туда, откуда провайдер его прочитает.
//
// Отменяется штатно: заливка дорога по времени, а повтор её бесплатен — объект
// ложится под тем же ключом.
func (s *YandexAudioRecognizerService) Upload(ctx context.Context, file io.Reader, objectKey string) (string, error) {
if err := s.s3Sevice.uploadFile(ctx, file, objectKey); err != nil {
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)
}
// А вот принятие операции от отмены защищено. Окно короткое и дорогое:
// SpeechKit может операцию принять и начать считать деньги, а ответ до нас
// не доедет — идентификатор потеряется навсегда, и повтор оплатит ту же
// запись второй раз. Свой предел вызову оставлен, чтобы остановка не ждала
// вечно.
// Submit заводит операцию распознавания. Оплачивается наружу, поэтому от отмены
// защищён: окно короткое и дорогое — SpeechKit может операцию принять и начать
// считать деньги, а ответ до нас не доедет, и повтор оплатит ту же запись второй
// раз. Свой предел вызову оставлен, чтобы остановка не ждала вечно.
func (s *YandexAudioRecognizerService) Submit(ctx context.Context, sourceURI string) (string, error) {
startCtx, cancel := protectFromCancel(ctx, startRecognitionTimeout)
defer cancel()
opId, err := s.sttService.recognizeFileFromS3(startCtx, uri)
if err != nil {
return "", err
}
return opId, nil
return s.sttService.recognizeFileFromS3(startCtx, sourceURI)
}
// protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения
@@ -95,11 +107,26 @@ func protectFromCancel(ctx context.Context, timeout time.Duration) (context.Cont
return context.WithTimeout(context.WithoutCancel(ctx), timeout)
}
func (s *YandexAudioRecognizerService) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
return s.sttService.getRecognitionText(ctx, operationID)
// Fetch забирает готовый результат и отдаёт его доменным: реплики со временем,
// плоский текст и байты ответа на хранение. Формата провайдера наружу не выходит
// ничего — ни один шаг конвейера не знает, каким потоком тот отвечает.
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)
if err != nil {
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/feature/s3/manager"
"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"
)
@@ -89,6 +90,37 @@ func (s *yandexS3Service) uploadFile(ctx context.Context, file io.Reader, fileNa
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 {
endpoint := strings.TrimRight(s.endpoint, "/")
return fmt.Sprintf("%s/%s/%s", endpoint, s.bucketName, fileName)
+112 -17
View File
@@ -2,17 +2,20 @@ package yandex
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"strings"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/proto"
stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3"
"github.com/yandex-cloud/go-genproto/yandex/cloud/operation"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
const (
@@ -134,9 +137,13 @@ func (s *speechKitService) recognizeFileFromS3(ctx context.Context, s3URI string
return op.Id, nil
}
// GetRecognitionResult получает результат распознавания по ID операции
func (s *speechKitService) getRecognitionText(ctx context.Context, operationID string) (string, error) {
// Добавляем авторизацию и folder_id в контекст
// fetchRecognition забирает результат операции целиком и отдаёт его доменным,
// вместе с сырым ответом на хранение.
//
// Ответ сохраняется потому, что **результат операции у провайдера не
// переспрашивается**: связь реплики с говорящим сервис строить пока не умеет, и
// когда научится, архив пересчитается из сохранённого без единого рубля.
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, "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)
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 {
resp, err := stream.Recv()
if err != nil {
@@ -160,19 +166,19 @@ func (s *speechKitService) getRecognitionText(ctx context.Context, operationID s
if errors.Is(err, io.EOF) {
break
}
return "", 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(" ")
}
}
return nil, fmt.Errorf("failed to receive recognition response: %w", err)
}
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 проверяет статус операции распознавания
@@ -190,3 +196,92 @@ func (s *speechKitService) checkOperationStatus(ctx context.Context, operationID
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
}
+18 -34
View File
@@ -112,11 +112,15 @@ func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
return work, nil
}
// CreateLocal кладёт рабочую копию в хранилище. Имя задаём мы: умолчание
// библиотеки строит его из имени, данного отправителем, а имя отправителя в
// хранилище не попадает — путь к файлу читается в журнале, и инвариант
// приватности этого не допускает. Свой суффикс хранилище допишет само.
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile, ownerID string) (*entity.File, error) {
// Create кладёт рабочую копию в хранилище. Имя задаём мы: умолчание библиотеки
// строит его из имени, данного отправителем, а имя отправителя в хранилище не
// попадает — путь к файлу читается в журнале, и инвариант приватности этого не
// допускает. Свой суффикс хранилище допишет само.
//
// Копий у записи ровно две — принятая и приведённая, — и обе местные. Прежний
// путь заведения записи о копии во внешнем хранилище отсюда ушёл: та копия
// файлом записи не считается, а её ключ живёт в строке попытки распознавания.
func (repo *FileRepository) Create(name string, work contract.WorkFile, meta contract.FileMeta, ownerID string) (*entity.File, error) {
collection, err := findCollection(repo.app, migrations.FilesCollection)
if err != nil {
return nil, err
@@ -132,6 +136,8 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile, own
record.Set("file", stored)
record.Set("location", entity.LocationLocal)
record.Set("size", stored.Size)
record.Set("format", meta.Format)
record.Set("duration_ms", meta.DurationMs)
// Владелец файла — владелец записи, которой файл принадлежит. Пустой значит
// «файл без владельца»: таков всякий файл записи, принятой ботом. Правило
// просмотра коллекции сужено этой колонкой, и без неё чужое аудио осталось
@@ -148,25 +154,6 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile, own
return recordToFile(record), nil
}
func (repo *FileRepository) CreateRemote(objectKey string, size int64, ownerID string) (*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)
record.Set("owner", ownerID)
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) {
record, err := repo.app.FindRecordById(migrations.FilesCollection, id)
if err != nil {
@@ -262,16 +249,13 @@ func firstFileName(record *core.Record) string {
}
func recordToFile(record *core.Record) *entity.File {
name := firstFileName(record)
if name == "" {
name = record.GetString("object_key")
}
return &entity.File{
Id: record.Id,
Location: record.GetString("location"),
FileName: name,
Size: int64(record.GetInt("size")),
CreatedAt: record.GetDateTime("created").Time(),
Id: record.Id,
Location: record.GetString("location"),
FileName: firstFileName(record),
Size: int64(record.GetInt("size")),
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,214 +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)
// Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага,
// записанный поверх, стёр бы его молча.
record.Set("owner", derefString(job.OwnerID))
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"),
OwnerID: nilIfEmpty(record.GetString("owner")),
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"`
// Владелец конвейеру не нужен для выборки — она им не сужается, — но
// читается: снимок задачи, в котором владелец всегда пуст, был бы ловушкой
// для первого же шага, начавшего сохранять задачу целиком. Сегодня это ещё
// и несущее чтение: шаг конвейера кладёт владельца на файл, который заводит.
OwnerID sql.NullString `db:"owner"`
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,
OwnerID: nullToPtr(r.OwnerID),
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,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
}
@@ -23,7 +23,19 @@ import (
// меняются только новым шагом схемы.
const (
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 заводит не наш шаг, а системный шаг библиотеки. Имя стоит
// здесь потому, что на него ссылаются и шаги схемы, и проверка предъявителя
// на приёме: строковый литерал в двух местах разошёлся бы молча.
@@ -36,6 +48,7 @@ func init() {
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.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")
}
func ptr[T any](v T) *T { return &v }
@@ -11,7 +11,7 @@ import (
)
// GuardOwnerDeletion отвергает удаление учётной записи, у которой остались
// задачи расшифровки.
// аудиозаписи, их файлы либо темы её словаря.
//
// Колонка владельца — связь с выключенным каскадным удалением, и одного этого
// мало: при выключенном каскаде хранилище не удаляет ссылающуюся запись, а
@@ -29,10 +29,11 @@ import (
// только панельное — умолчание библиотеки разрешает вошедшему удалить свою
// учётную запись запросом, так что страж закрывает и публичную поверхность.
//
// Считаются **обе** коллекции с владельцем. Файл переживает свою задачу: шаг
// конвейера заводит его до сохранения задачи, и потерянный захват оставляет файл
// с владельцем и без ссылки. Учётная запись, у которой остались одни такие
// файлы, без этого счёта удалялась бы штатно, а аудио становилось бы ничьим.
// Считаются **все** коллекции с владельцем, и перечень их живёт одним списком
// ниже. Файл переживает свою запись: шаг конвейера заводит его до сохранения, и
// потерянный захват оставляет файл с владельцем и без ссылки. Учётная запись, у
// которой остались одни такие файлы, без этого счёта удалялась бы штатно, а
// аудио становилось бы ничьим.
func GuardOwnerDeletion(app core.App) {
app.OnRecordDelete(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
count, err := countOwned(e.App, e.Record.Id)
@@ -62,13 +63,25 @@ func GuardOwnerDeletion(app core.App) {
})
}
// countOwned считает всё, что принадлежит учётной записи, — по обеим коллекциям
// с колонкой владельца. Перечень живёт здесь одним списком: разойдясь с шагом
// схемы, он оставил бы половину архива без защиты молча.
// 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 []string{migrations.JobsCollection, migrations.FilesCollection} {
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)
+92 -137
View File
@@ -4,179 +4,134 @@ import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"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"
)
// newAccount заводит учётную запись и отдаёт её идентификатор.
func newAccount(t *testing.T, app core.App, email string) string {
// Страж удаления учётной записи — единственное, что стоит между владельцем
// панели и молчаливым обезличиванием чужого архива: при выключенном каскаде
// хранилище снимает ссылку и сохраняет запись без проверок.
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", email)
record.Set("email", uuid.NewString()+"@example.test")
record.Set("verified", true)
record.SetRandomPassword()
record.Set("password", uuid.NewString())
require.NoError(t, app.Save(record))
return record.Id
return record
}
// Колонка владельца заводится шагом схемы на чистой базе, и умолчания у неё нет.
func TestOwnerColumnHasNoDefault(t *testing.T) {
app := newTestApp(t)
// newRecordOf заводит аудиозапись названного владельца.
func newRecordOf(t *testing.T, app core.App, ownerID string) *entity.AudioRecord {
t.Helper()
for _, name := range []string{migrations.JobsCollection, migrations.FilesCollection} {
collection, err := app.FindCollectionByNameOrId(name)
require.NoError(t, err)
field := collection.Fields.GetByName("owner")
require.NotNil(t, field, "колонка владельца заведена в %s", name)
relation, ok := field.(*core.RelationField)
require.True(t, ok, "владелец — связь с учётной записью, а не строка")
assert.False(t, relation.Required, "пустое значение допустимо ради записей бота")
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит записи")
// Умолчания у связи нет по устройству: запись, чей владелец не назван,
// не достаётся никому по недосмотру схемы.
record := core.NewRecord(collection)
assert.Empty(t, record.GetString("owner"), "новая запись приходит без владельца")
record := &entity.AudioRecord{
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
Source: entity.SourceApi,
}
}
// Чужая задача, ничья и несуществующая дают одну и ту же ошибку.
func TestGetByID_NarrowedByOwner(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
mine := newAccount(t, app, "mine@example.com")
stranger := newAccount(t, app, "stranger@example.com")
owned := newJob(t, repo, entity.StateCreated)
owned.OwnerID = &mine
require.NoError(t, repo.Save(owned, ""))
// Владельца кладёт заведение, а не сохранение конвейера, — ставим его прямо.
record, err := app.FindRecordById(migrations.JobsCollection, owned.Id)
require.NoError(t, err)
record.Set("owner", mine)
require.NoError(t, app.Save(record))
ownerless := newJob(t, repo, entity.StateCreated)
got, err := repo.GetByID(owned.Id, mine)
require.NoError(t, err, "своя задача отдаётся")
assert.Equal(t, owned.Id, got.Id)
_, foreignErr := repo.GetByID(owned.Id, stranger)
_, ownerlessErr := repo.GetByID(ownerless.Id, mine)
_, missingErr := repo.GetByID("nonexistent0000", mine)
var notFound *contract.JobNotFoundError
require.ErrorAs(t, foreignErr, &notFound, "чужая задача не отдаётся")
require.ErrorAs(t, ownerlessErr, &notFound, "ничья задача не отдаётся")
require.ErrorAs(t, missingErr, &notFound, "несуществующая тоже")
}
// Пустой владелец не совпадает ни с чем: ни со своей задачей, ни с чужой, ни с
// ничьей. Правило записано со стороны спрашивающего — обязательность, которую
// держит одна лишь подпись метода, пустую строку пропускает.
func TestGetByID_EmptyOwnerMatchesNothing(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
owner := newAccount(t, app, "mine@example.com")
owned := newJob(t, repo, entity.StateCreated)
record, err := app.FindRecordById(migrations.JobsCollection, owned.Id)
require.NoError(t, err)
record.Set("owner", owner)
require.NoError(t, app.Save(record))
ownerless := newJob(t, repo, entity.StateCreated)
var notFound *contract.JobNotFoundError
for _, id := range []string{owned.Id, ownerless.Id, "nonexistent0000"} {
_, err := repo.GetByID(id, "")
require.ErrorAs(t, err, &notFound, "пустой владелец не открывает %s", id)
if ownerID != "" {
record.OwnerID = &ownerID
}
require.NoError(t, NewAudioRecordRepository(app).Create(record))
return record
}
// Удаление учётной записи с задачами отвергается: связь с выключенным каскадом
// иначе снимает ссылку, и архив человека становится ничьим и недостижимым.
// Учётная запись с архивом не удаляется, и отказ называет причину — иначе
// наружу приезжает подсказка библиотеки про обязательную связь, по которой
// владелец панели пойдёт удалять записи руками.
func TestGuardOwnerDeletion(t *testing.T) {
// Страж вешает сама сборка хранилища — звать его отдельно не нужно и нельзя:
// второй вызов повесил бы второй слой.
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
app := newTestStorage(t)
withJobs := newAccount(t, app, "keeper@example.com")
empty := newAccount(t, app, "empty@example.com")
account := newAccount(t, app)
record := newRecordOf(t, app, account.Id)
job := newJob(t, repo, entity.StateCreated)
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("owner", withJobs)
require.NoError(t, app.Save(record))
err := app.Delete(account)
require.Error(t, err, "учётная запись с архивом не удаляется")
assert.Contains(t, err.Error(), "остались записи", "отказ называет причину")
keeper, err := app.FindRecordById(migrations.UsersCollection, withJobs)
require.NoError(t, err)
err = app.Delete(keeper)
require.Error(t, err, "учётная запись с задачами не удаляется")
// Отказ обязан быть ошибкой роутера, а не обычной: наружу библиотека
// пропускает только её, а всякую другую подменяет своим сообщением про
// обязательную связь — подсказкой, по которой владелец панели пойдёт удалять
// задачи и файлы руками. Проверяется поэтому тип и текст, а не сам факт
// отказа: на потерянном сообщении факт остаётся прежним.
var apiErr *router.ApiError
require.ErrorAs(t, err, &apiErr, "отказ доезжает до владельца панели")
assert.Contains(t, apiErr.Message, "остались записи",
"причина названа, а не подменена библиотечной")
// Задача и её владелец остались прежними.
after, err := app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
assert.Equal(t, withJobs, after.GetString("owner"), "владелец не снят")
free, err := app.FindRecordById(migrations.UsersCollection, empty)
require.NoError(t, err)
assert.NoError(t, app.Delete(free), "учётная запись без записей удаляется")
after, err := NewAudioRecordRepository(app).Get(record.Id)
require.NoError(t, err, "запись на месте")
require.NotNil(t, after.OwnerID, "и владелец у неё прежний")
assert.Equal(t, account.Id, *after.OwnerID)
}
// Файл переживает свою задачу: шаг конвейера заводит его до сохранения задачи, и
// потерянный захват оставляет файл с владельцем и без ссылки. Считать одни
// задачи значило бы отдать такое аудио на молчаливое обезличивание.
func TestGuardOwnerDeletion_CountsFilesToo(t *testing.T) {
app := newTestApp(t)
repo := NewFileRepository(app)
// Считаются все коллекции с владельцем, а не одни записи: файл переживает свою
// запись, а тема живёт в словаре человека.
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()) }()
owner := newAccount(t, app, "files@example.com")
_, 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)
work, err := repo.Stage(".ogg", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
topic := core.NewRecord(topics)
topic.Set("owner", ownerID)
topic.Set("name", "личная тема")
require.NoError(t, app.Save(topic))
},
}
_, err = repo.CreateLocal("sample.ogg", work, owner)
require.NoError(t, err)
for name, own := range cases {
t.Run(name, func(t *testing.T) {
app := newTestStorage(t)
account := newAccount(t, app)
own(t, app, account.Id)
record, err := app.FindRecordById(migrations.UsersCollection, owner)
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 TestOwnerColumnHasNoDefault(t *testing.T) {
app := newTestStorage(t)
records, err := app.FindCollectionByNameOrId(migrations.RecordsCollection)
require.NoError(t, err)
err = app.Delete(record)
require.Error(t, err, "учётная запись с одними файлами тоже не удаляется")
field := records.Fields.GetByName("owner")
require.NotNil(t, field, "колонка владельца заведена")
after, err := app.FindAllRecords(migrations.FilesCollection)
require.NoError(t, err)
require.Len(t, after, 1)
assert.Equal(t, owner, after[0].GetString("owner"), "владелец файла не снят")
relation, ok := field.(*core.RelationField)
require.True(t, ok, "владелец — связь с учётной записью, а не строка")
assert.False(t, relation.Required, "пустое значение допустимо ради записей бота")
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом")
}
+66 -18
View File
@@ -4,35 +4,83 @@ import (
"github.com/pocketbase/pocketbase/core"
"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) {
app.OnRecordUpdateRequest(migrations.JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
app.OnRecordUpdateRequest(migrations.RecordsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
original := e.Record.Original()
if original == nil || original.GetString("state") == e.Record.GetString("state") {
if original == nil {
return e.Next()
}
e.Record.Set("acquisition_id", "")
e.Record.Set("acquire_time", "")
e.Record.Set("delay_time", "")
e.Record.Set("attempts", 0)
stateChanged := original.GetString("state") != e.Record.GetString("state")
// Снятие признака остановки — то самое движение, ради которого признак и
// заведён: запись возвращается в работу с сохранённого рубежа.
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, "")
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, "")
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, "")
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, "")
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, "")
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, "")
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,151 @@
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", derefString(r.OwnerID))
record.Set("source", r.Source)
record.Set("title", derefString(r.Title))
record.Set("brief", derefString(r.Brief))
record.Set("tg_chat_id", derefInt64(r.TgChatId))
record.Set("tg_reply_message_id", derefInt(r.TgReplyMessageId))
}
func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
return &entity.AudioRecord{
Id: record.Id,
OwnerID: nilIfEmpty(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")),
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(),
}
}
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 || 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
}
func nilIfZero64(v int64) *int64 {
if v == 0 {
return nil
}
return &v
}
func nilIfZeroInt(v int) *int {
if v == 0 {
return nil
}
return &v
}
@@ -0,0 +1,235 @@
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,151 @@
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 кладёт текст записи, заменяя прежний того же вида.
//
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
// человеку» стал бы вопросом порядка записи, а не состояния.
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)
}
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:
// Строка есть — заменяем содержимое.
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,189 +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
}
// GetByID отдаёт задачу, только если её владелец — ownerID.
//
// Чужая задача, задача без владельца и несуществующая дают одну и ту же ошибку:
// по разнице ответов иначе перебирается список заведённых задач, а
// идентификатор задачи и есть то, что разграничение прячет.
//
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем: иначе
// вызывающий без учётной записи получил бы ровно множество задач без владельца,
// то есть все записи бота.
//
// Владелец сверяется тем же чтением, каким берётся состояние, а не отдельным
// запросом: между двумя чтениями задача успевает измениться, и ответ перестаёт
// быть функцией от того, что с ней произошло.
func (repo *TranscriptJobRepository) GetByID(id, ownerID string) (*entity.TranscribeJob, error) {
if ownerID == "" {
return nil, &contract.JobNotFoundError{Message: "job not found"}
}
record, err := repo.app.FindRecordById(migrations.JobsCollection, id)
if err != nil {
// «Такой записи нет» переводится в доменную ошибку **здесь**, у
// источника, как велит конвенция об ошибках. Иначе три исхода, которые
// разграничение обязано сделать неразличимыми, разъезжаются: чужая и
// ничья задачи дают доменную ошибку, а несуществующая — отказ базы,
// неотличимый от настоящей аварии хранилища. Держалась бы эта
// неразличимость только тем, что транспорт кладёт в `404` любую ошибку,
// — то есть ровно тем расхождением, которое конвенция велит закрыть.
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{Message: "job not found"}
}
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
}
if record.GetString("owner") != ownerID {
return nil, &contract.JobNotFoundError{Message: "job not found"}
}
return recordToJob(record), nil
}
// Колонки, которые читает захват. Список нужен запросу дословно: `RETURNING *`
// отдал бы и порядок, зависящий от схемы.
const acquireColumns = `id, state, owner, 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,425 +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 := readJobByID(t, app, 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 := readJobByID(t, app, 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 := readJobByID(t, app, 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 := readJobByID(t, app, 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 := readJobByID(t, app, 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 := readJobByID(t, app, 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, "правка владельца пережила сохранение шага")
}
// readJobByID читает задачу мимо сужения владельцем: проверки хранилища смотрят
// задачи без владельца, а читающий метод их не отдаёт никому. Отображение при
// этом то же самое — сверяется именно оно.
func readJobByID(t *testing.T, app core.App, id string) (*entity.TranscribeJob, error) {
t.Helper()
record, err := app.FindRecordById(migrations.JobsCollection, id)
if err != nil {
return nil, err
}
return recordToJob(record), nil
}
+154 -129
View File
@@ -174,186 +174,211 @@ func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) {
}
}
// Колонки очереди правятся в четырёх местах пакета хранилища плюс шаг схемы, и
// компилятор видит два из них (инвариант CLAUDE.md, «Инварианты», major).
// Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата
// нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения
// поле теряется только у задачи, попавшей к воркеру.
// Перечень колонок аудиозаписи компилятор не видит: их пишет `applyOwnedByPipeline`,
// читает `recordToAudioRecord`, и заводит шаг схемы. Колонка, забытая в паре
// «пишем — читаем», теряется молча: запись, прочитанная не тем путём, приезжает
// с нулевым полем, и первое же сохранение пишет этот ноль поверх значения.
//
// Правила ниже закрывают все четыре места плюс шаг схемы: перечень запроса,
// структуру захвата, запись коллекции (`applyToRecord`/`recordToJob`) и перенос
// поля в задачу (`toJob`). Литерал колонки ищется **в телах** нужных функций, а
// не в файле: файл держит и структуру с тегами `db:"…"`, и по ней условие
// выполнялось бы само собой.
// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет
// колонки поимённо, а возвращает идентификатор и признак своего захвата. Правила
// ниже держат оставшуюся пару плюс шаг схемы.
const (
repoPkg = "internal/adapter/repo/pocketbase"
acquireFile = repoPkg + "/transcript_job_repo.go"
mappingFile = repoPkg + "/job_mapping.go"
mappingFile = repoPkg + "/record_mapping.go"
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}
func TestПереченьЗахватаСовпадаетСоСтруктурой(t *testing.T) {
query := acquireColumnNames(t)
row := rowColumnNames(t)
func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) {
written := writtenColumns(t)
read := readColumns(t)
for _, col := range query {
if !row[col] {
for col := range written {
if storageOwned[col] {
continue
}
if !read[col] {
t.Errorf(
"колонка %q есть в acquireColumns, но не в acquiredRow: из захвата "+
"она приедет нулевой, и первый Save затрёт сохранённое значение",
"колонку %q пишет отображение записи, но recordToAudioRecord её не "+
"читает: запись приедет из хранилища без этого поля",
col,
)
}
delete(row, col)
}
for col := range row {
t.Errorf(
"колонка %q есть в acquiredRow, но не в acquireColumns: запрос её не "+
"читает, и поле остаётся нулевым",
col,
)
for col := range read {
if storageOwned[col] {
continue
}
if !written[col] {
t.Errorf(
"колонку %q читает recordToAudioRecord, но её не пишет ни "+
"applyOwnedByPipeline, ни applyToRecord: поле не сохранится",
col,
)
}
}
}
func TestКолонкиЗахватаЗаведеныШагомСхемы(t *testing.T) {
func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) {
declared := schemaFieldNames(t)
for _, col := range acquireColumnNames(t) {
if col == "id" {
continue // ключ заводит само хранилище, шаг схемы его не объявляет
for col := range writtenColumns(t) {
if storageOwned[col] {
continue
}
if !declared[col] {
t.Errorf(
"колонка %q читается захватом, но ни один шаг схемы её не заводит: "+
"запрос отвалится на живой базе",
"колонка %q пишется отображением записи, но ни один шаг схемы её не "+
"заводит: сохранение отвалится на живой базе",
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(")
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{}
for _, m := range regexp.MustCompile("`db:\"([^\"]+)\"`").FindAllStringSubmatch(rowStruct(t), -1) {
for _, m := range regexp.MustCompile(`record\.Set\("([^"]+)"`).FindAllStringSubmatch(body, -1) {
out[m[1]] = true
}
if len(out) == 0 {
t.Fatalf("у acquiredRow не прочитан ни один тег db: правило потеряло предмет")
t.Fatalf("отображение записи не пишет ни одной колонки: правило потеряло предмет")
}
return out
}
// rowFieldNames достаёт имена полей структуры `acquiredRow` — те, к которым
// обращается `toJob`.
func rowFieldNames(t *testing.T) []string {
// readColumns — колонки, которые читает обратное отображение.
func readColumns(t *testing.T) map[string]bool {
t.Helper()
var out []string
for _, m := range regexp.MustCompile(`(?m)^\t([A-Z]\w*)\s`).FindAllStringSubmatch(rowStruct(t), -1) {
out = append(out, m[1])
body := funcBody(t, mappingFile, "func recordToAudioRecord(")
out := map[string]bool{}
for _, m := range regexp.MustCompile(`\.Get\w+\("([^"]+)"\)`).FindAllStringSubmatch(body, -1) {
out[m[1]] = true
}
if len(out) == 0 {
t.Fatalf("у acquiredRow не прочитано ни одно поле: правило потеряло предмет")
t.Fatalf("recordToAudioRecord не читает ни одной колонки: правило потеряло предмет")
}
return out
}
// rowStruct — текст объявления структуры `acquiredRow`.
func rowStruct(t *testing.T) string {
// stageIdents — имена констант рубежей, перечисленных дескриптором.
func stageIdents(t *testing.T) []string {
t.Helper()
body := readFile(t, mappingFile)
start := strings.Index(body, "type acquiredRow struct {")
out, _ := stageDescriptor(t)
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 {
t.Fatalf("в %s нет структуры acquiredRow: правило потеряло предмет", mappingFile)
t.Fatalf("в %s нет дескриптора рубежей: правило потеряло предмет", stageFile)
}
end := strings.Index(body[start:], "\n}")
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 — текст тела функции от её заголовка до закрывающей скобки в первой
// позиции строки. Пропавший заголовок — отказ, а не пустое тело: правило,
// потерявшее предмет, обязано краснеть, а не зеленеть.
+50
View File
@@ -7,18 +7,59 @@ import (
"os"
"sort"
"strings"
"time"
"github.com/BurntSushi/toml"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
type Config struct {
Server ServerConfig `toml:"server"`
Storage StorageConfig `toml:"storage"`
Pipeline PipelineConfig `toml:"pipeline"`
Yandex YandexConfig `toml:"yandex"`
Telegram TelegramConfig `toml:"telegram"`
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 {
Port int `toml:"port"`
ShutdownTimeout int `toml:"shutdown_timeout"`
@@ -143,6 +184,15 @@ func defaultConfig() *Config {
Storage: StorageConfig{
DataDir: "data",
},
Pipeline: PipelineConfig{
Workers: 3,
// Час на свою работу и сутки на чужую. Час меньше времени, которое
// многочасовая запись занимает на приведении, и это принято
// сознательно: сторож ловит зависание, живой шаг наблюдается по
// самому процессу, а остановка обратима.
OwnWorkLimitMinutes: 60,
ForeignWorkLimitMinutes: 24 * 60,
},
Yandex: YandexConfig{
FolderID: "",
SpeechKitAPIKey: "",
+69
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
// Проверка входа — единственная страховка от того, чтобы сервис поднялся с
@@ -274,3 +275,71 @@ func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) {
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
[telegram]
enabled = false
`)
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, "[telegram]\nenabled = false\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 -3
View File
@@ -24,10 +24,39 @@ type AudioFileConverter interface {
Convert(ctx context.Context, src, dest string) error
}
// AudioRecognizer — внешний распознаватель речи.
//
// Заливка и отправка операции разделены: это два обращения с разной ценой
// повтора. Повтор заливки бесплатен и кладёт объект под тем же ключом; повтор
// отправки оплачивается наружу, и шаг обязан проверить сделанное прежде, чем
// платить второй раз.
//
// Результат отдаётся **доменным** — реплики со временем, плоский текст и байты
// ответа на хранение, — а не сырым форматом провайдера: разбор потока это
// обязанность адаптера, и ни один шаг конвейера не знает, каким потоком и какими
// полями провайдер отвечает.
type AudioRecognizer interface {
Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error)
GetRecognitionText(ctx context.Context, operationID string) (string, error)
CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error)
// Provider — имя провайдера, под которым сохраняется попытка.
Provider() string
// Model — имя модели распознавания.
Model() string
// Upload кладёт аудио туда, откуда провайдер его прочитает, и отдаёт адрес.
// Повтор кладёт объект под тем же ключом и оплаты не стоит.
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)
}
type TelegramMessageSender interface {
+85 -26
View File
@@ -2,7 +2,6 @@ package contract
import (
"io"
"time"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
@@ -23,6 +22,14 @@ type WorkFile interface {
Close() error
}
// FileMeta — что известно о копии сверх её содержимого.
type FileMeta struct {
// Format — расширение без точки, в нижнем регистре.
Format string
// DurationMs — длительность, если её удалось прочитать.
DurationMs int64
}
type FileRepository interface {
// Stage принимает содержимое потоком в рабочую копию с заданным
// расширением: по нему внешняя программа выбирает разбор. В память запись
@@ -33,44 +40,96 @@ type FileRepository interface {
StageEmpty(ext string) (WorkFile, error)
// Localize выдаёт рабочую копию хранимого файла.
Localize(fileID string) (WorkFile, error)
// CreateLocal кладёт рабочую копию в хранилище под именем name и заводит
// запись о файле. Имя задаёт сервис: умолчание хранилища, строящее его из
// имени отправителя, не применяется.
// Create кладёт рабочую копию в хранилище под именем name и заводит запись о
// файле. Имя задаёт сервис: умолчание хранилища, строящее его из имени
// отправителя, не применяется.
//
// ownerID — владелец записи, которой файл принадлежит; пустой значит «файл
// без владельца», и таков всякий файл записи, принятой ботом. Владелец
// лежит своей колонкой, а не выводится через задачу: ссылку на файл в
// задаче переставляет каждый шаг конвейера, и исходная копия после
// конвертации не связана с задачей ничем.
CreateLocal(name string, work WorkFile, ownerID string) (*entity.File, error)
// CreateRemote заводит запись о копии, лежащей во внешнем хранилище.
CreateRemote(objectKey string, size int64, ownerID string) (*entity.File, error)
// лежит своей колонкой, а не выводится через запись: файл переживает свою
// запись — шаг заводит его до сохранения, и потерянный захват оставляет файл
// с владельцем и без ссылки.
Create(name string, work WorkFile, meta FileMeta, ownerID string) (*entity.File, error)
GetByID(id string) (*entity.File, error)
// Open отдаёт содержимое хранимого файла потоком.
Open(fileID string) (io.ReadCloser, error)
}
type TranscriptJobRepository interface {
Create(job *entity.TranscribeJob) error
// Save сохраняет задачу, захват которой держит holder. Захват, доставшийся
// AcquiredRecord — то, что отдаёт захват: идентификатор записи и признак
// **этого** захвата.
//
// Перечня колонок здесь нет намеренно. Захват, возвращавший колонки поимённо,
// требовал править их в четырёх местах сразу, и забытая колонка приезжала
// нулевой, а первое же сохранение писало этот ноль поверх значения. Колонки шаг
// читает обычным чтением.
type AcquiredRecord struct {
ID string
// Holder — значение, уникальное для каждого захвата. Запись результата
// условна по нему, а не по занятости записи: захват, перевыданный другому по
// протуханию срока или после снятия остановки человеком, обязан обратить
// запись первого в отказ.
Holder string
}
type AudioRecordRepository interface {
Create(record *entity.AudioRecord) error
// Save сохраняет запись, захват которой держит holder. Захват, доставшийся
// за время работы другому, даёт LostAcquisitionError и запись не проводит.
// Пустой holder снимает эту условность и в конвейере не употребляется: все
// его шаги получают признак захвата от FindAndAcquire.
Save(job *entity.TranscribeJob, holder string) error
// GetByID отдаёт задачу, только если её владелец — ownerID. Чужая задача,
// ничья задача и несуществующая дают одну и ту же ошибку: по разнице
// ответов иначе перебирается список заведённых задач.
Save(record *entity.AudioRecord, holder string) error
// GetByID отдаёт запись, только если её владелец — ownerID. Чужая запись,
// ничья и несуществующая дают одну и ту же ошибку: по разнице ответов иначе
// перебирается список заведённых записей.
//
// Владелец здесь обязателен, и пустой ownerID не совпадает ни с чем —
// включая задачи без владельца. Правило записано со стороны спрашивающего:
// включая записи без владельца. Правило записано со стороны спрашивающего:
// обязательность, которую держит одна лишь подпись метода, пустую строку
// пропускает, и вызывающий без учётной записи получил бы ровно множество
// записей бота.
// пропускает.
GetByID(id, ownerID string) (*entity.AudioRecord, error)
// Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья
// выборка владельцем не сужается.
Get(id string) (*entity.AudioRecord, error)
// FindAndAcquire забирает пригодную к работе запись одним неделимым шагом и
// увеличивает число её отказов. Отбор идёт по рабочим рубежам, паузе, сроку
// протухания захвата и отсутствию признака остановки; срок протухания
// приезжает с рубежом и пишется в саму запись.
//
// Второго читающего метода нет намеренно: он выбирался бы по
// внимательности вызывающего.
GetByID(id, ownerID string) (*entity.TranscribeJob, error)
// FindAndAcquire забирает задачу одним неделимым шагом и увеличивает число
// её попыток. Работы в состоянии нет — JobNotFoundError.
FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error)
// Работы нет — 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)
assert.Empty(t, files)
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
require.NoError(t, err)
assert.Empty(t, jobs)
})
@@ -64,7 +64,7 @@ func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
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.Len(t, jobs, 1)
+1 -1
View File
@@ -188,7 +188,7 @@ func TestLoginCreatesAccountAndSession(t *testing.T) {
r, err := apis.NewRouter(env.app)
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()
require.NoError(t, err)
checkMux.ServeHTTP(checkResponse, check)
+15 -11
View File
@@ -12,6 +12,9 @@ import (
"github.com/stretchr/testify/require"
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/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
@@ -74,7 +77,7 @@ func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) {
assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же")
// Ни состояния, ни текста расшифровки в теле нет.
assert.NotContains(t, foreign.Body.String(), entity.StateCreated)
assert.NotContains(t, foreign.Body.String(), entity.StateUploaded)
assert.NotContains(t, foreign.Body.String(), "transcription_text")
}
@@ -88,15 +91,16 @@ func TestGetTranscribeJobStatus_OwnerlessJobLooksMissing(t *testing.T) {
defer func() { require.NoError(t, work.Close()) }()
// Файл записи из Telegram владельца тоже не имеет.
file, err := fileRepo.CreateLocal("voice.ogg", work, "")
file, err := fileRepo.Create("voice.ogg", work, contract.FileMeta{Format: "ogg"}, "")
require.NoError(t, err)
job := &entity.TranscribeJob{
State: entity.StateCreated,
Source: entity.SourceTelegram,
FileID: &file.Id,
job := &entity.AudioRecord{
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
Source: entity.SourceTelegram,
OriginalFileID: &file.Id,
}
require.NoError(t, env.handler.jobRepo.Create(job))
require.NoError(t, env.handler.recordRepo.Create(job))
w := httptest.NewRecorder()
env.serve(w, httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody))
@@ -116,11 +120,11 @@ func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
record, err := env.app.FindRecordById("transcribe_jobs", response.JobID)
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("file"))
fileRecord, err := env.app.FindRecordById("files", record.GetString("original_file"))
require.NoError(t, err)
assert.Equal(t, env.account.Id, fileRecord.GetString("owner"), "владелец файла — он же")
}
@@ -143,7 +147,7 @@ func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
record, err := env.app.FindRecordById("transcribe_jobs", response.JobID)
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.JobID)
require.NoError(t, err)
assert.Equal(t, env.account.Id, record.GetString("owner"))
}
@@ -187,7 +191,7 @@ func TestFileDownload_NarrowedByOwner(t *testing.T) {
job := jobWithFile(t, env)
_, stranger := newSecondAccount(t, env.app)
record, err := env.app.FindRecordById("files", *job.FileID)
record, err := env.app.FindRecordById("files", *job.OriginalFileID)
require.NoError(t, err)
require.Equal(t, env.account.Id, record.GetString("owner"))
+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",
"владелец сервиса узнаёт об аварии из журнала")
}
+59 -26
View File
@@ -18,16 +18,22 @@ import (
)
type TranscribeHandler struct {
jobRepo contract.TranscriptJobRepository
recordRepo contract.AudioRecordRepository
textRepo contract.TextRepository
trsService *service.TranscribeService
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 {
logger = slog.Default()
}
return &TranscribeHandler{jobRepo: jobRepo, trsService: trsService, logger: logger}
return &TranscribeHandler{recordRepo: recordRepo, textRepo: textRepo, trsService: trsService, logger: logger}
}
type CreateTranscribeJobResponse struct {
@@ -35,16 +41,27 @@ type CreateTranscribeJobResponse struct {
State string `json:"status"`
}
// GetTranscribeJobResponse — ответ об одной записи.
//
// Имена полей нормативны и остались прежними: контракт HTTP API объявлен
// проектом необратимым, и переименование поля ломает внешнюю программу молча.
// Изменились **значения** поля состояния — рубеж теперь называет достигнутое, — и
// это объявленная ломка.
//
// Поле `halted` новое: отказ перестал быть состоянием, и без него остановленная
// запись выглядела бы как обычная, стоящая на своём рубеже. Машинный текст
// отказа в ответ не идёт: он принадлежит журналу владельца сервиса.
type GetTranscribeJobResponse struct {
JobID string `json:"job_id"`
State string `json:"status"`
Halted bool `json:"halted"`
CreatedAt time.Time `json:"created_at"`
TranscriptionText *string `json:"transcription_text,omitempty"`
}
// Register вешает маршруты сервиса на роутер хранилища. Порт у сервиса и у
// панели один, поэтому и роутер один; имена полей ответа и коды при переезде
// сохранены — публичный контракт API объявлен необратимым.
// сохранены — публичный контракт HTTP API объявлен необратимым.
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
api := r.Group("/api")
@@ -80,18 +97,18 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
}
}()
// Запись доехала целиком, поэтому задача заводится независимо от того,
// дождётся ли отправитель ответа: на контексте запроса приём терял бы
// полностью загруженную запись от одного обрыва соединения, а забрать
// результат он может и позже — по `GET /status/{id}`. Значения контекста
// (журнал запроса, сессия) при этом сохраняются, теряется только отмена.
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
// ли отправитель ответа: на контексте запроса приём терял бы полностью
// загруженную запись от одного обрыва соединения, а забрать результат он
// может и позже — по `GET /status/{id}`. Значения контекста (журнал запроса,
// сессия) при этом сохраняются, теряется только отмена.
ctx := context.WithoutCancel(e.Request.Context())
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
// чужое имя. Проверка предъявителя стоит слоем выше, поэтому здесь `e.Auth`
// уже есть и принадлежит коллекции пользователей.
job, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
if err != nil {
// Второй раз отказ не логируем: приём назван конвенцией логирующей
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
@@ -100,37 +117,53 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
// Возвращаем успешный ответ
return e.JSON(http.StatusCreated, CreateTranscribeJobResponse{
JobID: job.Id,
State: job.State,
JobID: record.Id,
State: record.State,
})
}
func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
jobID := e.Request.PathValue("id")
recordID := e.Request.PathValue("id")
// Чужая задача, задача без владельца и несуществующая отвечают одним и тем
// Чужая запись, запись без владельца и несуществующая отвечают одним и тем
// же: хранилище отдаёт на все три ту же ошибку, а транспорт — тот же код и
// то же тело. Различать их наружу нельзя — по разнице ответов перебирается
// список заведённых задач.
job, err := h.jobRepo.GetByID(jobID, e.Auth.Id)
// список заведённых записей.
record, err := h.recordRepo.GetByID(recordID, e.Auth.Id)
if err != nil {
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
// «Задачи нет» и «задача чужая» — штатная работа разграничения, о ней
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
// приходит отправителю как «вашей записи нет», а владелец сервиса об
// аварии не узнаёт ниоткуда. Журнал читает владелец, а не тот, кто
// перебирает, поэтому различать их здесь можно.
// аварии не узнаёт ниоткуда.
var notFound *contract.JobNotFoundError
if !errors.As(err, &notFound) {
h.logger.Error("Failed to read transcribe job", "error", err, "job_id", jobID)
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.StatusOK, GetTranscribeJobResponse{
JobID: job.Id,
State: job.State,
CreatedAt: job.CreatedAt,
TranscriptionText: job.TranscriptionText,
})
response := GetTranscribeJobResponse{
JobID: record.Id,
State: record.State,
Halted: record.IsHalted(),
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)
}
+40 -29
View File
@@ -14,6 +14,7 @@ import (
"strings"
"sync"
"testing"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
@@ -24,6 +25,7 @@ import (
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
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/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service"
@@ -167,8 +169,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
pbrepo.BindPanelRules(app)
fileRepo := pbrepo.NewFileRepository(app)
jobRepo := pbrepo.NewTranscriptJobRepository(app)
recordRepo := pbrepo.NewAudioRecordRepository(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-строки ветки
@@ -178,16 +188,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
logger := slog.New(slog.NewTextHandler(journal, nil))
trsService := service.NewTranscribeService(
jobRepo,
fileRepo,
repos,
metaviewer,
&stubConverter{},
&recognizer.MemoryAudioRecognizer{},
&TestTgSender{},
entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour},
logger,
)
handler := NewTranscribeHandler(jobRepo, trsService, logger)
handler := NewTranscribeHandler(recordRepo, textRepo, trsService, logger)
// Роутер собирается тем же способом, что и боевой: маршруты вешает сам
// обработчик, и проверка судит ту же цепочку, что и прод.
@@ -256,16 +266,16 @@ func countFiles(t *testing.T, env *testEnv) int {
return len(records)
}
// countJobs считает заведённые задачи расшифровки.
// countJobs считает заведённые аудиозаписи.
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)
return len(records)
}
// jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна
// схемой, потому что без неё задача не пройдёт ни одного шага.
func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob {
func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord {
t.Helper()
repo := pbrepo.NewFileRepository(env.app)
@@ -276,17 +286,18 @@ func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob {
// Владелец — учётная запись проверки: задача, пришедшая из веба, без
// владельца больше не заводится, и фикстура без него описывала бы состояние,
// которого в проде не бывает.
file, err := repo.CreateLocal("sample.mp3", work, env.account.Id)
file, err := repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, env.account.Id)
require.NoError(t, err)
job := &entity.TranscribeJob{
State: entity.StateCreated,
Source: entity.SourceApi,
OwnerID: &env.account.Id,
FileID: &file.Id,
record := &entity.AudioRecord{
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
Source: entity.SourceApi,
OwnerID: &env.account.Id,
OriginalFileID: &file.Id,
}
require.NoError(t, env.handler.jobRepo.Create(job))
return job
require.NoError(t, env.handler.recordRepo.Create(record))
return record
}
// storedContent читает содержимое файла из хранилища.
@@ -327,21 +338,21 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
require.NoError(t, err)
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))
job, err := env.handler.jobRepo.GetByID(response.JobID, env.account.Id)
job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateCreated, job.State)
require.NotNil(t, job.FileID)
assert.NotEmpty(t, *job.FileID)
assert.Equal(t, entity.StateUploaded, job.State)
require.NotNil(t, job.OriginalFileID)
assert.NotEmpty(t, *job.OriginalFileID)
// Содержимое лежит в хранилище одним файлом и целиком.
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) {
@@ -404,7 +415,7 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
require.NoError(t, err)
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) {
@@ -513,7 +524,7 @@ const senderNameMarker = "SENDERNAMELEAKMARKER7Q2"
// долг `docs/conventions/logging.md`: `msg` обязан стать короткой категорией.
// Когда долг закроют, правка будет здесь и одна.
const (
msgIntake = "Creating transcribe job"
msgIntake = "Creating audio record"
// Отказ пишет доменная граница — приём, — а не транспорт: конвенция просит
// логировать ошибку один раз, и повторная запись транспорта снята.
msgIntakeErr = "Failed to get file info"
@@ -603,15 +614,15 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
job, err := env.handler.jobRepo.GetByID(response.JobID, env.account.Id)
job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id)
require.NoError(t, err)
require.NotNil(t, job.FileID)
require.NotNil(t, job.OriginalFileID)
// Отбор по идентификатору **этого** прогона: иначе утверждение прошло бы по
// строке, оставленной соседней проверкой, и прослеживаемость числилась бы
// сохранённой при пустом журнале.
journal := env.journal.String()
assert.Contains(t, journal, *job.FileID, "по журналу видно, какой файл заведён")
assert.Contains(t, journal, *job.OriginalFileID, "по журналу видно, какой файл заведён")
assert.Contains(t, journal, ".mp3", "расширение принятой записи в журнале остаётся")
// Разделитель ключа и значения задаёт обработчик: сегодня текстовый, по
@@ -698,7 +709,7 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) {
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
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)
}
@@ -761,7 +772,7 @@ func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
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)
assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя")
}
+9 -13
View File
@@ -16,7 +16,6 @@ import (
// адаптера» правилом не держится и уже нарушено 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"
)
@@ -24,7 +23,6 @@ import (
type TelegramController struct {
// deps
transcribeService *service.TranscribeService
jobRepo contract.TranscriptJobRepository
logger *slog.Logger
// params
bot *tgbotapi.BotAPI
@@ -44,7 +42,6 @@ func NewTelegramController(
config TelegramConfig,
bot *tgbotapi.BotAPI,
transcribeService *service.TranscribeService,
jobRepo contract.TranscriptJobRepository,
logger *slog.Logger,
) (*TelegramController, error) {
if bot == nil {
@@ -54,7 +51,6 @@ func NewTelegramController(
controller := &TelegramController{
bot: bot,
transcribeService: transcribeService,
jobRepo: jobRepo,
logger: logger,
updateTimeout: config.UpdateTimeout,
userWhiteList: config.UserWhiteList,
@@ -178,16 +174,16 @@ func (c *TelegramController) handleAudioMessage(ctx context.Context, message *tg
defer fileReader.Close()
// Обрабатываем файл
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
record, 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)
c.logger.Error("Failed to create audio record", "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 := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", record.Id))
successMsg.ReplyToMessageID = message.MessageID
c.send(successMsg)
}
@@ -213,16 +209,16 @@ func (c *TelegramController) handleVoiceMessage(ctx context.Context, message *tg
defer fileReader.Close()
// Обрабатываем файл
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
record, 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)
c.logger.Error("Failed to create audio record", "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 := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", record.Id))
successMsg.ReplyToMessageID = message.MessageID
c.send(successMsg)
}
@@ -253,16 +249,16 @@ func (c *TelegramController) handleDocumentMessage(ctx context.Context, message
defer fileReader.Close()
// Обрабатываем файл
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
record, 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)
c.logger.Error("Failed to create audio record", "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 := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", record.Id))
successMsg.ReplyToMessageID = message.MessageID
c.send(successMsg)
}
+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 (
"context"
"errors"
"fmt"
"log/slog"
"strconv"
"sync"
"time"
"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 — пауза между прогонами шага. Полем, а не константой по месту:
// проверке нужен второй прогон, чтобы остановить воркер **после** того, как он
// рассудил об исходе первого. Отменять контекст изнутри шага она не может —
// отменённый контекст теперь и значит «нас остановили».
const pollInterval = time.Second
// CallbackWorker крутит один и тот же шаг, опрашивая очередь.
//
// Специализации у него нет: шаг сам берёт любую пригодную к работе запись и
// выбирает работу по её рубежу. Раньше воркеров было три именованных, по одному
// на состояние, и каждый новый рубеж требовал четвёртого.
type CallbackWorker struct {
name string
// Шаг принимает контекст воркера: остановка обязана доходить до чужой
@@ -64,15 +63,12 @@ func (w *CallbackWorker) Start(ctx context.Context) {
// первой же обёртки `%w`, которая в проекте — умолчание.
var noop *contract.NoopJobError
isNoop := errors.As(err, &noop)
// Остановка — не отказ шага: контекст отменили мы сами. Считать её
// в метрику и писать владельцу «Worker error» значит красить каждую
// выкладку как поломку — по тому же доводу, по которому не считается
// Остановка — не отказ шага: контекст отменили мы сами. Писать
// владельцу «Worker error» значит красить каждую выкладку как
// поломку — по тому же доводу, по которому не считается
// `NoopJobError`. Судит контекст, а не текст ошибки: убитый процесс
// отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
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 {
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())
}
// Счётчик работы растит сам шаг: только он знает рубеж, с которого
// взята запись, а воркер к рубежу больше не привязан.
// Ждем перед следующей итерацией
select {
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"
"git.vakhrushev.me/av/transcriber/internal/contract"
"github.com/prometheus/client_golang/prometheus"
)
// Проверки этого файла судят одну развилку воркера: пустой прогон против
// отказа. Инвариант проекта — «NoopJobError не ошибка» — стоит ровно на ней, а
// цена срабатывания отложенная: три воркера опрашивают базу раз в секунду, и
// пустой прогон, принятый за отказ, даёт три записи в секунду и столько же
// цена срабатывания отложенная: воркеры опрашивают базу раз в секунду, и пустой
// прогон, принятый за отказ, даёт запись в секунду с каждого и столько же
// засчитанных сбоев, которых не было.
//
// Счёт работы здесь не судится: он переехал в шаг конвейера вместе с меткой
// рубежа. Воркер к рубежу не привязан и назвать его не может, а метка,
// выведенная из имени потока, перестала что-либо значить с появлением пула.
// journalBuffer собирает журнал прогона. Пишут в него из горутины воркера, а
// читает проверка — отсюда мьютекс.
@@ -113,39 +116,6 @@ func runRecords(journal string) string {
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` объявлена конвенцией проекта умолчанием, и до этой задачи первая
// же обёртка на пути сломала бы распознавание молча. Оракул держит именно
// обёрнутое значение: на голом признак узнавался и приведением типа, то есть
@@ -153,11 +123,8 @@ func jobCount(t *testing.T, worker, errLabel string) float64 {
func TestWrappedNoopIsNotAFailure(t *testing.T) {
const name = "wrapped_noop_worker"
before := jobCount(t, name, "false")
beforeErr := jobCount(t, name, "true")
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 != "" {
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"
before := jobCount(t, name, "true")
journal := runOnce(t, name, func(context.Context) error {
return errors.New("database is gone")
})
@@ -187,42 +146,28 @@ func TestFailureIsLoggedAndCounted(t *testing.T) {
if !strings.Contains(journal, "database is gone") {
t.Errorf("отказ не виден владельцу: журнал %q", journal)
}
if got := jobCount(t, name, "true"); got != before+1 {
t.Errorf("отказ не засчитан: было %v, стало %v", before, got)
}
}
// Счёт успешных прогонов — знаменатель доли отказов. Реализация, снявшая его,
// проходит обе проверки выше, а владелец теряет способность отличить «три
// прогона в секунду, все отказали» от «три отказа среди тысячи прогонов».
func TestSuccessIsCounted(t *testing.T) {
// Успешный прогон отказом не записывается.
func TestSuccessIsNotLoggedAsFailure(t *testing.T) {
const name = "successful_worker"
before := jobCount(t, name, "false")
journal := runOnce(t, name, func(context.Context) error {
return nil
})
if got := jobCount(t, name, "false"); got != before+1 {
t.Errorf("успешный прогон не засчитан: было %v, стало %v", before, got)
}
if strings.Contains(journal, "Worker error") {
t.Errorf("успешный прогон записан отказом: журнал %q", journal)
}
}
// Остановка сервиса — не отказ шага: контекст отменили мы сами. Без этой
// развилки каждая выкладка красит журнал владельца отказами и накручивает
// счётчик сбоев, которых не было, — тот же довод, по которому не считается
// `NoopJobError`. Судит контекст, а не текст ошибки: убитый по контексту
// развилки каждая выкладка красит журнал владельца отказами, — тот же довод, по
// которому не пишется `NoopJobError`. Судит контекст, а не текст ошибки: убитый по контексту
// процесс отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
func TestShutdownIsNotAFailure(t *testing.T) {
const name = "stopped_worker"
beforeErr := jobCount(t, name, "true")
beforeOk := jobCount(t, name, "false")
journal := &journalBuffer{}
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") {
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)
}
}
+196
View File
@@ -0,0 +1,196 @@
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"
)
// AudioRecord — аудиозапись, центральная сущность сервиса.
//
// Приложения к ней — файлы, тексты, структура реплик, темы, журнал событий и
// попытка распознавания — живут своими строками и адресуются ссылками. Поля
// очереди соседствуют с доменом, но не с содержимым: расшифровка лежит строкой
// `texts`, и чтение очереди её не тянет.
type AudioRecord struct {
Id string
// OwnerID — учётная запись, от имени которой запись принята. Пуст у записей
// из Telegram: связи чата с учётной записью сервис не ведёт. Назначается
// один раз, при приёме, и конвейером не меняется.
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
TgChatId *int64
TgReplyMessageId *int
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 ГиБ
// File — одна физическая копия: исходник, результат конвертации и копия во
// внешнем хранилище — три разные записи.
// File — одна физическая копия записи. Их ровно две: принятая и приведённая к
// рабочему формату. Копия во внешнем хранилище файлом записи не считается — она
// существует только потому, что провайдер читает аудио по адресу, и её ключ
// живёт в строке попытки распознавания.
type File struct {
Id string
Location string
// FileName — имя, под которым файл лежит: у местной копии это имя, заданное
// сервисом, у внешней — ключ объекта. Своего суффикса хранилище к заданному
// имени не дописывает: суффикс появляется только у имён, которые оно строит
// само из имени отправителя, а это умолчание не применяется.
FileName string
Size int64
CreatedAt time.Time
// FileName — имя, под которым файл лежит: имя задаёт сервис. Своего суффикса
// хранилище к заданному имени не дописывает: суффикс появляется только у
// имён, которые оно строит само из имени отправителя, а это умолчание не
// применяется.
FileName string
Size int64
// Format — расширение без точки, приведённое к нижнему регистру. Наружу оно
// выходит только через метку метрики, приведённую к перечню известных.
Format string
// DurationMs — длительность записи, если её удалось прочитать.
DurationMs int64
CreatedAt time.Time
}
-98
View File
@@ -1,98 +0,0 @@
package entity
import (
"time"
"git.vakhrushev.me/av/transcriber/internal/clock"
)
type TranscribeJob struct {
Id string
State string
// OwnerID — учётная запись, от имени которой запись принята. Пуст у записей
// из Telegram: связи чата с учётной записью сервис не ведёт. Назначается
// один раз, при приёме, и у записи, где он есть, больше не меняется.
OwnerID *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
// RecognitionStatus представляет статус операции транскрипции
import "time"
// RecognitionStatus представляет статус операции распознавания у провайдера.
type RecognitionStatus int
const (
@@ -26,7 +28,7 @@ func (s RecognitionStatus) String() string {
}
}
// RecognitionResult представляет результат операции транскрипции
// RecognitionResult представляет исход опроса операции распознавания.
type RecognitionResult struct {
Status RecognitionStatus
Error string // Текст ошибки (заполняется при StatusFailed)
@@ -76,3 +78,43 @@ func (r *RecognitionResult) GetError() string {
}
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
}
+7 -2
View File
@@ -6,12 +6,17 @@ import (
)
var (
// Работа конвейера с разрезом по **рубежу**, с которого взята запись, а не по
// имени потока. Воркеры одинаковы, и имя потока перестало что-либо значить;
// а счётчик отказов — единственный сигнал, по которому владелец сервиса
// замечает поломку, и без разреза по шагу «падает приведение» и «падает
// распознавание» стали бы неразличимы.
WorkerJobCounter = promauto.NewCounterVec(
prometheus.CounterOpts{
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"},
)
// Размер принятых на обработку файлов (в байтах)
+33 -29
View File
@@ -5,67 +5,71 @@ import (
"fmt"
"log/slog"
"testing"
"time"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Путь признака «работы нет» состоит из двух звеньев: репозиторий рождает
// «подходящей задачи не нашлось», сервис переводит это в «работы нет», и уже
// его читает воркер. Проверки воркера подменяют работу целиком и второе звено
// не видят — без этого файла правку в сервисе принимал бы только линтер, а он
// судит форму записи, а не то, узнаётся ли признак на самом деле.
// «пригодной записи не нашлось», сервис переводит это в «работы нет», и уже его
// читает воркер. Проверки воркера подменяют работу целиком и второе звено не
// видят — без этого файла правку в сервисе принимал бы только линтер, а он судит
// форму записи, а не то, узнаётся ли признак на самом деле.
// stubJobRepo отдаёт заданную ошибку на запрос задачи. Прочих методов запроса
// задачи проверки этого файла не зовут.
type stubJobRepo struct {
// stubRecordRepo отдаёт заданную ошибку на запрос записи. Прочих методов
// проверки этого файла не зовут.
type stubRecordRepo struct {
err error
}
func (r *stubJobRepo) Create(*entity.TranscribeJob) error { return nil }
func (r *stubJobRepo) Save(*entity.TranscribeJob, string) error { return nil }
func (r *stubRecordRepo) Create(*entity.AudioRecord) error { return nil }
func (r *stubRecordRepo) Save(*entity.AudioRecord, string) error { return nil }
func (r *stubJobRepo) GetByID(string, string) (*entity.TranscribeJob, error) {
func (r *stubRecordRepo) GetByID(string, string) (*entity.AudioRecord, error) {
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
}
func serviceWithRepo(repo contract.TranscriptJobRepository) *TranscribeService {
logger := slog.New(slog.DiscardHandler)
return NewTranscribeService(repo, nil, nil, nil, nil, nil, logger)
func serviceWithRepo(repo contract.AudioRecordRepository) *TranscribeService {
return NewTranscribeService(
Repositories{Records: repo},
nil, nil, nil, nil,
entity.StuckLimits{},
slog.New(slog.DiscardHandler),
)
}
// Репозиторий вправе добавить своему отказу пояснение — соседние ветки того же
// метода уже оборачивают ошибки `%w` подряд. Пока признак узнавался приведением
// типа, первая такая обёртка превратила бы пустой прогон в отказ: воркер начал
// бы писать в журнал раз в секунду на каждом из трёх воркеров.
func TestFindJobTranslatesWrappedNotFoundToNoop(t *testing.T) {
svc := serviceWithRepo(&stubJobRepo{
err: fmt.Errorf("find and acquire job: %w",
&contract.JobNotFoundError{State: "created", Message: "appropriate job not found"}),
// бы писать в журнал раз в секунду на каждом воркере пула.
func TestAcquireTranslatesWrappedNotFoundToNoop(t *testing.T) {
svc := serviceWithRepo(&stubRecordRepo{
err: fmt.Errorf("find and acquire record: %w",
&contract.JobNotFoundError{Message: "no record is ready for work"}),
})
_, _, err := svc.findJob("created", time.Minute)
_, _, err := svc.acquire()
var noop *contract.NoopJobError
if !errors.As(err, &noop) {
t.Fatalf("обёрнутое «задачи нет» не переведено в пустой прогон: получено %v", err)
}
if noop.State != "created" {
t.Errorf("состояние потеряно при переводе: %q", noop.State)
t.Fatalf("обёрнутое «работы нет» не переведено в пустой прогон: получено %v", err)
}
}
// Оборотная сторона: настоящий отказ хранилища пустым прогоном считаться не
// должен, иначе задача молча крутилась бы в цикле без единой записи.
func TestFindJobKeepsRealFailure(t *testing.T) {
svc := serviceWithRepo(&stubJobRepo{err: errors.New("database is gone")})
// должен, иначе запись молча крутилась бы в цикле без единой записи в журнале.
func TestAcquireKeepsRealFailure(t *testing.T) {
svc := serviceWithRepo(&stubRecordRepo{err: errors.New("database is gone")})
_, _, err := svc.findJob("created", time.Minute)
_, _, err := svc.acquire()
var noop *contract.NoopJobError
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 := newTelegramRecord(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")
newTelegramRecord(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)
}
+61 -36
View File
@@ -3,7 +3,6 @@ package service
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -16,7 +15,7 @@ import (
// показывать, а не кому её считать. Сужение остановило бы расшифровку записей
// бота вовсе, а записи остальных поставило бы в зависимость от того, кто первым
// завёл учётную запись.
func TestWorkerTakesJobsOfEveryOwner(t *testing.T) {
func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
first, err := env.service.CreateJobFromApi(t.Context(),
@@ -28,42 +27,48 @@ func TestWorkerTakesJobsOfEveryOwner(t *testing.T) {
require.NoError(t, err)
// Третья пришла ботом, и владельца у неё нет вовсе.
third := newTelegramJob(t, env)
third := newTelegramRecord(t, env)
// Срок протухания в прошлом: захваченная задача остаётся за держателем, и
// следующий вызов берёт следующую, а не ту же самую.
// Захваченная запись остаётся за держателем, и следующий вызов берёт
// следующую, а не ту же самую.
taken := map[string]bool{}
for _, holder := range []string{"one", "two", "three"} {
job, err := env.jobRepo.FindAndAcquire(entity.StateCreated, holder, time.Now().Add(-time.Hour))
require.NoError(t, err, "воркер берёт задачи подряд, владельцем не сужаясь")
taken[job.Id] = true
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[first.Id], "запись первого владельца досталась воркеру")
assert.True(t, taken[second.Id], "и второго")
assert.True(t, taken[third.Id], "и задача без владельца")
assert.True(t, taken[third.Id], "и запись без владельца")
_, err = env.jobRepo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(-time.Hour))
_, err = env.recordRepo.FindAndAcquire(entity.WorkingStages())
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing, "больше в этом состоянии никого")
assert.ErrorAs(t, err, &missing, "больше пригодных к работе записей нет")
}
// Захват читает владельца: снимок задачи, в котором он всегда пуст, был бы
// ловушкой для первого же шага, начавшего сохранять задачу целиком, — и сегодня
// уже ломал бы файл, который шаг заводит.
func TestAcquireCarriesOwner(t *testing.T) {
// Захват отдаёт идентификатор и признак своего захвата — не перечень колонок.
// Колонки шаг читает сам: иначе всякая новая колонка записи попадала бы под
// инвариант проекта о колонках очереди.
func TestAcquireReturnsIdentifierAndHolder(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
owner := newOwner(t, env.app)
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
require.NoError(t, err)
acquired, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
acquired, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
require.Equal(t, job.Id, acquired.Id)
require.NotNil(t, acquired.OwnerID, "владелец приехал из захвата")
assert.Equal(t, owner, *acquired.OwnerID)
assert.Equal(t, record.Id, acquired.ID, "захват назвал запись")
assert.NotEmpty(t, acquired.Holder, "и признак своего захвата")
// Колонки читаются отдельным чтением, и владелец среди них.
read := readRecord(t, env, record.Id)
require.NotNil(t, read.OwnerID)
assert.Equal(t, owner, *read.OwnerID)
assert.Equal(t, acquired.Holder, *read.AcquisitionID, "признак захвата записан в саму запись")
require.NotNil(t, read.AcquireExpiresAt, "срок протухания приехал с рубежом")
}
// Шаг конвейера владельца не затирает: сохранение кладёт только то, чем
@@ -72,34 +77,54 @@ func TestPipelineStepKeepsOwner(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
owner := newOwner(t, env.app)
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
require.NoError(t, err)
// Отказ конвертации — приговор записи: шаг переводит задачу в `failed` и
// сохраняет её. Это сохранение владельца тронуть не должно.
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
// Отказ приведения — приговор записи: шаг останавливает её и сохраняет. Это
// сохранение владельца тронуть не должно.
require.NoError(t, env.service.RunStep(t.Context()))
after, err := readJob(env.app, job.Id)
require.NoError(t, err)
require.Equal(t, entity.StateFailed, after.State, "шаг записал свой приговор")
after := readRecord(t, env, record.Id)
require.True(t, after.IsHalted(), "шаг записал свой приговор")
require.NotNil(t, after.OwnerID, "владелец пережил шаг")
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)
}
records, err := env.app.FindAllRecords("transcribe_jobs")
// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
// ответов иначе перебирается список заведённых записей.
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)
assert.Empty(t, records, "задачи не заведено")
// Запись без владельца — принятая ботом.
orphan := newTelegramRecord(t, env)
var missing *contract.JobNotFoundError
files, err := env.app.FindAllRecords("files")
require.NoError(t, err)
assert.Empty(t, files, "и файла тоже: отказ наступает раньше укладки")
_, err = env.recordRepo.GetByID(record.Id, stranger)
require.ErrorAs(t, err, &missing, "чужая запись неотличима от несуществующей")
_, err = env.recordRepo.GetByID(orphan.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)
}
+547 -210
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -24,10 +25,22 @@ import (
"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 отказывает на каждой попытке.
type failingConverter struct{}
@@ -47,26 +60,50 @@ func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInf
return nil, errors.New("запись не читается")
}
// recordingSender запоминает, что и куда отправлено.
// recordingSender запоминает, что отправлено.
type recordingSender struct {
mu sync.Mutex
messages []string
}
func (s *recordingSender) Send(text string, chatId int64, replyMsgId *int) error {
s.mu.Lock()
defer s.mu.Unlock()
s.messages = append(s.messages, text)
return nil
}
type pipelineEnv struct {
app core.App
service *TranscribeService
jobRepo *pbrepo.TranscriptJobRepository
fileRepo *pbrepo.FileRepository
sender *recordingSender
func (s *recordingSender) sent() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.messages...)
}
type pipelineEnv struct {
app core.App
service *TranscribeService
repos Repositories
recordRepo *pbrepo.AudioRecordRepository
fileRepo *pbrepo.FileRepository
sender *recordingSender
}
// testLimits — пределы простоя проверок. Числа боевые; проверка застревания
// двигает не их, а время входа записи в рубеж: сторож меряет именно его.
var testLimits = entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour}
func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter contract.AudioFileConverter) *pipelineEnv {
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()
app, err := pbrepo.New(t.TempDir())
require.NoError(t, err)
@@ -81,158 +118,410 @@ func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter
// нет.
pbrepo.BindPanelRules(app)
jobRepo := pbrepo.NewTranscriptJobRepository(app)
recordRepo := pbrepo.NewAudioRecordRepository(app)
fileRepo := pbrepo.NewFileRepository(app)
repos := Repositories{
Records: recordRepo,
Files: fileRepo,
Texts: pbrepo.NewTextRepository(app),
Structures: pbrepo.NewStructureRepository(app),
Recognitions: pbrepo.NewRecognitionRepository(app),
Events: pbrepo.NewRecordEventRepository(app),
}
sender := &recordingSender{}
svc := NewTranscribeService(
jobRepo,
fileRepo,
metaviewer,
converter,
&recognizer.MemoryAudioRecognizer{},
sender,
slog.New(slog.DiscardHandler),
)
svc := NewTranscribeService(repos, metaviewer, converter, rec, sender, testLimits, slog.New(slog.DiscardHandler))
return &pipelineEnv{app: app, service: svc, jobRepo: jobRepo, fileRepo: fileRepo, sender: sender}
return &pipelineEnv{
app: app,
service: svc,
repos: repos,
recordRepo: recordRepo,
fileRepo: fileRepo,
sender: sender,
}
}
// newTelegramJob заводит задачу с записью — так, как её завёл бы приём.
func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
// newTelegramRecord заводит запись — так, как её завёл бы приём из бота.
func newTelegramRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord {
t.Helper()
chatId := int64(100)
job, err := env.service.CreateJobFromTelegram(t.Context(), strings.NewReader("запись"), "voice.ogg", chatId, 1)
record, err := env.service.CreateJobFromTelegram(t.Context(), strings.NewReader("запись"), "voice.ogg", 100, 1)
require.NoError(t, err)
return job
return record
}
// clearDelay снимает паузу, чтобы следующий прогон взял задачу сразу: проверка
// судит счётчик попыток, а не то, умеет ли она ждать.
func clearDelay(t *testing.T, env *pipelineEnv, jobID string) {
// clearDelay снимает паузу, чтобы следующий прогон взял запись сразу.
func clearDelay(t *testing.T, env *pipelineEnv, recordID string) {
t.Helper()
record, err := env.app.FindRecordById(migrations.JobsCollection, jobID)
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
require.NoError(t, err)
record.Set("delay_time", "")
require.NoError(t, env.app.Save(record))
}
// rotAcquisition отодвигает время захвата так, чтобы он протух: так это
// выглядит, когда шаг оборвался вместе с процессом.
func rotAcquisition(t *testing.T, env *pipelineEnv, jobID string) {
// enteredStateAt отодвигает время входа записи в рубеж: так это выглядит, когда
// запись простояла в нём дольше предела.
func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time.Time) {
t.Helper()
record, err := env.app.FindRecordById(migrations.JobsCollection, jobID)
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
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))
}
// Задача, падающая на каждой попытке, уходит в «мертва»: из выборки исчезает,
// видна отбором по состоянию, а отправитель узнаёт о неудаче. Инвариант
// «Принятая запись не теряется молча» допускает два исхода, и молчаливая смерть
// не подходит ни под один.
func TestJobDiesAfterAttemptLimit(t *testing.T) {
// 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{})
job := newTelegramJob(t, env)
record := newTelegramRecord(t, env)
// Отказ конвертации переводит задачу в `failed` сразу, поэтому предел
// попыток проверяем на шаге, который отказывает *не* приговором: подменяем
// его отказом источника метаданных внутри самого шага конвертации нельзя, и
// вместо этого гоняем захват без выполнения шага — так же, как это выглядит
// при гибели процесса.
for i := 0; i < maxAttempts; i++ {
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
// Захват без выполнения шага — так это выглядит при гибели процесса: отказа
// шаг объявить не успевает, а попытка засчитана.
for range maxAttempts {
_, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
expireAcquisition(t, env, record.Id)
}
rotAcquisition(t, env, job.Id)
// Следующий захват видит перебор и хоронит задачу.
err := env.service.FindAndRunConversionJob(t.Context())
err := env.service.RunStep(t.Context())
var noop *contract.NoopJobError
require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся")
require.ErrorAs(t, err, &noop, "остановленная запись шагу не отдаётся")
after, err := readJob(env.app, job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDead, after.State, "задача видна отбором по состоянию")
assert.Greater(t, after.Attempts, maxAttempts, "число попыток сохранено")
after := readRecord(t, env, record.Id)
assert.True(t, after.IsHalted(), "запись остановлена признаком")
require.NotNil(t, after.HaltReason)
assert.Equal(t, entity.HaltReasonAttempts, *after.HaltReason)
assert.Equal(t, entity.StateUploaded, after.State, "рубеж пережил остановку")
assert.Greater(t, after.Attempts, maxAttempts, "число отказов сохранено")
require.Len(t, env.sender.messages, 1, "отправитель узнал о неудаче")
assert.Contains(t, env.sender.messages[0], "попытки исчерпаны")
require.Len(t, env.sender.sent(), 1, "отправитель узнал о неудаче")
assert.Contains(t, env.sender.sent()[0], "попытки исчерпаны")
// И из выборки она исчезла.
_, err = env.jobRepo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(time.Hour))
_, err = env.recordRepo.FindAndAcquire(entity.WorkingStages())
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing)
}
// Мёртвая задача возвращается в работу правкой состояния.
func TestDeadJobReturnsAfterStateEdit(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
// expireAcquisition отодвигает срок протухания захвата в прошлое: так это
// выглядит, когда шаг оборвался вместе с процессом.
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++ {
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
// Критерий приёмки 1. Остановленная на шаге запись перезапускается снятием
// признака и продолжает с того рубежа, где стояла, — следующим идёт отправка на
// распознавание, а не повторное приведение.
func TestHaltedRecordResumesFromItsStage(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newTelegramRecord(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 := newTelegramRecord(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)
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)
}
// Отказ шага не оставляет задачу захваченной до конца срока: захват снимается,
// и задача ждёт нарастающую паузу. Иначе повтор наступал бы через восемь часов.
func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
// Источник метаданных отказывает — это отказ шага, а не приговор записи.
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
// Критерий приёмки 3. Поведение записи не зависит от числа воркеров: при одном
// и при нескольких она доходит до конечного рубежа.
func TestOutcomeDoesNotDependOnWorkerCount(t *testing.T) {
for _, workers := range []int{1, 4} {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newTelegramRecord(t, env)
job := newTelegramJob(t, env)
for range 20 {
clearDelay(t, env, record.Id)
// Ссылку переставляем на запись без содержимого: шаг отказывает на получении
// рабочей копии — то есть отказом, а не приговором записи.
empty, err := env.fileRepo.CreateRemote("object-key", 1, "")
require.NoError(t, err)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
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)
require.NoError(t, err)
record.Set("file", empty.Id)
require.NoError(t, env.app.Save(record))
if readRecord(t, env, record.Id).State == entity.StateDone {
break
}
}
// Первый отказ.
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
after, err := readJob(env.app, 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 = readJob(env.app, job.Id)
require.NoError(t, err)
require.NotNil(t, after.DelayTime)
secondDelay := time.Until(*after.DelayTime)
assert.Greater(t, secondDelay, firstDelay, "вторая пауза длиннее первой")
after := readRecord(t, env, record.Id)
assert.Equalf(t, entity.StateDone, after.State, "запись дошла до конца при %d воркерах", workers)
assert.Falsef(t, after.IsHalted(), "запись не остановлена при %d воркерах", workers)
}
}
// Пауза растёт с числом попыток и упирается в потолок.
// Тот же критерий, вторая половина: нулевое число воркеров — законное значение.
// Записи принимаются и не двигаются, и это режим, а не поломка.
func TestZeroWorkersLeaveRecordUntouched(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newTelegramRecord(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 := newTelegramRecord(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, "рубеж сохранён")
require.Len(t, env.sender.sent(), 1, "отправитель узнал о неудаче")
assert.Contains(t, env.sender.sent()[0], "застряла")
}
// Конечный рубеж под предел простоя не подпадает: стоять в нём запись будет
// вечно по построению, и сторож остановил бы всякую доведённую запись.
func TestDoneRecordIsNeverStuck(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newTelegramRecord(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 := newTelegramRecord(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, "рубеж не сдвинут потерявшим захват")
assert.Empty(t, env.sender.sent(), "и отправителю от него ничего не ушло")
}
// Критерий приёмки 7. Всякий способ вывести запись из работы сообщает
// отправителю: причин остановки больше одной, и обязанность у них общая.
func TestEveryHaltReasonNotifiesSender(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)
},
},
}
for _, reason := range reasons {
t.Run(reason.name, func(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
record := newTelegramRecord(t, env)
reason.halt(t, env, record.Id)
after := readRecord(t, env, record.Id)
require.True(t, after.IsHalted(), "запись остановлена")
require.Len(t, env.sender.sent(), 1, "отправитель узнал о неудаче")
})
}
}
// Критерий приёмки 8. Повтор шага не создаёт второго приложения: пара «запись и
// вид» уникальна, и вопрос «какой текст отдавать человеку» не становится
// вопросом порядка записи.
func TestRepeatedStoreKeepsSingleText(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newTelegramRecord(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) {
assert.Equal(t, retryDelayBase, retryDelay(1))
assert.Equal(t, 2*retryDelayBase, retryDelay(2))
@@ -241,6 +530,42 @@ func TestRetryDelayGrowsAndCaps(t *testing.T) {
assert.Equal(t, retryDelayBase, retryDelay(0), "нулевая попытка не даёт нулевой паузы")
}
// Отказ шага не оставляет запись захваченной до конца срока: захват снимается,
// и запись ждёт нарастающую паузу. Иначе повтор наступал бы через часы.
func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
record := newTelegramRecord(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)
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) {
@@ -273,24 +598,43 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
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{})
job := newTelegramJob(t, env)
newTelegramRecord(t, env)
// Конвертация отказывает — задача уходит в `failed`, но ссылка остаётся на
// исходную запись, а не на несозданный результат.
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
after, err := readJob(env.app, job.Id)
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
require.NoError(t, err)
assert.Equal(t, entity.StateFailed, after.State)
require.NotNil(t, after.FileID)
require.Empty(t, leftovers, "приём убрал свою рабочую копию")
file, err := env.fileRepo.GetByID(*after.FileID)
require.NoError(t, err, "ссылка задачи ведёт на существующую запись о файле")
require.NoError(t, env.service.RunStep(t.Context()))
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 := newTelegramRecord(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)
}
@@ -300,11 +644,11 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
content := strings.Repeat("запись ", 1000)
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env.app))
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env.app))
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)
defer reader.Close()
@@ -312,22 +656,22 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, content, string(stored))
// И длина в учёте совпадает с длиной принятого.
file, err := env.fileRepo.GetByID(*job.FileID)
file, err := env.fileRepo.GetByID(*record.OriginalFileID)
require.NoError(t, err)
assert.Equal(t, int64(len(content)), file.Size)
assert.Equal(t, "mp3", file.Format, "формат копии записан")
}
// Рабочая копия хранимого файла отдаётся именем на диске — так её получают
// шаги, отдающие файл внешней программе.
// Рабочая копия хранимого файла отдаётся именем на диске — так её получают шаги,
// отдающие файл внешней программе.
func TestLocalizeGivesReadableCopy(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env.app))
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env.app))
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)
content, err := os.ReadFile(work.Path())
@@ -339,86 +683,10 @@ func TestLocalizeGivesReadableCopy(t *testing.T) {
assert.True(t, os.IsNotExist(err), "закрытая копия убрана")
}
// Уборка рабочей копии проверяется и на шаге конвертации: репозиторий даёт
// единственный способ убрать копию, но зовёт его шаг, и норма держится
// проверкой, а не построением. Копий здесь две — исходник и результат.
func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
newTelegramJob(t, env)
// Приём уже отработал — убеждаемся, что за собой он прибрал, иначе остаток
// от него зачёлся бы шагу конвертации.
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
require.NoError(t, err)
require.Empty(t, leftovers, "приём убрал свою рабочую копию")
// Конвертация отказывает — задача уходит в `failed`, копии убраны.
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
require.NoError(t, err)
assert.Empty(t, leftovers, "ни исходной копии, ни копии под результат не осталось")
}
// readJob читает задачу мимо сужения владельцем.
//
// Читающий метод хранилища отдаёт задачу только её владельцу, а проверки
// конвейера смотрят задачи, принятые ботом: владельца у таких нет вовсе, и по
// правилу разграничения они не достаются никому. Проверке нужен не доступ, а
// состояние записи после шага, поэтому она берёт его прямо из хранилища. Это
// не второй способ читать задачу в сервисе — в сервисе способ по-прежнему один.
func readJob(app core.App, id string) (*entity.TranscribeJob, error) {
record, err := app.FindRecordById(migrations.JobsCollection, id)
if err != nil {
return nil, err
}
job := &entity.TranscribeJob{
Id: record.Id,
State: record.GetString("state"),
Source: record.GetString("source"),
Attempts: record.GetInt("attempts"),
CreatedAt: record.GetDateTime("created").Time(),
UpdatedAt: record.GetDateTime("updated").Time(),
}
for _, field := range []struct {
name string
dst **string
}{
{"owner", &job.OwnerID},
{"file", &job.FileID},
{"error_text", &job.ErrorText},
{"acquisition_id", &job.AcquisitionID},
{"recognition_op_id", &job.RecognitionOpID},
{"transcription_text", &job.TranscriptionText},
} {
if value := record.GetString(field.name); value != "" {
stored := value
*field.dst = &stored
}
}
if delay := record.GetDateTime("delay_time"); !delay.IsZero() {
moment := delay.Time()
job.DelayTime = &moment
}
if acquired := record.GetDateTime("acquire_time"); !acquired.IsZero() {
moment := acquired.Time()
job.AcquireTime = &moment
}
return job, nil
}
// newOwner заводит учётную запись и отдаёт её идентификатор.
//
// Владелец — связь с коллекцией пользователей, и хранилище проверяет, что такая
// запись есть: выдуманный идентификатор задачу завести не даст.
// запись есть: выдуманный идентификатор запись завести не даст.
func newOwner(t *testing.T, app core.App) string {
t.Helper()
@@ -428,8 +696,77 @@ func newOwner(t *testing.T, app core.App) string {
record := core.NewRecord(users)
record.Set("email", uuid.NewString()+"@example.test")
record.Set("verified", true)
record.SetPassword(uuid.NewString())
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{})
beforeOk := stageCount(t, entity.StateUploaded, "false")
beforeErr := stageCount(t, entity.StateUploaded, "true")
record := newTelegramRecord(t, env)
require.NoError(t, env.service.RunStep(t.Context()))
after := readRecord(t, env, record.Id)
require.True(t, after.IsHalted(), "приговор шага остановил запись")
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 := newTelegramRecord(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)
var own []*core.Record
for _, event := range all {
if event.GetString("record") == recordID {
own = append(own, event)
}
}
return own
}
+148 -219
View File
@@ -2,267 +2,196 @@ package service
import (
"context"
"encoding/json"
"errors"
"io"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"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"
)
// Шаги распознавания переписаны переездом на новое хранилище целиком: они берут
// содержимое по записи, заводят запись о копии во внешнем хранилище и пишут
// результат условием по держателю захвата. Подставной распознаватель проекта
// умеет только «завершено с фиксированным текстом», поэтому ветки ожидания,
// отказа операции и пустого текста изобразить нечем — для них нужен управляемый
// двойник.
// scriptedRecognizer отдаёт заданный исход проверки операции и заданный текст.
type scriptedRecognizer struct {
result *entity.RecognitionResult
text string
recognizeErr error
recognizeCalls int
lastObjectKey string
// countingRecognizer считает обращения наружу: за них платят по факту, и повтор
// оплаченного шага — самый дорогой класс дефекта в этом сервисе.
type countingRecognizer struct {
uploads atomic.Int64
submits atomic.Int64
fetches atomic.Int64
parses atomic.Int64
objectHere atomic.Bool
inProgress atomic.Int64
}
func (r *scriptedRecognizer) Recognize(_ context.Context, file io.Reader, fileName string) (string, error) {
r.recognizeCalls++
r.lastObjectKey = fileName
if r.recognizeErr != nil {
return "", r.recognizeErr
func (r *countingRecognizer) Provider() string { return "counting" }
func (r *countingRecognizer) Model() string { return "counting" }
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
}
// Содержимое обязано быть читаемым: шаг отдаёт его наружу потоком.
if _, err := io.Copy(io.Discard, file); err != nil {
return "", err
return entity.NewCompletedResult(), nil
}
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) {
return r.text, nil
var t0Replicas = []entity.Replica{
{StartMs: 0, EndMs: 900, Text: "Первая реплика."},
{StartMs: 900, EndMs: 1800, Text: "Вторая реплика."},
}
func (r *scriptedRecognizer) CheckRecognitionStatus(context.Context, string) (*entity.RecognitionResult, error) {
return r.result, nil
func countingPayload(replicas []entity.Replica) []byte {
raw, err := json.Marshal(replicas)
if err != nil {
panic(err)
}
return raw
}
// convertedJob доводит задачу до состояния, с которого работает шаг
// распознавания: запись принята и сконвертирована.
func convertedJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
t.Helper()
// Критерий приёмки 4. Структура реплик строится из сохранённого ответа
// провайдера без единого обращения к нему: результат операции не
// переспрашивается, и архив пересчитывается из сохранённого без рубля.
func TestStructureIsBuiltFromStoredPayload(t *testing.T) {
rec := &countingRecognizer{}
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
job := newTelegramJob(t, env)
record := newTelegramRecord(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)
acquired.MoveToState(entity.StateConverted)
require.NoError(t, env.jobRepo.Save(acquired, "setup"))
require.NotEmpty(t, raw, "сырой ответ провайдера сохранён")
return job
}
// А теперь — построение структуры из сохранённого, без обращений наружу.
fetchesBefore := rec.fetches.Load()
submitsBefore := rec.submits.Load()
// withRecognizer пересобирает сервис с управляемым распознавателем поверх того
// же хранилища.
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 := readJob(env.app, job.Id)
outcome, err := env.service.recognizer.Parse(raw)
require.NoError(t, err)
assert.Equal(t, entity.StateTranscribe, after.State)
require.NotNil(t, after.RecognitionOpID)
assert.Equal(t, "operation-id", *after.RecognitionOpID)
require.NotNil(t, after.DelayTime, "задержка перед первой проверкой поставлена")
require.Len(t, outcome.Replicas, len(t0Replicas), "структура собрана")
assert.Equal(t, t0Replicas[0].Text, outcome.Replicas[0].Text)
assert.Equal(t, t0Replicas[0].StartMs, outcome.Replicas[0].StartMs, "время реплики сохранено")
// Ссылка задачи ведёт на существующую запись о копии, а не на несозданную.
require.NotNil(t, after.FileID)
copyRecord, err := env.fileRepo.GetByID(*after.FileID)
assert.Equal(t, fetchesBefore, rec.fetches.Load(), "к провайдеру за результатом не ходили")
assert.Equal(t, submitsBefore, rec.submits.Load(), "и новой операции не заводили")
// Структура записи собрана из того же ответа и лежит своей строкой.
require.NotNil(t, after.StructureID)
structure, err := env.repos.Structures.GetByID(*after.StructureID)
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{})
job := convertedJob(t, env)
// Шаг с внешней оплатой проверяет сделанное: объект нужного размера на месте —
// заливку не повторяем; операция заведена — не платим второй раз.
func TestPaidWorkIsNotRepeated(t *testing.T) {
rec := &countingRecognizer{}
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
rec := &scriptedRecognizer{recognizeErr: errors.New("распознаватель недоступен")}
svc := withRecognizer(env, rec)
record := newTelegramRecord(t, env)
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 := readJob(env.app, job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateConverted, after.State, "задача осталась на своём шаге")
assert.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору")
assert.NotNil(t, after.DelayTime, "пауза перед повтором поставлена")
assert.Empty(t, env.sender.messages, "отправителю про повторимый отказ не пишут")
// Отправка.
require.NoError(t, env.service.RunStep(t.Context()))
require.Equal(t, int64(1), rec.uploads.Load(), "залили один раз")
require.Equal(t, int64(1), rec.submits.Load(), "и заплатили один раз")
// Возвращаем запись на рубеж отправки — так это выглядит, когда шаг оборвался
// после оплаты, а захват протух.
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)
require.NoError(t, withRecognizer(env, rec).FindAndRunTranscribeJob(t.Context()))
record := newTelegramRecord(t, env)
clearDelay(t, env, job.Id)
return job
}
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)
// Ожидание чужой операции попытку не тратит и опрос не учащает: шаг отработал
// без отказа, и задержка у него своя, числом.
func TestCheckJobWaitsWithoutSpendingAttempts(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
entered := readRecord(t, env, record.Id).StateEnteredAt
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++ {
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
after := readRecord(t, env, record.Id)
require.Equal(t, entity.StateSubmitted, after.State, "рубеж не сдвинут откладыванием")
assert.Equal(t, 0, after.Attempts, "ожидание чужой операции отказа не тратит")
assert.WithinDuration(t, entered, after.StateEnteredAt, 0, "и отсчёт застревания не двигает")
after, err := readJob(env.app, job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateTranscribe, after.State)
assert.Equal(t, 0, after.Attempts, "ожидание операции попытку не тратит")
require.NotNil(t, after.DelayTime)
assert.InDelta(t, nextCheckDelay.Seconds(), time.Until(*after.DelayTime).Seconds(), 2,
"задержка опроса не выродилась в наименьшую паузу повтора")
clearDelay(t, env, job.Id)
delays = append(delays, after.DelayTime.Unix())
}
}
// Отказ операции распознавания — приговор записи: задача уходит в `failed`, а
// отправитель узнаёт причину человеческим текстом.
func TestCheckJobFailsJobAndTellsSender(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
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 := readJob(env.app, 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 TestCheckJobCompletesAndAnswersOnce(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
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 := readJob(env.app, 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)
rec.result = entity.NewCompletedResult()
rec.text = ""
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
after, err := readJob(env.app, job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDone, after.State)
require.Len(t, env.sender.messages, 1)
assert.Contains(t, strings.ToLower(env.sender.messages[0]), "нет текста")
}
// Шаг, потерявший захват за время работы, результата не пишет и отправителю не
// отвечает: иначе два воркера пишут в одну задачу, а отправитель получает два
// ответа на одну запись.
func TestCheckJobWritesNothingWhenAcquisitionLost(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
rec.result = entity.NewCompletedResult()
rec.text = "расшифровка записи"
// Захват задачи достался другому, пока шаг работал.
acquired, err := env.jobRepo.FindAndAcquire(entity.StateTranscribe, "mine", time.Now().Add(-time.Hour))
require.NoError(t, err)
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("acquisition_id", "someone-else")
require.NoError(t, env.app.Save(record))
svc := withRecognizer(env, rec)
err = svc.checkTranscribeJob(t.Context(), acquired, "mine")
var lost *contract.LostAcquisitionError
require.ErrorAs(t, err, &lost)
after, err := readJob(env.app, job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateTranscribe, after.State, "результат не записан")
assert.Empty(t, env.sender.messages, "отправителю ничего не отправлено")
assert.Len(t, delays, 3, "все три прогона отложили работу")
}
+23 -28
View File
@@ -8,7 +8,6 @@ import (
"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"
)
@@ -28,43 +27,43 @@ func (c *killedConverter) Convert(ctx context.Context, _, _ string) error {
return errors.New("ffmpeg conversion failed: signal: killed")
}
// Остановка сервиса посреди конвертации не выносит записи приговора: задача
// остаётся пригодной к повтору, попытку не тратит и отправителю о сбое,
// которого не было, не сообщает. Прежде любой отказ `Convert` уводил задачу в
// терминальное `failed`, откуда её возвращает только владелец правкой в панели.
func TestShutdownDuringConversionKeepsJobRetryable(t *testing.T) {
// Критерий приёмки 9. Остановка сервиса посреди приведения не выносит записи
// приговора и **не тратит отказа**: запись не виновата в том, что нас
// перезапустили, и несколько выкладок подряд иначе останавливают здоровую
// многочасовую запись с приговором «попытки исчерпаны».
func TestShutdownDuringConversionKeepsRecordRetryable(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
converter := &killedConverter{cancel: cancel}
env := newPipelineEnv(t, &okMetaViewer{}, converter)
job := newTelegramJob(t, env)
record := newTelegramRecord(t, env)
err := env.service.FindAndRunConversionJob(ctx)
err := env.service.RunStep(ctx)
require.Error(t, err, "шаг обязан сообщить об обрыве наверх")
require.ErrorIs(t, err, context.Canceled, "обрыв узнаётся по смыслу, а не по тексту")
after, err := readJob(env.app, job.Id)
require.NoError(t, err)
after := readRecord(t, env, record.Id)
assert.Equal(t, entity.StateCreated, after.State, "задача осталась на повтор, а не похоронена")
assert.Nil(t, after.AcquisitionID, "захват снят: задачу возьмёт следующий прогон")
assert.Equal(t, 0, after.Attempts, "остановка попытки не тратит")
assert.Nil(t, after.ErrorText, "приговора не выносили")
assert.Empty(t, env.sender.messages, "отправителю о несуществующем сбое не сообщают")
assert.Equal(t, entity.StateUploaded, after.State, "запись осталась на повтор")
assert.False(t, after.IsHalted(), "приговора не выносили")
assert.Nil(t, after.AcquisitionID, "захват снят: запись возьмёт следующий прогон")
assert.Equal(t, 0, after.Attempts, "остановка отказа не тратит")
assert.Nil(t, after.ErrorText)
assert.Empty(t, env.sender.sent(), "отправителю о несуществующем сбое не сообщают")
}
// Задача, которую шаг не успел взять, потому что нас уже остановили, остаётся
// нетронутой: захват не случился, попытка не потрачена.
func TestShutdownBeforeStepLeavesJobUntouched(t *testing.T) {
// Запись, которую шаг не успел взять, потому что нас уже остановили, остаётся
// нетронутой: захват не случился, отказ не потрачен.
func TestShutdownBeforeStepLeavesRecordUntouched(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := newTelegramJob(t, env)
record := newTelegramRecord(t, env)
ctx, cancel := context.WithCancel(t.Context())
cancel()
err := env.service.FindAndRunConversionJob(ctx)
err := env.service.RunStep(ctx)
// Исход «шаг не сделал ничего» — это `NoopJobError`: воркер не пишет о нём
// владельцу и не считает его в метрику.
@@ -72,12 +71,8 @@ func TestShutdownBeforeStepLeavesJobUntouched(t *testing.T) {
var noop *contract.NoopJobError
require.ErrorAs(t, err, &noop)
after, err := readJob(env.app, job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateCreated, after.State)
assert.Equal(t, 0, after.Attempts, "захвата не было — попытке взяться неоткуда")
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
assert.Empty(t, record.GetString("acquisition_id"))
after := readRecord(t, env, record.Id)
assert.Equal(t, entity.StateUploaded, after.State)
assert.Equal(t, 0, after.Attempts, "захвата не было — отказу взяться неоткуда")
assert.Nil(t, after.AcquisitionID)
}
File diff suppressed because it is too large Load Diff
+66 -52
View File
@@ -14,10 +14,10 @@ import (
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Ответ отправителю уходит после того, как достигнутое состояние сохранено.
// Значит, недоставка не может быть отказом шага: объявленный отказ засчитался
// бы воркеру сбоем, лёг бы владельцу записью отказа и переписал бы служебные
// поля завершённой задачи. Причин недоставки две, исход у них общий.
// Ответ отправителю уходит после того, как достигнутый рубеж сохранён. Значит,
// недоставка не может быть отказом шага: объявленный отказ засчитался бы воркеру
// сбоем, лёг бы владельцу записью отказа и переписал бы служебные поля
// доведённой записи. Причин недоставки две, исход у них общий.
// downSender изображает неподнятый канал доставки: так ведёт себя заглушка,
// которую ядро получает вместо отправителя Telegram.
@@ -31,91 +31,105 @@ func (s *downSender) Send(string, int64, *int) error {
}
// 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,
env.repos,
&okMetaViewer{},
&failingConverter{},
rec,
&okConverter{},
env.service.recognizer,
sender,
testLimits,
slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})),
)
return svc, journal
}
// Канал не поднят: задача доводится до конца, шаг отказа не объявляет, а
// transcribedRecord доводит запись до рубежа, с которого уходит ответ.
func transcribedRecord(t *testing.T, env *pipelineEnv, svc *TranscribeService) *entity.AudioRecord {
t.Helper()
record := newTelegramRecord(t, env)
for range 5 {
clearDelay(t, env, record.Id)
require.NoError(t, svc.RunStep(t.Context()))
if readRecord(t, env, record.Id).State == entity.StateTranscribed {
return readRecord(t, env, record.Id)
}
}
t.Fatal("запись не дошла до рубежа расшифровки")
return nil
}
// Канал не поднят: запись доводится до конца, шаг отказа не объявляет, а
// владелец узнаёт о недоставке из журнала.
func TestUndeliveredOnDownChannelKeepsJobDone(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
func TestUndeliveredOnDownChannelKeepsRecordDone(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
rec.result = entity.NewCompletedResult()
rec.text = "расшифровка записи"
sender := &downSender{}
svc, journal := journalEnv(t, env, rec, sender)
svc, journal := journalEnv(t, env, sender)
record := transcribedRecord(t, env, svc)
// Шаг завершается без отказа — именно это воркер считает в свой счётчик.
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
clearDelay(t, env, record.Id)
require.NoError(t, svc.RunStep(t.Context()))
assert.Equal(t, 1, sender.calls, "ответ до отправителя доехал")
after, err := readJob(env.app, job.Id)
after := readRecord(t, env, record.Id)
assert.Equal(t, entity.StateDone, after.State, "запись осталась на достигнутом рубеже")
assert.False(t, after.IsHalted(), "отказ записи не приписан")
require.NotNil(t, after.TranscriptTextID, "расшифровка сохранена")
text, err := env.repos.Texts.GetByID(*after.TranscriptTextID)
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, "отказ задаче не приписан")
require.NotEmpty(t, text.Contents)
written := journal.String()
assert.Contains(t, written, "Reply was not delivered", "недоставка названа")
assert.Contains(t, written, job.Id, "запись несёт идентификатор задачи")
assert.Contains(t, written, record.Id, "запись несёт идентификатор")
assert.Contains(t, written, "level=WARN", "объявленный режим — «может стать проблемой»")
assert.NotContains(t, written, "расшифровка записи", "текста расшифровки в журнале нет")
assert.NotContains(t, written, text.Contents, "текста расшифровки в журнале нет")
}
// Адресат у задачи не назван: исход тот же. Прежде эта ветка объявляла отказ
// Адресат у записи не назван: исход тот же. Прежде эта ветка объявляла отказ
// шага на уже завершённой работе.
func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
func TestUndeliveredWithoutChatKeepsRecordDone(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
// Задача из 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)
svc, journal := journalEnv(t, env, sender)
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
record := transcribedRecord(t, env, svc)
// Запись из Telegram, у которой чат не назван: такую отдаёт правка в панели.
// Колонка чистится мимо захвата — иначе подготовка унесла бы запись у шага.
stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id)
require.NoError(t, err)
stored.Set("tg_chat_id", nil)
require.NoError(t, env.app.Save(stored))
clearDelay(t, env, record.Id)
require.NoError(t, svc.RunStep(t.Context()))
assert.Equal(t, 0, sender.calls, "до отправителя дело не дошло: адресата нет")
after, err := readJob(env.app, job.Id)
require.NoError(t, err)
after := readRecord(t, env, record.Id)
assert.Equal(t, entity.StateDone, after.State)
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
assert.False(t, after.IsHalted(), "отказ записи не приписан")
written := journal.String()
assert.Contains(t, written, "Reply was not delivered")
assert.Contains(t, written, job.Id)
assert.Contains(t, written, record.Id)
assert.Contains(t, written, "chat is not specified", "причина названа")
assert.Contains(t, written, "level=ERROR",
"порча записи громче штатного «бот не настроен»: иначе сигнал утонет")
@@ -123,17 +137,17 @@ func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) {
// Запись, принятая по HTTP, до отправителя не доходит вовсе: недоставки нет, и
// записи о ней в журнале быть не должно — иначе журнал владельца заполнят
// строки о задачах основного входа.
func TestApiJobDoesNotReachSenderAndLogsNothing(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
// строки о записях основного входа.
func TestApiRecordDoesNotReachSenderAndLogsNothing(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app))
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app))
require.NoError(t, err)
sender := &downSender{}
svc, journal := journalEnv(t, env, &scriptedRecognizer{}, sender)
svc, journal := journalEnv(t, env, sender)
require.NoError(t, svc.send(job, "расшифровка записи"))
require.NoError(t, svc.send(record, "расшифровка записи"))
assert.Equal(t, 0, sender.calls, "отправителя не звали")
assert.NotContains(t, journal.String(), "Reply was not delivered", "недоставки не было")
+30 -28
View File
@@ -68,6 +68,14 @@ func main() {
os.Exit(1)
}
// Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с
// ней значит остановить всякую запись первым же захватом.
if err := cfg.Pipeline.Validate(); err != nil {
logger.Error("Unable to start with incorrect pipeline settings", "error", err)
os.Exit(1)
}
// Загружаем переменные окружения из .env файла
if err := godotenv.Load(); err != nil {
logger.Warn("Warning: .env file not found, using system environment variables")
@@ -90,8 +98,15 @@ func main() {
pbrepo.BindPanelRules(storage)
// Создаем репозитории
fileRepo := pbrepo.NewFileRepository(storage)
jobRepo := pbrepo.NewTranscriptJobRepository(storage)
recordRepo := pbrepo.NewAudioRecordRepository(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()
@@ -128,12 +143,12 @@ func main() {
// Создаем сервисы
transcribeService := service.NewTranscribeService(
jobRepo,
fileRepo,
repos,
metaviewer,
converter,
recognizer,
tgSender,
cfg.Pipeline.StuckLimits(),
logger,
)
@@ -154,7 +169,7 @@ func main() {
// же факте.
var tgController *tgcontroller.TelegramController
if tgBot != nil {
tgController, err = tgcontroller.NewTelegramController(tgConfig, tgBot, transcribeService, jobRepo, logger)
tgController, err = tgcontroller.NewTelegramController(tgConfig, tgBot, transcribeService, logger)
if err != nil {
logger.Error("Failed to create Telegram controller", "error", err)
os.Exit(1)
@@ -170,26 +185,14 @@ func main() {
}()
}
// Создаем воркеры
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)
}
// Пул одинаковых воркеров: специализации у них нет, шаг выбирается по рубежу
// самой записи. Число приходит настройкой, ноль — законное значение.
pool := worker.NewPool(cfg.Pipeline.Workers, transcribeService.RunStep, logger)
wg.Add(1)
go func() {
defer wg.Done()
pool.Start(ctx)
}()
// Вход по HTTP поднимается всегда: он основной, и отдельного разреза у него
// нет. Признак ставится рядом с признаком Telegram, чтобы владелец судил об
@@ -198,7 +201,7 @@ func main() {
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
// и второму серверу на нём взяться неоткуда.
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger)
transcribeHandler := httpcontroller.NewTranscribeHandler(recordRepo, repos.Texts, transcribeService, logger)
authHandler := httpcontroller.NewAuthHandler(storage, httpcontroller.AuthHandlerConfig{
AuthURL: cfg.Auth.AuthURL,
RedirectURL: cfg.Auth.RedirectURL,
@@ -291,8 +294,7 @@ func main() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
logger.Info("Transcriber service started with background workers")
logger.Info("Workers: ConversionWorker, TranscribeWorker, CheckWorker")
logger.Info("Transcriber service started", "pipeline_workers", pool.Size())
logger.Info("Press Ctrl+C to stop...")
// Ждем сигнал завершения либо отказ сервера
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-14
@@ -0,0 +1,345 @@
## Context
Сегодня в хранилище одна строка несёт всё разом: домен записи (владелец, файл),
поля очереди (захват, пауза, попытки) и содержимое (расшифровка целиком, в
колонке). Отсюда четыре следствия, и все они наблюдаемы:
- **захват тянет содержимое.** Запрос захвата перечисляет колонки поимённо и
читает среди них расшифровку; часовая запись едет в память при каждом опросе;
- **ссылка на файл одна, и её переставляет каждый шаг.** У прошедшей конвейер
записи она ведёт на копию во внешнем хранилище, и принятого человеком файла не
найти ничем — задача `play-recording-in-app` упирается именно в это;
- **отказ стирает достигнутое.** Переход в состояние отказа чистит служебные
поля и не оставляет рубежа: продолжить с места остановки не с чего, и владелец
правит состояние в панели наугад;
- **один счётчик несёт две обязанности.** Число попыток ограничивает и повторы
внутри шага, и застревание. Опрос чужой операции обнуляет его переходом в то
же самое состояние — значит зависшая операция опрашивается вечно; перестань он
обнуляться, и здоровая долгая запись умирала бы на шестом опросе.
Замысел выработан разговором 2026-08-14 (запись `record-centric-model`), там же
разобраны и закрыты четыре развилки: где живут темы, дробить ли задачу, как
зовётся конечный рубеж и чем ограничивается застревание. Открытых вопросов
постановка не оставила.
Ограничения, которые эта работа не выбирает: хранилище — встроенная PocketBase,
применённый шаг схемы не переписывается, публичный контракт HTTP API объявлен
необратимым. Записей на сервере при этом нет: сервис остановлен, а прежние
данные удалены решением владельца 2026-08-14 — новая модель заводится с чистого
листа, и переноса данных эта работа не делает.
## Goals / Non-Goals
**Goals:**
- Аудиозапись становится центральной сущностью, приложения к ней живут
отдельными строками, а поля очереди перестают соседствовать с содержимым.
- Рубеж называет достигнутое, остановка становится признаком, и запись
продолжает с места остановки.
- Два сторожа вместо одного: отказы ограничивают повторы, время в рубеже —
застревание.
- Воркеры теряют специализацию, их число задаётся настройкой.
- Схема заводится одним шагом; переноса прежних данных нет.
**Non-Goals:**
- Резать длинную запись на фрагменты — конвейер остаётся цепочкой
(`long-audio-chunking`).
- Выносить доставку из конвейера: ответ в Telegram остаётся хвостом последнего
шага.
- Считать уровни текста: сам шаг обращения к языковой модели делает
`llm-insights-adapter`, вычитанный текст — `literary-text-level`. Здесь
заводятся только места, куда они лягут.
- Размечать говорящих в структуре: связь реплики с разбором говорящего у
провайдера не выяснена.
- Ставить таймауты внешним вызовам — `external-call-timeouts`.
- **Заводить обязательность и необязательность шага.** Ревью дизайна показало,
что носителя у неё нет ни одного: все пять рубежей обязательны, а первый
необязательный шаг приносит `llm-insights-adapter`. Норма без экземпляра
проверяется либо подставным шагом ради теста — что запрещено запретом на
проверки над проверками, — либо галочкой без оракула; вернётся вместе с первым
своим шагом.
- **Давать владельцу записи снимать остановку.** Постановка этого просила, но
поверхности нет: адресов у сервиса два (приём и опрос), экранов нет вовсе, а
открыть правку коллекции запросом запрещает действующая норма `storage`
(«Наружу хранилище отдаёт только то, что заказано»). Сегодня остановку снимает
владелец панели; владельцу записи это даст задача, заводящая экраны.
- Нарастающая пауза опроса и отступ на пустой очереди: первое —
`speechkit-callback-fit`, второе при единицах записей в день не нужно.
- Экраны.
## Decisions
### Разрез сущностей идёт по зависимости от провайдера
Границ для разреза было три, и выбрана одна.
- **По зависимости от провайдера распознавания** — принято. Всё, что перестанет
быть верным при смене провайдера (идентификатор операции, адрес, по которому
провайдер читает аудио, имя модели, сырой ответ), уезжает в строку попытки;
всё остальное остаётся доменом записи. Смена провайдера тогда не трогает
доменную сущность вовсе.
- **По изменчивости** (что правит конвейер против того, что правит человек) —
отвергнуто: граница проходит внутри одной колонки. Рубеж правят оба.
- **По частоте чтения** — отвергнуто как единственная граница: она объясняет,
почему тексты уезжают из записи, но ничего не говорит про идентификатор
операции, который читается ровно так же часто, как рубеж.
Частота чтения при этом остаётся **вторым** разрезом, внутри домена: `title` и
`brief` читаются сотней штук разом и остаются колонками записи, расшифровка и
вычитанный текст читаются по открытию одной записи и уезжают строками `texts`.
### Сырой ответ провайдера хранится вложением, а не колонкой
Ответ на многочасовую запись — мегабайты. Хранилище читает запись целиком, а шаг
опроса читает строку попытки раз в несколько секунд: положенный колонкой, ответ
ехал бы в память при каждом опросе — тот же промах, что расшифровка в перечне
колонок захвата сегодня. Вложение читается только тогда, когда его просят.
Хранится он вообще потому, что **результат операции у провайдера не
переспрашивается**. Отвергнутый вариант — не хранить и разобрать на лету:
дешевле сегодня, но связь реплики с говорящим мы строить пока не умеем, и когда
научимся, архив пересчитать будет не из чего, а повторная операция стоит денег
за каждую запись.
### Остановка — признак, а не рубеж
Прежние `failed` и `dead` схлопываются в признак `halted_at` с причиной, а
`state` не стирается.
- **Признак** — принято: рубеж переживает остановку, продолжение идёт с места
остановки, массовый перезапуск после выкатки правки делается одним обновлением,
а различие «мы рассудили» против «мы перестали пробовать» остаётся причиной,
которую человек читает.
- **Отдельное состояние на каждую причину** — отвергнуто: перечень состояний
закрыт схемой, и каждая новая причина стоила бы необратимого шага.
- **Оставить как есть** — отвергнуто: именно из-за этого перезапись состояния
руками в панели остаётся единственным способом вернуть запись в работу, и
делается он наугад.
### Сторожей двое, и предела времени — два числа
`attempts` считает **отказы** и ограничивает повторы внутри шага;
`state_entered_at` считает **время** и ограничивает застревание.
Пределов времени два, и граница проходит не по рубежам, а по тому, чью работу
ждём: своя (`uploaded`, `normalized`, `transcribed`) и чужая (`submitted`).
- **Одно общее число** — отвергнуто: мерить его пришлось бы по самому долгому, и
застрявшее приведение стояло бы столько же, сколько застрявшая чужая операция.
- **Число на каждый рубеж** — отвергнуто: четыре числа назвали бы разными вещи,
различающиеся только исполнителем, и три из них были бы одинаковы.
Сколько идёт распознавание долгой записи, никто не мерил (`speechkit-limits`,
`intake-limits-measure`), поэтому ошибаемся в сторону долгого: ложная остановка
хуже поздней.
**Час на свою работу меньше самой работы, и это принято сознательно.** Расчётный
потолок записи — шесть часов, приведение такой записи идёт дольше часа по
построению (`docs/review.md`, запись 2026-08-13), а срок захвата шага приведения
стоит сегодня восемью часами. Значит длинная запись, отказавшая один раз и
ждущая повтора дольше часа, будет остановлена сторожем застревания вместо
расшифровки. Ревью дизайна предлагало вывести предел из срока захвата (12 часов
на свою работу); владелец решил 2026-08-14 оставить час, потому что сторож ловит
**зависание**, а живая работа наблюдается по самому процессу конвертера, и
остановка теперь обратима — снятие признака возвращает запись на её рубеж, и
цена ложной остановки равна одному движению владельца. Оба числа в конфиге,
ключи — `[pipeline] own_work_limit` и `[pipeline] foreign_work_limit`.
**Второй сторож пришлось сбрасывать там же, где первые.** Снятие признака
остановки и правка рубежа в панели заново ставят время входа в рубеж: запись,
простоявшая остановленной дольше предела, иначе останавливалась бы снова первым
же захватом, и перезапуск — главное, ради чего заводится признак, — не работал бы
ни для одной записи старше предела.
### Признак захвата — значение, а не занятость
Захват возвращает идентификатор записи **и признак этого захвата**, уникальный
для каждого захвата. Условие записи результата сверяет именно это значение.
Причина проверяемая: панель по требованию `storage` очищает признак захвата,
когда человек снимает остановку. Условие, проверяющее лишь непустоту признака
или срок протухания, пропустило бы обоих — шаг A, потерявший запись, и шаг B,
её подобравший, — и оба записали бы результат и оба ответили бы отправителю.
Гонки часов для этого не нужно: достаточно одного движения человека в панели.
### Имена, которые уезжают необратимым шагом
Коллекция зовётся `audio_records`, а не `audiorecords`: соседи по схеме —
`transcribe_jobs`, `files`, `record_events` — в snake_case, и одно исключение
разошлось бы молча по константе имён, запросу захвата, правилам панели и запрету
удаления.
Вид текста лежит колонкой `kind`, а не `format`: словом `format` в этой же схеме
зовут формат файла (`files.format`), и третий смысл у одного слова проект уже
разводил однажды — комментарий к `location` в `entity/file.go` называет довод.
Текст отказа в журнале событий зовётся `outcome_text`, а не `error_text`:
последнее имя названо поимённо инвариантом проекта о секрете, и две колонки с
этим именем сделали бы инвариант двусмысленным.
### Переход и откладывание — разные операции
Сегодня шаг опроса зовёт переход с **тем же** состоянием, и мнимость этого
перехода обнуляет счётчик. `Postpone(delay)` ставит паузу и снимает захват, а
рубежа и времени входа в него не трогает; отказы обнуляет — ожидание чужой
операции отказом не является.
Без разделения `state_entered_at` сбрасывался бы на каждом опросе и повторил бы
ровно тот промах, ради которого заводится.
### Захват возвращает идентификатор
Сегодня захват перечисляет колонки поимённо в трёх местах сразу, и инвариант
проекта требует править их в четырёх. Захват, возвращающий один `id`, съёживает
инвариант до трёх мест и **перестаёт расти с моделью**: иначе каждая новая
колонка аудиозаписи попадала бы под него, а модель растёт именно сейчас.
Цена названа прямо: захват и чтение записи становятся двумя обращениями к базе
вместо одного. При нагрузке в единицы записей в день это не замеряется, а
неделимость самого захвата не страдает — она держится тем же одним запросом с
`RETURNING`, только возвращает он один столбец.
Отвергнутый вариант — оставить перечень колонок и дописывать его: он ровно тот,
из-за которого инвариант получил серьёзность `major`, и первая же забытая
колонка приезжает нулевой, а первое сохранение пишет этот ноль поверх значения.
### Воркеры теряют специализацию
Пул одинаковых потоков, число из настроек, шаг выбирается по рубежу таблицей
диспетчеризации.
- **Пул** — принято: рубежей станет больше (уровни текста впереди), и каждый
новый рубеж перестаёт требовать своего воркера в `main.go`.
- **Смотритель отдельно от очереди** — отвергнуто для опроса чужой операции:
очередь даёт неделимость захвата и возврат брошенного даром, а единственный
смотритель умирает молча и уносит с собой целый класс записей.
`N = 0` — законное значение: записи принимаются и не двигаются. Это нужный режим
для местного запуска и для выкладки, где конвейер надо остановить, не роняя
приём.
### Темы живут коллекцией со словарём на каждого владельца
Перечень тем человека нужен целиком **перед каждым обращением к модели**: она
получает его в запросе и переиспользует подходящую тему, а новую заводит, только
если не годится ни одна. Собрать такой перечень из наборов строк в записях можно
только перебором всех записей владельца — значит словарь живёт коллекцией.
Потолок — пять тем на запись, и он же уезжает в запрос: без него часовой
разговор даёт два десятка тем, и словарь распухает за неделю.
Отвергнуто: темы набором строк в самой записи (перечень не собрать) и общий
словарь на всех (тема — слепок того, о чём человек говорит, и общий словарь
показал бы одному темы другого).
### Конечный рубеж зовётся `done`
Доставка ответа отправителю в конвейер не входит, и слово описывает пройденный
конвейер, а не полученный человеком текст. Отвергнут `ready`: он обещает «готово
для человека», а человек к этому моменту текста ещё не получил. Прежний довод в
пользу `done` — «переносится с живых записей тождеством» — отпал вместе с
переносом, но само решение он не держал.
### Задача делается одним заходом
Швы у неё есть — сущности со схемой, цепочка рубежей, провайдерская таблица,
обобщение пула, — но резать по ним значит платить **четырьмя** необратимыми
шагами схемы вместо одного и держать на сервере промежуточные раскладки. Решение
владельца 2026-08-14.
## Risks / Trade-offs
- **Шаг схемы уезжает на сервер и не переписывается** → под ним пусто: сервис
остановлен и прежние данные удалены, поэтому цена ошибки в шаге ограничена
повторным пересозданием каталога данных, а не потерей чужого архива.
- **Ложная остановка длинной записи** сторожем застревания принята решением
владельца (см. решение о пределах) → цена ограничена обратимостью остановки;
если класс начнёт всплывать, число правится настройкой без шага схемы.
- **Публичный контракт HTTP API ломается**: перечень значений `status` меняется
целиком → ломка объявлена прямо в спеке `intake`; имена полей сохранены.
Потребителей у контракта сегодня двое — свои же будущие экраны и внешняя
программа, которой ещё нет (`api-tokens`).
- **Таймаутов у внешних вызовов по-прежнему нет** (`external-call-timeouts` не
сделана) → срок захвата остаётся единственным пределом. Молчащий провайдер
держит шаг до конца срока захвата, а протухший захват на шаге отправки даёт
вторую платную операцию. Смягчение частичное: шаг отправки проверяет сделанное
прежде, чем платить, и укладку не повторяет. Полностью закрывается только той
задачей.
- **Захват стал двумя обращениями к базе вместо одного** → между захватом и
чтением запись может измениться. Запись результата остаётся условной по
признаку захвата, поэтому шаг, потерявший захват, ничего не пишет; худший исход
— потерянная работа шага, а не порча записи.
- **Сторожей стало двое, и оба надо сбрасывать в правильных местах** → правило
проверяется тестом на подставных часах: сотня откладываний подряд не двигает
время входа в рубеж и не обнуляет отсчёт.
- **Инвариант проекта о колонках очереди меняет форму** → он съёживается до трёх
мест, и `CLAUDE.md` правится тем же изменением; забыть об этом нельзя, иначе
инвариант станет требовать несуществующего.
- **Класс «перечень, которого не видит компилятор» не исчезает, а переезжает с
колонок на рубежи** → рубеж, забытый в отборе захвата, не выдаётся никому и не
пишет ни строки: пустой прогон по инварианту не логируется. Смягчение —
дескриптор рубежа одним объявлением, из которого выводятся выбор шага, отбор
захвата и оба предела; сканеры `internal/archrules`, стоящие сегодня на
`acquireColumns` и `acquiredRow`, перенацеливаются на этот дескриптор, а не
удаляются.
- **Обрыв процесса между ответом провайдера и записью идентификатора операции**
→ строка попытки заводится **до** обращения, и повторный шаг начинает с
проверки, не заведена ли операция. Жёсткий обрыв (`SIGKILL`, OOM) окна всё
равно не закрывает: это остаточный риск, названный здесь и **не** записанный
нормой — норма, обязывающая к недостижимому, зеленела бы на тесте мягкой
остановки и объявляла бы защиту сделанной.
- **Метка счётчика работы воркера теряет смысл вместе со специализацией**
метка переводится с имени потока на рубеж, иначе единственный сигнал отказа у
владельца сервиса перестаёт отличать «падает приведение» от «падает
распознавание». `docs/architecture.md`, раздел «Эксплуатация», правится тем же
изменением.
- **Журнал событий `record_events` — второй канал наблюдаемости рядом с
метриками**, а колонки расхода в нём заходят на открытый вопрос «Учёт расхода»
(`usage-accounting`) и на разведку `opentelemetry-fit` → читателя у журнала
сегодня нет: экранов нет, конвейеру читать его запрещено требованием. Колонки
расхода отложены до задачи, которая учёт заводит.
- **`topics` заводится вперёд своего потребителя** → писать и читать темы в этом
изменении не будет ничто. Довод за включение — цена: коллекция, заведённая
позже, стоит второго необратимого шага схемы задаче `llm-insights-adapter`.
Цена включения — имена коллекции и колонок закрепляются раньше, чем известен
запрос потребителя.
## Migration Plan
Переноса данных нет: сервис на сервере остановлен, прежние записи и файлы удалены
решением владельца 2026-08-14. Работа идёт так, будто выкладки не было ни разу.
1. Новый шаг схемы заводит `audio_records` и коллекции приложений, правит `files`
и **удаляет** прежнюю `transcribe_jobs`: данных под ней нет, а оставленная
пустая коллекция висела бы в панели вторым домом для того же понятия.
Применённые шаги при этом не переписываются — изменение идёт новым файлом.
2. Порядок выкладки: сперва образ. Новых обязательных ключей настройки нет — у
числа воркеров и обоих пределов времени есть умолчания.
3. **Откат.** Прежний образ ищет `transcribe_jobs`, которой уже нет, и работать
не будет: откат означает пересоздание каталога данных, и это осознанная цена
пустого старта. Обратного шага схемы нет и не планируется.
4. Резервную копию каталога данных перед выкладкой делает человек — не ради
записей, а ради учётных записей и настроек провайдера входа.
## Open Questions
Четыре развилки постановки разобраны в записи задачи 2026-08-14 и закрыты
решениями владельца. Ревью дизайна открыло ещё несколько мест, и все они вынесены
человеку на чекпоинт; решения, записанные выше, — предложенные, а не принятые:
- **предел «час на свою работу»** — оставлен часом: сторож ловит зависание, а
живая работа наблюдается по процессу; остановка обратима;
- **перенос данных** — отменён целиком: прод остановлен, прежние данные удалены;
- **снятие остановки владельцем записи** — намерение зафиксировано, реализация
приходит с экранами и своим адресом API;
- **`topics`** — оставлены, как решено постановкой: один шаг схемы вместо двух;
- **обязательность шага** — отложена до первого необязательного шага (решение
исполнителя по находке ревью, названо на чекпоинте);
- **имена ключей конфига** объявлены проектом необратимыми и потому названы
дословно: `[pipeline] workers`, `[pipeline] own_work_limit`,
`[pipeline] foreign_work_limit`.
Осталось названным риском, а не вопросом: `external-call-timeouts` в плане
стройки стоит **ниже** этой задачи, хотя «Рамки» постановки называют её
предшествующей. Порядок расставил владелец, и решение о нём принято.
@@ -0,0 +1,102 @@
## Why
Сервис объявлен архивом: записи и расшифровки лежат бессрочно, к ним
возвращаются через месяцы, а поверх них строятся список, темы, уровни текста и
учёт расхода. Держать всё это негде — центральной сущности «аудиозапись» в
сервисе нет вовсе: есть задача конвейера, у которой поля захвата лежат в одной
строке с расшифровкой, указатель на файл переставляет каждый шаг, а отказ стирает
достигнутый рубеж и делает продолжение с места остановки невозможным.
Всякая задача, взятая раньше этой, будет переписана вместе с моделью — потому
владелец 2026-08-14 поставил её первой в план стройки.
## What Changes
- **Центральная сущность — аудиозапись.** Домен записи (владелец, заголовок,
краткое описание, рубеж, ссылки на приложения) отделяется от того, что нужно
только конвейеру, и от того, что принадлежит провайдеру распознавания.
- **Приложения к записи живут отдельными строками.** Файлы, тексты, структура
реплик, темы, журнал событий и попытка распознавания перестают быть колонками
одной строки и адресуются ссылками с записи.
- **Ссылки на файлы перестают переставляться.** У записи две отдельные ссылки —
на исходник и на приведённую копию, — и обе живут до конца. Сегодня их одна, и
прошедшая конвейер запись ведёт на объект во внешнем хранилище: послушать
загруженное нечем.
- **Копия во внешнем хранилище перестаёт быть файлом записи.** Она существует
только потому, что распознаватель читает аудио по адресу, и переезжает в
строку о попытке распознавания вместе с идентификатором операции.
- **Сырой ответ распознавателя сохраняется целиком** — вложением, а не колонкой.
Результат операции у провайдера не переспрашивается, а связь реплики с
говорящим сервис строить пока не умеет: когда научится, архив пересчитается из
сохранённого без единого рубля.
- **Состояние называет достигнутое, а не предстоящее.** Цепочка рубежей:
`uploaded → normalized → submitted → transcribed → done`. **BREAKING**: перечень
состояний в ответе о записи меняется целиком — публичный контракт HTTP API
объявлен проектом необратимым.
- **Остановка становится признаком, а не состоянием.** Прежние `failed` и `dead`
схлопываются в признак остановки с причиной; достигнутый рубеж при этом
сохраняется, и снятие признака продолжает работу с места остановки, а не с
начала. Снимает признак владелец панели — поверхности для владельца записи у
сервиса пока нет, её заводит задача с экранами.
- **Сторожей становится двое.** Число отказов ограничивает повторы внутри шага,
время в рубеже — застревание. Сегодня обе роли навешаны на счётчик попыток, и
он не справляется ни с одной: операция, зависшая у провайдера, опрашивается
вечно.
- **Предел времени в рубеже** — два числа: час на свою работу, сутки на чужую.
Достигнут предел — запись останавливается с причиной «застряла».
- **Переход и откладывание разводятся.** Шаг опроса перестаёт изображать переход
в то же самое состояние: откладывание ставит паузу и снимает захват, а рубежа и
времени входа в него не трогает.
- **Шаг с внешней оплатой проверяет сделанное** прежде, чем платить второй раз.
- **Воркеры теряют специализацию**, а их число задаётся настройкой; ноль —
законное значение: записи принимаются и не двигаются.
- **Переноса данных нет.** Сервис на сервере остановлен, прежние записи удалены
решением владельца 2026-08-14, и новая модель заводится с чистого листа.
## Capabilities
### New Capabilities
- `recognition`: попытка распознавания у внешнего провайдера — что о ней
хранится, почему сырой ответ сохраняется целиком, как из сохранённого строится
структура реплик и почему разбор формата провайдера не доходит до конвейера.
### Modified Capabilities
- `pipeline`: цепочка рубежей и смысл состояния; остановка признаком вместо
состояний отказа и смерти; два сторожа вместо одного; предел времени в рубеже;
разведение перехода и откладывания; необязательный шаг, чей отказ не роняет
запись; захват, возвращающий один идентификатор; воркер без специализации и его
число настройкой.
- `storage`: аудиозапись центральной сущностью и её приложения отдельными
коллекциями; две отдельные ссылки на файлы вместо одной переставляемой;
словарь тем на каждого владельца; журнал событий записи; перенос живых записей
шагом схемы.
- `intake`: перечень состояний в ответе о приёме и об опросе готовности.
## Impact
- **Схема хранилища**: новый шаг — коллекции `audio_records`, `texts`,
`structures`, `recognitions`, `record_events`, `topics`; прежняя
`transcribe_jobs` уходит; правка `files`. Применённые шаги не переписываются.
- **Публичный контракт HTTP API**: значения поля состояния. Необратимо.
- `internal/entity` — сущность записи, перечень рубежей, переходы, откладывание,
остановка признаком.
- `internal/contract` — распознаватель отдаёт доменный результат вместо строки,
заливка и отправка разделены; контракты репозиториев записи, файлов, текстов,
структуры, попыток распознавания и журнала.
- `internal/adapter/recognizer/yandex` — разбор потока результата в реплики,
раздельные заливка и отправка, отдача сырых байтов на хранение.
- `internal/adapter/repo/pocketbase` — запрос захвата, отображение записи,
правила панели.
- `internal/service` — шаги, таблица выбора следующего шага по рубежу, остановка
признаком.
- `internal/controller/worker` и `main.go` — пул вместо трёх именованных
воркеров.
- `internal/config` и `config.example.toml` — число воркеров, срок захвата по
шагу, два предела времени в рубеже.
- `docs/architecture.md`, `docs/database.md`, инварианты `CLAUDE.md` о колонках
очереди и о держателе захвата.
- **Предшествующая задача**: `external-call-timeouts` в плане стройки стоит
ниже, а по «Рамкам» постановки предшествует — без предела по времени у шага
срок захвата не может его превысить.
@@ -0,0 +1,443 @@
# Триаж ревью: record-centric-model
## Сводка
- **Режим прогона:** по графу. **Метка:** `large`, обоснование разметки — «крупное ×
незнакомое» (смена модели очереди: захват, повторы и воркеры разом; конвейер задач
трогается целиком). Размер и сложность числом в переданном плане не названы; свой
замер объёма — 61 путь в рабочем дереве (`git status --short | wc -l`), изменение
лежит некоммитнутым поверх `d079f03`.
- **Состояние гейта:** зелёный, `task gate` exit 0 (проход `autotests`, лог в
`scratchpad/gate_run.log`).
- **Находок на входе:** 28 пронумерованных находок шести проходов плюс 14 пунктов в их
дополнительных секциях (specs — 9 «поведение вне спеки», architecture — 3 «дешевле
переделать до мерджа», ops — 2 ответа сверх перечня). После дедупликации по причине
осталось 24 различимые причины; в первых двух секциях — **7**.
- **Сверка с «Типовыми ложноположительными»** (`docs/review.md`) выполнена: под пункт
«файлы и объекты не удаляются, диск растёт» формально попадали две находки, обе
оставлены — у обеих есть замер, которого пункт и требует. Пункт про молчание воркера
на `NoopJobError` не выбрасывает находку №2, но **ограничивает её починку** — см.
внутри находки. Пункты про гонку захвата и про запись без владельца отменены
редакциями 2026-08-14 и к находкам этого прогона не применялись.
### План с исходом по каждой теме
| тема | дом | глубина | кто закрывает | исход |
|---|---|---|---|---|
| requirements | `openspec/specs/` + дельты change | разбор | specs | закрыта, 6 находок + секция из 9 пунктов |
| autotests | CLAUDE.md, «Гейт» | — | autotests | закрыта, 3 находки; гейт зелёный, флаки не найдены (3 прогона `-race`) |
| conventions | `docs/conventions/` | разбор | code | закрыта, 8 находок (4 техника + 4 конвенции) |
| architecture | `docs/architecture.md` + источник `passport.md` | доказательство | architecture | закрыта, 3 находки + секция из 3 пунктов |
| security | `docs/security.md` | доказательство | adversary | закрыта, 2 построенных пути + 1 свойство без пути |
| operations | `docs/architecture.md` «Эксплуатация» + источник `database.md` | доказательство | ops | закрыта, 5 находок |
| темы проекта | дома нет | — | basics | **не запускался**: своих тем у проекта нет, все документы `docs/` разошлись по шести темам ядра |
- **Тем без отчёта нет.** Каждая заявленная тема отчиталась; единственная строка «дома
нет» — `темы проекта`, и она заявлена такой в самом плане, а не потеряна на прогоне.
- **Сигнал о заниженной метке не пришёл ни от одного прохода.** `review-code`
отработал и возражений по метке не подал; `review-basics` на этом прогоне не
запускался, то есть его половина корректора не работала вовсе. Метку выбирал
`review-scope`, и независимая проверка метки прошла в одном лице из двух.
---
## Блокирует мердж
### Архив хранит не то, что пришло от провайдера: неизвестные поля ответа исчезают молча
- Файл: `internal/adapter/recognizer/yandex/speechkit.go:200-235`
- Severity: major
- Confidence: high
- Оракул: зонд прохода `specs``proto.Marshal` сохраняет неизвестное поле (4 байта),
пара `encodeResponses`/`decodeResponses` через `protojson` отдаёт 0 байт. Сверено
чтением на месте: `encodeResponses` зовёт `protojson.Marshal(resp)`, а комментарий
над ней обещает «ответ провайдера целиком, в том виде, в каком он пришёл».
Норма — дельта `recognition`, `openspec/changes/record-centric-model/specs/recognition/spec.md:36-38`:
«Сервис SHALL сохранять ответ распознавателя целиком, в том виде, в каком он пришёл».
- Последствие: `protojson` выбрасывает поля, которых нет в вендоренной схеме. Всё, что
SpeechKit добавит в ответ (и всё, что уже есть в версии сервиса новее нашей
go-genproto), в сохранённой попытке отсутствует, и узнать об этом нечем: разбор
проходит успешно. Ради этого архива и заведена коллекция `recognitions` — «станут
доступны, когда мы научимся их читать». Не станут. Повторное распознавание стоит
денег (`CLAUDE.md`, «Запреты», Yandex Cloud за деньги), а исходное аудио к тому
моменту может быть уже единственным, что осталось.
- Предложение: развилка, потому что решается формат файла на диске, а он в проекте
необратим (`CLAUDE.md`, «Работа»: «формат файла на диске» спрашивается у человека
всегда). Варианты: **(а)** хранить `proto.Marshal` — неизвестные поля переживают
цикл, цена: содержимое перестаёт читаться глазами и в панели; **(б)** оставить
`protojson` и переписать требование дельты, назвав цену прямо («храним разобранное
нашей схемой, а не пришедшее»); **(в)** хранить оба представления — цена в объёме
файла, вдвое.
- Найдено проходом: specs
- Действие: развилка
### Шаг сообщает исход одним `nil`, и остановка приговором засчитывается успехом наравне с откладыванием опроса
- Файл: `internal/service/transcribe.go:274-285`, `:634-644`, `:734-762`
- Severity: major
- Confidence: high
- Оракул: три независимых замера на живом хранилище (specs, code, ops) плюс сверка
чтением на месте. `failStep` (`:735-739`) возвращает `nil` после `halt`, ветка
«операция ещё идёт» (`:634-644`) тоже возвращает `nil`, а `RunStep` судит исход
только по `stepErr != nil` (`:274-285`). Замеры: остановленная запись даёт два
события — `halted`, следом `done`; счётчик `error=false` 1→2, `error=true` 0→0;
halt по `stuck``error=true` 0→0; 3 откладывания дают 3 строки `poll/done`,
50 циклов опроса — +50 строк `record_events`. Константа `EventOutcomeFailed`
объявлена (`internal/entity/record_event.go:15`) и не пишется ни одной строкой кода.
Нормы: `docs/architecture.md:156-161` — «Владелец — по метрике
`transcriber_worker_job_count` с меткой `error="true"` … Отдельного оповещения нет»;
дельта `pipeline`, `spec.md:264` — «Журнал MUST не писаться на каждое откладывание
опроса» и сценарий `spec.md:280-284` «Откладывание строки не пишет».
- Последствие: три причины остановки — исчерпанные попытки, застревание, приговор шага
— не двигают единственный канал владельца. Запись умерла, метрика показывает успех,
журнал записи утверждает `done`. Отправителю сообщение уходит (`halt` зовёт
`notify`), то есть инвариант «Принятая запись не теряется молча» формально держится
ровно наполовину: пользователь знает, владелец — нет. Второй половиной та же причина
забивает журнал: часовое распознавание кладёт ≈720 строк `done`, суточное — до ~17000
на одну запись, и настоящие события в нём тонут.
- Предложение: исход шага должен называться, а не выводиться из `nil` — отдельным
значением («сделано» / «отложено» / «остановлено»), и `RunStep` пишет `done` только
на первом. Остановка пишет `EventOutcomeFailed` (либо оставляет один `halted`) и
двигает `transcriber_worker_job_count{error="true"}` — тот, который назван каналом
владельца. **Ограничение починки, нарушить его нельзя:** считать в метрику сам
`NoopJobError`, которым `acquire()` возвращает остановленную запись воркеру,
запрещено инвариантом `CLAUDE.md``NoopJobError` — не ошибка», major) и записано
ложноположительным в `docs/review.md`. Значит счёт и событие ставит сам `halt`, а не
воркер.
- Найдено проходом: specs (2 находки), code (2), ops (2) — шесть формулировок одной
причины; оракулы независимые, `Confidence` от совпадения не растёт
- Действие: инлайн
### Единственный объявленный способ убрать запись оставляет полный текст речи на диске, а удаление учётной записи отвергается чужим сообщением
- Файл: `internal/adapter/repo/pocketbase/owner_guard.go:36-80`,
`internal/adapter/repo/pocketbase/migrations/202608140002_record_centric_model.go:225-302`
- Severity: major
- Confidence: high
- Оракул: прогон прохода `adversary` на живом хранилище — удаление строки
`audio_records` отвергается (связи приложений `Required:true` без каскада), удаление
строки `files` проходит молча, на диске остаётся
`storage/<recognitions>/<запись>/<имя>.payload` с текстом речи. Второй прогон:
удаление учётной записи с архивом даёт наш отказ с причиной, с одной темой —
«Make sure that the record is not part of a required relation reference». Сверено
чтением: `countOwned` перебирает `RecordsCollection` и `FilesCollection`, а
коллекций с колонкой `owner` в шаге схемы **три**`topics` заводится с полем
`owner` и уникальным индексом `idx_topics_owner_name`. Комментарий над функцией
обещает «по обеим коллекциям». Нормы: `docs/security.md:335-341` — «единственный
способ убрать запись — руками в базе и в каталоге на сервере», а задача
`delete-record` обязана убирать «все уровни текста»; дельта `storage` требует, чтобы
отказ называл причину.
- Последствие: владелец, выполнивший единственную записанную процедуру удаления,
получает отказ на строке записи и удаляет файл — после чего считает данные
удалёнными, а расшифровка речи человека остаётся на диске бессрочно. Второй путь:
собственный страж, заведённый ровно ради того, чтобы владелец не пошёл удалять
связи руками, на учётной записи с темой молчит и пропускает вперёд «ведущую»
подсказку библиотеки — то есть ведёт владельца делать необратимое. `docs/security.md`
этим изменением не тронут вовсе, хотя содержимое переехало в шесть коллекций и
завелась вторая раскладка файла на диске.
- Предложение: перечень коллекций с владельцем — одно место, выводимое из шага схемы
(инлайн-часть, `topics` добавляется сразу). Дальше развилка по удалению: **(а)**
завести каскад/процедуру, убирающую запись со всеми уровнями текста и файлами, до
мерджа; **(б)** оставить как есть, но переписать `docs/security.md` под новую
раскладку и назвать процедуру поимённо, включая `recognitions/*.payload`, и
дополнить «Затрагивает» задачи `delete-record`; **(в)** признать удаление
недоступным до `delete-record` и сказать это в `security.md` прямо.
- Найдено проходом: adversary (2 пути), code (1 находка о `topics`) — один корень
- Действие: развилка
---
## Стоит исправить сейчас
### Откат образа поверх применённого шага схемы не диагностируется: старый бинарь встаёт молча и ломает 100% очереди
- Файл: `internal/adapter/repo/pocketbase/migrations/migrations.go`,
шаг `202608140002_record_centric_model.go`
- Severity: major
- Confidence: high
- Оракул: замер прохода `ops` двумя реальными бинарями — старый бинарь поднимается на
каталоге новой схемы **без ошибки**, затем 100% обращений к очереди дают
`failed to find collection transcribe_jobs`. `RunAllMigrations` накатывает
недостающие из своего списка и шагов новее не видит; `down` коллекцию не
восстанавливает.
- Последствие: это первый шаг схемы проекта, который убирает коллекцию, а не
добавляет. Штатное средство владельца на инциденте — откатить образ — с этого
момента делает хуже и не говорит об этом: сервис стартует зелёным и отказывает на
каждой записи. Обратно чинится повторной выкладкой нового образа, то есть ущерб
обратим, но обнаруживается он в худший момент и не тем сообщением. Шаг схемы после
выкладки не переписывается (`CLAUDE.md`, инвариант, **critical**), поэтому дешёвая
минута — сейчас.
- Предложение: развилка. **(а)** старт отказывается работать на схеме новее своего
списка — явным сообщением «база новее бинаря, откат образа не поддержан»; цена:
проверка версии схемы на подъёме, ~десяток строк плюс её норма;
**(б)** записать в `docs/architecture.md`, «Эксплуатация», что откат образа через
этот шаг невозможен и что делать вместо него; цена: только текст, ловушка остаётся;
**(в)** признать осознанным и не делать ничего.
- Найдено проходом: ops
- Действие: развилка
### После жёсткого падения воркера запись невидима до восьми часов, а `own_work_limit_minutes` этим не управляет
- Файл: `internal/service/transcribe.go:296-339`,
`internal/adapter/repo/pocketbase/record_repo.go:190-215`, `internal/entity/stage.go`
- Severity: major
- Confidence: high
- Оракул: замер прохода `ops` — после захвата без `Save` повторный захват записи не
выдаёт; halt по `stuck` наступает только по истечении `acquire_expires_at`. Сверено
чтением: сторож простоя `isStuck` проверяется **после** захвата (`:328`), а захват
фильтрует по `acquire_expires_at` (`record_repo.go:215`), чей срок для приведения и
отправки — 8 часов (`docs/database.md:274-275`).
- Последствие: настройка продана владельцу как сторож зависания — «предел простоя, своя
работа, 60 минут, сторож ловит зависание» (`docs/database.md:279`), — но для
крашнутого или убитого держателя реальный предел вчетверо с лишним больше и задаётся
другим числом из другого файла. Запись человека молча стоит до восьми часов, и ни
один документ этого расхождения не называет. Класс — «молчание»: владелец узнает
только по отсутствию ответа.
- Предложение: свести к одному числу либо назвать оба и их роли — в
`docs/database.md` и в дельте `pipeline`. Развилка: **(а)** срок захвата опустить до
предела простоя (цена: многочасовое приведение начнёт терять захват и перезапускаться
— именно та цена, ради которой 8 часов и стоят); **(б)** оставить два числа, но
сделать протухший захват видимым (сторож простоя судит и по времени захвата);
**(в)** оставить как есть и записать в `database.md` прямо, что для крашнутого
держателя предел — срок захвата, а не `own_work_limit_minutes`.
- Найдено проходом: ops. **Понижено при триаже:** половина находки прохода
`architecture` — «имя ключа в коде разошлось с `design.md`» — из основного списка
снята: `config.example.toml`, `internal/config/config.go:35` и канон
`docs/database.md:279` называют ключ `own_work_limit_minutes` согласованно, разошёлся
один `design.md` изменения. Необратимости здесь нет, это дрейф документа — его дом
`av-dev:doc-healthcheck`, не ревью.
- Действие: развилка
### Переписанный конвейер уехал без проверок: пять тестов снесено, четыре узла с нулевым покрытием
- Файл: `internal/controller/worker/worker.go:100-147`,
`internal/adapter/recognizer/yandex/speechkit.go:206-280`,
`internal/controller/http/transcribe.go:140-166`,
`internal/adapter/repo/pocketbase/panel.go:33-99`,
`internal/adapter/repo/pocketbase/owner_guard.go:36-80`
- Severity: major
- Confidence: high
- Оракул: `go test ./... -coverpkg=./...` (проход `autotests`) — `Pool`/`NewPool`/
`Size`/`Start` 0.0%, `encodeResponses`/`decodeResponses`/`outcomeFromResponses` 0.0%,
`GetTranscribeJobStatus` 52.9% (ветка выдачи готовой расшифровки, ветка 500 при
отказе `textRepo` и новое поле `halted` без единого assert). `git status`: удалены
`owner_test.go`, `transcript_job_repo_test.go`, `file_repo_test.go` — пять проверок
снесено, а не переписано. Норма: `docs/review.md`, «Типовые узлы», «Любой узел» —
«изменённое место покрыто хоть одним **проходящим** тестом»; журнал 2026-08-10
показывает, чем это кончается.
- Последствие: пул одинаковых воркеров — предмет всего изменения — не поднимается ни
одним тестом; разбор реального ответа SpeechKit не проверен ничем, хотя `Parse`
чистая функция и сети не требует; панельный возврат записи в работу и страж удаления
учётной записи держатся на чтении глазами, и находка №3 показывает, что чтение уже
один раз промахнулось. Зелёный гейт здесь не означает проверенного кода.
- Предложение: инлайн, четыре адреса. Приоритет — `encodeResponses`/`decodeResponses`
(дешевле всех, чистые функции, и это оракул находки №1), `owner_guard` с тремя
коллекциями, ветки `GetTranscribeJobStatus` включая `halted`, подъём пула.
Панельный возврат — тестом против настоящего хранилища, как это делают
`schema_test.go` и `ownership_test.go`.
- Найдено проходом: autotests (3), specs (1)
- Действие: инлайн
### Колонка `source_uri` уезжает необратимым шагом схемы, и читателя у неё нет
- Файл: `internal/contract/repository.go`, `internal/contract/contract.go`,
шаг `202608140002_record_centric_model.go`
- Severity: minor
- Confidence: high
- Оракул: проход `architecture``grep` по рабочим путям: колонка пишется и не
читается ни одним, адрес объекта пересчитывается `SourceURI(objectKey)` на месте
употребления. Контракт распознавателя вырос с 3 методов до 9: `Upload`,
`ObjectExists`, `SourceURI` вынесли в ядро ключ объекта и идемпотентность заливки.
- Последствие: шаг схемы после выкладки не переписывается (`CLAUDE.md`, инвариант,
**critical**), поэтому снять колонку потом можно только новым шагом. Пока она есть,
следующий читатель обязан гадать, что из двух — колонка или пересчёт — правда, а
ядро обязано знать про объектное хранилище провайдера, чего оно знать не должно.
Цена сегодня — минуты, после мерджа — новый шаг схемы и вопрос человеку.
- Предложение: развилка. **(а)** свернуть заливку в один метод `EnsureUploaded` и
убрать `SourceURI`/`ObjectExists` из контракта; **(б)** оставить контракт и начать
читать `attempt.SourceURI` вместо пересчёта — тогда у колонки появляется читатель;
**(в)** признать колонку заделом осознанно и снять её из шага схемы **до** мерджа,
вернув отдельной задачей.
- Найдено проходом: architecture
- Действие: развилка
---
## Гипотезы без доказательства
Понижены: оракула нет либо путь не построен. Все — `major` и ниже по контракту.
- **Отказ чтения в `Put` подменяется заведением новой строки** (`text_repo.go:33-42,
92-101`, было minor/high у specs и code). Код не различает `sql.ErrNoRows` от отказа
базы: на кратком отказе хранилища вместо обновления заводится вторая строка текста.
Зонда никто не снял — понижено до гипотезы, но починка дешёвая и очевидная
(`errors.Is(err, sql.ErrNoRows)`).
- **Приговор шага выносится с первой попытки, и `maxAttempts` не работает никогда**
(secция «поведение вне спеки» прохода specs). Отказ `ffmpeg` идёт через `failStep`
сразу, то есть счётчик попыток не доживает до сторожа. Замера нет, дельтой вопрос
«какие отказы приговор, а какие повтор» не решён вовсе — это **самый весомый из
отложенного**: если гипотеза верна, кратковременный отказ внешней программы хоронит
запись человека с первой попытки. Стоит зонда в следующем прогоне либо строки в
дельте `pipeline`.
- **Признак «работы нет» сервис выдаёт сам за прогоны, в которых работа была**
(`transcribe.go:244,258,319,332`). Дельта требует, чтобы признак рождался только
ответом хранилища на опрос. Последствие — искажение наблюдаемости, не поведения;
зонда нет.
- **Пул без верхней границы** (замер ops: 12.6k оп/с не растёт с N, латентность
78.7µs → 7.93ms при 1→100 воркерах; `Validate()` проверяет только `Workers<0`).
Замер настоящий, но `docs/review.md`, «Недоступно проверке», прямо говорит: реального
профиля нагрузки у проекта нет, «утверждения о росте остаются условиями». Проект
работает на единицах записей в день, ущерб сегодня нулевой — гипотеза, не находка.
- **Ручная правка рубежа в панели у остановленной записи не снимает признак остановки**
(секция specs). Путь не построен, поведение панели проверено только чтением.
- **Пауза при переходе на `submitted` нигде не нормирована** и **мёртвая пара
`location`/`object_key`** (секция specs). Расхождения без названного последствия.
- **Таймаутов у Telegram, S3 и SpeechKit по-прежнему нет ни одного, `FindAndAcquire` не
принимает контекст** (ops). Не находка этого изменения: отсутствие таймаутов
записано в `docs/review.md`, «Вопросы по темам», чтением от 2026-08-13 и старше
задачи. Названо, чтобы не читалось как новое.
## Promote candidates
- **Сканер на пару «правило домена ↔ его повтор в адаптере».** `panel.go:33-99`
повторяет `entity.AudioRecord.Resume()` колонками, а `grep '\.Resume()'` даёт
единственное вхождение — в тесте. `internal/archrules` такую пару не держит, и
разойдутся они молча. Правило механизируемо — значит это кандидат в сканер, а не
находка ревью (контракт находок, `nit`).
- **Правило конвенции для новых `select`-перечислений.** Пять новых перечислений
закрыты схемой, `docs/conventions/database.md` даёт изъятие только для `state`, ни
одной строки «*Расхождение:*» не добавлено. Либо изъятие расширяется, либо каждая
новая строка объявляется — сегодня не сказано ни то, ни другое.
- **`schemaFieldNames` в `internal/archrules` собирает поля из всех коллекций каталога
шагов, включая снесённую `transcribe_jobs`.** Правило остаётся зелёным и при колонке,
объявленной в чужой коллекции, — то есть страж инварианта про колонки ослаб. Это
правка самого правила (не «проверка над проверкой», запрет `CLAUDE.md` сюда не
достаёт), но она сама себе кандидат в конвенцию: перечень схемы читается по текущей
схеме, а не по истории каталога.
## Урожай (отложено, сработал потолок)
Ни один пункт ниже не выброшен — они не поместились в семь и ждут своей задачи.
- **Опрос чужой операции пишется на `INFO` двумя строками за цикл**
(`transcribe.go:623,637`). `docs/conventions/logging.md:60,77,183` называет «проверку
готовности операции распознавания» уровнем `DEBUG` поимённо; ≈1440 строк `INFO` на
часовую запись. Плюс записанное там же *Расхождение*: `DEBUG` включить нечем, уровень
зашит в `main.go`. Починка — одна замена уровня, но она без предмета, пока уровень не
настраивается.
- **Правило возврата записи в работу написано дважды** (`panel.go:33-99` против
`entity.AudioRecord.Resume()`), и событие журнала пишется двумя способами —
`appendEvent` через контракт и `appendResumeEvent` вручную через `core.NewRecord` в
адаптере. Механизируемая часть ушла в promote выше; остаток — решение, какой из двух
путей настоящий.
- **Поверхность без вызывающих:** `contract.Clock` (реализаций нет), `entity.Stages()`,
`entity.WorkingStates()`, поле `recordRepo` в `TelegramController`, интерфейс `Worker`
с единственной реализацией.
- **`docs/conventions/logging.md:103` называет поле `job_id`, код перешёл на
`record_id`.** Записанная конвенция разошлась с кодом — дрейф документа.
- **`design.md` изменения называет ключи `own_work_limit`/`foreign_work_limit`, код и
канон — `own_work_limit_minutes`/`foreign_work_limit_minutes`.** Дрейф документа,
дом — `av-dev:doc-healthcheck`.
- **Словарь «job» пережил понятие** (`JobNotFoundError`, `CreateJobFromApi`,
`TranscribeHandler.CreateTranscribeJob`). **Выброшено как вкусовщина**, а не отложено:
поведения не меняет, стоимости следующего изменения не меняет заметно, записанной
конвенции не нарушает; публичные имена полей API трогать всё равно нельзя. Названо,
чтобы не всплыло третьим прогоном как новое.
---
## Границы покрытия
**План: темы, дома, глубины.** requirements (`openspec/specs/` + дельты, разбор),
autotests (`CLAUDE.md` «Гейт», глубины нет), conventions (`docs/conventions/`, разбор),
architecture (`docs/architecture.md` + `passport.md`, доказательство), security
(`docs/security.md`, доказательство), operations (`docs/architecture.md`
«Эксплуатация» + `database.md`, доказательство), темы проекта — **дома нет**.
**Что запускалось.** Шесть проходов на метке `large`, режим «по графу»: specs,
autotests, code, architecture, adversary, ops. **Не запускался** `basics` — своих тем
у проекта нет, все документы `docs/` разошлись по шести темам ядра; это решение плана,
а не отказ прогона. Пятая строка про метку `small` неприменима: метка `large`, дома
`security`, `operations` и `architecture` открывались.
**Что не мог проверить каждый проход.** Уставы проходов мне дословно не переданы —
границы ниже выведены из их же выводов, и это деградация строкой: `autotests` судит
наличие и способность проверок падать, но не правильность самой нормы; `specs` судит
код против дельт и молчит там, где дельта молчит (раздел «поведение вне спеки» — ровно
этот остаток); `code` читает и не запускает боевых сценариев; `architecture` судит
форму решения и не мерит; `adversary` строит пути в границах прогона — настоящих
Telegram, SpeechKit и Object Storage в прогоне нет; `ops` мерит на своей машине и
одном каталоге данных, а не на сервере.
**Что осталось целиком на человеке** (`docs/review.md`, «Недоступно проверке»; списки
раздельные и не сливаются).
*Не проверит ни один проход:*
- `operations`: поведение внешних сервисов под нагрузкой и на границах — SpeechKit и
Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки; проект работает на единицах записей в день,
и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу;
- `security`: поведение настоящей Authelia и её правило на нашего клиента;
- `security`: поведение браузера с куками — `SameSite`, приём `Set-Cookie` при переходе
с чужого сайта.
*Перестали проверять сознательно:*
- `autotests`: разбор вывода настоящего `ffprobe` — длительность даёт подставной
источник (ADR-2026-08-11-stub-adapters-in-tests);
- работа сервиса с настоящими внешними собеседниками: живой прогон отвечает за подъём,
отказ старта, маршруты и остановку; приём из Telegram, расшифровку и заливку он не
проверяет — боевым токеном запускаться запрещено, ключи Yandex выдуманы,
распознавание подменяется в коде.
Сверх этого никем не проверено: история инцидентов этого сервиса, поведение под
реальным потоком, поведение внешних систем в их сегодняшних версиях, завязка
потребителей на текущее поведение и вопрос «а нужна ли эта функциональность вообще».
**Каких документов не хватило** — строкой на каждый, с причиной:
- `docs/security.md` **есть, но не тронут этим изменением**: периметр в нём описан по
прежней модели (задачи и файлы), про шесть коллекций и вторую раскладку файла на
диске он не знает. Проход `adversary` судил новый периметр по старому документу;
- `design.md` изменения расходится с каноном и кодом по именам ключей конфига —
документ был, но как источник имён недостоверен;
- `docs/conventions/database.md` даёт изъятие только для `state`: как объявлять новые
`select`-перечисления, не сказано ни в одну сторону, и проход `code` судил по
умолчанию;
- дельта `pipeline` не решает, какие отказы приговор, а какие повтор, — из-за этого
самая весомая гипотеза осталась гипотезой;
- `docs/review.md`, «Типовые ложноположительные», **был и использован**; раздел не
пуст, отсев шёл не вслепую.
**Сработавшие потолки — по проходу.** `code` объявил свой: конвенций 4 из 4, потолок
сработал ровно, что осталось за срезом — не названо. `architecture` объявил: 3 находки
плюс секция «дешевле переделать до мерджа». `autotests` (3), `specs` (6 + 9), `ops`
(5 + ответы на 9 вопросов) и `adversary` (2 пути + 1 свойство) **своего потолка не
сообщили** — то есть «находок больше нет» у них неотличимо от «больше не поместилось».
Это ровно то, что записано в `docs/review.md` от 2026-08-13, и класс всплыл снова.
**Потолок триажа.** В первые две секции не влезло семь причин; все они выписаны в
«Урожай» и «Гипотезы» поимённо, молча не выброшено ничего. Одна выброшена как
вкусовщина и названа там же.
**Четыре строки, которые не принёс ни один проход:**
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон его не
открывает. Расхождение изменения с записанным решением (в том числе с
ADR-2026-08-11 про архив и ADR-2026-08-12 про сессию) ловит скилл
`av-dev:doc-healthcheck`, а не ревью.
2. **Записанные наблюдения проекта не использовались.** `docs/research/` не
открывался. Всякое число в этом отчёте снято проходом на этом прогоне или прочитано
в коде и каноне со ссылкой на строку.
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом.**
Различение «идиоматично против распространено» не спрашивал никто.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.**
«Не знаю, чего не знаю» здесь не достаёт никто — на изменении с меткой «незнакомое»
это самый дорогой пробел прогона.
Формулировка «критичных проблем не обнаружено» к этому отчёту неприменима: `critical`
в нём нет потому, что ни одна находка не получила оракула, поднимающего её до
нарушения инварианта необратимого класса, — а не потому, что таких свойств не искали
и не нашли.
@@ -0,0 +1,168 @@
## MODIFIED Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
получить заведённую под неё аудиозапись на рубеже `uploaded`; ответ MUST нести
идентификатор записи полем `job_id` и её рубеж полем `status`.
Значение рубежа в ответе изменилось: прежде приём отдавал `created`. Перечень
состояний назван проектом необратимым, и ломка объявлена прямо — состояние
теперь называет достигнутое, а не предстоящее, и `created` в новом перечне нет
вовсе.
Имена полей ответа нормативны и MUST остаться прежними: контракт HTTP API
объявлен проектом необратимым, и переименование поля ломает внешнюю программу
молча. Меняются значения поля рубежа, а не его имя.
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Проверка
стоит здесь, а не только в схеме хранилища: колонка владельца допускает пустое
значение ради записей из Telegram, и приём по HTTP — то место, где
обязательность держится.
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё
же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он
стать не может.
Код здесь другой, чем у запроса без сессии, и это не оплошность: `401` значит
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
пишется.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `uploaded`
- **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии
#### Scenario: Сессия не даёт учётной записи пользователя
- **GIVEN** предъявлена сессия владельца панели
- **WHEN** он шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `403`
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Сессии нет
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни аудиозаписи не заводится
- **AND** тело ответа не несёт данных записи
#### Scenario: Поля с записью нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
### Requirement: Опрос готовности задачи
Сервис SHALL отдавать рубеж аудиозаписи по запросу `GET /api/status/:id`
**только её владельцу**. Запрос без сессии MUST получать код `401`, и тело
такого ответа MUST не нести ни рубежа записи, ни текста расшифровки. Ответ
владельцу MUST нести идентификатор полем `job_id`, рубеж полем `status` и время
заведения полем `created_at`, а текст расшифровки полем `transcription_text`, и
это поле MUST отсутствовать в ответе, пока текста нет: пустая строка на месте
отсутствующего текста читается как «расшифровка пуста».
Видов текста у записи больше одного, поэтому ответ MUST называть вид, который
отдаёт: в поле `transcription_text` уходит **сырая расшифровка**, и только она.
Вычитанный текст этим полем MUST не подменяться — иначе значение поля менялось бы
у одной и той же записи от того, успел ли отработать необязательный шаг, а
контракт объявлен необратимым. Отдача «последнего записанного» текста MUST не
применяться: она делает ответ функцией порядка записи, а не состояния записи.
Перечень значений поля `status` MUST совпадать с перечнем рубежей конвейера:
`uploaded`, `normalized`, `submitted`, `transcribed`, `done`. Прежних значений
`created`, `converted`, `transcribe`, `failed` и `dead` в ответе MUST не быть.
Это объявленная ломка публичного контракта: рубеж называет достигнутое, а отказ
перестал быть состоянием.
Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST
нести признак остановки отдельным полем `halted` со значением истины. Машинный
текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса,
а не отправителю. Отправитель узнаёт о неудаче ответом там, откуда пришла
запись, — это нормирует capability `pipeline`.
Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по
кодам ответа перебирается список заведённых записей.
Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
идентификатор, — кодом `404` и тем же телом. То же MUST относиться к записи без
владельца: запись, принятая ботом, по этому адресу не достаётся никому.
#### Scenario: Запись найдена
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает рубеж своей записи
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
- **AND** значение `status` принадлежит перечню рубежей конвейера
#### Scenario: Запись остановлена
- **GIVEN** запись остановлена признаком на рубеже приведения
- **WHEN** владелец спрашивает её рубеж
- **THEN** поле `status` несёт рубеж приведения
- **AND** поле `halted` несёт истину
- **AND** машинного текста отказа в ответе нет
#### Scenario: Сессии нет
- **WHEN** программа спрашивает рубеж заведённой записи без сессии
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
#### Scenario: Без сессии неизвестная запись неотличима от заведённой
- **WHEN** программа без сессии спрашивает рубеж заведённой записи, а затем
рубеж по неизвестному идентификатору
- **THEN** оба ответа имеют код `401`
#### Scenario: Чужая запись неотличима от неизвестной
- **GIVEN** запись заведена одним вошедшим
- **WHEN** её рубеж спрашивает другой вошедший
- **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному
идентификатору
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
#### Scenario: Расшифровки ещё нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает рубеж своей записи, которая ещё не дошла до текста
- **THEN** поля `transcription_text` в ответе нет вовсе
#### Scenario: Записи с таким идентификатором нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает рубеж по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной записи
@@ -0,0 +1,714 @@
## ADDED Requirements
### Requirement: Рубеж записи называет достигнутое
Аудиозапись SHALL нести рубеж — состояние, называющее **достигнутое**, а не
предстоящее. Цепочка рубежей: `uploaded`, `normalized`, `submitted`,
`transcribed`, `done`. Какой шаг делать дальше, сервис MUST выбирать по рубежу
одним общим местом, а не тем, какой воркер пришёл за записью.
Прежние состояния называли предстоящую работу (`created`, `converted`,
`transcribe`), и потому по состоянию нельзя было сказать, что с записью уже
сделано: продолжить с места остановки было не с чего.
Конечный рубеж MUST зваться `done`. Доставка ответа отправителю в конвейер не
входит, и слово описывает пройденный конвейер, а не полученный человеком текст.
Перечень рубежей, из которых запись берётся в работу, MUST выводиться из одного
объявления рубежа, а не перечисляться отдельно каждым потребителем. Рубеж,
забытый в отборе захвата, не выдаётся ни одному воркеру никогда, а пустой прогон
по инварианту проекта не пишется в журнал и не считается в метрику: запись
встала бы без единого следа. Потребителей у перечня больше двух — выбор шага,
отбор захвата, срок захвата, предел времени, закрытый перечень значений в схеме,
— и человеческая сверка между ними не механизируема.
Записи на конечном рубеже MUST не браться в работу и MUST не подпадать под
предел времени в рубеже: `done` не ждёт работы, и стоять в нём запись будет
вечно по построению.
#### Scenario: Рубеж называет сделанное
- **GIVEN** запись прошла приведение к рабочему формату
- **WHEN** смотрят её рубеж
- **THEN** он называет приведение сделанным, а не предстоящим
#### Scenario: Следующий шаг выбирается по рубежу
- **GIVEN** запись стоит на рубеже приведения
- **WHEN** её берёт воркер
- **THEN** идёт отправка на распознавание, а не повторное приведение
#### Scenario: Запись на конечном рубеже не берут и не останавливают
- **GIVEN** запись стоит на конечном рубеже дольше любого предела
- **WHEN** приходит захват
- **THEN** запись ему не выдаётся
- **AND** признака остановки у неё не появляется
### Requirement: Остановка записи — признак, а не рубеж
Сервис SHALL останавливать запись отдельным признаком с причиной и MUST не
стирать при этом достигнутый рубеж. Признак MUST нести время остановки, причину
и машинный текст отказа.
Снятие признака SHALL возвращать запись в работу **с того рубежа, где она
стояла**, и MUST сбрасывать число отказов, паузу **и время входа в рубеж**.
Время входа сбрасывается по той же причине, что и остальные сторожа: запись,
простоявшая остановленной дольше предела, иначе останавливалась бы снова первым
же захватом, и перезапуск не работал бы вовсе.
Прежние состояния отказа и смерти MUST не заводиться заново: обе причины
восстанавливаются одинаково — снятием признака, — и различие между ними
перестаёт быть структурным, оставаясь причиной остановки. Состояние, называющее
отказ, стирает достигнутый рубеж, и продолжение с места остановки становится
невозможным.
**Способ вывести запись из выборки MUST быть один — этот признак.** Второго
признака, исключающего запись из работы помимо рубежа и паузы, MUST не
заводиться: два способа расходятся, и молчаливо теряется тот, который забыли
проверить. Условие отбора MUST не выводить запись из выборки молча — запись,
переставшая браться в работу, обязана нести признак остановки с причиной.
Остановку MUST ставить тот, кто запись захватил. Перевод принадлежит одному
месту: условие отбора, молча пропускающее запись мимо выборки, оставило бы её
без следа.
Остановленная запись MUST не выдаваться захвату.
#### Scenario: Остановленная запись продолжает с места остановки
- **GIVEN** шаг остановил запись на рубеже приведения
- **WHEN** признак остановки снимают
- **THEN** следующим идёт отправка на распознавание, а не повторное приведение
#### Scenario: Остановленная запись не выдаётся захвату
- **GIVEN** у записи стоит признак остановки
- **WHEN** за её рубежом приходит захват
- **THEN** запись ему не выдаётся
#### Scenario: Снятие признака сбрасывает всех сторожей
- **GIVEN** запись остановлена с накопленными отказами и паузой
- **AND** остановленной она простояла дольше предела времени в рубеже
- **WHEN** признак остановки снимают
- **THEN** число отказов, пауза и время входа в рубеж сброшены
- **AND** ближайший захват выдаёт запись, а не останавливает её снова
### Requirement: Всякая остановка сообщает отправителю
Остановка записи по любой причине SHALL сообщать отправителю о неудаче ровно
так же, как сообщает о ней отказ шага, и MUST быть видна владельцу сервиса
записью в журнале.
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
инвариант допускает два исхода — запись пригодна к повтору либо об отказе
сказано, — а остановленная запись захвату не выдаётся, значит первый исход
исключён.
Причин остановки больше одной, и обязанность общая для всех: исчерпанные
отказы, застревание в рубеже, приговор шага. Обязанность, записанная у одной
причины, у остальных читалась бы как снятая.
Ответ уходит **после** того, как признак остановки сохранён, и недоставка этого
ответа MUST не отменять остановку: её нормирует требование «Недоставленный ответ
не роняет шаг».
#### Scenario: Остановка по отказам сообщает отправителю
- **GIVEN** запись остановлена по исчерпании отказов
- **WHEN** шаг доходит до ответа отправителю
- **THEN** отправитель получает сообщение о неудаче
#### Scenario: Остановка по времени сообщает отправителю
- **GIVEN** запись остановлена по пределу времени в рубеже
- **WHEN** шаг доходит до ответа отправителю
- **THEN** отправитель получает сообщение о неудаче
- **AND** в журнале владельца есть запись об остановке с причиной
### Requirement: Время в рубеже ограничено
У аудиозаписи SHALL быть время входа в рубеж, и оно MUST ставиться только при
смене рубежа и при возврате записи в работу. Запись, простоявшая в рубеже дольше
предела, MUST останавливаться признаком с причиной «застряла».
Пределов MUST быть два, и граница проходит по тому, **чью работу ждём**: своя
работа — час, ожидание чужой операции — сутки. Одно общее число пришлось бы
мерить по самому долгому, и застрявшее приведение стояло бы сутки; число на
каждый рубеж назвало бы разными вещи, различающиеся только исполнителем. Сколько
идёт распознавание долгой записи, сервис не мерил, поэтому у чужой работы ошибка
идёт в сторону долгого: ложная остановка хуже поздней. Оба числа MUST лежать в
настройках.
Сторож этот ловит **зависание**, а не долгую работу, и час у своей работы меньше
времени, которое многочасовая запись занимает на приведении. Цена решения
названа прямо: длинная запись, отказавшая один раз и ждущая повтора дольше часа,
будет остановлена как застрявшая. Цена ограничена тем, что остановка обратима —
снятие признака возвращает запись на её рубеж, — и тем, что живой шаг проверяется
по самому процессу. Решение владельца 2026-08-14.
Откладывание опроса MUST не двигать время входа в рубеж и MUST не сдвигать этот
предел. Иначе запись, чью чужую операцию опрашивают раз в несколько секунд,
никогда не достигнет предела, и застревание останется незамеченным.
Остановка по этому пределу MUST ничего не терять: идентификатор чужой операции
остаётся в строке попытки распознавания, и снятие признака возобновляет опрос
той же операции, а не заводит вторую.
#### Scenario: Сотня откладываний не двигает отсчёт
- **GIVEN** запись стоит на рубеже отправки, и чужая операция ещё идёт
- **WHEN** опрос откладывается сотню раз подряд
- **THEN** время входа в рубеж не изменилось
- **AND** отсчёт до предела не обнулился
#### Scenario: Предел достигнут
- **GIVEN** запись простояла в рубеже дольше своего предела
- **WHEN** за ней приходит захват
- **THEN** запись получает признак остановки с причиной «застряла»
#### Scenario: Возобновление опроса не заводит вторую операцию
- **GIVEN** запись остановлена по пределу на рубеже отправки
- **WHEN** признак остановки снимают
- **THEN** опрос идёт по прежнему идентификатору операции
- **AND** новая операция у провайдера не заводится
### Requirement: Откладывание не является переходом
Сервис SHALL различать переход на новый рубеж и откладывание работы над
записью. Откладывание MUST ставить паузу и снимать захват, MUST не трогать ни
рубеж, ни время входа в него, и MUST не считаться отказом: ожидание чужой
операции отказом не является, поэтому число отказов оно MUST обнулять.
Сегодня шаг опроса зовёт переход с **тем же** состоянием, и мнимость этого
перехода обнуляет счётчик. Без разделения время входа в рубеж сбрасывалось бы на
каждом опросе и повторило бы ровно тот промах, ради которого заводится.
#### Scenario: Откладывание не двигает рубеж
- **GIVEN** шаг опроса увидел, что чужая операция ещё идёт
- **WHEN** он откладывает работу
- **THEN** рубеж записи прежний, и время входа в него прежнее
- **AND** захват с записи снят, а пауза поставлена
### Requirement: Шаг с внешней оплатой проверяет сделанное
Шаг, чьё повторение оплачивается наружу, SHALL проверять, не сделана ли работа
уже, и MUST не делать её второй раз. Проверка MUST идти по наблюдаемому признаку
присутствия результата, а не по сверке содержимого хешем: у составного объекта
во внешнем хранилище признак целостности не равен отпечатку содержимого.
Признак MUST записываться прежде, чем оплачиваемое обращение считается
состоявшимся: строка попытки распознавания заводится до обращения к провайдеру,
и повторный шаг начинает с проверки, не заведена ли операция.
Полной защиты от обрыва процесса между ответом провайдера и записью признака
требование не даёт и дать не может: жёсткая остановка контейнера не оставляет
места ни одной записи. Это остаточный риск, названный в дизайне, а не норма:
норма, обязывающая к недостижимому, не пишется.
#### Scenario: Работа уже сделана
- **GIVEN** результат оплачиваемого шага уже на месте, и признак его записан
- **WHEN** шаг повторяется
- **THEN** внешнее обращение не повторяется
### Requirement: Число воркеров задаётся настройкой
Сервис SHALL брать число рабочих потоков конвейера из настроек, а сами потоки
MUST не быть привязаны к отдельному шагу: каждый берёт любую подходящую запись и
выбирает шаг по её рубежу. Поведение записи MUST не зависеть от числа потоков.
Ноль MUST быть законным значением: сервис поднимается, записи принимаются и не
двигаются. Это режим, а не поломка.
Счётчик работы воркера MUST различать шаги: метка счётчика MUST нести рубеж, с
которого запись взята, а не имя или номер потока. У одинаковых потоков имя
перестаёт что-либо значить, а счётчик отказов — единственный сигнал, по которому
владелец сервиса замечает поломку; без разреза по шагу «падает приведение» и
«падает распознавание» становятся неразличимы.
Опрос чужой операции MUST оставаться работой очереди, а не отдельного
смотрителя: очередь даёт ему неделимость захвата и возврат брошенного даром, а
единственный смотритель умирает молча и уносит с собой целый класс записей.
#### Scenario: Запись доходит при одном потоке и при нескольких
- **GIVEN** число потоков конвейера равно одному
- **WHEN** запись проходит конвейер
- **THEN** она доходит до конечного рубежа
- **AND** при числе потоков больше одного исход тот же
#### Scenario: Потоков нет вовсе
- **GIVEN** число потоков конвейера равно нулю
- **WHEN** запись принимают
- **THEN** сервис принимает её и не теряет
- **AND** запись остаётся на первом рубеже
#### Scenario: Отказ виден с разрезом по шагу
- **GIVEN** шаг приведения отказал
- **WHEN** наблюдатель читает счётчик работы воркера
- **THEN** отказ засчитан с меткой рубежа приведения
### Requirement: Журнал событий записи пишется на смену рубежа
Сервис SHALL вести журнал событий аудиозаписи и MUST писать в него строку на
смену рубежа, на остановку и на снятие остановки. Строка MUST называть источник
события — шаг конвейера или человека, — сам шаг, исход и длительность.
Журнал MUST не писаться на каждое откладывание опроса: часовая запись дала бы
сотни строк ни о чём.
Ни один шаг конвейера MUST не читать этот журнал, чтобы решить, что делать
дальше: решение принимается по рубежу записи, и второй источник решения
разошёлся бы с первым молча.
Содержимое записи в журнал событий MUST не попадать — инвариант приватности
действует здесь наравне с журналом сервиса.
#### Scenario: Смена рубежа записана
- **GIVEN** шаг конвейера довёл запись до нового рубежа
- **WHEN** смотрят журнал событий этой записи
- **THEN** в нём есть строка с шагом, исходом и длительностью
#### Scenario: Откладывание строки не пишет
- **GIVEN** опрос чужой операции откладывается многократно
- **WHEN** смотрят журнал событий записи
- **THEN** строк об откладываниях в нём нет
#### Scenario: Перезапуск человеком виден в журнале
- **GIVEN** запись остановлена признаком
- **WHEN** человек снимает признак
- **THEN** в журнале событий есть строка с указанием, что это сделал человек
### Requirement: Число отказов ограничивает повторы шага
У аудиозаписи SHALL быть число отказов. Оно MUST расти при каждом захвате и MUST
возвращаться к нулю, когда шаг завершился без отказа либо отложил работу. Рост
при захвате, а не при отказе, засчитывает попытку и записи, брошенной на
середине: шаг, уносящий с собой процесс, до объявления отказа не доходит
никогда.
**Остановка сервиса отказом не считается.** Шаг, прерванный отменой по
собственной остановке сервиса, MUST возвращать число отказов назад и MUST не
выносить записи приговора: запись не виновата в том, что нас перезапустили, и
несколько выкладок подряд иначе останавливают здоровую многочасовую запись с
приговором «отказы исчерпаны». Всякая другая причина, по которой шаг не дошёл до
объявления исхода, отказ тратит.
Запись, захваченная с числом отказов сверх заданного предела, MUST
останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в
работу. Об этой остановке отправителю сообщается наравне с прочими — норму
держит требование «Всякая остановка сообщает отправителю».
Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое
записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни
с одной из двух обязанностей — опрос, вернувший «ещё в работе», обнуляет его, и
зависшая чужая операция опрашивается вечно, а не обнулял бы — убивал бы здоровую
запись.
#### Scenario: Запись отказывает на каждой попытке
- **GIVEN** шаг конвейера отказывает на каждой попытке
- **WHEN** запись проходит заданное число отказов
- **THEN** у неё появляется признак остановки
- **AND** следующий захват её не выдаёт
- **AND** отправитель получает сообщение о неудаче
#### Scenario: Шаг уносит процесс, не объявив отказа
- **GIVEN** шаг конвейера обрывается вместе с процессом на каждой попытке
- **WHEN** запись захватывается снова заданное число раз
- **THEN** у неё появляется признак остановки
#### Scenario: Остановка сервиса отказа не тратит
- **GIVEN** шаг работает над записью
- **WHEN** сервис останавливают, и шаг прерывается отменой
- **THEN** число отказов записи прежнее
- **AND** признака остановки у записи не появляется
#### Scenario: Прошедшая запись отказов не копит
- **GIVEN** запись прошла подряд несколько рубежей без единого отказа
- **WHEN** смотрят её число отказов
- **THEN** оно не приблизилось к пределу
## MODIFIED Requirements
### Requirement: Захват задачи неделим
Захват записи воркером SHALL быть одним неделимым шагом хранилища: выбор
подходящей записи и пометка её захваченной MUST происходить вместе.
Захват MUST возвращать **идентификатор записи и признак этого захвата**, а не
перечень её колонок. Колонки записи шаг читает сам, обычным чтением. Иначе
всякая новая колонка аудиозаписи попадала бы под инвариант проекта о колонках
очереди, и забытая в захвате колонка приезжала бы нулевой, а первое же
сохранение писало бы этот ноль поверх сохранённого значения.
**Признак захвата MUST быть значением, уникальным для каждого захвата**, а не
признаком занятости. Условие записи результата сверяет именно это значение:
захват, перевыданный другому — по протуханию срока или после того, как человек
снял признак остановки в панели, — обязан обращать запись первого в отказ.
Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и
два шага записали бы в одну запись и оба ответили бы отправителю.
Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим,
пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST
получить признак «работы сейчас нет».
Срок протухания захвата MUST ехать с рубежом записи, а не с воркером: воркер не
привязан к шагу и не знает заранее, что вытянет. Срок MUST записываться числом
при самом захвате.
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному
значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок
обработки невоспроизводим, а проверка, опирающаяся на «следующую» запись, зелена
через раз.
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа
одного из них теряется без следа.
Признак «работы нет» этим требованием не переопределяется — его нормирует
требование «Пустой прогон воркера — не отказ».
#### Scenario: За работой пришли трое разом
- **GIVEN** к работе пригодна ровно одна запись
- **WHEN** три захвата идут одновременно
- **THEN** запись получает ровно один из них
- **AND** двое остальных получают признак «работы сейчас нет»
#### Scenario: Захваченная запись не выдаётся второй раз
- **GIVEN** запись захвачена и срок захвата не истёк
- **WHEN** приходит следующий захват
- **THEN** эта запись ему не выдаётся
#### Scenario: Захват отдаёт идентификатор и свой признак
- **GIVEN** к работе пригодна запись
- **WHEN** воркер её захватывает
- **THEN** захват возвращает идентификатор записи и признак этого захвата
- **AND** колонки записи шаг читает отдельным чтением
#### Scenario: Признак перевыданного захвата отличается от прежнего
- **GIVEN** запись захвачена, и признак первого захвата известен
- **WHEN** человек снимает признак остановки, и запись захватывает другой воркер
- **THEN** признак нового захвата отличается от признака первого
### Requirement: Результат пишет только держатель захвата
Шаг конвейера SHALL записывать свой результат только тогда, когда захват записи
всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата**
значению, уникальному для каждого захвата, — а не по занятости записи вообще.
Шаг, чей захват за время работы достался другому, MUST завершиться без записи
результата и без ответа отправителю.
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
теряет запись, продолжая работать. Снять захват может и человек, вернувший
остановленную запись в работу. Без условия по уникальному признаку два воркера
пишут в одну запись по очереди, счётчик отказов сбрасывает тот, кто уже не
владелец, а отправитель получает два ответа на одну запись.
Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
стёрла бы всё, что владелец правил в панели за это время: молча, без строки в
журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы
уверен, что правка на месте. Владелец записи, заголовок, краткое описание и темы
конвейер MUST не трогать.
#### Scenario: Правка владельца пережила сохранение шага
- **GIVEN** шаг держит захваченную запись
- **AND** владелец за это время изменил в панели поле, которого шаг не касается
- **WHEN** шаг записывает свой результат
- **THEN** результат шага записан
- **AND** правка владельца на месте
#### Scenario: Захват ушёл под работающим шагом
- **GIVEN** шаг работает над захваченной записью
- **AND** за это время та же запись досталась другому захвату
- **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается
- **AND** отправителю ничего не отправляется
#### Scenario: Человек снял остановку под работающим шагом
- **GIVEN** шаг работает над захваченной записью
- **AND** человек за это время снял с неё признак остановки, освободив захват
- **AND** запись досталась другому воркеру
- **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается
### Requirement: Брошенная задача возвращается в работу
Запись, захваченная и брошенная на середине, SHALL доставаться снова по
истечении срока захвата. Срок MUST считаться от времени захвата, а истёкший
захват MUST не мешать выдать запись следующему.
Срок задаётся рубежом, с которого запись взята, и MUST быть не меньше того
времени, которое шаг этого рубежа может занять на самом длинном допустимом
входе. Срок короче делает протухание штатным событием живого шага, а не
признаком беды. Срок MUST записываться в саму запись при захвате: воркер шага не
знает и вывести срок из себя не может.
Все значения времени, по которым идёт этот отбор, MUST записываться и
сравниваться в одном виде — том же, в каком хранилище пишет собственные времена
записи. Сравнение идёт побайтово, и вид, разошедшийся хоть разделителем,
обращает условие в постоянную истину или постоянную ложь, причём молча.
#### Scenario: Захват протух
- **GIVEN** запись захвачена, а время захвата отстоит дальше срока
- **WHEN** приходит захват
- **THEN** запись выдаётся ему
#### Scenario: Срок сравнивается с временем, записанным хранилищем
- **GIVEN** запись захвачена, и время захвата записано в том же виде, в каком
хранилище пишет время изменения записи
- **WHEN** приходит захват до истечения срока
- **THEN** запись ему не выдаётся
#### Scenario: Срок протухания приехал с рубежом
- **GIVEN** записи двух рубежей с разными сроками захвата пригодны к работе
- **WHEN** их захватывает один и тот же воркер
- **THEN** у каждой записан срок её рубежа
### Requirement: Пауза перед повтором нарастает
Перед повтором **отказавшей** записи сервис SHALL выдерживать паузу, и пауза
MUST расти с числом её отказов до объявленного потолка. Запись MUST не
выдаваться захвату, пока пауза не кончилась.
Ожидание чужой операции этой паузой MUST не выражаться. Шаг, увидевший, что
внешняя операция ещё идёт, отработал без отказа: он **откладывает** работу своей
задержкой, заданной числом, и отказов при этом не тратит. Пауза, выведенная из
числа отказов, на таком шаге вырождается в наименьшее своё значение и учащает
опрос внешнего сервиса во столько раз, во сколько задержка опроса длиннее
секунды.
#### Scenario: Отказавшая запись ждёт
- **GIVEN** запись отказала на шаге конвейера
- **WHEN** захват приходит раньше конца её паузы
- **THEN** запись ему не выдаётся
#### Scenario: Вторая пауза длиннее первой
- **GIVEN** запись отказала дважды подряд
- **WHEN** сравнивают паузу после второго отказа с паузой после первого
- **THEN** вторая длиннее
#### Scenario: Ожидание операции не учащается и не тратит отказов
- **GIVEN** внешняя операция распознавания ещё идёт
- **WHEN** шаг опроса отрабатывает подряд несколько раз
- **THEN** задержка до следующей проверки каждый раз одна и та же
- **AND** число отказов записи не растёт
### Requirement: Пустой прогон воркера — не отказ
Воркер SHALL отличать «пригодной к работе записи сейчас нет» от отказа шага. На
пустом прогоне он MUST не считать прогон отказом: не увеличивать счётчик работы
и не писать о нём на уровне владельца сервиса. Признак пустого прогона MUST
узнаваться по смыслу значения, а не по его точной форме, и MUST переживать
пояснения, добавленные к этому значению на любом промежуточном шаге пути.
Формулировка сменилась вместе с моделью: воркер больше не привязан к рубежу и
опрашивает не «своё состояние», а очередь целиком, поэтому пустой прогон значит
«работы нет ни на одном рубеже», а не «работы нет в этом состоянии».
Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: воркеры
опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт от
каждого запись отказа в секунду и столько же засчитанных сбоев, которых не было.
Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же
шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак
в цепочке своей ошибки. Воркер узнаёт признак по смыслу на любой глубине, поэтому
отказ, к которому признак примешался, тоже зачёл бы пустым прогоном: запись
осталась бы на своём рубеже и переопрашивалась раз в секунду без единой записи
— ровно то, что запрещает инвариант «Принятая запись не теряется молча».
Отказ шага, наоборот, MUST быть виден владельцу сервиса записью в журнале и MUST
быть засчитан в счётчик работы с пометкой отказа и с меткой рубежа.
**Сколько раз он записывается и каким уровнем — это требование не нормирует, и
умолчанием тут считать нечего.** Сегодня один отказ даёт две записи: пишет шаг
конвейера и следом воркер, — а уровень стоит `ERROR` там, где конвенция просит
`WARN` для повторяющегося сбоя фонового цикла. И то и другое записано долгом в
`docs/conventions/logging.md`, раздел «Ошибки», строкой «Расхождение, и оно
системное». Долгом оно и остаётся: требование, объявившее одиночную запись
нормой, сделало бы недостижимое обязательным, а требование, объявившее нормой
двойную, — закрыло бы долг контрактом. Задача, которая возьмётся за этот долг,
дописывает норму сюда.
#### Scenario: Пригодной к работе записи нет
- **GIVEN** ни одной записи, пригодной к работе, нет ни на одном рубеже
- **WHEN** воркер делает свой прогон
- **THEN** на уровне владельца сервиса об этом прогоне не пишется ничего
- **AND** счётчик работы воркера не растёт
#### Scenario: Признак пустого прогона дошёл с пояснением
- **GIVEN** пригодной к работе записи нет
- **AND** промежуточный шаг добавил к этому признаку своё пояснение
- **WHEN** воркер делает свой прогон
- **THEN** прогон по-прежнему считается пустым: счётчик не растёт, записи на
уровне владельца нет
#### Scenario: Шаг отказал
- **GIVEN** шаг конвейера вернул отказ
- **WHEN** воркер завершает прогон
- **THEN** отказ виден владельцу сервиса записью в журнале
- **AND** счётчик работы воркера растёт с пометкой отказа и меткой рубежа
#### Scenario: Шаг сделал работу
- **GIVEN** шаг конвейера отработал запись без отказа
- **WHEN** воркер завершает прогон
- **THEN** счётчик работы воркера растёт с пометкой успеха
- **AND** записи об отказе в журнале нет
### Requirement: Недоставленный ответ не роняет шаг
Шаг конвейера SHALL доводить запись до достигнутого рубежа, когда ответ
отправителю доставить не удалось, и MUST не считать недоставку отказом шага.
Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор
записи, MUST называть причину и MUST считаться отдельной метрикой с причиной
меткой.
Причин у недоставки две, и исход у них общий: **вход отправителя не поднят**
запись заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
у записи не назван** — источником значится Telegram, а чата в записи нет.
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим,
и его уровень «может стать проблемой». Неназванный адресат — симптом порчи
записи: у записи из Telegram чат есть всегда, и пропасть он может только от
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
записей о ненастроенном боте.
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
достигнутый рубеж сохранён. Работа к этой минуте сделана, и объявленный отказ
засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть соврал бы
про исход дважды. Повтор делу не помогает: ни бот, ни адресат от ожидания не
появятся. Поэтому запись остаётся на достигнутом рубеже, в повтор не уходит и
**признака остановки не получает**, а причина недоставки живёт в записи журнала,
а не в рубеже записи.
То же MUST относиться к недоставке сообщения об **остановке**: остановка уже
сохранена, и недоставка её MUST не отменять.
Идентификатор записи в этой строке обязателен: без него владелец видит, что
ответ не ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя
в эту запись MUST не попадать — приватность содержимого записи требование не
ослабляет.
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
уходит и потом. Забрать расшифровку можно там же, где лежат остальные.
#### Scenario: Вход отправителя не поднят
- **GIVEN** запись принята входом Telegram прошлым запуском сервиса
- **AND** сервис поднялся без этого входа
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** запись остаётся на достигнутом рубеже, в повтор не уходит и признака
остановки не получает
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
записи и причиной
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
#### Scenario: Адресат у записи не назван
- **GIVEN** у записи источником значится Telegram, а чат не назван
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** запись остаётся на достигнутом рубеже
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
записи и причиной: неназванный адресат — симптом порчи записи
#### Scenario: Не доехало сообщение об остановке
- **GIVEN** запись остановлена признаком
- **AND** вход отправителя не поднят
- **WHEN** шаг доходит до ответа отправителю
- **THEN** признак остановки у записи остаётся
- **AND** в журнале есть запись о недоставке с идентификатором записи и причиной
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
- **GIVEN** запись принята по HTTP
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа и без записи о недоставке
### Requirement: Выборка воркера владельцем не сужается
Воркер SHALL брать записи всех владельцев подряд и MUST не учитывать владельца
при выборе очередной записи. Запись без владельца — принятая ботом — MUST
обрабатываться наравне с прочими.
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
владельцем остановило бы расшифровку записей бота вовсе, а записи остальных
поставило бы в зависимость от того, кто первым завёл учётную запись.
Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой
результат, владельца не трогает и не затирает.
#### Scenario: Записи двух владельцев проходят одним воркером
- **GIVEN** заведены записи двух разных владельцев на одном рубеже
- **WHEN** воркер забирает работу
- **THEN** ему достаются обе, в порядке заведения
#### Scenario: Запись без владельца обрабатывается
- **GIVEN** заведена запись, принятая ботом, — без владельца
- **WHEN** воркер забирает работу
- **THEN** она достаётся ему наравне с прочими
#### Scenario: Шаг конвейера владельца не затирает
- **GIVEN** запись с владельцем прошла шаг конвейера
- **WHEN** шаг сохраняет свой результат
- **THEN** владелец записи остаётся прежним
## REMOVED Requirements
### Requirement: Число попыток и состояние «мертва»
**Reason**: Одно число несло две обязанности сразу — ограничивать повторы внутри
шага и ограничивать застревание, — и не справлялось ни с одной: опрос, вернувший
«ещё в работе», обнулял его, и зависшая чужая операция опрашивалась вечно.
Состояние «мертва», как и состояние отказа, стирало достигнутый рубеж, и
продолжить с места остановки было не с чего.
**Migration**: Обязанности разведены по двум требованиям — «Число отказов
ограничивает повторы шага» и «Время в рубеже ограничено». Состояния «мертва» и
отказа заменены признаком остановки с причиной, который рубежа не стирает:
требование «Остановка записи — признак, а не рубеж»; туда же дословно перенесены
запрет на второй способ вывести запись из выборки и правило «перевод принадлежит
одному месту». Обязанность сообщить отправителю вынесена в общее требование
«Всякая остановка сообщает отправителю»: причин остановки стало больше одной, и
обязанность, записанная у одной из них, у остальных читалась бы как снятая.
Возврат в работу по-прежнему делает владелец, но снятием признака, а не правкой
состояния.
@@ -0,0 +1,147 @@
## ADDED Requirements
### Requirement: Попытка распознавания хранится отдельно от записи
Сервис SHALL держать всё, что принадлежит внешнему распознавателю, отдельной
строкой, связанной с аудиозаписью, и MUST не хранить это колонками самой записи.
К попытке относятся имя провайдера, имя модели, идентификатор операции у
провайдера, адрес, по которому провайдер читал аудио, время начала и время
завершения.
Разрез проходит по одной границе: **зависит ли вещь от провайдера
распознавания**. Идентификатор операции — самое провайдерское, что есть в
модели, а копия аудио во внешнем хранилище существует только потому, что
сегодняшний провайдер читает запись по адресу; другой провайдер её не потребует.
Оставленные колонками записи, они делают смену провайдера правкой доменной
сущности.
Копия аудио во внешнем хранилище MUST не считаться файлом записи: у записи
остаётся ровно две своих копии — принятая и приведённая, — а ключ объекта живёт
в строке попытки.
#### Scenario: Идентификатор операции лежит в попытке
- **GIVEN** запись отправлена на распознавание
- **WHEN** смотрят, где лежит идентификатор операции у провайдера
- **THEN** он лежит в строке попытки распознавания
- **AND** колонки с ним у самой записи нет
#### Scenario: Копия во внешнем хранилище не подменяет файл записи
- **GIVEN** запись прошла отправку на распознавание
- **WHEN** смотрят ссылки записи на файлы
- **THEN** они ведут на принятую и на приведённую копии
- **AND** ключ объекта во внешнем хранилище лежит в строке попытки
### Requirement: Сырой ответ провайдера сохраняется целиком
Сервис SHALL сохранять ответ распознавателя целиком, в том виде, в каком он
пришёл, и MUST хранить его вложением, а не колонкой строки попытки.
Хранится он потому, что **результат операции у провайдера не переспрашивается**:
связь реплики с говорящим сервис строить пока не умеет, и когда научится, архив
пересчитается из сохранённого без повторной оплаты.
Вложением, а не колонкой, — потому что шаг опроса читает строку попытки часто, а
хранилище читает запись целиком: ответ на многочасовую запись, положенный
колонкой, ехал бы в память при каждом опросе.
Чтение строки попытки шагом опроса MUST не тянуть за собой сохранённый ответ.
Сохранённый ответ — это полный текст речи, и закрыт он MUST быть наравне с самой
записью: поле вложения помечено защищённым, правило просмотра пускает только
владельца связанной записи, ссылка не попадает ни в журнал, ни в метку метрики.
Норму держит capability `storage`, требование «Содержимое записи закрыто во всех
коллекциях, где лежит»; здесь она названа потому, что коллекция попыток — то
место, куда содержимое приезжает впервые.
#### Scenario: Ответ сохранён и читается позже
- **GIVEN** распознавание завершилось и ответ провайдера получен
- **WHEN** запись доходит до конечного рубежа
- **THEN** сохранённый ответ доступен по строке попытки целиком
#### Scenario: Опрос не тянет сохранённый ответ
- **GIVEN** у попытки распознавания есть сохранённый ответ
- **WHEN** шаг опроса читает строку попытки
- **THEN** сохранённый ответ в память при этом не читается
### Requirement: Структура реплик строится из сохранённого ответа
Сервис SHALL строить структуру реплик записи из сохранённого ответа провайдера и
MUST не обращаться к провайдеру повторно ради неё. Структура MUST хранить время
каждой реплики и MUST лежать отдельной строкой со ссылкой с записи, а не
колонкой записи.
У структуры MUST быть номер версии её вида: разбор сохранённого ответа изменится
раньше, чем архив пересчитают, и по номеру видно, какой разбор её построил.
Говорящих структура сегодня не размечает: связь реплики с разбором говорящего у
провайдера не выяснена. Требование этого и не заказывает — оно заказывает
источник, из которого разметка станет возможной без повторной оплаты.
#### Scenario: Структура собрана без обращения к провайдеру
- **GIVEN** ответ провайдера сохранён
- **WHEN** сервис строит структуру реплик
- **THEN** структура собрана с временем каждой реплики
- **AND** к провайдеру не уходит ни одного обращения
### Requirement: Разбор формата провайдера не выходит за адаптер
Распознаватель SHALL отдавать сервису доменный результат — реплики со временем,
плоский текст и байты ответа на хранение, — и MUST не отдавать сырой формат
провайдера. Ни один шаг конвейера MUST не знать, каким потоком и какими полями
провайдер отвечает.
Сегодня разбор потока лежит в шаге: адаптер отдаёт строку, склеенную из
альтернатив, и всё, что провайдер сказал сверх текста, теряется на границе
контракта.
#### Scenario: Шаг получает реплики, а не поток провайдера
- **WHEN** шаг конвейера забирает результат распознавания
- **THEN** он получает реплики со временем, плоский текст и байты на хранение
- **AND** формата провайдера в этом результате нет
### Requirement: Заливка и отправка на распознавание разделены
Сервис SHALL разделять укладку аудио туда, откуда провайдер его прочитает, и
отправку операции распознавания: это два разных обращения с разной ценой
повтора. Повтор укладки MUST быть бесплатен и класть объект под тем же ключом;
повтор отправки оплачивается наружу и MUST не происходить, когда операция уже
заведена.
Разделение нужно затем, чтобы шаг мог проверить сделанное прежде, чем платить:
объект нужного размера на месте — укладку MUST не повторять; идентификатор
операции в строке попытки есть — отправку MUST не повторять.
Строка попытки MUST заводиться **до** обращения к провайдеру: окно между ответом
провайдера и записью идентификатора — то место, где теряется оплаченное. Мягкую
остановку сервиса отправка MUST переживать своим пределом по времени; полной
защиты от жёсткого обрыва процесса требование не даёт и дать не может — это
остаточный риск, названный в дизайне, а не норма.
#### Scenario: Объект уже лежит, а операции ещё нет
- **GIVEN** аудио уже уложено туда, откуда провайдер его читает, и размер совпадает
- **AND** идентификатора операции в строке попытки нет
- **WHEN** шаг повторяется
- **THEN** укладка не повторяется
- **AND** операция отправляется
#### Scenario: Операция уже заведена
- **GIVEN** в строке попытки есть идентификатор операции
- **WHEN** шаг повторяется
- **THEN** отправка не повторяется
- **AND** шаг переходит к опросу этой операции
#### Scenario: Операция принята, а сервис мягко останавливают
- **GIVEN** отправка операции ушла провайдеру
- **AND** сервис в эту минуту останавливают мягко
- **WHEN** провайдер отвечает идентификатором операции
- **THEN** идентификатор сохраняется в строке попытки
- **AND** повторная отправка той же записи не заводится
@@ -0,0 +1,313 @@
## ADDED Requirements
### Requirement: Аудиозапись — центральная сущность хранилища
Хранилище SHALL держать аудиозапись отдельной сущностью, а всё, что к ней
приложено, — отдельными строками со ссылками с записи. Приложениями считаются
файлы, тексты, структура реплик, темы, журнал событий и попытка распознавания.
Поля, которыми распоряжается очередь — признак захвата, срок его протухания,
пауза, число отказов, время входа в рубеж, — MUST не соседствовать с содержимым
записи в одной строке настолько, чтобы чтение очереди тянуло содержимое: сегодня
расшифровка лежит колонкой той же строки и читается при каждом захвате.
Запись MUST нести заголовок и краткое описание своими колонками: они читаются
вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать
отдельными строками: они читаются по открытию одной записи.
#### Scenario: Список читается без содержимого
- **GIVEN** у записи есть расшифровка
- **WHEN** читают запись ради её рубежа и заголовка
- **THEN** текст расшифровки при этом не читается
### Requirement: Содержимое записи закрыто во всех коллекциях, где лежит
Всякая коллекция, куда переезжает содержимое аудиозаписи, SHALL быть закрыта
наравне с самой записью: её правило просмотра MUST не открывать содержимое
никому, кроме владельца связанной записи, а поле, хранящее файл или вложение,
MUST быть помечено защищённым.
Пока содержимое отдаётся собственным адресом сервиса, а не поверхностью
хранилища, правило просмотра MUST оставаться незаданным — то есть «только
владелец панели». Непустое правило открывает перечисление коллекции, и заводить
его раньше, чем появится потребитель, значит открывать поверхность впрок:
норму держит требование «Наружу хранилище отдаёт только то, что заказано».
Требование распространяется на все коллекции приложений — тексты, структуру
реплик, попытку распознавания с её сохранённым ответом, журнал событий и темы, —
и заводится потому, что содержимое **переезжает** из одной строки в шесть. Норма
о защищённом поле файла сегодня написана про файл записи, а сырой ответ
распознавателя — это полный текст речи в другой коллекции: реализация, следующая
только прежней норме, завела бы поле с умолчанием библиотеки, и ссылка на него
отдавала бы расшифровку любому, кто её знает, без сессии.
Ссылка на такое вложение MUST не попадать ни в журнал, ни в метку метрики, ни в
ответ отправителю — теми же словами, какими это нормировано для файла записи.
Умолчание библиотеки здесь не годится ни в одном месте: незаданное правило
просмотра значит «только владелец панели» и отнимает содержимое у самого
владельца записи, а незащищённое поле файла отдаёт его всем.
#### Scenario: Чужой сохранённый ответ не отдаётся
- **GIVEN** запись принята одним вошедшим и прошла распознавание
- **WHEN** другой вошедший идёт по ссылке на сохранённый ответ провайдера
- **THEN** содержимого он не получает
#### Scenario: Без сессии содержимое не отдаётся
- **WHEN** ссылку на сохранённый ответ провайдера запрашивают без сессии
- **THEN** приходит отказ, а содержимого в ответе нет
#### Scenario: Перечисление приложений закрыто
- **WHEN** запрос без прав владельца просит список записей коллекции текстов
- **THEN** приходит отказ
### Requirement: Ссылки на исходник и приведённую копию живут порознь
Аудиозапись SHALL нести две отдельные ссылки на файлы — на принятую копию и на
копию, приведённую к рабочему формату, — и шаг конвейера MUST не переставлять
одну ссылку на свой результат.
Сегодня ссылка одна, и её переставляет каждый шаг: у прошедшей конвейер записи
она ведёт на копию во внешнем хранилище, а принятого человеком файла не найти
ничем. Послушать загруженное нечем именно поэтому.
Обе копии MUST оставаться доступными после того, как запись прошла конвейер.
#### Scenario: После конвейера доступны обе копии
- **GIVEN** запись прошла конвейер целиком
- **WHEN** смотрят её ссылки на файлы
- **THEN** ссылка на принятую копию и ссылка на приведённую заполнены
- **AND** обе открываются
### Requirement: Тексты и структура лежат отдельно от записи
Хранилище SHALL держать тексты записи отдельными строками, каждая со своим видом
текста, и структуру реплик — своей строкой. Запись MUST ссылаться на них, а не
хранить их колонками.
Видов текста больше одного: сырая расшифровка и вычитанный текст. Колонкой на
каждый вид схема росла бы с каждым новым видом, а необратимый шаг схемы платится
за каждую такую колонку отдельно.
**Приложение MUST быть уникально по паре «запись и вид»**, а структура — по паре
«запись и версия разбора». Шаг завершения пишет текст, структуру и сохранённый
ответ несколькими операциями и только потом двигает рубеж: прерванный на середине
и повторённый с прежнего рубежа, он завёл бы второй комплект строк, и вопрос
«какой текст отдавать человеку» стал бы вопросом порядка записи, а не состояния.
Потребитель текста MUST называть **вид**, который берёт, а не брать последний
записанный: иначе исход зависит от порядка записи. Ответ опроса готовности берёт
сырую расшифровку — норму держит capability `intake`.
#### Scenario: Расшифровка лежит своей строкой
- **GIVEN** запись прошла распознавание
- **WHEN** смотрят, где лежит текст расшифровки
- **THEN** он лежит отдельной строкой, на которую запись ссылается
#### Scenario: Повтор шага не заводит второй расшифровки
- **GIVEN** шаг завершения записал расшифровку и оборвался до смены рубежа
- **WHEN** шаг повторяется с прежнего рубежа
- **THEN** строка расшифровки у записи одна
### Requirement: Словарь тем ведётся по владельцу
Хранилище SHALL держать темы отдельной коллекцией, и тема MUST быть уникальна в
паре «владелец и название»: словарь тем свой у каждого человека. У записи MUST
быть не больше пяти тем.
Коллекцией, а не набором строк в записи, — потому что перечень тем человека
нужен целиком перед каждым обращением к модели, а собрать его из наборов строк
можно только перебором всех его записей.
Потолок в пять тем MUST быть у самой записи: без него часовой разговор даёт два
десятка тем, и словарь распухает за неделю.
Название темы выведено из содержимого записи, а перечень тем человека — слепок
того, о чём он вообще говорит. В журнал сервиса темы MUST не попадать наравне с
текстом расшифровки.
Ни один шаг этого изменения тем не пишет и не читает: место заводится вперёд,
чтобы задача, считающая темы языковой моделью, не платила вторым необратимым
шагом схемы. Цена решения названа прямо — имена коллекции и её колонок
закрепляются раньше, чем известен их потребитель.
#### Scenario: Тема одного человека не мешает теме другого
- **GIVEN** у двух владельцев заведена тема с одинаковым названием
- **WHEN** смотрят словарь тем
- **THEN** это две разные темы, каждая своего владельца
#### Scenario: Шестая тема не заводится
- **WHEN** записи назначают шестую тему
- **THEN** назначение не проходит
## MODIFIED Requirements
### Requirement: Владелец задачи лежит связью с учётной записью
Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
не назван, не достаётся никому по недосмотру схемы.
Колонка MUST допускать пустое значение, и это решение с названной ценой: записи,
принятые ботом, владельца не имеют, потому что связи чата Telegram с учётной
записью сервис не ведёт. Обязательность для приёма по HTTP держит сама
capability `intake`, а не схема.
Владелец MUST не назначаться и не меняться конвейером.
#### Scenario: Колонка появляется на пустой базе
- **WHEN** сервис поднимается на чистом каталоге данных
- **THEN** у аудиозаписи есть колонка владельца
- **AND** умолчания у неё нет
#### Scenario: Конвейер владельца не назначает
- **GIVEN** запись с владельцем прошла шаг конвейера
- **WHEN** смотрят её владельца
- **THEN** он прежний
### Requirement: Файл записи сужается владельцем наравне с задачей
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
Владелец файла MUST назначаться там же, где владелец записи, — при приёме, из
предъявленной сессии, — и MUST оставаться пустым у файлов, заведённых конвейером
для записи без владельца.
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
выводиться через запись: файл переживает свою запись, и заведённый шагом до
сохранения записи он остаётся с владельцем и без ссылки.
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
касаясь пути, по которому аудио и уходит.
#### Scenario: Чужой файл не отдаётся
- **GIVEN** запись принята одним вошедшим
- **WHEN** другой вошедший идёт по ссылке на файл этой записи со своим токеном
- **THEN** содержимого он не получает
#### Scenario: Свой файл отдаётся
- **GIVEN** человек принял запись
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
- **THEN** содержимое отдаётся
#### Scenario: Файл записи из Telegram не отдаётся по API
- **GIVEN** запись принята ботом, и владельца у неё нет
- **WHEN** вошедший человек идёт по ссылке на её файл со своим токеном
- **THEN** содержимого он не получает
### Requirement: Владелец видит записи в панели
Сервис SHALL давать владельцу панель, где аудиозапись видна строкой, отбирается
по своему идентификатору и правится, а её файлы слушаются и скачиваются.
Панель MUST отдаваться тем же сервисом по своему адресу и MUST не требовать
второго процесса.
Панель — вход в запись наравне с конвейером, а не окно просмотра. Снятие
признака остановки в панели MUST возвращать запись в работу с сохранённого
рубежа и MUST очищать служебные поля прошлого захвата — признак захвата, срок
его протухания, паузу, число отказов — и MUST заново ставить время входа в
рубеж. Правка рубежа руками MUST делать то же самое. Иначе владелец, вернувший
запись в работу, получит запись, которая не выдаётся захвату до конца прежнего
срока, останавливается от первого же отказа или останавливается снова первым же
захватом по пределу времени, — и не узнает об этом.
Запись, заведённая в панели руками, MUST не уносить сервис: поля, без которых
шаг конвейера не может работать, MUST быть обязательными в самой схеме, а
перечень рубежей — закрытым.
Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все
записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не
сервис — это записано моделью угроз проекта.
#### Scenario: Принятая запись видна владельцу
- **GIVEN** запись принята и заведена
- **WHEN** владелец отбирает записи по идентификатору принятой
- **THEN** он видит её строкой со своим рубежом
- **AND** её файл скачивается из той же строки
#### Scenario: Остановленную запись вернули в работу правкой в панели
- **GIVEN** запись остановлена признаком, с накопленными отказами и признаком
прежнего захвата
- **AND** остановленной она простояла дольше предела времени в рубеже
- **WHEN** владелец снимает признак остановки
- **THEN** признак захвата, срок его протухания, пауза и число отказов очищены
- **AND** время входа в рубеж поставлено заново
- **AND** ближайший захват выдаёт запись с сохранённого рубежа
### Requirement: Учётная запись с записями не удаляется
Хранилище SHALL отвергать удаление учётной записи, у которой остались
аудиозаписи **либо файлы**. Отказ MUST называть причину, и MUST доезжать до
спрашивающего: хранилище пропускает наружу только свою ошибку роутера, а всякую
другую подменяет сообщением про обязательную связь — подсказкой, по которой
владелец панели пойдёт удалять записи руками.
Считаются **все** коллекции с колонкой владельца, и перечень их MUST жить одним
местом: коллекция, пропущенная в счёте, пропускает удаление вперёд, и наружу
приезжает не наш отказ с причиной, а подсказка библиотеки про обязательную связь
— та самая, по которой владелец панели пойдёт удалять записи руками. Сегодня их
три: аудиозаписи, файлы и словарь тем.
Файл переживает свою запись: шаг конвейера заводит его до сохранения записи, и
потерянный захват оставляет файл с владельцем и без ссылки. Тема переживает её
так же: словарь принадлежит человеку, а не записи.
Запрет MUST ставить сама сборка хранилища, а не вызывающий: сборка, забывшая его
позвать, теряет защиту молча — и теряла, пока запрет вешался отдельной строкой
запуска, а окружение проверок его не ставило вовсе.
Удаление при этом не только панельное: умолчание библиотеки разрешает вошедшему
удалить **свою** учётную запись запросом, так что запрет закрывает и публичную
поверхность.
Цена требования названа прямо: владелец панели упирается в отказ, а способа
удалить записи в сервисе пока нет вовсе — его приносит задача про удаление
записи. До неё удаление учётной записи с записями невозможно, и это осознанный
тупик, а не недосмотр.
#### Scenario: Удаление учётной записи с записями отвергается
- **GIVEN** у учётной записи есть аудиозаписи
- **WHEN** её удаляют
- **THEN** удаление не проходит, а отказ называет причину
- **AND** записи и их владелец остаются прежними
#### Scenario: Учётная запись с одними файлами тоже не удаляется
- **GIVEN** у учётной записи остались файлы, но записей нет
- **WHEN** её удаляют
- **THEN** удаление не проходит, а владелец файлов остаётся прежним
#### Scenario: Учётная запись с одними темами тоже не удаляется
- **GIVEN** у учётной записи остались темы словаря, но ни записей, ни файлов нет
- **WHEN** её удаляют
- **THEN** удаление не проходит, а отказ называет причину нашими словами
#### Scenario: Учётная запись без записей удаляется
- **GIVEN** у учётной записи нет ни аудиозаписей, ни файлов, ни тем
- **WHEN** её удаляют
- **THEN** удаление проходит
@@ -0,0 +1,193 @@
# Критерии приёмки
Дословно из записи задачи `record-centric-model`. Файл задачи закрытие удалит —
критерии обязаны его пережить.
1. Запись, остановленная на шаге, перезапускается снятием признака и продолжает
с того рубежа, где стояла. Оракул — тест: шаг останавливает запись на
`normalized`, снятие `halted_at` возвращает её в работу, и следующим идёт
отправка на распознавание, а не повторная нормализация.
2. У прошедшей конвейер записи ссылки на исходник и на opus ведут на разные
существующие копии. Оракул — тест полного прохода: обе ссылки заполнены и обе
открываются.
3. Число воркеров задаётся конфигом, и поведение от него не зависит. Оракул —
прогон теста конвейера при `N=1` и `N=4`: запись доходит до `done` в обоих;
при `N=0` она остаётся в `uploaded` и не теряется.
4. Структура реплик строится из сохранённого ответа провайдера без обращения к
нему. Оракул — тест на сохранённом вложении: структура собрана, клиент
SpeechKit не позван ни разу.
5. Запись, застрявшая в рубеже дольше предела, останавливается, а откладывание
опроса предела не сдвигает. Оракул — тест на подставных часах: сотня
откладываний подряд не двигает `state_entered_at` и не обнуляет отсчёт, а по
истечении предела запись получает признак остановки с причиной «застряла».
## Приёмочные критерии из ревью дизайна
Рубрика прохода `review-rubric`, пункты, не покрытые критериями выше. Приёмка
судится по одному списку.
6. Держатель захвата отличим **значением**, а не занятостью записи. Оракул —
тест: шаг A держит запись, признак остановки снимает человек, запись
захватывает шаг B; запись результата шагом A не проходит, и отправителю от
него ничего не уходит.
7. Всякий способ вывести запись из работы сообщает отправителю. Оракул — тест по
перечню причин остановки: у каждой отправитель получает сообщение.
8. Повтор шага не создаёт второго приложения. Оракул — тест: шаг завершения
оборван после записи текста и повторён с прежнего рубежа; строка расшифровки
у записи одна.
9. Штатная остановка сервиса не тратит отказ. Оракул — тест: шаг прерван
отменой контекста, число отказов записи прежнее, признака остановки нет.
10. Содержимое записи закрыто во всех коллекциях, где лежит. Оракул — тест:
ссылка на сохранённый ответ провайдера без сессии отдаёт отказ, с чужой
сессией — тоже.
11. Отказ виден с разрезом по шагу. Оракул — тест: отказ шага приведения растит
счётчик с меткой своего рубежа.
## 1. Сущности и рубежи
- [x] 1.1 Завести `entity.AudioRecord` с рубежом, временем входа в рубеж,
признаком остановки и её причиной, полями очереди и ссылками на приложения
- [x] 1.2 Дескриптор рубежа одним объявлением: имя, шаг, чья работа, срок захвата,
предел времени, берётся ли в работу. Таблица выбора шага, список отбора
захвата и пределы **выводятся** из него, а не перечисляются порознь
- [x] 1.3 Развести `MoveToState` и `Postpone`: первый двигает рубеж и время входа
в него, второй ставит паузу и снимает захват, рубежа не трогая
- [x] 1.4 Заменить `Fail` и `Die` на `Halt(причина, текст)` и `Resume()`;
`Resume` сбрасывает отказы, паузу **и время входа в рубеж**, рубеж сохраняет
- [x] 1.5 Завести сущности приложения: файл, текст с видом, структура реплик с
версией, тема, событие журнала, попытка распознавания
- [x] 1.6 Тест: `Postpone` не двигает время входа в рубеж и не сбрасывает отсчёт
- [x] 1.7 Тест: `Halt` сохраняет рубеж, `Resume` возвращает на него же и заново
ставит время входа **(критерий 1)**
## 2. Шаг схемы
- [x] 2.1 Новым файлом шага завести коллекции `audio_records`, `texts`,
`structures`, `recognitions`, `record_events`, `topics`
- [x] 2.2 Дописать `files` полями формата и длительности
- [x] 2.3 Уникальность: тема по паре «владелец и название», текст по паре
«запись и вид» (`texts.kind`), структура по паре «запись и версия»
- [x] 2.4 Индексы под выборку захвата: рубеж, пауза, срок захвата, признак
остановки
- [x] 2.5 Правила доступа новых коллекций: просмотр только владельцем связанной
записи; поле вложения в `recognitions` помечено защищённым
- [x] 2.6 Тот же шаг удаляет прежнюю коллекцию `transcribe_jobs`: данных под ней
нет, а пустая коллекция висела бы в панели вторым домом для того же понятия
- [x] 2.7 Тест шага: на чистом каталоге поднимаются все коллекции новой модели, и
принятая следом запись доходит до конечного рубежа
- [x] 2.8 Тест: ссылка на сохранённый ответ без сессии и с чужой сессией даёт
отказ **(критерий 10)**
- [x] 2.9 Обновить `docs/database.md` тем же изменением: гейт сверяет шаг схемы
с правкой этого документа
## 3. Контракты и репозитории
- [x] 3.1 `AudioRecognizer` отдаёт доменный результат — реплики со временем,
плоский текст, байты на хранение — вместо строки; заливка и отправка
разделены
- [x] 3.2 Контракты репозиториев: запись, файлы, тексты, структура, попытки
распознавания, журнал событий, темы
- [x] 3.3 Захват возвращает **идентификатор записи и признак этого захвата**;
признак уникален для каждого захвата; `acquireColumns` и `acquiredRow`
уходят, срок протухания пишется числом при захвате
- [x] 3.4 Запрос захвата: отбор по рубежам из дескриптора, паузе, сроку захвата и
отсутствию признака остановки, порядок по времени заведения и ключу
- [x] 3.5 Запись результата условна по признаку **этого** захвата, а не по
занятости; владелец, заголовок, краткое описание и темы шагом не
переписываются
- [x] 3.6 Перенацелить сканеры `internal/archrules` с колонок захвата на перечень
рубежей: дескриптор против списка отбора против значений шага схемы
- [x] 3.7 Тест: захват отдаёт идентификатор и признак, троим одновременным
достаётся одному
- [x] 3.8 Тест: остановленная запись захвату не выдаётся
- [x] 3.9 Тест: шаг A теряет захват после снятия остановки человеком и записи не
проводит **(критерий 6)**
## 4. Адаптер распознавания
- [x] 4.1 Разбор потока `GetRecognition` в реплики со временем внутри адаптера
- [x] 4.2 Раздельные заливка в Object Storage и отправка операции; строка попытки
заводится до обращения к провайдеру
- [x] 4.3 Заливка проверяет объект нужного размера и не повторяется; отправка не
повторяется при заведённом идентификаторе операции
- [x] 4.4 Адаптер отдаёт сырые байты ответа на хранение вложением
- [x] 4.5 Тест: структура собрана из сохранённого вложения, клиент SpeechKit не
позван ни разу **(критерий 4)**
- [x] 4.6 Тест: объект на месте — заливка не повторяется; идентификатор операции
на месте — отправка не повторяется
## 5. Конвейер
- [x] 5.1 Выбор шага по рубежу из дескриптора; шаги перестают быть привязаны к
воркеру
- [x] 5.2 Шаг приведения: две отдельные ссылки на файлы вместо одной
переставляемой
- [x] 5.3 Шаг отправки: строка попытки распознавания, идентификатор операции и
ключ объекта уезжают туда
- [x] 5.4 Шаг опроса зовёт `Postpone`, а не переход в то же состояние
- [x] 5.5 Шаг завершения: текст строкой `texts`, структура строкой `structures`,
сырой ответ вложением; повтор не заводит второго комплекта
- [x] 5.6 Предел времени в рубеже из дескриптора: остановка с причиной «застряла»,
идентификатор операции при этом сохраняется
- [x] 5.7 Единое место ответа отправителю на всякую остановку, независимо от
причины
- [x] 5.8 Отмена по остановке сервиса возвращает число отказов назад и приговора
не выносит
- [x] 5.9 Журнал событий пишется на смену рубежа, на остановку и на снятие
остановки; на откладывание — нет; колонка текста отказа зовётся
`outcome_text`, чтобы `error_text` осталось именем одной колонки
- [x] 5.10 Тест полного прохода: обе ссылки на файлы заполнены и обе открываются
**(критерий 2)**
- [x] 5.11 Тест: остановка на `normalized`, снятие признака, следующим идёт
отправка **(критерий 1)**
- [x] 5.12 Тест на подставных часах: сотня откладываний не двигает
`state_entered_at`, по истечении предела запись останавливается с причиной
«застряла» **(критерий 5)**
- [x] 5.13 Тест по перечню причин остановки: у каждой отправитель получает
сообщение **(критерий 7)**
- [x] 5.14 Тест: повтор шага завершения не заводит второй расшифровки
**(критерий 8)**
- [x] 5.15 Тест: отмена контекста не тратит отказ **(критерий 9)**
## 6. Воркеры, метрики и настройки
- [x] 6.1 Пул одинаковых воркеров вместо трёх именованных в `main.go`
- [x] 6.2 Метка счётчика `transcriber_worker_job_count` — рубеж, а не имя потока
- [x] 6.3 Число воркеров и два предела времени — в `internal/config` и
`config.example.toml` с умолчаниями; имена ключей названы в дизайне
- [x] 6.4 `N = 0` поднимает сервис без движения записей
- [x] 6.5 Тест конвейера при `N=1` и `N=4` — запись доходит до `done`; при `N=0`
остаётся в `uploaded` **(критерий 3)**
- [x] 6.6 Тест: отказ шага приведения растит счётчик с меткой своего рубежа
**(критерий 11)**
## 7. Поверхность и панель
- [x] 7.1 Ответ приёма отдаёт рубеж `uploaded`; ответ опроса — рубеж из нового
перечня плюс поле `halted`
- [x] 7.2 Текст расшифровки в ответе опроса читается из `texts` по **виду**
«сырая расшифровка», а не по последней записи
- [x] 7.3 Правила панели: снятие признака остановки и правка рубежа очищают
захват, срок, паузу и отказы и заново ставят время входа в рубеж
- [x] 7.4 Запрет удаления учётной записи считает `audio_records` и `files`
- [x] 7.5 Тесты транспорта под новые значения `status` и поле `halted`
## 8. Документы и гейт
- [x] 8.1 Инвариант `CLAUDE.md` о колонках очереди: перечень колонок съёживается,
предмет правила переезжает на перечень рубежей
- [x] 8.2 Инвариант `CLAUDE.md` о держателе захвата: держатель отличим значением
признака захвата, а не занятостью записи
- [x] 8.3 `docs/architecture.md`: компоненты, цепочка рубежей, единые точки,
таблица внешних зависимостей, раздел «Эксплуатация» — чем владелец теперь
замечает отказ
- [x] 8.4 `docs/database.md`: коллекции, представление данных, настройки с
числовым значением (число воркеров, два предела времени, сроки захвата)
- [x] 8.5 `docs/review.md`: снять ложноположительное «гонка захвата по построению
невозможна» — построение снято пулом одинаковых воркеров
- [x] 8.6 Разделы `Purpose` спек `pipeline` и `storage` при архивации: цепочка
рубежей перестала быть «сознательно не описанной», а строка про непереносимые
прежние данные — верной
- [x] 8.7 `task gate` зелёный целиком
- [x] 8.8 Поведенческая проверка живым запуском: подъём с `telegram.enabled =
false` и подставным распознавателем, запись доходит до `done`
+72 -40
View File
@@ -18,18 +18,22 @@ Telegram, дописывает его сюда.
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть
сохранена и получить заведённую под неё задачу расшифровки в состоянии
`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние
полем `status`.
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
получить заведённую под неё аудиозапись на рубеже `uploaded`; ответ MUST нести
идентификатор записи полем `job_id` и её рубеж полем `status`.
Значение рубежа в ответе изменилось: прежде приём отдавал `created`. Перечень
состояний назван проектом необратимым, и ломка объявлена прямо — состояние
теперь называет достигнутое, а не предстоящее, и `created` в новом перечне нет
вовсе.
Имена полей ответа нормативны и MUST остаться прежними: контракт HTTP API
объявлен проектом необратимым, и переименование поля ломает внешнюю программу
молча. Меняются значения поля рубежа, а не его имя.
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
и переименование поля ломает внешнюю программу молча. Появление отказа без
сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
@@ -61,30 +65,30 @@ Telegram, дописывает его сюда.
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `created`
со значением `uploaded`
- **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** владельцем заведённой задачи стоит предъявитель сессии
- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии
#### Scenario: Сессия не даёт учётной записи пользователя
- **GIVEN** предъявлена сессия владельца панели
- **WHEN** он шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `403`
- **AND** ни файла, ни задачи не заводится
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Сессии нет
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни задачи не заводится
- **AND** тело ответа не несёт данных задачи
- **AND** ни файла, ни аудиозаписи не заводится
- **AND** тело ответа не несёт данных записи
#### Scenario: Поля с записью нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни задачи не заводится
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Размеру записи приём не судья
@@ -237,58 +241,86 @@ Telegram, дописывает его сюда.
### Requirement: Опрос готовности задачи
Сервис SHALL отдавать состояние задачи расшифровки по запросу
`GET /api/status/:id` **только её владельцу**. Запрос без сессии MUST получать
код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни текста
расшифровки. Ответ владельцу MUST нести идентификатор полем `job_id`, состояние
полем `status` и время заведения полем `created_at`, а текст расшифровки полем
`transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет:
пустая строка на месте отсутствующего текста читается как «расшифровка пуста».
Сервис SHALL отдавать рубеж аудиозаписи по запросу `GET /api/status/:id`
**только её владельцу**. Запрос без сессии MUST получать код `401`, и тело
такого ответа MUST не нести ни рубежа записи, ни текста расшифровки. Ответ
владельцу MUST нести идентификатор полем `job_id`, рубеж полем `status` и время
заведения полем `created_at`, а текст расшифровки полем `transcription_text`, и
это поле MUST отсутствовать в ответе, пока текста нет: пустая строка на месте
отсутствующего текста читается как «расшифровка пуста».
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
кодам ответа перебирается список заведённых задач.
Видов текста у записи больше одного, поэтому ответ MUST называть вид, который
отдаёт: в поле `transcription_text` уходит **сырая расшифровка**, и только она.
Вычитанный текст этим полем MUST не подменяться — иначе значение поля менялось бы
у одной и той же записи от того, успел ли отработать необязательный шаг, а
контракт объявлен необратимым. Отдача «последнего записанного» текста MUST не
применяться: она делает ответ функцией порядка записи, а не состояния записи.
Задача, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
идентификатор, — кодом `404` и тем же телом. То же MUST относиться к задаче без
Перечень значений поля `status` MUST совпадать с перечнем рубежей конвейера:
`uploaded`, `normalized`, `submitted`, `transcribed`, `done`. Прежних значений
`created`, `converted`, `transcribe`, `failed` и `dead` в ответе MUST не быть.
Это объявленная ломка публичного контракта: рубеж называет достигнутое, а отказ
перестал быть состоянием.
Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST
нести признак остановки отдельным полем `halted` со значением истины. Машинный
текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса,
а не отправителю. Отправитель узнаёт о неудаче ответом там, откуда пришла
запись, — это нормирует capability `pipeline`.
Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по
кодам ответа перебирается список заведённых записей.
Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
идентификатор, — кодом `404` и тем же телом. То же MUST относиться к записи без
владельца: запись, принятая ботом, по этому адресу не достаётся никому.
#### Scenario: Задача найдена
#### Scenario: Запись найдена
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает состояние своей задачи
- **WHEN** он спрашивает рубеж своей записи
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
- **AND** значение `status` принадлежит перечню рубежей конвейера
#### Scenario: Запись остановлена
- **GIVEN** запись остановлена признаком на рубеже приведения
- **WHEN** владелец спрашивает её рубеж
- **THEN** поле `status` несёт рубеж приведения
- **AND** поле `halted` несёт истину
- **AND** машинного текста отказа в ответе нет
#### Scenario: Сессии нет
- **WHEN** программа спрашивает состояние заведённой задачи без сессии
- **WHEN** программа спрашивает рубеж заведённой записи без сессии
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
#### Scenario: Без сессии неизвестная задача неотличима от заведённой
#### Scenario: Без сессии неизвестная запись неотличима от заведённой
- **WHEN** программа без сессии спрашивает состояние заведённой задачи, а затем
состояние по неизвестному идентификатору
- **WHEN** программа без сессии спрашивает рубеж заведённой записи, а затем
рубеж по неизвестному идентификатору
- **THEN** оба ответа имеют код `401`
#### Scenario: Чужая задача неотличима от неизвестной
#### Scenario: Чужая запись неотличима от неизвестной
- **GIVEN** задача заведена одним вошедшим
- **WHEN** её состояние спрашивает другой вошедший
- **GIVEN** запись заведена одним вошедшим
- **WHEN** её рубеж спрашивает другой вошедший
- **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному
идентификатору
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
#### Scenario: Расшифровки ещё нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает состояние своей задачи, которая ещё не дошла до текста
- **WHEN** он спрашивает рубеж своей записи, которая ещё не дошла до текста
- **THEN** поля `transcription_text` в ответе нет вовсе
#### Scenario: Задачи с таким идентификатором нет
#### Scenario: Записи с таким идентификатором нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
- **WHEN** программа спрашивает рубеж по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной записи
### Requirement: Поднятые входы видны наблюдателю
+516 -162
View File
@@ -2,28 +2,36 @@
## Purpose
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
Конвейер расшифровки: как аудиозапись движется по рубежам, что делает воркер,
когда работы нет, что считается отказом шага и что бывает с ответом отправителю,
когда доставить его некуда.
Описаны пустой прогон воркера, неделимость захвата и срок его протухания, число
попыток и состояние «мертва», нарастающая пауза перед повтором, условие записи
результата держателем захвата и недоставка ответа при неподнятом входе.
Сознательно не описаны: цепочка переходов `created → converted → transcribe →
done | failed`, отмена контекста посреди шага и освобождение ресурсов внешних
клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а
требования на него не написаны, потому что требование без проверки —
предположение, а не норма. Первая задача, которая трогает любое из
перечисленного, дописывает его сюда.
Описаны цепочка рубежей и смысл рубежа, остановка признаком и её причины, оба
сторожа — число отказов и время в рубеже, — откладывание работы отдельно от
перехода, неделимость захвата и срок его протухания, условие записи результата
держателем захвата, нарастающая пауза перед повтором, число воркеров настройкой,
журнал событий записи и недоставка ответа при неподнятом входе.
Сознательно не описаны: освобождение ресурсов внешних клиентов и **какие отказы
считаются приговором записи, а какие поводом к повтору**. Второе — не пробел
формулировки, а неразобранный вопрос: сегодня отказ приведения останавливает
запись с первой попытки, и предел отказов на нём не работает никогда. Это не
значит, что поведения нет: оно живёт в коде, а требования на него не написаны,
потому что требование без проверки — предположение, а не норма. Первая задача,
которая трогает любое из перечисленного, дописывает его сюда.
## Requirements
### Requirement: Пустой прогон воркера — не отказ
Воркер SHALL отличать «работы в этом состоянии сейчас нет» от отказа шага. На
Воркер SHALL отличать «пригодной к работе записи сейчас нет» от отказа шага. На
пустом прогоне он MUST не считать прогон отказом: не увеличивать счётчик работы
и не писать о нём на уровне владельца сервиса. Признак пустого прогона MUST
узнаваться по смыслу значения, а не по его точной форме, и MUST переживать
пояснения, добавленные к этому значению на любом промежуточном шаге пути.
Формулировка сменилась вместе с моделью: воркер больше не привязан к рубежу и
опрашивает не «своё состояние», а очередь целиком, поэтому пустой прогон значит
«работы нет ни на одном рубеже», а не «работы нет в этом состоянии».
Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: воркеры
опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт от
каждого запись отказа в секунду и столько же засчитанных сбоев, которых не было.
@@ -31,12 +39,12 @@ done | failed`, отмена контекста посреди шага и ос
Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же
шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак
в цепочке своей ошибки. Воркер узнаёт признак по смыслу на любой глубине, поэтому
отказ, к которому признак примешался, тоже зачёл бы пустым прогоном: задача
осталась бы в своём состоянии и переопрашивалась раз в секунду без единой записи
отказ, к которому признак примешался, тоже зачёл бы пустым прогоном: запись
осталась бы на своём рубеже и переопрашивалась раз в секунду без единой записи
— ровно то, что запрещает инвариант «Принятая запись не теряется молча».
Отказ шага, наоборот, MUST быть виден владельцу сервиса записью в журнале и MUST
быть засчитан в счётчик работы с пометкой отказа.
быть засчитан в счётчик работы с пометкой отказа и с меткой рубежа.
**Сколько раз он записывается и каким уровнем — это требование не нормирует, и
умолчанием тут считать нечего.** Сегодня один отказ даёт две записи: пишет шаг
@@ -48,16 +56,16 @@ done | failed`, отмена контекста посреди шага и ос
двойную, — закрыло бы долг контрактом. Задача, которая возьмётся за этот долг,
дописывает норму сюда.
#### Scenario: Работы в состоянии нет
#### Scenario: Пригодной к работе записи нет
- **GIVEN** ни одной задачи в опрашиваемом состоянии нет
- **GIVEN** ни одной записи, пригодной к работе, нет ни на одном рубеже
- **WHEN** воркер делает свой прогон
- **THEN** на уровне владельца сервиса об этом прогоне не пишется ничего
- **AND** счётчик работы воркера не растёт
#### Scenario: Признак пустого прогона дошёл с пояснением
- **GIVEN** работы в опрашиваемом состоянии нет
- **GIVEN** пригодной к работе записи нет
- **AND** промежуточный шаг добавил к этому признаку своё пояснение
- **WHEN** воркер делает свой прогон
- **THEN** прогон по-прежнему считается пустым: счётчик не растёт, записи на
@@ -68,29 +76,45 @@ done | failed`, отмена контекста посреди шага и ос
- **GIVEN** шаг конвейера вернул отказ
- **WHEN** воркер завершает прогон
- **THEN** отказ виден владельцу сервиса записью в журнале
- **AND** счётчик работы воркера растёт с пометкой отказа
- **AND** счётчик работы воркера растёт с пометкой отказа и меткой рубежа
#### Scenario: Шаг сделал работу
- **GIVEN** шаг конвейера отработал задачу без отказа
- **GIVEN** шаг конвейера отработал запись без отказа
- **WHEN** воркер завершает прогон
- **THEN** счётчик работы воркера растёт с пометкой успеха
- **AND** записи об отказе в журнале нет
### Requirement: Захват задачи неделим
Захват задачи воркером SHALL быть одним неделимым шагом хранилища: выбор
подходящей задачи и пометка её захваченной MUST происходить вместе, и захваченная
задача MUST возвращаться тем же шагом.
Захват записи воркером SHALL быть одним неделимым шагом хранилища: выбор
подходящей записи и пометка её захваченной MUST происходить вместе.
Одна и та же задача MUST доставаться ровно одному захватившему. Двум вызывающим,
пришедшим за одним состоянием одновременно, запись MUST достаться одному, а
второй MUST получить признак «работы в этом состоянии нет».
Захват MUST возвращать **идентификатор записи и признак этого захвата**, а не
перечень её колонок. Колонки записи шаг читает сам, обычным чтением. Иначе
всякая новая колонка аудиозаписи попадала бы под инвариант проекта о колонках
очереди, и забытая в захвате колонка приезжала бы нулевой, а первое же
сохранение писало бы этот ноль поверх сохранённого значения.
**Признак захвата MUST быть значением, уникальным для каждого захвата**, а не
признаком занятости. Условие записи результата сверяет именно это значение:
захват, перевыданный другому — по протуханию срока или после того, как человек
снял признак остановки в панели, — обязан обращать запись первого в отказ.
Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и
два шага записали бы в одну запись и оба ответили бы отправителю.
Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим,
пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST
получить признак «работы сейчас нет».
Срок протухания захвата MUST ехать с рубежом записи, а не с воркером: воркер не
привязан к шагу и не знает заранее, что вытянет. Срок MUST записываться числом
при самом захвате.
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному
значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок
обработки невоспроизводим, а проверка, опирающаяся на «следующую» задачу,
зелена через раз.
обработки невоспроизводим, а проверка, опирающаяся на «следующую» запись, зелена
через раз.
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа
@@ -99,41 +123,57 @@ done | failed`, отмена контекста посреди шага и ос
Признак «работы нет» этим требованием не переопределяется — его нормирует
требование «Пустой прогон воркера — не отказ».
#### Scenario: За задачей пришли трое разом
#### Scenario: За работой пришли трое разом
- **GIVEN** в опрашиваемом состоянии лежит ровно одна задача
- **WHEN** три захвата этого состояния идут одновременно
- **GIVEN** к работе пригодна ровно одна запись
- **WHEN** три захвата идут одновременно
- **THEN** запись получает ровно один из них
- **AND** двое остальных получают признак «работы в этом состоянии нет»
- **AND** двое остальных получают признак «работы сейчас нет»
#### Scenario: Захваченная задача не выдаётся второй раз
#### Scenario: Захваченная запись не выдаётся второй раз
- **GIVEN** задача захвачена и срок захвата не истёк
- **WHEN** за тем же состоянием приходит следующий захват
- **THEN** эта задача ему не выдаётся
- **GIVEN** запись захвачена и срок захвата не истёк
- **WHEN** приходит следующий захват
- **THEN** эта запись ему не выдаётся
#### Scenario: Захват отдаёт идентификатор и свой признак
- **GIVEN** к работе пригодна запись
- **WHEN** воркер её захватывает
- **THEN** захват возвращает идентификатор записи и признак этого захвата
- **AND** колонки записи шаг читает отдельным чтением
#### Scenario: Признак перевыданного захвата отличается от прежнего
- **GIVEN** запись захвачена, и признак первого захвата известен
- **WHEN** человек снимает признак остановки, и запись захватывает другой воркер
- **THEN** признак нового захвата отличается от признака первого
### Requirement: Результат пишет только держатель захвата
Шаг конвейера SHALL записывать свой результат только тогда, когда захват задачи
всё ещё принадлежит ему. Запись MUST быть условна по признаку захвата, а шаг,
чей захват за время работы достался другому, MUST завершиться без записи
Шаг конвейера SHALL записывать свой результат только тогда, когда захват записи
всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата**
значению, уникальному для каждого захвата, — а не по занятости записи вообще.
Шаг, чей захват за время работы достался другому, MUST завершиться без записи
результата и без ответа отправителю.
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
теряет задачу, продолжая работать. Без этого условия два воркера пишут в одну
задачу по очереди, счётчик попыток сбрасывает тот, кто уже не владелец, а
отправитель получает два ответа на одну запись.
теряет запись, продолжая работать. Снять захват может и человек, вернувший
остановленную запись в работу. Без условия по уникальному признаку два воркера
пишут в одну запись по очереди, счётчик отказов сбрасывает тот, кто уже не
владелец, а отправитель получает два ответа на одну запись.
Шаг MUST записывать только те поля, которыми распоряжается сам. Задачу он держит
Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
стёрла бы всё, что владелец правил в панели за это время: молча, без строки в
журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы
уверен, что правка на месте.
уверен, что правка на месте. Владелец записи, заголовок, краткое описание и темы
конвейер MUST не трогать.
#### Scenario: Правка владельца пережила сохранение шага
- **GIVEN** шаг держит захваченную задачу
- **GIVEN** шаг держит захваченную запись
- **AND** владелец за это время изменил в панели поле, которого шаг не касается
- **WHEN** шаг записывает свой результат
- **THEN** результат шага записан
@@ -141,157 +181,121 @@ done | failed`, отмена контекста посреди шага и ос
#### Scenario: Захват ушёл под работающим шагом
- **GIVEN** шаг работает над захваченной задачей
- **AND** за это время та же задача досталась другому захвату
- **GIVEN** шаг работает над захваченной записью
- **AND** за это время та же запись досталась другому захвату
- **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается
- **AND** отправителю ничего не отправляется
#### Scenario: Человек снял остановку под работающим шагом
- **GIVEN** шаг работает над захваченной записью
- **AND** человек за это время снял с неё признак остановки, освободив захват
- **AND** запись досталась другому воркеру
- **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается
### Requirement: Брошенная задача возвращается в работу
Задача, захваченная и брошенная на середине, SHALL доставаться снова по
Запись, захваченная и брошенная на середине, SHALL доставаться снова по
истечении срока захвата. Срок MUST считаться от времени захвата, а истёкший
захват MUST не мешать выдать задачу следующему.
захват MUST не мешать выдать запись следующему.
Срок задаётся шагом конвейера и MUST быть не меньше того времени, которое этот
шаг может занять на самом длинном допустимом входе. Срок короче делает
протухание штатным событием живого шага, а не признаком беды.
Срок задаётся рубежом, с которого запись взята, и MUST быть не меньше того
времени, которое шаг этого рубежа может занять на самом длинном допустимом
входе. Срок короче делает протухание штатным событием живого шага, а не
признаком беды. Срок MUST записываться в саму запись при захвате: воркер шага не
знает и вывести срок из себя не может.
Все значения времени, по которым идёт этот отбор, MUST записываться и сравниваться
в одном виде — том же, в каком хранилище пишет собственные времена записи.
Сравнение идёт побайтово, и вид, разошедшийся хоть разделителем, обращает
условие в постоянную истину или постоянную ложь, причём молча.
Все значения времени, по которым идёт этот отбор, MUST записываться и
сравниваться в одном виде — том же, в каком хранилище пишет собственные времена
записи. Сравнение идёт побайтово, и вид, разошедшийся хоть разделителем,
обращает условие в постоянную истину или постоянную ложь, причём молча.
#### Scenario: Захват протух
- **GIVEN** задача захвачена, а время захвата отстоит дальше срока
- **WHEN** за её состоянием приходит захват
- **THEN** задача выдаётся ему
- **GIVEN** запись захвачена, а время захвата отстоит дальше срока
- **WHEN** приходит захват
- **THEN** запись выдаётся ему
#### Scenario: Срок сравнивается с временем, записанным хранилищем
- **GIVEN** задача захвачена, и время захвата записано в том же виде, в каком
- **GIVEN** запись захвачена, и время захвата записано в том же виде, в каком
хранилище пишет время изменения записи
- **WHEN** за её состоянием приходит захват до истечения срока
- **THEN** задача ему не выдаётся
- **WHEN** приходит захват до истечения срока
- **THEN** запись ему не выдаётся
### Requirement: Число попыток и состояние «мертва»
#### Scenario: Срок протухания приехал с рубежом
У задачи SHALL быть число попыток. Оно MUST расти при каждом захвате и MUST
возвращаться к нулю, когда шаг завершился без отказа. Рост при захвате, а не при
отказе, засчитывает попытку и задаче, брошенной на середине: шаг, уносящий с
собой процесс, до объявления отказа не доходит никогда, и без этого такая задача
крутилась бы вечно.
Задача, захваченная с числом попыток сверх заданного предела, MUST переводиться в
состояние «мертва» тем, кто её захватил, и MUST не отдаваться шагу в работу. Перевод
принадлежит одному месту: условие отбора, молча пропускающее задачу мимо выборки,
оставило бы её без состояния и без следа.
Мёртвая задача MUST отбираться владельцем по своему состоянию и MUST
возвращаться в работу правкой этого состояния — без запроса в консоли сервера.
Переход в «мертва» MUST сообщать отправителю о неудаче ровно так же, как
сообщает о ней отказ шага. Иначе он становится третьим исходом там, где инвариант
проекта «Принятая запись не теряется молча» допускает два: задача не пригодна к
повтору и об отказе никто не сказал.
От состояния отказа «мертва» отличается тем, чей это приговор. В `failed` задачу
переводит шаг, рассудивший об этой записи окончательно: конвертация не удалась,
распознавание вернуло ошибку. В «мертва» задача уходит без такого суждения — мы
повторяли и перестали. Ни один шаг конвейера в «мертва» не переводит сам.
Прежний признак «задача с ошибкой», исключавший задачу из выборки навсегда и
отдельный от перечня состояний, MUST не заводиться заново: два способа вывести
задачу из выборки расходятся, и молчаливо теряется тот, который забыли проверить.
#### Scenario: Задача падает на каждой попытке
- **GIVEN** шаг конвейера отказывает на каждой попытке
- **WHEN** задача проходит заданное число попыток
- **THEN** она переходит в состояние «мертва»
- **AND** следующий захват её не выдаёт
- **AND** отправитель получает сообщение о неудаче
#### Scenario: Шаг уносит процесс, не объявив отказа
- **GIVEN** шаг конвейера обрывается вместе с процессом на каждой попытке
- **WHEN** задача захватывается снова заданное число раз
- **THEN** она переходит в состояние «мертва»
#### Scenario: Прошедшая задача попыток не копит
- **GIVEN** задача прошла подряд несколько состояний без единого отказа
- **WHEN** смотрят её число попыток
- **THEN** оно не приблизилось к пределу
#### Scenario: Мёртвая задача возвращена в работу
- **GIVEN** задача в состоянии «мертва»
- **WHEN** её состояние сменили на то, с которого она отказывала
- **THEN** следующий захват выдаёт её снова
- **GIVEN** записи двух рубежей с разными сроками захвата пригодны к работе
- **WHEN** их захватывает один и тот же воркер
- **THEN** у каждой записан срок её рубежа
### Requirement: Пауза перед повтором нарастает
Перед повтором **отказавшей** задачи сервис SHALL выдерживать паузу, и пауза
MUST расти с числом её попыток до объявленного потолка. Задача MUST не
Перед повтором **отказавшей** записи сервис SHALL выдерживать паузу, и пауза
MUST расти с числом её отказов до объявленного потолка. Запись MUST не
выдаваться захвату, пока пауза не кончилась.
Ожидание чужой операции этой паузой MUST не выражаться. Шаг, увидевший, что
внешняя операция ещё идёт, отработал без отказа: он назначает **свою** задержку
опроса, заданную числом, и попытки при этом не тратит. Пауза, выведенная из
числа попыток, на таком шаге вырождается в наименьшее своё значение и учащает
опрос внешнего сервиса во столько раз, во сколько задержка опроса длиннее секунды.
внешняя операция ещё идёт, отработал без отказа: он **откладывает** работу своей
задержкой, заданной числом, и отказов при этом не тратит. Пауза, выведенная из
числа отказов, на таком шаге вырождается в наименьшее своё значение и учащает
опрос внешнего сервиса во столько раз, во сколько задержка опроса длиннее
секунды.
#### Scenario: Отказавшая задача ждёт
#### Scenario: Отказавшая запись ждёт
- **GIVEN** задача отказала на шаге конвейера
- **GIVEN** запись отказала на шаге конвейера
- **WHEN** захват приходит раньше конца её паузы
- **THEN** задача ему не выдаётся
- **THEN** запись ему не выдаётся
#### Scenario: Вторая пауза длиннее первой
- **GIVEN** задача отказала дважды подряд
- **GIVEN** запись отказала дважды подряд
- **WHEN** сравнивают паузу после второго отказа с паузой после первого
- **THEN** вторая длиннее
#### Scenario: Ожидание операции не учащается и не тратит попыток
#### Scenario: Ожидание операции не учащается и не тратит отказов
- **GIVEN** внешняя операция распознавания ещё идёт
- **WHEN** шаг проверки отрабатывает подряд несколько раз
- **WHEN** шаг опроса отрабатывает подряд несколько раз
- **THEN** задержка до следующей проверки каждый раз одна и та же
- **AND** число попыток задачи не растёт
- **AND** число отказов записи не растёт
### Requirement: Недоставленный ответ не роняет шаг
Шаг конвейера SHALL доводить задачу до достигнутого состояния, когда ответ
Шаг конвейера SHALL доводить запись до достигнутого рубежа, когда ответ
отправителю доставить не удалось, и MUST не считать недоставку отказом шага.
Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор
задачи, MUST называть причину и MUST считаться отдельной метрикой с причиной
записи, MUST называть причину и MUST считаться отдельной метрикой с причиной
меткой.
Причин у недоставки две, и исход у них общий: **вход отправителя не поднят**
задача заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
у задачи не назван** — источником значится Telegram, а чата в задаче нет.
запись заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
у записи не назван** — источником значится Telegram, а чата в записи нет.
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим,
и его уровень «может стать проблемой». Неназванный адресат — симптом порчи
записи: у задачи из Telegram чат есть всегда, и пропасть он может только от
записи: у записи из Telegram чат есть всегда, и пропасть он может только от
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
записей о ненастроенном боте.
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
достигнутое состояние сохранено. Работа к этой минуте сделана, и объявленный
отказ засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть
соврал бы про исход дважды. Повтор делу не помогает: ни бот, ни адресат от
ожидания не появятся. Поэтому задача остаётся в достигнутом состоянии, в повтор
не уходит и в `failed` не переводится, а причина недоставки живёт в записи
журнала, а не в состоянии задачи.
достигнутый рубеж сохранён. Работа к этой минуте сделана, и объявленный отказ
засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть соврал бы
про исход дважды. Повтор делу не помогает: ни бот, ни адресат от ожидания не
появятся. Поэтому запись остаётся на достигнутом рубеже, в повтор не уходит и
**признака остановки не получает**, а причина недоставки живёт в записи журнала,
а не в рубеже записи.
Идентификатор задачи в записи обязателен: без него владелец видит, что ответ не
ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя в эту
запись MUST не попадать — приватность содержимого записи требование не
То же MUST относиться к недоставке сообщения об **остановке**: остановка уже
сохранена, и недоставка её MUST не отменять.
Идентификатор записи в этой строке обязателен: без него владелец видит, что
ответ не ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя
в эту запись MUST не попадать — приватность содержимого записи требование не
ослабляет.
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
@@ -299,60 +303,410 @@ MUST расти с числом её попыток до объявленног
#### Scenario: Вход отправителя не поднят
- **GIVEN** задача принята входом Telegram прошлым запуском сервиса
- **GIVEN** запись принята входом Telegram прошлым запуском сервиса
- **AND** сервис поднялся без этого входа
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** задача остаётся в достигнутом состоянии, в повтор не уходит и в
`failed` не переводится
- **AND** запись остаётся на достигнутом рубеже, в повтор не уходит и признака
остановки не получает
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
задачи и причиной
записи и причиной
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
#### Scenario: Адресат у задачи не назван
#### Scenario: Адресат у записи не назван
- **GIVEN** у задачи источником значится Telegram, а чат не назван
- **GIVEN** у записи источником значится Telegram, а чат не назван
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** задача остаётся в достигнутом состоянии
- **AND** запись остаётся на достигнутом рубеже
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
задачи и причиной: неназванный адресат — симптом порчи записи
записи и причиной: неназванный адресат — симптом порчи записи
#### Scenario: Не доехало сообщение об остановке
- **GIVEN** запись остановлена признаком
- **AND** вход отправителя не поднят
- **WHEN** шаг доходит до ответа отправителю
- **THEN** признак остановки у записи остаётся
- **AND** в журнале есть запись о недоставке с идентификатором записи и причиной
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
- **GIVEN** задача принята по HTTP
- **GIVEN** запись принята по HTTP
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа и без записи о недоставке
### Requirement: Выборка воркера владельцем не сужается
Воркер SHALL брать задачи всех владельцев подряд и MUST не учитывать владельца
при выборе очередной задачи. Задача без владельца — принятая ботом — MUST
Воркер SHALL брать записи всех владельцев подряд и MUST не учитывать владельца
при выборе очередной записи. Запись без владельца — принятая ботом — MUST
обрабатываться наравне с прочими.
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
владельцем остановило бы расшифровку записей бота вовсе, а записи остальных
поставило бы в зависимость от того, кто первым завёл учётную запись.
Владелец задачи MUST переживать работу конвейера: шаг, сохраняющий свой
Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой
результат, владельца не трогает и не затирает.
#### Scenario: Задачи двух владельцев проходят одним воркером
#### Scenario: Записи двух владельцев проходят одним воркером
- **GIVEN** заведены задачи двух разных владельцев в одном состоянии
- **WHEN** воркер забирает задачи этого состояния
- **GIVEN** заведены записи двух разных владельцев на одном рубеже
- **WHEN** воркер забирает работу
- **THEN** ему достаются обе, в порядке заведения
#### Scenario: Задача без владельца обрабатывается
#### Scenario: Запись без владельца обрабатывается
- **GIVEN** заведена задача, принятая ботом, — без владельца
- **WHEN** воркер забирает задачи её состояния
- **GIVEN** заведена запись, принятая ботом, — без владельца
- **WHEN** воркер забирает работу
- **THEN** она достаётся ему наравне с прочими
#### Scenario: Шаг конвейера владельца не затирает
- **GIVEN** задача с владельцем прошла шаг конвейера
- **GIVEN** запись с владельцем прошла шаг конвейера
- **WHEN** шаг сохраняет свой результат
- **THEN** владелец задачи остаётся прежним
- **THEN** владелец записи остаётся прежним
### Requirement: Рубеж записи называет достигнутое
Аудиозапись SHALL нести рубеж — состояние, называющее **достигнутое**, а не
предстоящее. Цепочка рубежей: `uploaded`, `normalized`, `submitted`,
`transcribed`, `done`. Какой шаг делать дальше, сервис MUST выбирать по рубежу
одним общим местом, а не тем, какой воркер пришёл за записью.
Прежние состояния называли предстоящую работу (`created`, `converted`,
`transcribe`), и потому по состоянию нельзя было сказать, что с записью уже
сделано: продолжить с места остановки было не с чего.
Конечный рубеж MUST зваться `done`. Доставка ответа отправителю в конвейер не
входит, и слово описывает пройденный конвейер, а не полученный человеком текст.
Перечень рубежей, из которых запись берётся в работу, MUST выводиться из одного
объявления рубежа, а не перечисляться отдельно каждым потребителем. Рубеж,
забытый в отборе захвата, не выдаётся ни одному воркеру никогда, а пустой прогон
по инварианту проекта не пишется в журнал и не считается в метрику: запись
встала бы без единого следа. Потребителей у перечня больше двух — выбор шага,
отбор захвата, срок захвата, предел времени, закрытый перечень значений в схеме,
— и человеческая сверка между ними не механизируема.
Записи на конечном рубеже MUST не браться в работу и MUST не подпадать под
предел времени в рубеже: `done` не ждёт работы, и стоять в нём запись будет
вечно по построению.
#### Scenario: Рубеж называет сделанное
- **GIVEN** запись прошла приведение к рабочему формату
- **WHEN** смотрят её рубеж
- **THEN** он называет приведение сделанным, а не предстоящим
#### Scenario: Следующий шаг выбирается по рубежу
- **GIVEN** запись стоит на рубеже приведения
- **WHEN** её берёт воркер
- **THEN** идёт отправка на распознавание, а не повторное приведение
#### Scenario: Запись на конечном рубеже не берут и не останавливают
- **GIVEN** запись стоит на конечном рубеже дольше любого предела
- **WHEN** приходит захват
- **THEN** запись ему не выдаётся
- **AND** признака остановки у неё не появляется
### Requirement: Остановка записи — признак, а не рубеж
Сервис SHALL останавливать запись отдельным признаком с причиной и MUST не
стирать при этом достигнутый рубеж. Признак MUST нести время остановки, причину
и машинный текст отказа.
Снятие признака SHALL возвращать запись в работу **с того рубежа, где она
стояла**, и MUST сбрасывать число отказов, паузу **и время входа в рубеж**.
Время входа сбрасывается по той же причине, что и остальные сторожа: запись,
простоявшая остановленной дольше предела, иначе останавливалась бы снова первым
же захватом, и перезапуск не работал бы вовсе.
Прежние состояния отказа и смерти MUST не заводиться заново: обе причины
восстанавливаются одинаково — снятием признака, — и различие между ними
перестаёт быть структурным, оставаясь причиной остановки. Состояние, называющее
отказ, стирает достигнутый рубеж, и продолжение с места остановки становится
невозможным.
**Способ вывести запись из выборки MUST быть один — этот признак.** Второго
признака, исключающего запись из работы помимо рубежа и паузы, MUST не
заводиться: два способа расходятся, и молчаливо теряется тот, который забыли
проверить. Условие отбора MUST не выводить запись из выборки молча — запись,
переставшая браться в работу, обязана нести признак остановки с причиной.
Остановку MUST ставить тот, кто запись захватил. Перевод принадлежит одному
месту: условие отбора, молча пропускающее запись мимо выборки, оставило бы её
без следа.
Остановленная запись MUST не выдаваться захвату.
#### Scenario: Остановленная запись продолжает с места остановки
- **GIVEN** шаг остановил запись на рубеже приведения
- **WHEN** признак остановки снимают
- **THEN** следующим идёт отправка на распознавание, а не повторное приведение
#### Scenario: Остановленная запись не выдаётся захвату
- **GIVEN** у записи стоит признак остановки
- **WHEN** за её рубежом приходит захват
- **THEN** запись ему не выдаётся
#### Scenario: Снятие признака сбрасывает всех сторожей
- **GIVEN** запись остановлена с накопленными отказами и паузой
- **AND** остановленной она простояла дольше предела времени в рубеже
- **WHEN** признак остановки снимают
- **THEN** число отказов, пауза и время входа в рубеж сброшены
- **AND** ближайший захват выдаёт запись, а не останавливает её снова
### Requirement: Всякая остановка сообщает отправителю
Остановка записи по любой причине SHALL сообщать отправителю о неудаче ровно
так же, как сообщает о ней отказ шага, и MUST быть видна владельцу сервиса
записью в журнале.
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
инвариант допускает два исхода — запись пригодна к повтору либо об отказе
сказано, — а остановленная запись захвату не выдаётся, значит первый исход
исключён.
Причин остановки больше одной, и обязанность общая для всех: исчерпанные
отказы, застревание в рубеже, приговор шага. Обязанность, записанная у одной
причины, у остальных читалась бы как снятая.
Ответ уходит **после** того, как признак остановки сохранён, и недоставка этого
ответа MUST не отменять остановку: её нормирует требование «Недоставленный ответ
не роняет шаг».
#### Scenario: Остановка по отказам сообщает отправителю
- **GIVEN** запись остановлена по исчерпании отказов
- **WHEN** шаг доходит до ответа отправителю
- **THEN** отправитель получает сообщение о неудаче
#### Scenario: Остановка по времени сообщает отправителю
- **GIVEN** запись остановлена по пределу времени в рубеже
- **WHEN** шаг доходит до ответа отправителю
- **THEN** отправитель получает сообщение о неудаче
- **AND** в журнале владельца есть запись об остановке с причиной
### Requirement: Время в рубеже ограничено
У аудиозаписи SHALL быть время входа в рубеж, и оно MUST ставиться только при
смене рубежа и при возврате записи в работу. Запись, простоявшая в рубеже дольше
предела, MUST останавливаться признаком с причиной «застряла».
Пределов MUST быть два, и граница проходит по тому, **чью работу ждём**: своя
работа — час, ожидание чужой операции — сутки. Одно общее число пришлось бы
мерить по самому долгому, и застрявшее приведение стояло бы сутки; число на
каждый рубеж назвало бы разными вещи, различающиеся только исполнителем. Сколько
идёт распознавание долгой записи, сервис не мерил, поэтому у чужой работы ошибка
идёт в сторону долгого: ложная остановка хуже поздней. Оба числа MUST лежать в
настройках.
Сторож этот ловит **зависание**, а не долгую работу, и час у своей работы меньше
времени, которое многочасовая запись занимает на приведении. Цена решения
названа прямо: длинная запись, отказавшая один раз и ждущая повтора дольше часа,
будет остановлена как застрявшая. Цена ограничена тем, что остановка обратима —
снятие признака возвращает запись на её рубеж, — и тем, что живой шаг проверяется
по самому процессу. Решение владельца 2026-08-14.
Откладывание опроса MUST не двигать время входа в рубеж и MUST не сдвигать этот
предел. Иначе запись, чью чужую операцию опрашивают раз в несколько секунд,
никогда не достигнет предела, и застревание останется незамеченным.
Остановка по этому пределу MUST ничего не терять: идентификатор чужой операции
остаётся в строке попытки распознавания, и снятие признака возобновляет опрос
той же операции, а не заводит вторую.
#### Scenario: Сотня откладываний не двигает отсчёт
- **GIVEN** запись стоит на рубеже отправки, и чужая операция ещё идёт
- **WHEN** опрос откладывается сотню раз подряд
- **THEN** время входа в рубеж не изменилось
- **AND** отсчёт до предела не обнулился
#### Scenario: Предел достигнут
- **GIVEN** запись простояла в рубеже дольше своего предела
- **WHEN** за ней приходит захват
- **THEN** запись получает признак остановки с причиной «застряла»
#### Scenario: Возобновление опроса не заводит вторую операцию
- **GIVEN** запись остановлена по пределу на рубеже отправки
- **WHEN** признак остановки снимают
- **THEN** опрос идёт по прежнему идентификатору операции
- **AND** новая операция у провайдера не заводится
### Requirement: Откладывание не является переходом
Сервис SHALL различать переход на новый рубеж и откладывание работы над
записью. Откладывание MUST ставить паузу и снимать захват, MUST не трогать ни
рубеж, ни время входа в него, и MUST не считаться отказом: ожидание чужой
операции отказом не является, поэтому число отказов оно MUST обнулять.
Сегодня шаг опроса зовёт переход с **тем же** состоянием, и мнимость этого
перехода обнуляет счётчик. Без разделения время входа в рубеж сбрасывалось бы на
каждом опросе и повторило бы ровно тот промах, ради которого заводится.
#### Scenario: Откладывание не двигает рубеж
- **GIVEN** шаг опроса увидел, что чужая операция ещё идёт
- **WHEN** он откладывает работу
- **THEN** рубеж записи прежний, и время входа в него прежнее
- **AND** захват с записи снят, а пауза поставлена
### Requirement: Шаг с внешней оплатой проверяет сделанное
Шаг, чьё повторение оплачивается наружу, SHALL проверять, не сделана ли работа
уже, и MUST не делать её второй раз. Проверка MUST идти по наблюдаемому признаку
присутствия результата, а не по сверке содержимого хешем: у составного объекта
во внешнем хранилище признак целостности не равен отпечатку содержимого.
Признак MUST записываться прежде, чем оплачиваемое обращение считается
состоявшимся: строка попытки распознавания заводится до обращения к провайдеру,
и повторный шаг начинает с проверки, не заведена ли операция.
Полной защиты от обрыва процесса между ответом провайдера и записью признака
требование не даёт и дать не может: жёсткая остановка контейнера не оставляет
места ни одной записи. Это остаточный риск, названный в дизайне, а не норма:
норма, обязывающая к недостижимому, не пишется.
#### Scenario: Работа уже сделана
- **GIVEN** результат оплачиваемого шага уже на месте, и признак его записан
- **WHEN** шаг повторяется
- **THEN** внешнее обращение не повторяется
### Requirement: Число воркеров задаётся настройкой
Сервис SHALL брать число рабочих потоков конвейера из настроек, а сами потоки
MUST не быть привязаны к отдельному шагу: каждый берёт любую подходящую запись и
выбирает шаг по её рубежу. Поведение записи MUST не зависеть от числа потоков.
Ноль MUST быть законным значением: сервис поднимается, записи принимаются и не
двигаются. Это режим, а не поломка.
Счётчик работы воркера MUST различать шаги: метка счётчика MUST нести рубеж, с
которого запись взята, а не имя или номер потока. У одинаковых потоков имя
перестаёт что-либо значить, а счётчик отказов — единственный сигнал, по которому
владелец сервиса замечает поломку; без разреза по шагу «падает приведение» и
«падает распознавание» становятся неразличимы.
Опрос чужой операции MUST оставаться работой очереди, а не отдельного
смотрителя: очередь даёт ему неделимость захвата и возврат брошенного даром, а
единственный смотритель умирает молча и уносит с собой целый класс записей.
#### Scenario: Запись доходит при одном потоке и при нескольких
- **GIVEN** число потоков конвейера равно одному
- **WHEN** запись проходит конвейер
- **THEN** она доходит до конечного рубежа
- **AND** при числе потоков больше одного исход тот же
#### Scenario: Потоков нет вовсе
- **GIVEN** число потоков конвейера равно нулю
- **WHEN** запись принимают
- **THEN** сервис принимает её и не теряет
- **AND** запись остаётся на первом рубеже
#### Scenario: Отказ виден с разрезом по шагу
- **GIVEN** шаг приведения отказал
- **WHEN** наблюдатель читает счётчик работы воркера
- **THEN** отказ засчитан с меткой рубежа приведения
### Requirement: Журнал событий записи пишется на смену рубежа
Сервис SHALL вести журнал событий аудиозаписи и MUST писать в него строку на
смену рубежа, на остановку и на снятие остановки. Строка MUST называть источник
события — шаг конвейера или человека, — сам шаг, исход и длительность.
Журнал MUST не писаться на каждое откладывание опроса: часовая запись дала бы
сотни строк ни о чём.
Ни один шаг конвейера MUST не читать этот журнал, чтобы решить, что делать
дальше: решение принимается по рубежу записи, и второй источник решения
разошёлся бы с первым молча.
Содержимое записи в журнал событий MUST не попадать — инвариант приватности
действует здесь наравне с журналом сервиса.
#### Scenario: Смена рубежа записана
- **GIVEN** шаг конвейера довёл запись до нового рубежа
- **WHEN** смотрят журнал событий этой записи
- **THEN** в нём есть строка с шагом, исходом и длительностью
#### Scenario: Откладывание строки не пишет
- **GIVEN** опрос чужой операции откладывается многократно
- **WHEN** смотрят журнал событий записи
- **THEN** строк об откладываниях в нём нет
#### Scenario: Перезапуск человеком виден в журнале
- **GIVEN** запись остановлена признаком
- **WHEN** человек снимает признак
- **THEN** в журнале событий есть строка с указанием, что это сделал человек
### Requirement: Число отказов ограничивает повторы шага
У аудиозаписи SHALL быть число отказов. Оно MUST расти при каждом захвате и MUST
возвращаться к нулю, когда шаг завершился без отказа либо отложил работу. Рост
при захвате, а не при отказе, засчитывает попытку и записи, брошенной на
середине: шаг, уносящий с собой процесс, до объявления отказа не доходит
никогда.
**Остановка сервиса отказом не считается.** Шаг, прерванный отменой по
собственной остановке сервиса, MUST возвращать число отказов назад и MUST не
выносить записи приговора: запись не виновата в том, что нас перезапустили, и
несколько выкладок подряд иначе останавливают здоровую многочасовую запись с
приговором «отказы исчерпаны». Всякая другая причина, по которой шаг не дошёл до
объявления исхода, отказ тратит.
Запись, захваченная с числом отказов сверх заданного предела, MUST
останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в
работу. Об этой остановке отправителю сообщается наравне с прочими — норму
держит требование «Всякая остановка сообщает отправителю».
Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое
записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни
с одной из двух обязанностей — опрос, вернувший «ещё в работе», обнуляет его, и
зависшая чужая операция опрашивается вечно, а не обнулял бы — убивал бы здоровую
запись.
#### Scenario: Запись отказывает на каждой попытке
- **GIVEN** шаг конвейера отказывает на каждой попытке
- **WHEN** запись проходит заданное число отказов
- **THEN** у неё появляется признак остановки
- **AND** следующий захват её не выдаёт
- **AND** отправитель получает сообщение о неудаче
#### Scenario: Шаг уносит процесс, не объявив отказа
- **GIVEN** шаг конвейера обрывается вместе с процессом на каждой попытке
- **WHEN** запись захватывается снова заданное число раз
- **THEN** у неё появляется признак остановки
#### Scenario: Остановка сервиса отказа не тратит
- **GIVEN** шаг работает над записью
- **WHEN** сервис останавливают, и шаг прерывается отменой
- **THEN** число отказов записи прежнее
- **AND** признака остановки у записи не появляется
#### Scenario: Прошедшая запись отказов не копит
- **GIVEN** запись прошла подряд несколько рубежей без единого отказа
- **WHEN** смотрят её число отказов
- **THEN** оно не приблизилось к пределу
+151
View File
@@ -0,0 +1,151 @@
# recognition Specification
## Purpose
TBD - created by archiving change record-centric-model. Update Purpose after archive.
## Requirements
### Requirement: Попытка распознавания хранится отдельно от записи
Сервис SHALL держать всё, что принадлежит внешнему распознавателю, отдельной
строкой, связанной с аудиозаписью, и MUST не хранить это колонками самой записи.
К попытке относятся имя провайдера, имя модели, идентификатор операции у
провайдера, адрес, по которому провайдер читал аудио, время начала и время
завершения.
Разрез проходит по одной границе: **зависит ли вещь от провайдера
распознавания**. Идентификатор операции — самое провайдерское, что есть в
модели, а копия аудио во внешнем хранилище существует только потому, что
сегодняшний провайдер читает запись по адресу; другой провайдер её не потребует.
Оставленные колонками записи, они делают смену провайдера правкой доменной
сущности.
Копия аудио во внешнем хранилище MUST не считаться файлом записи: у записи
остаётся ровно две своих копии — принятая и приведённая, — а ключ объекта живёт
в строке попытки.
#### Scenario: Идентификатор операции лежит в попытке
- **GIVEN** запись отправлена на распознавание
- **WHEN** смотрят, где лежит идентификатор операции у провайдера
- **THEN** он лежит в строке попытки распознавания
- **AND** колонки с ним у самой записи нет
#### Scenario: Копия во внешнем хранилище не подменяет файл записи
- **GIVEN** запись прошла отправку на распознавание
- **WHEN** смотрят ссылки записи на файлы
- **THEN** они ведут на принятую и на приведённую копии
- **AND** ключ объекта во внешнем хранилище лежит в строке попытки
### Requirement: Сырой ответ провайдера сохраняется целиком
Сервис SHALL сохранять ответ распознавателя целиком, в том виде, в каком он
пришёл, и MUST хранить его вложением, а не колонкой строки попытки.
Хранится он потому, что **результат операции у провайдера не переспрашивается**:
связь реплики с говорящим сервис строить пока не умеет, и когда научится, архив
пересчитается из сохранённого без повторной оплаты.
Вложением, а не колонкой, — потому что шаг опроса читает строку попытки часто, а
хранилище читает запись целиком: ответ на многочасовую запись, положенный
колонкой, ехал бы в память при каждом опросе.
Чтение строки попытки шагом опроса MUST не тянуть за собой сохранённый ответ.
Сохранённый ответ — это полный текст речи, и закрыт он MUST быть наравне с самой
записью: поле вложения помечено защищённым, правило просмотра пускает только
владельца связанной записи, ссылка не попадает ни в журнал, ни в метку метрики.
Норму держит capability `storage`, требование «Содержимое записи закрыто во всех
коллекциях, где лежит»; здесь она названа потому, что коллекция попыток — то
место, куда содержимое приезжает впервые.
#### Scenario: Ответ сохранён и читается позже
- **GIVEN** распознавание завершилось и ответ провайдера получен
- **WHEN** запись доходит до конечного рубежа
- **THEN** сохранённый ответ доступен по строке попытки целиком
#### Scenario: Опрос не тянет сохранённый ответ
- **GIVEN** у попытки распознавания есть сохранённый ответ
- **WHEN** шаг опроса читает строку попытки
- **THEN** сохранённый ответ в память при этом не читается
### Requirement: Структура реплик строится из сохранённого ответа
Сервис SHALL строить структуру реплик записи из сохранённого ответа провайдера и
MUST не обращаться к провайдеру повторно ради неё. Структура MUST хранить время
каждой реплики и MUST лежать отдельной строкой со ссылкой с записи, а не
колонкой записи.
У структуры MUST быть номер версии её вида: разбор сохранённого ответа изменится
раньше, чем архив пересчитают, и по номеру видно, какой разбор её построил.
Говорящих структура сегодня не размечает: связь реплики с разбором говорящего у
провайдера не выяснена. Требование этого и не заказывает — оно заказывает
источник, из которого разметка станет возможной без повторной оплаты.
#### Scenario: Структура собрана без обращения к провайдеру
- **GIVEN** ответ провайдера сохранён
- **WHEN** сервис строит структуру реплик
- **THEN** структура собрана с временем каждой реплики
- **AND** к провайдеру не уходит ни одного обращения
### Requirement: Разбор формата провайдера не выходит за адаптер
Распознаватель SHALL отдавать сервису доменный результат — реплики со временем,
плоский текст и байты ответа на хранение, — и MUST не отдавать сырой формат
провайдера. Ни один шаг конвейера MUST не знать, каким потоком и какими полями
провайдер отвечает.
Сегодня разбор потока лежит в шаге: адаптер отдаёт строку, склеенную из
альтернатив, и всё, что провайдер сказал сверх текста, теряется на границе
контракта.
#### Scenario: Шаг получает реплики, а не поток провайдера
- **WHEN** шаг конвейера забирает результат распознавания
- **THEN** он получает реплики со временем, плоский текст и байты на хранение
- **AND** формата провайдера в этом результате нет
### Requirement: Заливка и отправка на распознавание разделены
Сервис SHALL разделять укладку аудио туда, откуда провайдер его прочитает, и
отправку операции распознавания: это два разных обращения с разной ценой
повтора. Повтор укладки MUST быть бесплатен и класть объект под тем же ключом;
повтор отправки оплачивается наружу и MUST не происходить, когда операция уже
заведена.
Разделение нужно затем, чтобы шаг мог проверить сделанное прежде, чем платить:
объект нужного размера на месте — укладку MUST не повторять; идентификатор
операции в строке попытки есть — отправку MUST не повторять.
Строка попытки MUST заводиться **до** обращения к провайдеру: окно между ответом
провайдера и записью идентификатора — то место, где теряется оплаченное. Мягкую
остановку сервиса отправка MUST переживать своим пределом по времени; полной
защиты от жёсткого обрыва процесса требование не даёт и дать не может — это
остаточный риск, названный в дизайне, а не норма.
#### Scenario: Объект уже лежит, а операции ещё нет
- **GIVEN** аудио уже уложено туда, откуда провайдер его читает, и размер совпадает
- **AND** идентификатора операции в строке попытки нет
- **WHEN** шаг повторяется
- **THEN** укладка не повторяется
- **AND** операция отправляется
#### Scenario: Операция уже заведена
- **GIVEN** в строке попытки есть идентификатор операции
- **WHEN** шаг повторяется
- **THEN** отправка не повторяется
- **AND** шаг переходит к опросу этой операции
#### Scenario: Операция принята, а сервис мягко останавливают
- **GIVEN** отправка операции ушла провайдеру
- **AND** сервис в эту минуту останавливают мягко
- **WHEN** провайдер отвечает идентификатором операции
- **THEN** идентификатор сохраняется в строке попытки
- **AND** повторная отправка той же записи не заводится
+219 -59
View File
@@ -2,14 +2,17 @@
## Purpose
Где живут запись, её метаданные и её файл: раскладка каталога данных, приведение
схемы при подъёме, отдача файла ссылкой по токену, собственная поверхность
хранилища и панель владельца.
Где живут аудиозапись, её приложения и её файлы: раскладка каталога данных,
приведение схемы при подъёме, отдача файла ссылкой по токену, собственная
поверхность хранилища и панель владельца.
Приём и опрос готовности нормирует `intake`, вход и сессию — `access`.
Сознательно не описаны: перенос прежних данных — его нет по решению задачи
`pocketbase-storage`; удаление записей и файлов — сервис объявлен архивом
2026-08-11, а удаление приносит задача `delete-record`.
Приём и опрос готовности нормирует `intake`, вход и сессию — `access`, попытку
распознавания у внешнего провайдера — `recognition`.
Сознательно не описаны: перенос прежних данных — его нет ни по решению задачи
`pocketbase-storage`, ни по решению владельца 2026-08-14, которым прежние записи
удалены вместе с остановкой сервиса; удаление записей и файлов — сервис объявлен
архивом 2026-08-11, а удаление приносит задача `delete-record`.
## Requirements
### Requirement: Сервис поднимается на чистом каталоге данных
@@ -208,22 +211,24 @@ MUST завести свою схему и принимать записи об
### Requirement: Владелец видит записи в панели
Сервис SHALL давать владельцу панель, где задача видна строкой, отбирается по
своему идентификатору и правится, а её файл слушается и скачивается.
Сервис SHALL давать владельцу панель, где аудиозапись видна строкой, отбирается
по своему идентификатору и правится, а её файлы слушаются и скачиваются.
Панель MUST отдаваться тем же сервисом по своему адресу и MUST не требовать
второго процесса.
Панель — вход в задачу наравне с конвейером, а не окно просмотра, и правка
состояния задачи в ней MUST подчиняться тем же правилам перехода, что и правка
из кода: служебные поля прошлого состояния — признак захвата, время захвата,
пауза, число попыток — MUST очищаться. Иначе владелец, вернувший мёртвую задачу в
работу, получит задачу, которая не выдаётся захвату до конца прежнего срока и
умирает от первого же отказа, — и не узнает об этом.
Панель — вход в запись наравне с конвейером, а не окно просмотра. Снятие
признака остановки в панели MUST возвращать запись в работу с сохранённого
рубежа и MUST очищать служебные поля прошлого захвата — признак захвата, срок
его протухания, паузу, число отказов и MUST заново ставить время входа в
рубеж. Правка рубежа руками MUST делать то же самое. Иначе владелец, вернувший
запись в работу, получит запись, которая не выдаётся захвату до конца прежнего
срока, останавливается от первого же отказа или останавливается снова первым же
захватом по пределу времени, — и не узнает об этом.
Задача, заведённая в панели руками, MUST не уносить сервис: поля, без которых
Запись, заведённая в панели руками, MUST не уносить сервис: поля, без которых
шаг конвейера не может работать, MUST быть обязательными в самой схеме, а
перечень состояний — закрытым.
перечень рубежей — закрытым.
Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все
записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не
@@ -231,18 +236,20 @@ MUST завести свою схему и принимать записи об
#### Scenario: Принятая запись видна владельцу
- **GIVEN** запись принята и её задача заведена
- **WHEN** владелец отбирает задачи по идентификатору принятой
- **THEN** он видит её строкой со своим состоянием
- **AND** файл этой записи скачивается из той же строки
- **GIVEN** запись принята и заведена
- **WHEN** владелец отбирает записи по идентификатору принятой
- **THEN** он видит её строкой со своим рубежом
- **AND** её файл скачивается из той же строки
#### Scenario: Мёртвую задачу вернули в работу правкой в панели
#### Scenario: Остановленную запись вернули в работу правкой в панели
- **GIVEN** задача в состоянии «мертва» с исчерпанными попытками и признаком
- **GIVEN** запись остановлена признаком, с накопленными отказами и признаком
прежнего захвата
- **WHEN** владелец меняет её состояние на рабочее
- **THEN** признак захвата, время захвата, пауза и число попыток очищены
- **AND** ближайший захват выдаёт задачу
- **AND** остановленной она простояла дольше предела времени в рубеже
- **WHEN** владелец снимает признак остановки
- **THEN** признак захвата, срок его протухания, пауза и число отказов очищены
- **AND** время входа в рубеж поставлено заново
- **AND** ближайший захват выдаёт запись с сохранённого рубежа
### Requirement: Пароль владельца от панели не лежит в конфигурации
@@ -278,8 +285,8 @@ MUST завести свою схему и принимать записи об
### Requirement: Владелец задачи лежит связью с учётной записью
Хранилище SHALL держать владельца задачи расшифровки отдельной колонкой — связью
с учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
не назван, не достаётся никому по недосмотру схемы.
Колонка MUST допускать пустое значение, и это решение с названной ценой: записи,
@@ -287,35 +294,33 @@ MUST завести свою схему и принимать записи об
записью сервис не ведёт. Обязательность для приёма по HTTP держит сама
capability `intake`, а не схема.
Колонка приезжает **новым шагом схемы**: применённый шаг не переписывается.
Записей, заведённых до этого шага, сервис не переносит — проект заводится с
чистого листа.
Владелец MUST не назначаться и не меняться конвейером.
#### Scenario: Колонка появляется на пустой базе
- **WHEN** сервис поднимается на чистом каталоге данных
- **THEN** у таблицы задач есть колонка владельца
- **THEN** у аудиозаписи есть колонка владельца
- **AND** умолчания у неё нет
#### Scenario: Конвейер владельца не назначает
- **GIVEN** запись с владельцем прошла шаг конвейера
- **WHEN** смотрят её владельца
- **THEN** он прежний
### Requirement: Файл записи сужается владельцем наравне с задачей
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
записью, тем же шагом схемы, — и правило просмотра файлов MUST пускать к файлу
только его владельца. Прежнее правило пускало всякого узнанного, и знание
идентификатора файловой записи равнялось праву скачать чужое аудио.
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
Без этого требования разграничение закрывает метаданные задачи и оставляет
открытым содержимое — то самое, что оно и заведено прятать. Хуже самой дыры была
бы отметка о закрытии: паспорт и модель угроз называют исполнителем этой работы
именно эту задачу, и слово «закрыто» скрыло бы открытый путь.
Владелец файла MUST назначаться там же, где владелец задачи, — при приёме, из
Владелец файла MUST назначаться там же, где владелец записи, — при приёме, из
предъявленной сессии, — и MUST оставаться пустым у файлов, заведённых конвейером
для записи без владельца.
Ссылка на файл в задаче переставляется каждым шагом конвейера, поэтому владелец
файла MUST лежать своей колонкой, а не выводиться через задачу: исходная копия
после конвертации не связана с задачей ничем.
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
выводиться через запись: файл переживает свою запись, и заведённый шагом до
сохранения записи он остаётся с владельцем и без ссылки.
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
@@ -343,15 +348,21 @@ capability `intake`, а не схема.
### Requirement: Учётная запись с записями не удаляется
Хранилище SHALL отвергать удаление учётной записи, у которой остались задачи
расшифровки **либо файлы**. Отказ MUST называть причину, и MUST доезжать до
Хранилище SHALL отвергать удаление учётной записи, у которой остались
аудиозаписи **либо файлы**. Отказ MUST называть причину, и MUST доезжать до
спрашивающего: хранилище пропускает наружу только свою ошибку роутера, а всякую
другую подменяет сообщением про обязательную связь — подсказкой, по которой
владелец панели пойдёт удалять записи руками.
Считаются обе коллекции с владельцем. Файл переживает свою задачу: шаг конвейера
заводит его до сохранения задачи, и потерянный захват оставляет файл с владельцем
и без ссылки.
Считаются **все** коллекции с колонкой владельца, и перечень их MUST жить одним
местом: коллекция, пропущенная в счёте, пропускает удаление вперёд, и наружу
приезжает не наш отказ с причиной, а подсказка библиотеки про обязательную связь
— та самая, по которой владелец панели пойдёт удалять записи руками. Сегодня их
три: аудиозаписи, файлы и словарь тем.
Файл переживает свою запись: шаг конвейера заводит его до сохранения записи, и
потерянный захват оставляет файл с владельцем и без ссылки. Тема переживает её
так же: словарь принадлежит человеку, а не записи.
Запрет MUST ставить сама сборка хранилища, а не вызывающий: сборка, забывшая его
позвать, теряет защиту молча — и теряла, пока запрет вешался отдельной строкой
@@ -361,12 +372,6 @@ capability `intake`, а не схема.
удалить **свою** учётную запись запросом, так что запрет закрывает и публичную
поверхность.
Требование заведено вместо прежнего «удаление не уносит задачи следом»: оно
выглядело выполненным, а на деле хранилище при выключенном каскаде **снимает
ссылку** — задачи остаются, но становятся ничьими, а ничья задача не достаётся
по API никому. Архив человека исчезал бы молча и восстановлению не подлежал:
прежнего владельца не остаётся нигде.
Цена требования названа прямо: владелец панели упирается в отказ, а способа
удалить записи в сервисе пока нет вовсе — его приносит задача про удаление
записи. До неё удаление учётной записи с записями невозможно, и это осознанный
@@ -374,20 +379,175 @@ capability `intake`, а не схема.
#### Scenario: Удаление учётной записи с записями отвергается
- **GIVEN** у учётной записи есть задачи расшифровки
- **GIVEN** у учётной записи есть аудиозаписи
- **WHEN** её удаляют
- **THEN** удаление не проходит, а отказ называет причину
- **AND** задачи и их владелец остаются прежними
- **AND** записи и их владелец остаются прежними
#### Scenario: Учётная запись с одними файлами тоже не удаляется
- **GIVEN** у учётной записи остались файлы, но задач нет
- **GIVEN** у учётной записи остались файлы, но записей нет
- **WHEN** её удаляют
- **THEN** удаление не проходит, а владелец файлов остаётся прежним
#### Scenario: Учётная запись с одними темами тоже не удаляется
- **GIVEN** у учётной записи остались темы словаря, но ни записей, ни файлов нет
- **WHEN** её удаляют
- **THEN** удаление не проходит, а отказ называет причину нашими словами
#### Scenario: Учётная запись без записей удаляется
- **GIVEN** у учётной записи нет ни задач, ни файлов
- **GIVEN** у учётной записи нет ни аудиозаписей, ни файлов, ни тем
- **WHEN** её удаляют
- **THEN** удаление проходит
### Requirement: Аудиозапись — центральная сущность хранилища
Хранилище SHALL держать аудиозапись отдельной сущностью, а всё, что к ней
приложено, — отдельными строками со ссылками с записи. Приложениями считаются
файлы, тексты, структура реплик, темы, журнал событий и попытка распознавания.
Поля, которыми распоряжается очередь — признак захвата, срок его протухания,
пауза, число отказов, время входа в рубеж, — MUST не соседствовать с содержимым
записи в одной строке настолько, чтобы чтение очереди тянуло содержимое: сегодня
расшифровка лежит колонкой той же строки и читается при каждом захвате.
Запись MUST нести заголовок и краткое описание своими колонками: они читаются
вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать
отдельными строками: они читаются по открытию одной записи.
#### Scenario: Список читается без содержимого
- **GIVEN** у записи есть расшифровка
- **WHEN** читают запись ради её рубежа и заголовка
- **THEN** текст расшифровки при этом не читается
### Requirement: Содержимое записи закрыто во всех коллекциях, где лежит
Всякая коллекция, куда переезжает содержимое аудиозаписи, SHALL быть закрыта
наравне с самой записью: её правило просмотра MUST не открывать содержимое
никому, кроме владельца связанной записи, а поле, хранящее файл или вложение,
MUST быть помечено защищённым.
Пока содержимое отдаётся собственным адресом сервиса, а не поверхностью
хранилища, правило просмотра MUST оставаться незаданным — то есть «только
владелец панели». Непустое правило открывает перечисление коллекции, и заводить
его раньше, чем появится потребитель, значит открывать поверхность впрок:
норму держит требование «Наружу хранилище отдаёт только то, что заказано».
Требование распространяется на все коллекции приложений — тексты, структуру
реплик, попытку распознавания с её сохранённым ответом, журнал событий и темы, —
и заводится потому, что содержимое **переезжает** из одной строки в шесть. Норма
о защищённом поле файла сегодня написана про файл записи, а сырой ответ
распознавателя — это полный текст речи в другой коллекции: реализация, следующая
только прежней норме, завела бы поле с умолчанием библиотеки, и ссылка на него
отдавала бы расшифровку любому, кто её знает, без сессии.
Ссылка на такое вложение MUST не попадать ни в журнал, ни в метку метрики, ни в
ответ отправителю — теми же словами, какими это нормировано для файла записи.
Умолчание библиотеки здесь не годится ни в одном месте: незаданное правило
просмотра значит «только владелец панели» и отнимает содержимое у самого
владельца записи, а незащищённое поле файла отдаёт его всем.
#### Scenario: Чужой сохранённый ответ не отдаётся
- **GIVEN** запись принята одним вошедшим и прошла распознавание
- **WHEN** другой вошедший идёт по ссылке на сохранённый ответ провайдера
- **THEN** содержимого он не получает
#### Scenario: Без сессии содержимое не отдаётся
- **WHEN** ссылку на сохранённый ответ провайдера запрашивают без сессии
- **THEN** приходит отказ, а содержимого в ответе нет
#### Scenario: Перечисление приложений закрыто
- **WHEN** запрос без прав владельца просит список записей коллекции текстов
- **THEN** приходит отказ
### Requirement: Ссылки на исходник и приведённую копию живут порознь
Аудиозапись SHALL нести две отдельные ссылки на файлы — на принятую копию и на
копию, приведённую к рабочему формату, — и шаг конвейера MUST не переставлять
одну ссылку на свой результат.
Сегодня ссылка одна, и её переставляет каждый шаг: у прошедшей конвейер записи
она ведёт на копию во внешнем хранилище, а принятого человеком файла не найти
ничем. Послушать загруженное нечем именно поэтому.
Обе копии MUST оставаться доступными после того, как запись прошла конвейер.
#### Scenario: После конвейера доступны обе копии
- **GIVEN** запись прошла конвейер целиком
- **WHEN** смотрят её ссылки на файлы
- **THEN** ссылка на принятую копию и ссылка на приведённую заполнены
- **AND** обе открываются
### Requirement: Тексты и структура лежат отдельно от записи
Хранилище SHALL держать тексты записи отдельными строками, каждая со своим видом
текста, и структуру реплик — своей строкой. Запись MUST ссылаться на них, а не
хранить их колонками.
Видов текста больше одного: сырая расшифровка и вычитанный текст. Колонкой на
каждый вид схема росла бы с каждым новым видом, а необратимый шаг схемы платится
за каждую такую колонку отдельно.
**Приложение MUST быть уникально по паре «запись и вид»**, а структура — по паре
«запись и версия разбора». Шаг завершения пишет текст, структуру и сохранённый
ответ несколькими операциями и только потом двигает рубеж: прерванный на середине
и повторённый с прежнего рубежа, он завёл бы второй комплект строк, и вопрос
«какой текст отдавать человеку» стал бы вопросом порядка записи, а не состояния.
Потребитель текста MUST называть **вид**, который берёт, а не брать последний
записанный: иначе исход зависит от порядка записи. Ответ опроса готовности берёт
сырую расшифровку — норму держит capability `intake`.
#### Scenario: Расшифровка лежит своей строкой
- **GIVEN** запись прошла распознавание
- **WHEN** смотрят, где лежит текст расшифровки
- **THEN** он лежит отдельной строкой, на которую запись ссылается
#### Scenario: Повтор шага не заводит второй расшифровки
- **GIVEN** шаг завершения записал расшифровку и оборвался до смены рубежа
- **WHEN** шаг повторяется с прежнего рубежа
- **THEN** строка расшифровки у записи одна
### Requirement: Словарь тем ведётся по владельцу
Хранилище SHALL держать темы отдельной коллекцией, и тема MUST быть уникальна в
паре «владелец и название»: словарь тем свой у каждого человека. У записи MUST
быть не больше пяти тем.
Коллекцией, а не набором строк в записи, — потому что перечень тем человека
нужен целиком перед каждым обращением к модели, а собрать его из наборов строк
можно только перебором всех его записей.
Потолок в пять тем MUST быть у самой записи: без него часовой разговор даёт два
десятка тем, и словарь распухает за неделю.
Название темы выведено из содержимого записи, а перечень тем человека — слепок
того, о чём он вообще говорит. В журнал сервиса темы MUST не попадать наравне с
текстом расшифровки.
Ни один шаг этого изменения тем не пишет и не читает: место заводится вперёд,
чтобы задача, считающая темы языковой моделью, не платила вторым необратимым
шагом схемы. Цена решения названа прямо — имена коллекции и её колонок
закрепляются раньше, чем известен их потребитель.
#### Scenario: Тема одного человека не мешает теме другого
- **GIVEN** у двух владельцев заведена тема с одинаковым названием
- **WHEN** смотрят словарь тем
- **THEN** это две разные темы, каждая своего владельца
#### Scenario: Шестая тема не заводится
- **WHEN** записи назначают шестую тему
- **THEN** назначение не проходит