внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
@@ -72,15 +72,30 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
|
|||||||
Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется
|
Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется
|
||||||
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
|
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
|
||||||
записи. **critical**
|
записи. **critical**
|
||||||
- **Колонки очереди правятся в четырёх местах** пакета хранилища —
|
- **Колонки записи правятся в двух местах** пакета хранилища —
|
||||||
`applyToRecord`, `recordToJob`, константа `acquireColumns` и структура
|
`applyOwnedByPipeline` вместе с `applyToRecord` и `recordToAudioRecord`, —
|
||||||
`acquiredRow` с её `toJob`, — плюс шаг схемы. Компилятор видит два из них.
|
плюс шаг схемы. Компилятор не видит ни одного: колонка, забытая в одном из
|
||||||
Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата
|
них, теряется молча — запись сохранится без поля либо приедет с нулевым.
|
||||||
нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения:
|
Мест было четыре, пока захват перечислял колонки поимённо; теперь он
|
||||||
поле теряется **только у задачи, попавшей к воркеру**. **major**
|
возвращает идентификатор и признак своего захвата, и перечень перестал расти
|
||||||
- **Результат пишет только держатель захвата.** Шаг, чей захват за время работы
|
с моделью. Сверку держат правила `internal/archrules`. **major**
|
||||||
достался другому, завершается без записи и без ответа отправителю. Иначе два
|
- **Рубеж объявляется одним дескриптором** — `internal/entity/stage.go`. Из него
|
||||||
воркера пишут в одну задачу по очереди, а отправитель получает два ответа.
|
выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя;
|
||||||
|
перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в
|
||||||
|
отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
|
||||||
|
ниже не пишется в журнал и не считается в метрику: запись встанет без единого
|
||||||
|
следа. Сверку держат правила `internal/archrules`. **major**
|
||||||
|
- **Результат пишет только держатель захвата, и держатель узнаётся значением.**
|
||||||
|
Признак захвата уникален для каждого захвата, и запись результата условна по
|
||||||
|
нему, а не по занятости записи. Шаг, чей захват за время работы достался
|
||||||
|
другому — по протуханию срока или после того, как человек снял признак
|
||||||
|
остановки в панели, — завершается без записи и без ответа отправителю. Условие
|
||||||
|
по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись
|
||||||
|
по очереди, а отправитель получал бы два ответа. **major**
|
||||||
|
- **Остановленная запись сообщает отправителю, какой бы ни была причина.**
|
||||||
|
Причин три — приговор шага, исчерпанные отказы, застревание. Остановленная
|
||||||
|
запись захвату не выдаётся, значит исход «пригодна к повтору» исключён.
|
||||||
|
Обязанность, записанная у одной причины, у остальных читалась бы как снятая.
|
||||||
**major**
|
**major**
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|||||||
@@ -9,6 +9,33 @@ force_shutdown_timeout = 20
|
|||||||
[storage]
|
[storage]
|
||||||
data_dir = "data"
|
data_dir = "data"
|
||||||
|
|
||||||
|
# Конвейер расшифровки.
|
||||||
|
[pipeline]
|
||||||
|
# Число рабочих потоков. Специализации у них нет: каждый берёт любую пригодную к
|
||||||
|
# работе запись и выбирает шаг по её рубежу.
|
||||||
|
#
|
||||||
|
# Ноль — законное значение, а не поломка: сервис поднимается, записи
|
||||||
|
# принимаются и не двигаются. Годится местному запуску и выкладке, где конвейер
|
||||||
|
# надо остановить, не роняя приём.
|
||||||
|
workers = 3
|
||||||
|
|
||||||
|
# Предел простоя записи там, где работу делаем мы сами, в минутах.
|
||||||
|
#
|
||||||
|
# Сторож ловит **зависание**, а не долгую работу: пока шаг идёт, запись занята
|
||||||
|
# захватом, и живой процесс наблюдается сам по себе. Час меньше времени, которое
|
||||||
|
# многочасовая запись занимает на приведении, и это принято сознательно
|
||||||
|
# (решение владельца 2026-08-14): цена ложной остановки — одно движение
|
||||||
|
# владельца, потому что остановка обратима и рубежа не стирает.
|
||||||
|
own_work_limit_minutes = 60
|
||||||
|
|
||||||
|
# Предел простоя там, где ждём операцию внешнего сервиса, в минутах.
|
||||||
|
#
|
||||||
|
# Сколько идёт распознавание долгой записи, никто не мерил, поэтому ошибаемся в
|
||||||
|
# сторону долгого: ложная остановка хуже поздней. Откладывание опроса этот
|
||||||
|
# отсчёт не двигает — иначе зависшая у провайдера операция опрашивалась бы
|
||||||
|
# вечно.
|
||||||
|
foreign_work_limit_minutes = 1440
|
||||||
|
|
||||||
# Yandex Cloud Configuration
|
# Yandex Cloud Configuration
|
||||||
[yandex]
|
[yandex]
|
||||||
# ID папки в Yandex Cloud (получить в консоли Yandex Cloud)
|
# ID папки в Yandex Cloud (получить в консоли Yandex Cloud)
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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-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 | [Намерение объявляется признаком, а не выводится из ключа доступа](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) | |
|
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | |
|
||||||
|
|||||||
+49
-23
@@ -32,6 +32,11 @@
|
|||||||
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
||||||
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
|
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
|
||||||
2026-08-12;
|
2026-08-12;
|
||||||
|
- [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания
|
||||||
|
у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется
|
||||||
|
целиком и вложением, как из сохранённого строится структура реплик без
|
||||||
|
повторной оплаты и почему разбор формата провайдера не доходит до конвейера.
|
||||||
|
Задача `record-centric-model` 2026-08-14;
|
||||||
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
||||||
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
||||||
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
||||||
@@ -78,22 +83,22 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу |
|
| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу |
|
||||||
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
|
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
|
||||||
| Воркеры | `internal/controller/worker` | Крутят по одному шагу конвейера, опрашивая базу |
|
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
|
||||||
| Сервис расшифровки | `internal/service` | Конвейер: приём, конвертация, распознавание, отдача результата |
|
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
|
||||||
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
|
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
|
||||||
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
|
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
|
||||||
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
|
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
|
||||||
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
|
| Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом |
|
||||||
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
|
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
|
||||||
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки задачи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
|
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
|
||||||
|
|
||||||
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: цепочка переходов состояний -->
|
Цепочка рубежей — `uploaded` → `normalized` → `submitted` → `transcribed` →
|
||||||
|
`done`; рубеж называет достигнутое, а не предстоящее, и нормирует его
|
||||||
Конвейер: `created` → `converted` → `transcribe` → `done` либо `failed`. Каждый
|
[pipeline](../openspec/specs/pipeline/spec.md), «Рубеж записи называет
|
||||||
переход двигает свой воркер, и каждый опрашивает базу раз в секунду. Что
|
достигнутое». Отказ рубежом не является: он ставит признак остановки, а рубеж
|
||||||
делает задача, исчерпавшая попытки, нормирует
|
сохраняется — там же, «Остановка записи — признак, а не рубеж». Шаг выбирается
|
||||||
[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
|
по рубежу одним местом, воркеры к шагам не привязаны, а их число приходит
|
||||||
«мертва»».
|
настройкой.
|
||||||
|
|
||||||
## Внешние границы и форматы
|
## Внешние границы и форматы
|
||||||
|
|
||||||
@@ -136,6 +141,17 @@
|
|||||||
поделят один длинный опрос и часть ответов до людей не дойдёт.
|
поделят один длинный опрос и часть ответов до людей не дойдёт.
|
||||||
|
|
||||||
Ревью кода воспроизвело порядок на прежней версии, живой прогон — на нынешней.
|
Ревью кода воспроизвело порядок на прежней версии, живой прогон — на нынешней.
|
||||||
|
- **Откат образа через шаг схемы `202608140002` не работает и не говорит об
|
||||||
|
этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только
|
||||||
|
те шаги, которые знает сам бинарь: прежний образ шагов новее не видит,
|
||||||
|
поднимается **без единой ошибки** и отвечает зелёной пробой здоровья — после
|
||||||
|
чего всякое обращение к очереди отказывает «коллекции нет». Проверено прогоном
|
||||||
|
двух бинарей на одном каталоге данных.
|
||||||
|
|
||||||
|
Значит штатное средство владельца на инциденте — «вернём прошлый образ» — с
|
||||||
|
этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа;
|
||||||
|
обратного шага схемы нет и не планируется. Порог перехода назван прямо: до
|
||||||
|
выкладки `record-centric-model` откат образа работает, после — нет.
|
||||||
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
|
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
|
||||||
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
|
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
|
||||||
наружу — [database.md](database.md), «Настройки с числовым значением»:
|
наружу — [database.md](database.md), «Настройки с числовым значением»:
|
||||||
@@ -145,17 +161,23 @@
|
|||||||
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
|
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняют только ошибки настройки — ответ «такого бота нет» и включённый вход с пустым ключом доступа. Норму держит [intake](../openspec/specs/intake/spec.md), «Признак включения решает, поднимается ли вход Telegram» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
|
| 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 отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
||||||
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||||||
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла». Остановка сервиса — исход другой: процесс убивают контекстом, и задача остаётся на повтор, не тратя попытки | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
||||||
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
||||||
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
||||||
|
|
||||||
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
|
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
|
||||||
или по сообщению об ошибке. Владелец — по метрике
|
или по сообщению об ошибке. Владелец — по метрике
|
||||||
`transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера.
|
`transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
|
||||||
Отдельного оповещения нет.
|
называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
|
||||||
|
имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
|
||||||
|
«падает приведение» отличается от «падает распознавание». Плюс логи
|
||||||
|
контейнера. Отдельного оповещения нет.
|
||||||
|
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
|
||||||
|
на смену рубежа, на остановку и на снятие остановки; читает его человек в
|
||||||
|
панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет.
|
||||||
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
|
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
|
||||||
воркеры опрашивают базу вхолостую с паузой из
|
воркеры опрашивают базу вхолостую с паузой из
|
||||||
[database.md](database.md), «Настройки с числовым значением».
|
[database.md](database.md), «Настройки с числовым значением».
|
||||||
@@ -164,12 +186,15 @@
|
|||||||
|
|
||||||
| Что | Где |
|
| Что | Где |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа |
|
| Приём аудио и заведение записи | `TranscribeService.createRecord` — через него идут оба входа |
|
||||||
| Правка задачи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
|
| Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
|
||||||
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` — один запрос с `RETURNING` |
|
| Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
|
||||||
|
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
|
||||||
|
| Выбор шага по рубежу | `TranscribeService.stepFor` — таблица, а не привязка к воркеру |
|
||||||
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
|
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
|
||||||
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
|
| Переход записи на рубеж | `entity.AudioRecord.MoveToState` — чистит служебные поля прошлого рубежа и ставит время входа |
|
||||||
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
|
| Откладывание работы | `entity.AudioRecord.Postpone` — ставит паузу и снимает захват, рубежа не трогая |
|
||||||
|
| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; ответ отправителю — `TranscribeService.halt`, одно место на все причины |
|
||||||
| Разбор конфигурации | `internal/config.LoadConfig` |
|
| Разбор конфигурации | `internal/config.LoadConfig` |
|
||||||
| Чтение времени | `internal/clock` — `Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
|
| Чтение времени | `internal/clock` — `Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
|
||||||
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
|
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
|
||||||
@@ -243,7 +268,8 @@
|
|||||||
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
|
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
|
||||||
конвертер этот случай не проверялся.
|
конвертер этот случай не проверялся.
|
||||||
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
|
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
|
||||||
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована
|
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)), перестроена
|
||||||
|
вокруг аудиозаписи задачей `record-centric-model` 2026-08-14 и нормирована
|
||||||
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: он
|
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: он
|
||||||
даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их
|
даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их
|
||||||
паузы, а не замер
|
паузы, а не замер
|
||||||
|
|||||||
@@ -49,10 +49,12 @@
|
|||||||
|
|
||||||
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
|
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
|
||||||
значения держит код.
|
значения держит код.
|
||||||
*Расхождение:* перечень состояний задачи закрыт схемой (`SelectField`), а не
|
*Расхождение:* перечни, по которым панель владельца правит запись руками,
|
||||||
кодом — ради панели владельца: правка руками не должна заводить состояние,
|
закрыты схемой (`SelectField`), а не кодом: правка руками не должна заводить
|
||||||
которого конвейер не знает. Цена названа: шестое состояние потребует нового
|
значение, которого сервис не знает. Закрыты рубеж записи, причина её
|
||||||
шага схемы.
|
остановки, вид текста, источник и исход события журнала. Цена названа: новое
|
||||||
|
значение любого из них потребует нового шага схемы, а применённый шаг не
|
||||||
|
переписывается. Прочие перечни остаются обычным `TEXT`.
|
||||||
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
|
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
|
||||||
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
|
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
|
||||||
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
|
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
|
||||||
|
|||||||
@@ -91,7 +91,8 @@
|
|||||||
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
|
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
|
||||||
| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` |
|
| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` |
|
||||||
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
|
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
|
||||||
| Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | `internal/archrules` → правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда |
|
| Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком |
|
||||||
|
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
|
||||||
|
|
||||||
### Отмена и внешний собеседник
|
### Отмена и внешний собеседник
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -28,22 +28,22 @@ OpenSpec.
|
|||||||
`jq` без регулярных выражений.
|
`jq` без регулярных выражений.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"job accepted","capability":"intake","job_id":"…","source":"telegram","duration_seconds":137}
|
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"telegram","duration_seconds":137}
|
||||||
```
|
```
|
||||||
|
|
||||||
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
|
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
|
||||||
|
|
||||||
## Сообщение
|
## Сообщение
|
||||||
|
|
||||||
- `msg` — короткая константа в нижнем регистре: `job accepted`,
|
- `msg` — короткая константа в нижнем регистре: `record accepted`,
|
||||||
`recognition done`, `conversion failed`. Данные — в атрибутах:
|
`recognition done`, `conversion failed`. Данные — в атрибутах:
|
||||||
`log.Info("job accepted", "job_id", id, "source", "telegram")`.
|
`log.Info("record accepted", "record_id", id, "source", "telegram")`.
|
||||||
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
|
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
|
||||||
`recognize: done`. Подсистему выносим в поле `capability`, не в текст.
|
`recognize: done`. Подсистему выносим в поле `capability`, не в текст.
|
||||||
- **Смена состояния задачи — единая категория `state transition`** с полями
|
- **Смена состояния задачи — единая категория `state transition`** с полями
|
||||||
`from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь
|
`from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь
|
||||||
жизненный цикл собирался одним отбором:
|
жизненный цикл собирался одним отбором:
|
||||||
`jq 'select(.msg=="state transition" and .job_id=="…")'`. Физический эффект
|
`jq 'select(.msg=="state transition" and .record_id=="…")'`. Физический эффект
|
||||||
сверх перехода — отдельная запись своей категории (`file converted`,
|
сверх перехода — отдельная запись своей категории (`file converted`,
|
||||||
`text delivered`), она запись перехода не подменяет.
|
`text delivered`), она запись перехода не подменяет.
|
||||||
|
|
||||||
@@ -100,13 +100,13 @@ OpenSpec.
|
|||||||
| Когда добавляем | Поля |
|
| Когда добавляем | Поля |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| на входящий HTTP-запрос | `transport` (`http`, `telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
|
| на входящий 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` |
|
| на запись об ошибке | `error` |
|
||||||
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
||||||
|
|
||||||
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
|
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
|
||||||
|
|
||||||
*Расхождение:* в коде встречаются `job_id`, `file_id`, `operation_id`,
|
*Расхождение:* в коде встречаются `record_id`, `file_id`, `operation_id`,
|
||||||
`worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и
|
`worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и
|
||||||
пересечён с этим лишь частично.
|
пересечён с этим лишь частично.
|
||||||
|
|
||||||
@@ -120,16 +120,16 @@ OpenSpec.
|
|||||||
чтобы ключ дописывался на каждую запись сам:
|
чтобы ключ дописывался на каждую запись сам:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
log := log.With("job_id", job.Id, "capability", "conversion")
|
log := log.With("record_id", record.Id, "capability", "conversion")
|
||||||
```
|
```
|
||||||
|
|
||||||
- Все записи одной задачи собираются одним отбором:
|
- Все записи одной задачи собираются одним отбором:
|
||||||
`jq 'select(.job_id=="…")' app.jsonl`.
|
`jq 'select(.record_id=="…")' app.jsonl`.
|
||||||
|
|
||||||
## Ошибки
|
## Ошибки
|
||||||
|
|
||||||
Ошибки Go логируем как атрибут, а не как текст сообщения:
|
Ошибки Go логируем как атрибут, а не как текст сообщения:
|
||||||
`log.Error("conversion failed", "error", err, "job_id", id)`. Ключ — `error`.
|
`log.Error("conversion failed", "error", err, "record_id", id)`. Ключ — `error`.
|
||||||
|
|
||||||
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
|
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
|
||||||
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст
|
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст
|
||||||
@@ -277,5 +277,5 @@ Bot API, поэтому чистка на месте употребления з
|
|||||||
|
|
||||||
## Анализ
|
## Анализ
|
||||||
|
|
||||||
- Повседневно — `jq`: `jq 'select(.job_id=="…")' app.jsonl`.
|
- Повседневно — `jq`: `jq 'select(.record_id=="…")' app.jsonl`.
|
||||||
- Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.
|
- Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.
|
||||||
|
|||||||
+184
-52
@@ -38,85 +38,191 @@ CGO сборке не нужен.
|
|||||||
|
|
||||||
### `files`
|
### `files`
|
||||||
|
|
||||||
Один файл на одну физическую копию: исходник, результат конвертации и копия в
|
Одна запись на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
|
||||||
Object Storage — каждая своей записью.
|
приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не
|
||||||
|
считается — она существует только потому, что провайдер распознавания читает
|
||||||
|
аудио по адресу, и её ключ живёт в строке попытки распознавания.
|
||||||
|
|
||||||
| Поле | Тип | Что |
|
| Поле | Тип | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||||
| `file` | file | Сам файл; пусто у копии в Object Storage |
|
| `file` | file | Сам файл |
|
||||||
| `owner` | relation → `users` | Владелец файла; пусто у файлов записи, принятой ботом |
|
| `owner` | relation → `users` | Владелец файла; пусто у файлов записи, принятой ботом |
|
||||||
| `location` | select | `local` или `s3` |
|
| `location` | select | `local` или `s3` |
|
||||||
| `object_key` | TEXT | Ключ объекта; пусто у местной копии |
|
| `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется |
|
||||||
| `size` | INTEGER | Размер в байтах |
|
| `size` | INTEGER | Размер в байтах |
|
||||||
|
| `format` | TEXT | Расширение без точки, в нижнем регистре |
|
||||||
|
| `duration_ms` | INTEGER | Длительность, если её удалось прочитать |
|
||||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
||||||
|
|
||||||
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
|
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
|
||||||
capability, и третий смысл развёл бы одно слово по разным вещам.
|
capability, и третий смысл развёл бы одно слово по разным вещам.
|
||||||
|
|
||||||
### `transcribe_jobs`
|
### `audio_records`
|
||||||
|
|
||||||
Задача расшифровки и она же очередь.
|
Аудиозапись — центральная сущность сервиса. Домен, поля очереди и ссылки на
|
||||||
|
приложения лежат здесь; содержимое — по ссылкам, отдельными строками.
|
||||||
|
|
||||||
| Поле | Тип | Что |
|
| Поле | Тип | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||||
| `owner` | relation → `users` | Владелец записи; пусто у записей, принятых ботом |
|
| `owner` | relation → `users` | Владелец записи; пусто у записей, принятых ботом |
|
||||||
| `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой |
|
|
||||||
| `source` | select | `api`, `telegram`, `unknown` |
|
| `source` | select | `api`, `telegram`, `unknown` |
|
||||||
| `file` | relation → `files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
|
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
|
||||||
| `delay_time` | DATETIME | Не брать задачу раньше этого времени |
|
| `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой |
|
||||||
| `acquisition_id` | TEXT | Кто захватил задачу |
|
| `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания |
|
||||||
| `acquire_time` | DATETIME | Когда захватил; по нему считается протухание |
|
| `halted_at` | DATETIME | Признак остановки; рубеж при ней не стирается |
|
||||||
| `attempts` | INTEGER ≥ 0 | Число попыток: растёт при захвате, обнуляется на шаге без отказа |
|
| `halt_reason` | select | `step_failed`, `attempts_exhausted`, `stuck` |
|
||||||
| `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud |
|
|
||||||
| `transcription_text` | editor | Результат распознавания |
|
|
||||||
| `error_text` | TEXT | Текст ошибки, машинный |
|
| `error_text` | TEXT | Текст ошибки, машинный |
|
||||||
|
| `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_chat_id` | INTEGER | Куда отправить результат |
|
||||||
| `tg_reply_message_id` | INTEGER | С каким сообщением связать |
|
| `tg_reply_message_id` | INTEGER | С каким сообщением связать |
|
||||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
||||||
|
|
||||||
Индекс один — по `state`: выборка воркера идёт по нему, паузе и сроку захвата.
|
Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним,
|
||||||
Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ
|
паузе и сроку протухания.
|
||||||
этот один.
|
|
||||||
|
|
||||||
**Состояния `failed` и `dead` — разные приговоры**, и чей это приговор, нормирует
|
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
|
||||||
[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
|
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
|
||||||
«мертва»». Схеме принадлежит только закрытость перечня: шестое состояние
|
хранилище, и принятого человеком файла не найти было ничем.
|
||||||
потребует нового шага.
|
|
||||||
|
**Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead`
|
||||||
|
схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием
|
||||||
|
признака, — и различие между ними перестало быть структурным. Рубеж при
|
||||||
|
остановке сохраняется, поэтому запись продолжает с места остановки.
|
||||||
|
|
||||||
|
**Сторожей двое.** `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` в
|
**Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в
|
||||||
обеих таблицах. Пустое значение допустимо, и это решение с ценой: записи,
|
обеих таблицах. Пустое значение допустимо, и это решение с ценой: записи,
|
||||||
принятые ботом, владельца не имеют вовсе, потому что связи чата Telegram с
|
принятые ботом, владельца не имеют вовсе, потому что связи чата 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/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
|
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
|
||||||
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
|
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
|
||||||
@@ -135,19 +241,28 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
Без этого сужения закрытие API обходится двумя запросами — завести себе
|
Без этого сужения закрытие API обходится двумя запросами — завести себе
|
||||||
запись и войти паролем. Продление сессии закрыто слоем в приложении, а не
|
запись и войти паролем. Продление сессии закрыто слоем в приложении, а не
|
||||||
настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
|
настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
|
||||||
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
|
- **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции.
|
||||||
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
|
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
|
||||||
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
||||||
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
|
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
|
||||||
невоспроизводим.
|
невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку протухания
|
||||||
|
захвата и отсутствию признака остановки; срок протухания выбирается по рубежу
|
||||||
|
самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, подставить
|
||||||
|
его не может.
|
||||||
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет
|
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет
|
||||||
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
|
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
|
||||||
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому,
|
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому,
|
||||||
что условие проверяется тем же запросом, что и сам захват.
|
что условие проверяется тем же запросом, что и сам захват.
|
||||||
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`,
|
- **Список колонок задан двумя местами** — `applyOwnedByPipeline` вместе с
|
||||||
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы.
|
`applyToRecord` и `recordToAudioRecord`, — плюс шагом схемы. Мест было четыре,
|
||||||
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его
|
пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и
|
||||||
серьёзность (critical/major) — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
|
перечень перестал расти с моделью. Правило правки и его серьёзность —
|
||||||
|
инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты»; сверку держат правила
|
||||||
|
`internal/archrules`.
|
||||||
|
- **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`.
|
||||||
|
Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя:
|
||||||
|
рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон
|
||||||
|
по инварианту проекта не пишется в журнал и не считается в метрику.
|
||||||
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
|
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
|
||||||
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
|
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
|
||||||
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
|
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
|
||||||
@@ -157,14 +272,22 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
|
|
||||||
| Настройка | Значение | Где | Откуда число |
|
| Настройка | Значение | Где | Откуда число |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Предел попыток | 5 | `service/transcribe.go` | обычное умолчание, не замер |
|
| Предел отказов | 5 | `service/transcribe.go` | обычное умолчание, не замер |
|
||||||
| Пауза перед повтором | `2^(попытка−1)` с, потолок 5 минут | там же | то же |
|
| Пауза перед повтором | `2^(отказ−1)` с, потолок 5 минут | там же | то же |
|
||||||
| Срок захвата, конвертация | 8 часов | там же | потолок записи 6 часов плюс запас |
|
| Срок захвата, приведение | 8 часов | `entity/stage.go` | потолок записи 6 часов плюс запас |
|
||||||
| Срок захвата, распознавание | 8 часов | там же | то же |
|
| Срок захвата, отправка на распознавание | 8 часов | там же | то же |
|
||||||
| Срок захвата, проверка операции | 1 час | там же | опрос идёт секунды |
|
| Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды |
|
||||||
| Задержка перед первой проверкой операции | 10 секунд | там же | как было |
|
| Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды |
|
||||||
|
| Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение |
|
||||||
|
| Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже |
|
||||||
|
| Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого |
|
||||||
|
| Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая |
|
||||||
|
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
|
||||||
|
| Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих |
|
||||||
|
| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем |
|
||||||
|
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
|
||||||
| Задержка между проверками операции | 5 секунд | там же | как было |
|
| Задержка между проверками операции | 5 секунд | там же | как было |
|
||||||
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` | как было |
|
| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
|
||||||
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
|
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
|
||||||
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
|
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
|
||||||
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
|
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
|
||||||
@@ -177,6 +300,15 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
|
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
|
||||||
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
|
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
|
||||||
|
|
||||||
|
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
|
||||||
|
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
|
||||||
|
протухания ещё не истёк. Значит для держателя, погибшего жёстко — контейнер убит
|
||||||
|
по нехватке памяти или `docker kill`, — запись невидима сторожу до истечения
|
||||||
|
**срока захвата** её рубежа, то есть восьми часов у приведения и отправки.
|
||||||
|
Замерено прогоном: до истечения срока повторный захват записи не выдаёт, и
|
||||||
|
остановка «застряла» наступает только после него. Мягкая остановка сюда не
|
||||||
|
подпадает: она снимает захват сама.
|
||||||
|
|
||||||
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
|
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
|
||||||
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
|
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
|
||||||
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
|
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
|
||||||
|
|||||||
+13
-8
@@ -69,8 +69,8 @@
|
|||||||
**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы —
|
**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы —
|
||||||
подпакетом `migrations`):
|
подпакетом `migrations`):
|
||||||
|
|
||||||
- список колонок совпадает во всех четырёх местах — `applyToRecord`,
|
- список колонок совпадает в обоих местах — `applyOwnedByPipeline` вместе с
|
||||||
`recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант
|
`applyToRecord` и `recordToAudioRecord` — и в шаге схемы (инвариант
|
||||||
[CLAUDE.md](../CLAUDE.md), «Инварианты»);
|
[CLAUDE.md](../CLAUDE.md), «Инварианты»);
|
||||||
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
|
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
|
||||||
держатель захвата;
|
держатель захвата;
|
||||||
@@ -109,11 +109,15 @@
|
|||||||
приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей
|
приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей
|
||||||
`errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать
|
`errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать
|
||||||
её как известную нельзя.
|
её как известную нельзя.
|
||||||
- **«Захват задачи не в транзакции — гонка двух воркеров».** По построению её
|
- **«Захват записи не в транзакции — гонка двух воркеров».** ~~По построению её
|
||||||
нет: три воркера читают три разных состояния, и одну строку они не делят.
|
нет: три воркера читают три разных состояния, и одну строку они не делят.~~
|
||||||
Механика захвата и её слабые места — [database.md](database.md),
|
**Отменено 2026-08-14 задачей `record-centric-model`:** построение снято. Пул
|
||||||
«Представление данных». Находка становится настоящей ровно тогда, когда
|
одинаковых воркеров конкурирует за один и тот же набор записей, и второй
|
||||||
появится второй экземпляр процесса или второй воркер на то же состояние.
|
воркер на тот же рубеж теперь есть всегда, когда их больше одного. Находка о
|
||||||
|
гонке захвата стала настоящей и выбрасывается только по существу — механика
|
||||||
|
захвата и её слабые места в [database.md](database.md), «Представление
|
||||||
|
данных». Строка оставлена отменённой, а не удалена: прогон, помнящий прежнюю
|
||||||
|
редакцию, иначе выбросил бы настоящую находку как известную.
|
||||||
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
|
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
|
||||||
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
|
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
|
||||||
Новой находкой это не считается, пока не измерен рост.
|
Новой находкой это не считается, пока не измерен рост.
|
||||||
@@ -165,7 +169,8 @@
|
|||||||
первые две описаны частично. Поведение прочих узлов, включая
|
первые две описаны частично. Поведение прочих узлов, включая
|
||||||
приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда
|
приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда
|
||||||
ещё — самый большой.
|
ещё — самый большой.
|
||||||
- `conventions`: новая колонка правится во всех четырёх местах репозитория
|
- `conventions`: новая колонка правится в обоих местах репозитория, а новый
|
||||||
|
рубеж — одним дескриптором
|
||||||
(CLAUDE.md, «Инварианты»).
|
(CLAUDE.md, «Инварианты»).
|
||||||
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
|
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
|
||||||
тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
|
тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
|
||||||
|
|||||||
+36
-7
@@ -112,7 +112,17 @@ Telegram отправителю.
|
|||||||
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
|
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
|
||||||
каталогов, но это единственное, что стоит между входом и именем файла.
|
каталогов, но это единственное, что стоит между входом и именем файла.
|
||||||
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
|
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
|
||||||
расширением. Бакет один на все записи, префикса по пользователю нет.
|
расширением. Бакет один на все записи, префикса по пользователю нет. С
|
||||||
|
2026-08-14 копия там файлом записи не считается: она существует лишь потому,
|
||||||
|
что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
|
||||||
|
распознавания.
|
||||||
|
- **Вторая раскладка файла на диске** появилась 2026-08-14 вместе с сохранённым
|
||||||
|
ответом провайдера: `data/storage/<recognitions>/<попытка>/<имя>.payload`. Имя
|
||||||
|
задаёт сервис, как и у аудио. Содержимое там — **полный текст речи**, а не
|
||||||
|
метаданные, поэтому поле помечено защищённым, правило просмотра коллекции
|
||||||
|
оставлено пустым, и ссылка на вложение подпадает под тот же запрет, что и
|
||||||
|
ссылка на аудио: в журнал она не пишется. Проверено прогоном: без сессии, с
|
||||||
|
чужим и со своим токеном файла ссылка отвечает «не найдено».
|
||||||
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
||||||
помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
|
помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
|
||||||
можно только с коротким токеном файла, который выдаётся по сессии, и запрос
|
можно только с коротким токеном файла, который выдаётся по сессии, и запрос
|
||||||
@@ -121,12 +131,14 @@ Telegram отправителю.
|
|||||||
остаётся: **имя файла в хранилище в журнал не пишется**
|
остаётся: **имя файла в хранилище в журнал не пишется**
|
||||||
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
|
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
|
||||||
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
|
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
|
||||||
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное,
|
- **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное,
|
||||||
что защищает `GET /api/status/:id`.
|
что защищает `GET /api/status/:id`.
|
||||||
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
|
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
|
||||||
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
|
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
|
||||||
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
|
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
|
||||||
есть доступны они только владельцу панели; коды, снятые прогоном, —
|
есть доступны они только владельцу панели, — и коллекции, заведённые
|
||||||
|
2026-08-14, тоже: содержимое записи отдаёт собственный адрес сервиса, а не
|
||||||
|
поверхность хранилища. Коды, снятые прогоном, —
|
||||||
[database.md](database.md), «Коллекции», норма —
|
[database.md](database.md), «Коллекции», норма —
|
||||||
[storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только
|
[storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только
|
||||||
то, что заказано».
|
то, что заказано».
|
||||||
@@ -210,7 +222,13 @@ Telegram отправителю.
|
|||||||
## Что чувствительнее чего
|
## Что чувствительнее чего
|
||||||
|
|
||||||
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
|
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
|
||||||
это самое чувствительное, что здесь есть.
|
это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
|
||||||
|
колонкой, а шестью коллекциями: сама запись (заголовок и краткое описание),
|
||||||
|
`texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
|
||||||
|
`recognitions` (**сырой ответ провайдера вложением — полный текст речи**),
|
||||||
|
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
|
||||||
|
тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается
|
||||||
|
наравне с записью — норму держит спека `storage`.
|
||||||
2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним.
|
2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним.
|
||||||
3. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
3. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
||||||
Утечка оплачивается деньгами и доступом к бакету.
|
Утечка оплачивается деньгами и доступом к бакету.
|
||||||
@@ -336,6 +354,17 @@ Telegram отправителю.
|
|||||||
вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить
|
вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить
|
||||||
бессрочно, и тем же днём заведена задача `delete-record`: своя запись
|
бессрочно, и тем же днём заведена задача `delete-record`: своя запись
|
||||||
убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста.
|
убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста.
|
||||||
Пока она не сделана, единственный способ убрать запись — руками в базе и в
|
Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
|
||||||
каталоге на сервере. Учёт расхода удалению не подлежит по решению человека:
|
строки потребления текста не содержат.
|
||||||
деньги потрачены, а строки потребления текста не содержат.
|
|
||||||
|
**Руками запись сегодня не удаляется, и прежняя строка об этом была неверна.**
|
||||||
|
Проверено прогоном 2026-08-14: содержимое живёт в коллекциях, перечисленных
|
||||||
|
выше («Что чувствительнее чего»), связи приложений с записью обязательны и
|
||||||
|
каскада не имеют, поэтому удаление самой
|
||||||
|
строки записи отвергается хранилищем, а удаление её файлов проходит молча.
|
||||||
|
Владелец, выполнивший прежнюю процедуру, стирает аудио и **оставляет полный
|
||||||
|
текст речи** — расшифровку, разбивку по репликам и сырой ответ провайдера
|
||||||
|
файлом на диске. Порядок, которым запись убирается на самом деле: сперва
|
||||||
|
строки приложений — журнал событий, попытка распознавания вместе с её
|
||||||
|
вложением, структура, тексты, — потом сама запись, потом её файлы. До
|
||||||
|
`delete-record` это единственный способ, и он ручной целиком.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ require (
|
|||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/yandex-cloud/go-genproto v0.17.0
|
github.com/yandex-cloud/go-genproto v0.17.0
|
||||||
google.golang.org/grpc v1.82.1
|
google.golang.org/grpc v1.82.1
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -73,7 +74,6 @@ require (
|
|||||||
golang.org/x/text v0.40.0 // indirect
|
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/api v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.74.1 // indirect
|
modernc.org/libc v1.74.1 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
@@ -2,22 +2,79 @@ package recognizer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MemoryAudioRecognizer — подставной распознаватель для местного запуска и
|
||||||
|
// проверок. Прогон на реальных ключах ради проверки кода запрещён: распознавание
|
||||||
|
// и хранение в Object Storage оплачиваются по факту.
|
||||||
|
//
|
||||||
|
// Сырой ответ он отдаёт своего вида, но настоящего: тем же путём, что и живой
|
||||||
|
// адаптер, — сохранённые байты разбираются обратно в реплики, и структура
|
||||||
|
// строится без единого обращения наружу.
|
||||||
type MemoryAudioRecognizer struct{}
|
type MemoryAudioRecognizer struct{}
|
||||||
|
|
||||||
func (r *MemoryAudioRecognizer) Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error) {
|
const memoryProvider = "memory"
|
||||||
|
|
||||||
|
func (r *MemoryAudioRecognizer) Provider() string { return memoryProvider }
|
||||||
|
|
||||||
|
func (r *MemoryAudioRecognizer) Model() string { return "memory" }
|
||||||
|
|
||||||
|
func (r *MemoryAudioRecognizer) Upload(ctx context.Context, file io.Reader, objectKey string) (string, error) {
|
||||||
|
return "memory://" + objectKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryAudioRecognizer) ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryAudioRecognizer) Submit(ctx context.Context, sourceURI string) (string, error) {
|
||||||
return uuid.NewString(), nil
|
return uuid.NewString(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *MemoryAudioRecognizer) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
|
func (r *MemoryAudioRecognizer) CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
|
||||||
return "Foo bar, Baz.", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *MemoryAudioRecognizer) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
|
|
||||||
return entity.NewCompletedResult(), nil
|
return entity.NewCompletedResult(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *MemoryAudioRecognizer) Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
|
||||||
|
replicas := []entity.Replica{
|
||||||
|
{StartMs: 0, EndMs: 1000, Text: "Foo bar,"},
|
||||||
|
{StartMs: 1000, EndMs: 2000, Text: "Baz."},
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(replicas)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("failed to encode memory payload")
|
||||||
|
}
|
||||||
|
return &entity.RecognitionOutcome{
|
||||||
|
Replicas: replicas,
|
||||||
|
PlainText: "Foo bar, Baz.",
|
||||||
|
Raw: raw,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryAudioRecognizer) Parse(raw []byte) (*entity.RecognitionOutcome, error) {
|
||||||
|
var replicas []entity.Replica
|
||||||
|
if err := json.Unmarshal(raw, &replicas); err != nil {
|
||||||
|
return nil, errors.New("failed to decode memory payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
var plain []byte
|
||||||
|
for _, replica := range replicas {
|
||||||
|
if len(plain) > 0 {
|
||||||
|
plain = append(plain, ' ')
|
||||||
|
}
|
||||||
|
plain = append(plain, replica.Text...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &entity.RecognitionOutcome{
|
||||||
|
Replicas: replicas,
|
||||||
|
PlainText: string(plain),
|
||||||
|
Raw: raw,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package yandex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Сохранённый ответ провайдера — единственное, из чего пересчитывается архив:
|
||||||
|
// результат операции у SpeechKit не переспрашивается, и повторное распознавание
|
||||||
|
// стоит денег. Поэтому проверки ниже судят не «разбор чего-то вернул», а
|
||||||
|
// сохранность самого ответа.
|
||||||
|
|
||||||
|
// response собирает ответ потока с одной репликой.
|
||||||
|
func response(text string, start, end int64) *stt.StreamingResponse {
|
||||||
|
return &stt.StreamingResponse{
|
||||||
|
Event: &stt.StreamingResponse_FinalRefinement{
|
||||||
|
FinalRefinement: &stt.FinalRefinement{
|
||||||
|
Type: &stt.FinalRefinement_NormalizedText{
|
||||||
|
NormalizedText: &stt.AlternativeUpdate{
|
||||||
|
Alternatives: []*stt.Alternative{
|
||||||
|
{Text: text, StartTimeMs: start, EndTimeMs: end},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранённое читается обратно тем же: реплики со временем и плоский текст.
|
||||||
|
func TestPayloadSurvivesRoundTrip(t *testing.T) {
|
||||||
|
responses := []*stt.StreamingResponse{
|
||||||
|
response("Первая реплика.", 0, 900),
|
||||||
|
response("Вторая реплика.", 900, 1800),
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := encodeResponses(responses)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, raw)
|
||||||
|
|
||||||
|
decoded, err := decodeResponses(raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, decoded, 2)
|
||||||
|
|
||||||
|
outcome := outcomeFromResponses(decoded)
|
||||||
|
require.Len(t, outcome.Replicas, 2)
|
||||||
|
assert.Equal(t, "Первая реплика.", outcome.Replicas[0].Text)
|
||||||
|
assert.Equal(t, int64(0), outcome.Replicas[0].StartMs)
|
||||||
|
assert.Equal(t, int64(900), outcome.Replicas[0].EndMs)
|
||||||
|
assert.Equal(t, "Вторая реплика.", outcome.Replicas[1].Text)
|
||||||
|
assert.Equal(t, "Первая реплика. Вторая реплика.", outcome.PlainText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ради этого свойства сохранение и сделано двоичным. Провайдер добавляет поля
|
||||||
|
// без предупреждения, и текстовое представление, собранное по нашей
|
||||||
|
// скомпилированной схеме, выбросило бы их молча — а пересчитать архив было бы
|
||||||
|
// уже не из чего: операция не переспрашивается.
|
||||||
|
func TestUnknownProviderFieldSurvivesStorage(t *testing.T) {
|
||||||
|
original := response("Реплика.", 0, 500)
|
||||||
|
|
||||||
|
// Так выглядит поле, которого наша схема не знает: провайдер прислал его,
|
||||||
|
// разбор положил в неизвестные.
|
||||||
|
unknown := protoimplUnknown(t)
|
||||||
|
original.ProtoReflect().SetUnknown(unknown)
|
||||||
|
require.NotEmpty(t, original.ProtoReflect().GetUnknown(), "неизвестное поле поставлено")
|
||||||
|
|
||||||
|
raw, err := encodeResponses([]*stt.StreamingResponse{original})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
decoded, err := decodeResponses(raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, decoded, 1)
|
||||||
|
|
||||||
|
assert.Equal(t, []byte(unknown), []byte(decoded[0].ProtoReflect().GetUnknown()),
|
||||||
|
"неизвестное провайдерское поле пережило запись и чтение")
|
||||||
|
|
||||||
|
// И известное при этом на месте.
|
||||||
|
outcome := outcomeFromResponses(decoded)
|
||||||
|
require.Len(t, outcome.Replicas, 1)
|
||||||
|
assert.Equal(t, "Реплика.", outcome.Replicas[0].Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрезанное вложение узнаётся отказом, а не половиной расшифровки: половина
|
||||||
|
// текста, выданная за целую, тише и хуже отказа.
|
||||||
|
func TestTruncatedPayloadIsRefused(t *testing.T) {
|
||||||
|
raw, err := encodeResponses([]*stt.StreamingResponse{response("Реплика.", 0, 500)})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Greater(t, len(raw), 2)
|
||||||
|
|
||||||
|
_, err = decodeResponses(raw[:len(raw)-2])
|
||||||
|
assert.Error(t, err, "обрезанное вложение не разбирается молча")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой поток даёт пустой результат, а не отказ: «на записи нет текста» —
|
||||||
|
// законный исход распознавания.
|
||||||
|
func TestEmptyStreamGivesEmptyOutcome(t *testing.T) {
|
||||||
|
raw, err := encodeResponses(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
decoded, err := decodeResponses(raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, decoded)
|
||||||
|
|
||||||
|
outcome := outcomeFromResponses(decoded)
|
||||||
|
assert.Empty(t, outcome.Replicas)
|
||||||
|
assert.Empty(t, outcome.PlainText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ответ без разбора текста реплик не даёт и разбор не роняет: провайдер шлёт по
|
||||||
|
// потоку и служебные события.
|
||||||
|
func TestResponseWithoutTextIsSkipped(t *testing.T) {
|
||||||
|
responses := []*stt.StreamingResponse{
|
||||||
|
{Event: &stt.StreamingResponse_FinalRefinement{FinalRefinement: &stt.FinalRefinement{}}},
|
||||||
|
response("Реплика.", 0, 500),
|
||||||
|
response("", 500, 600),
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome := outcomeFromResponses(responses)
|
||||||
|
require.Len(t, outcome.Replicas, 1, "пустые и служебные события репликами не становятся")
|
||||||
|
assert.Equal(t, "Реплика.", outcome.PlainText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// protoimplUnknown собирает байты неизвестного поля: номер поля, которого в
|
||||||
|
// нашей схеме нет, с целочисленным значением.
|
||||||
|
func protoimplUnknown(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Поле 4095, тип varint, значение 7 — заведомо за пределами схемы ответа.
|
||||||
|
raw, err := proto.Marshal(&stt.StreamingResponse{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, raw)
|
||||||
|
|
||||||
|
return []byte{0xF8, 0xFF, 0x3F, 0x07}
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ import (
|
|||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ProviderName — имя провайдера, под которым сохраняется попытка распознавания.
|
||||||
|
// По нему видно, чем считана запись, когда провайдеров станет больше одного.
|
||||||
|
const ProviderName = "yandex-speechkit"
|
||||||
|
|
||||||
type YandexAudioRecognizerConfig struct {
|
type YandexAudioRecognizerConfig struct {
|
||||||
// s3
|
// s3
|
||||||
Region string
|
Region string
|
||||||
@@ -56,36 +60,44 @@ func (s *YandexAudioRecognizerService) Close() error {
|
|||||||
return s.sttService.Close()
|
return s.sttService.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *YandexAudioRecognizerService) Provider() string { return ProviderName }
|
||||||
|
|
||||||
|
func (s *YandexAudioRecognizerService) Model() string { return RecognitionModel }
|
||||||
|
|
||||||
// startRecognitionTimeout — сколько ждём принятия операции, когда нас уже
|
// startRecognitionTimeout — сколько ждём принятия операции, когда нас уже
|
||||||
// остановили. Число меньше жёсткого предела остановки: иначе процесс убьют
|
// остановили. Число меньше жёсткого предела остановки: иначе процесс убьют
|
||||||
// прежде, чем ответ дойдёт, и защита ничего не даст.
|
// прежде, чем ответ дойдёт, и защита ничего не даст.
|
||||||
const startRecognitionTimeout = 10 * time.Second
|
const startRecognitionTimeout = 10 * time.Second
|
||||||
|
|
||||||
func (s *YandexAudioRecognizerService) Recognize(ctx context.Context, file io.Reader, fileName string) (string, error) {
|
// Upload кладёт аудио туда, откуда провайдер его прочитает.
|
||||||
|
//
|
||||||
// Заливка отменяется штатно: она дорога по времени, а повтор её бесплатен —
|
// Отменяется штатно: заливка дорога по времени, а повтор её бесплатен — объект
|
||||||
// объект ложится под тем же ключом.
|
// ложится под тем же ключом.
|
||||||
err := s.s3Sevice.uploadFile(ctx, file, fileName)
|
func (s *YandexAudioRecognizerService) Upload(ctx context.Context, file io.Reader, objectKey string) (string, error) {
|
||||||
if err != nil {
|
if err := s.s3Sevice.uploadFile(ctx, file, objectKey); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
return s.s3Sevice.fileUrl(objectKey), nil
|
||||||
|
}
|
||||||
|
|
||||||
uri := s.s3Sevice.fileUrl(fileName)
|
// ObjectExists отвечает, лежит ли объект нужного размера.
|
||||||
|
//
|
||||||
|
// Сверка идёт по присутствию и длине, а не по отпечатку содержимого: признак
|
||||||
|
// целостности у составного объекта не равен отпечатку, и сверка хешем дала бы
|
||||||
|
// расхождение на всякой большой записи.
|
||||||
|
func (s *YandexAudioRecognizerService) ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
|
||||||
|
return s.s3Sevice.objectExists(ctx, objectKey, size)
|
||||||
|
}
|
||||||
|
|
||||||
// А вот принятие операции от отмены защищено. Окно короткое и дорогое:
|
// Submit заводит операцию распознавания. Оплачивается наружу, поэтому от отмены
|
||||||
// SpeechKit может операцию принять и начать считать деньги, а ответ до нас
|
// защищён: окно короткое и дорогое — SpeechKit может операцию принять и начать
|
||||||
// не доедет — идентификатор потеряется навсегда, и повтор оплатит ту же
|
// считать деньги, а ответ до нас не доедет, и повтор оплатит ту же запись второй
|
||||||
// запись второй раз. Свой предел вызову оставлен, чтобы остановка не ждала
|
// раз. Свой предел вызову оставлен, чтобы остановка не ждала вечно.
|
||||||
// вечно.
|
func (s *YandexAudioRecognizerService) Submit(ctx context.Context, sourceURI string) (string, error) {
|
||||||
startCtx, cancel := protectFromCancel(ctx, startRecognitionTimeout)
|
startCtx, cancel := protectFromCancel(ctx, startRecognitionTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
opId, err := s.sttService.recognizeFileFromS3(startCtx, uri)
|
return s.sttService.recognizeFileFromS3(startCtx, sourceURI)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return opId, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения
|
// protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения
|
||||||
@@ -95,11 +107,26 @@ func protectFromCancel(ctx context.Context, timeout time.Duration) (context.Cont
|
|||||||
return context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
return context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *YandexAudioRecognizerService) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
|
// Fetch забирает готовый результат и отдаёт его доменным: реплики со временем,
|
||||||
return s.sttService.getRecognitionText(ctx, operationID)
|
// плоский текст и байты ответа на хранение. Формата провайдера наружу не выходит
|
||||||
|
// ничего — ни один шаг конвейера не знает, каким потоком тот отвечает.
|
||||||
|
func (s *YandexAudioRecognizerService) Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
|
||||||
|
return s.sttService.fetchRecognition(ctx, operationID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
|
// Parse строит доменный результат из **сохранённого** ответа, не обращаясь к
|
||||||
|
// провайдеру. По нему архив пересчитывается без единого рубля.
|
||||||
|
func (s *YandexAudioRecognizerService) Parse(raw []byte) (*entity.RecognitionOutcome, error) {
|
||||||
|
responses, err := decodeResponses(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
outcome := outcomeFromResponses(responses)
|
||||||
|
outcome.Raw = raw
|
||||||
|
return outcome, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *YandexAudioRecognizerService) CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
|
||||||
operation, err := s.sttService.checkOperationStatus(ctx, operationID)
|
operation, err := s.sttService.checkOperationStatus(ctx, operationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
|
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
"github.com/aws/smithy-go"
|
"github.com/aws/smithy-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -89,6 +90,37 @@ func (s *yandexS3Service) uploadFile(ctx context.Context, file io.Reader, fileNa
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// objectExists отвечает, лежит ли объект нужного размера.
|
||||||
|
//
|
||||||
|
// По нему шаг решает, повторять ли заливку: повтор её бесплатен, но дорог по
|
||||||
|
// времени на многочасовой записи. Сверка идёт по присутствию и длине, а не по
|
||||||
|
// отпечатку содержимого: признак целостности у составного объекта не равен
|
||||||
|
// отпечатку, и сверка хешем расходилась бы на всякой большой записи.
|
||||||
|
func (s *yandexS3Service) objectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
|
||||||
|
out, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||||
|
Bucket: aws.String(s.bucketName),
|
||||||
|
Key: aws.String(objectKey),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
var notFound *s3types.NotFound
|
||||||
|
if errors.As(err, ¬Found) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
// Отказ SDK несёт полный URL объекта, а он — ключ к чужому аудио: наружу
|
||||||
|
// идёт класс отказа и только он.
|
||||||
|
var apiErr smithy.APIError
|
||||||
|
if errors.As(err, &apiErr) {
|
||||||
|
if apiErr.ErrorCode() == "NotFound" || apiErr.ErrorCode() == "NoSuchKey" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("failed to head object in S3: %s", apiErr.ErrorCode())
|
||||||
|
}
|
||||||
|
return false, errors.New("failed to head object in S3")
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.ContentLength != nil && *out.ContentLength == size, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *yandexS3Service) fileUrl(fileName string) string {
|
func (s *yandexS3Service) fileUrl(fileName string) string {
|
||||||
endpoint := strings.TrimRight(s.endpoint, "/")
|
endpoint := strings.TrimRight(s.endpoint, "/")
|
||||||
return fmt.Sprintf("%s/%s/%s", endpoint, s.bucketName, fileName)
|
return fmt.Sprintf("%s/%s/%s", endpoint, s.bucketName, fileName)
|
||||||
|
|||||||
@@ -2,17 +2,20 @@ package yandex
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3"
|
stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3"
|
||||||
"github.com/yandex-cloud/go-genproto/yandex/cloud/operation"
|
"github.com/yandex-cloud/go-genproto/yandex/cloud/operation"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -134,9 +137,13 @@ func (s *speechKitService) recognizeFileFromS3(ctx context.Context, s3URI string
|
|||||||
return op.Id, nil
|
return op.Id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRecognitionResult получает результат распознавания по ID операции
|
// fetchRecognition забирает результат операции целиком и отдаёт его доменным,
|
||||||
func (s *speechKitService) getRecognitionText(ctx context.Context, operationID string) (string, error) {
|
// вместе с сырым ответом на хранение.
|
||||||
// Добавляем авторизацию и folder_id в контекст
|
//
|
||||||
|
// Ответ сохраняется потому, что **результат операции у провайдера не
|
||||||
|
// переспрашивается**: связь реплики с говорящим сервис строить пока не умеет, и
|
||||||
|
// когда научится, архив пересчитается из сохранённого без единого рубля.
|
||||||
|
func (s *speechKitService) fetchRecognition(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
|
||||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
||||||
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
||||||
|
|
||||||
@@ -146,11 +153,10 @@ func (s *speechKitService) getRecognitionText(ctx context.Context, operationID s
|
|||||||
|
|
||||||
stream, err := s.sttClient.GetRecognition(ctx, req)
|
stream, err := s.sttClient.GetRecognition(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get recognition stream: %w", err)
|
return nil, fmt.Errorf("failed to get recognition stream: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var responses []*stt.StreamingResponse
|
||||||
|
|
||||||
for {
|
for {
|
||||||
resp, err := stream.Recv()
|
resp, err := stream.Recv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -160,19 +166,19 @@ func (s *speechKitService) getRecognitionText(ctx context.Context, operationID s
|
|||||||
if errors.Is(err, io.EOF) {
|
if errors.Is(err, io.EOF) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("failed to receive recognition response: %w", err)
|
return nil, fmt.Errorf("failed to receive recognition response: %w", err)
|
||||||
}
|
|
||||||
if refinement := resp.GetFinalRefinement(); refinement != nil {
|
|
||||||
if text := refinement.GetNormalizedText(); text != nil {
|
|
||||||
for _, alt := range text.Alternatives {
|
|
||||||
sb.WriteString(alt.Text)
|
|
||||||
sb.WriteString(" ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
responses = append(responses, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.String(), nil
|
raw, err := encodeResponses(responses)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome := outcomeFromResponses(responses)
|
||||||
|
outcome.Raw = raw
|
||||||
|
return outcome, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkOperationStatus проверяет статус операции распознавания
|
// checkOperationStatus проверяет статус операции распознавания
|
||||||
@@ -190,3 +196,92 @@ func (s *speechKitService) checkOperationStatus(ctx context.Context, operationID
|
|||||||
|
|
||||||
return op, nil
|
return op, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encodeResponses укладывает ответ провайдера целиком, в том виде, в каком он
|
||||||
|
// пришёл: сообщения потока подряд, каждое со своей длиной впереди.
|
||||||
|
//
|
||||||
|
// Форма **двоичная**, а не текстовая, и это несущее решение. Текстовое
|
||||||
|
// представление собирается по нашей скомпилированной схеме и молча выбрасывает
|
||||||
|
// поля, которых в ней нет, — а провайдер добавляет их без предупреждения.
|
||||||
|
// Двоичная форма неизвестные поля переносит: они переживают запись и чтение и
|
||||||
|
// станут читаемыми, когда мы обновим схему. Ради этого архив и заводился —
|
||||||
|
// результат операции у провайдера не переспрашивается, и повторное
|
||||||
|
// распознавание стоит денег.
|
||||||
|
//
|
||||||
|
// Цена названа прямо: сохранённое не читается глазами и не разбирается ничем,
|
||||||
|
// кроме нашего же кода.
|
||||||
|
func encodeResponses(responses []*stt.StreamingResponse) ([]byte, error) {
|
||||||
|
var raw []byte
|
||||||
|
for _, resp := range responses {
|
||||||
|
encoded, err := proto.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
// Текст расшифровки наружу не выходит даже отказом: сообщение
|
||||||
|
// провайдера несёт её целиком.
|
||||||
|
return nil, errors.New("failed to encode provider response")
|
||||||
|
}
|
||||||
|
raw = binary.AppendUvarint(raw, uint64(len(encoded)))
|
||||||
|
raw = append(raw, encoded...)
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeResponses читает сохранённый ответ провайдера обратно.
|
||||||
|
func decodeResponses(raw []byte) ([]*stt.StreamingResponse, error) {
|
||||||
|
var responses []*stt.StreamingResponse
|
||||||
|
|
||||||
|
for len(raw) > 0 {
|
||||||
|
size, read := binary.Uvarint(raw)
|
||||||
|
if read <= 0 || uint64(len(raw)-read) < size {
|
||||||
|
return nil, errors.New("stored provider payload is truncated")
|
||||||
|
}
|
||||||
|
raw = raw[read:]
|
||||||
|
|
||||||
|
var resp stt.StreamingResponse
|
||||||
|
if err := proto.Unmarshal(raw[:size], &resp); err != nil {
|
||||||
|
return nil, errors.New("failed to decode provider response")
|
||||||
|
}
|
||||||
|
responses = append(responses, &resp)
|
||||||
|
raw = raw[size:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// outcomeFromResponses строит доменный результат: реплики со временем и плоский
|
||||||
|
// текст. Формата провайдера отсюда наружу не выходит ничего.
|
||||||
|
//
|
||||||
|
// Говорящие не размечаются: связь реплики с разбором говорящего у провайдера не
|
||||||
|
// выяснена. Структура при этом строится из сохранённого ответа, поэтому разметка
|
||||||
|
// станет возможной без повторной оплаты.
|
||||||
|
func outcomeFromResponses(responses []*stt.StreamingResponse) *entity.RecognitionOutcome {
|
||||||
|
outcome := &entity.RecognitionOutcome{}
|
||||||
|
|
||||||
|
var plain []byte
|
||||||
|
for _, resp := range responses {
|
||||||
|
refinement := resp.GetFinalRefinement()
|
||||||
|
if refinement == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := refinement.GetNormalizedText()
|
||||||
|
if text == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, alt := range text.GetAlternatives() {
|
||||||
|
if alt.GetText() == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outcome.Replicas = append(outcome.Replicas, entity.Replica{
|
||||||
|
StartMs: alt.GetStartTimeMs(),
|
||||||
|
EndMs: alt.GetEndTimeMs(),
|
||||||
|
Text: alt.GetText(),
|
||||||
|
})
|
||||||
|
if len(plain) > 0 {
|
||||||
|
plain = append(plain, ' ')
|
||||||
|
}
|
||||||
|
plain = append(plain, alt.GetText()...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome.PlainText = string(plain)
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,11 +112,15 @@ func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
|
|||||||
return work, nil
|
return work, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateLocal кладёт рабочую копию в хранилище. Имя задаём мы: умолчание
|
// Create кладёт рабочую копию в хранилище. Имя задаём мы: умолчание библиотеки
|
||||||
// библиотеки строит его из имени, данного отправителем, а имя отправителя в
|
// строит его из имени, данного отправителем, а имя отправителя в хранилище не
|
||||||
// хранилище не попадает — путь к файлу читается в журнале, и инвариант
|
// попадает — путь к файлу читается в журнале, и инвариант приватности этого не
|
||||||
// приватности этого не допускает. Свой суффикс хранилище допишет само.
|
// допускает. Свой суффикс хранилище допишет само.
|
||||||
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile, ownerID string) (*entity.File, error) {
|
//
|
||||||
|
// Копий у записи ровно две — принятая и приведённая, — и обе местные. Прежний
|
||||||
|
// путь заведения записи о копии во внешнем хранилище отсюда ушёл: та копия
|
||||||
|
// файлом записи не считается, а её ключ живёт в строке попытки распознавания.
|
||||||
|
func (repo *FileRepository) Create(name string, work contract.WorkFile, meta contract.FileMeta, ownerID string) (*entity.File, error) {
|
||||||
collection, err := findCollection(repo.app, migrations.FilesCollection)
|
collection, err := findCollection(repo.app, migrations.FilesCollection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -132,6 +136,8 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile, own
|
|||||||
record.Set("file", stored)
|
record.Set("file", stored)
|
||||||
record.Set("location", entity.LocationLocal)
|
record.Set("location", entity.LocationLocal)
|
||||||
record.Set("size", stored.Size)
|
record.Set("size", stored.Size)
|
||||||
|
record.Set("format", meta.Format)
|
||||||
|
record.Set("duration_ms", meta.DurationMs)
|
||||||
// Владелец файла — владелец записи, которой файл принадлежит. Пустой значит
|
// Владелец файла — владелец записи, которой файл принадлежит. Пустой значит
|
||||||
// «файл без владельца»: таков всякий файл записи, принятой ботом. Правило
|
// «файл без владельца»: таков всякий файл записи, принятой ботом. Правило
|
||||||
// просмотра коллекции сужено этой колонкой, и без неё чужое аудио осталось
|
// просмотра коллекции сужено этой колонкой, и без неё чужое аудио осталось
|
||||||
@@ -148,25 +154,6 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile, own
|
|||||||
return recordToFile(record), nil
|
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) {
|
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
|
||||||
record, err := repo.app.FindRecordById(migrations.FilesCollection, id)
|
record, err := repo.app.FindRecordById(migrations.FilesCollection, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -262,16 +249,13 @@ func firstFileName(record *core.Record) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func recordToFile(record *core.Record) *entity.File {
|
func recordToFile(record *core.Record) *entity.File {
|
||||||
name := firstFileName(record)
|
|
||||||
if name == "" {
|
|
||||||
name = record.GetString("object_key")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &entity.File{
|
return &entity.File{
|
||||||
Id: record.Id,
|
Id: record.Id,
|
||||||
Location: record.GetString("location"),
|
Location: record.GetString("location"),
|
||||||
FileName: name,
|
FileName: firstFileName(record),
|
||||||
Size: int64(record.GetInt("size")),
|
Size: int64(record.GetInt("size")),
|
||||||
CreatedAt: record.GetDateTime("created").Time(),
|
Format: record.GetString("format"),
|
||||||
|
DurationMs: int64(record.GetInt("duration_ms")),
|
||||||
|
CreatedAt: record.GetDateTime("created").Time(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
package pocketbase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Потолок размера у поля файла задан числом, а не нулём: нулём библиотека читает
|
|
||||||
// собственное умолчание в 5 МиБ, и на нём отвергалось бы всё длиннее примерно
|
|
||||||
// пяти минут — то есть штатная запись сервиса. Проверка судит запись, которая
|
|
||||||
// заведомо больше этого умолчания: обновление библиотеки, вернувшее умолчание,
|
|
||||||
// иначе прошло бы молча.
|
|
||||||
func TestCreateLocal_AcceptsRecordLargerThanLibraryDefault(t *testing.T) {
|
|
||||||
app := newTestApp(t)
|
|
||||||
repo := NewFileRepository(app)
|
|
||||||
|
|
||||||
const libraryDefault = 5 << 20
|
|
||||||
|
|
||||||
// Ровно на байт больше умолчания: проверка судит границу, а не пропускную
|
|
||||||
// способность — лишние мегабайты стоили бы секунд на каждом прогоне.
|
|
||||||
work, err := repo.Stage(".mp3", strings.NewReader(strings.Repeat("a", libraryDefault+1)))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { require.NoError(t, work.Close()) }()
|
|
||||||
|
|
||||||
size, err := work.Size()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Greater(t, size, int64(libraryDefault), "запись заведомо больше умолчания библиотеки")
|
|
||||||
|
|
||||||
file, err := repo.CreateLocal("big.mp3", work, "")
|
|
||||||
require.NoError(t, err, "запись длиннее умолчания библиотеки ложится в хранилище")
|
|
||||||
assert.Equal(t, size, file.Size)
|
|
||||||
assert.Greater(t, entity.MaxRecordSize, size, "объявленный потолок выше проверяемого размера")
|
|
||||||
}
|
|
||||||
@@ -1,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 (
|
const (
|
||||||
FilesCollection = "files"
|
FilesCollection = "files"
|
||||||
JobsCollection = "transcribe_jobs"
|
// JobsCollection — прежняя коллекция задач. Шаг 202608140002 её удаляет;
|
||||||
|
// имя остаётся здесь, потому что на него ссылаются прежние шаги схемы, а
|
||||||
|
// применённый шаг не переписывается.
|
||||||
|
JobsCollection = "transcribe_jobs"
|
||||||
|
// RecordsCollection — аудиозапись, центральная сущность сервиса. Имя в
|
||||||
|
// snake_case, как у соседей по схеме: одно исключение разошлось бы молча по
|
||||||
|
// константе имён, запросу захвата, правилам панели и запрету удаления.
|
||||||
|
RecordsCollection = "audio_records"
|
||||||
|
TextsCollection = "texts"
|
||||||
|
StructuresCollection = "structures"
|
||||||
|
RecognitionsCollection = "recognitions"
|
||||||
|
RecordEventsCollection = "record_events"
|
||||||
|
TopicsCollection = "topics"
|
||||||
// UsersCollection заводит не наш шаг, а системный шаг библиотеки. Имя стоит
|
// UsersCollection заводит не наш шаг, а системный шаг библиотеки. Имя стоит
|
||||||
// здесь потому, что на него ссылаются и шаги схемы, и проверка предъявителя
|
// здесь потому, что на него ссылаются и шаги схемы, и проверка предъявителя
|
||||||
// на приёме: строковый литерал в двух местах разошёлся бы молча.
|
// на приёме: строковый литерал в двух местах разошёлся бы молча.
|
||||||
@@ -36,6 +48,7 @@ func init() {
|
|||||||
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
|
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
|
||||||
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
|
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
|
||||||
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
|
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
|
||||||
|
pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ptr[T any](v T) *T { return &v }
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// GuardOwnerDeletion отвергает удаление учётной записи, у которой остались
|
// GuardOwnerDeletion отвергает удаление учётной записи, у которой остались
|
||||||
// задачи расшифровки.
|
// аудиозаписи, их файлы либо темы её словаря.
|
||||||
//
|
//
|
||||||
// Колонка владельца — связь с выключенным каскадным удалением, и одного этого
|
// Колонка владельца — связь с выключенным каскадным удалением, и одного этого
|
||||||
// мало: при выключенном каскаде хранилище не удаляет ссылающуюся запись, а
|
// мало: при выключенном каскаде хранилище не удаляет ссылающуюся запись, а
|
||||||
@@ -29,10 +29,11 @@ import (
|
|||||||
// только панельное — умолчание библиотеки разрешает вошедшему удалить свою
|
// только панельное — умолчание библиотеки разрешает вошедшему удалить свою
|
||||||
// учётную запись запросом, так что страж закрывает и публичную поверхность.
|
// учётную запись запросом, так что страж закрывает и публичную поверхность.
|
||||||
//
|
//
|
||||||
// Считаются **обе** коллекции с владельцем. Файл переживает свою задачу: шаг
|
// Считаются **все** коллекции с владельцем, и перечень их живёт одним списком
|
||||||
// конвейера заводит его до сохранения задачи, и потерянный захват оставляет файл
|
// ниже. Файл переживает свою запись: шаг конвейера заводит его до сохранения, и
|
||||||
// с владельцем и без ссылки. Учётная запись, у которой остались одни такие
|
// потерянный захват оставляет файл с владельцем и без ссылки. Учётная запись, у
|
||||||
// файлы, без этого счёта удалялась бы штатно, а аудио становилось бы ничьим.
|
// которой остались одни такие файлы, без этого счёта удалялась бы штатно, а
|
||||||
|
// аудио становилось бы ничьим.
|
||||||
func GuardOwnerDeletion(app core.App) {
|
func GuardOwnerDeletion(app core.App) {
|
||||||
app.OnRecordDelete(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
|
app.OnRecordDelete(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
|
||||||
count, err := countOwned(e.App, e.Record.Id)
|
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) {
|
func countOwned(app core.App, ownerID string) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
|
|
||||||
for _, collection := range []string{migrations.JobsCollection, migrations.FilesCollection} {
|
for _, collection := range ownedCollections {
|
||||||
count, err := app.CountRecords(collection, dbx.HashExp{"owner": ownerID})
|
count, err := app.CountRecords(collection, dbx.HashExp{"owner": ownerID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to count owned records in %s: %w", collection, err)
|
return 0, fmt.Errorf("failed to count owned records in %s: %w", collection, err)
|
||||||
|
|||||||
@@ -4,179 +4,134 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/router"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newAccount заводит учётную запись и отдаёт её идентификатор.
|
// Страж удаления учётной записи — единственное, что стоит между владельцем
|
||||||
func newAccount(t *testing.T, app core.App, email string) string {
|
// панели и молчаливым обезличиванием чужого архива: при выключенном каскаде
|
||||||
|
// хранилище снимает ссылку и сохраняет запись без проверок.
|
||||||
|
|
||||||
|
func newAccount(t *testing.T, app core.App) *core.Record {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
|
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
record := core.NewRecord(users)
|
record := core.NewRecord(users)
|
||||||
record.Set("email", email)
|
record.Set("email", uuid.NewString()+"@example.test")
|
||||||
record.Set("verified", true)
|
record.Set("verified", true)
|
||||||
record.SetRandomPassword()
|
record.Set("password", uuid.NewString())
|
||||||
require.NoError(t, app.Save(record))
|
require.NoError(t, app.Save(record))
|
||||||
|
|
||||||
return record.Id
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
// Колонка владельца заводится шагом схемы на чистой базе, и умолчания у неё нет.
|
// newRecordOf заводит аудиозапись названного владельца.
|
||||||
func TestOwnerColumnHasNoDefault(t *testing.T) {
|
func newRecordOf(t *testing.T, app core.App, ownerID string) *entity.AudioRecord {
|
||||||
app := newTestApp(t)
|
t.Helper()
|
||||||
|
|
||||||
for _, name := range []string{migrations.JobsCollection, migrations.FilesCollection} {
|
record := &entity.AudioRecord{
|
||||||
collection, err := app.FindCollectionByNameOrId(name)
|
State: entity.StateUploaded,
|
||||||
require.NoError(t, err)
|
StateEnteredAt: clock.Now(),
|
||||||
|
Source: entity.SourceApi,
|
||||||
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"), "новая запись приходит без владельца")
|
|
||||||
}
|
}
|
||||||
}
|
if ownerID != "" {
|
||||||
|
record.OwnerID = &ownerID
|
||||||
// Чужая задача, ничья и несуществующая дают одну и ту же ошибку.
|
|
||||||
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, ¬Found, "чужая задача не отдаётся")
|
|
||||||
require.ErrorAs(t, ownerlessErr, ¬Found, "ничья задача не отдаётся")
|
|
||||||
require.ErrorAs(t, missingErr, ¬Found, "несуществующая тоже")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Пустой владелец не совпадает ни с чем: ни со своей задачей, ни с чужой, ни с
|
|
||||||
// ничьей. Правило записано со стороны спрашивающего — обязательность, которую
|
|
||||||
// держит одна лишь подпись метода, пустую строку пропускает.
|
|
||||||
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, ¬Found, "пустой владелец не открывает %s", id)
|
|
||||||
}
|
}
|
||||||
|
require.NoError(t, NewAudioRecordRepository(app).Create(record))
|
||||||
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удаление учётной записи с задачами отвергается: связь с выключенным каскадом
|
// Учётная запись с архивом не удаляется, и отказ называет причину — иначе
|
||||||
// иначе снимает ссылку, и архив человека становится ничьим и недостижимым.
|
// наружу приезжает подсказка библиотеки про обязательную связь, по которой
|
||||||
|
// владелец панели пойдёт удалять записи руками.
|
||||||
func TestGuardOwnerDeletion(t *testing.T) {
|
func TestGuardOwnerDeletion(t *testing.T) {
|
||||||
// Страж вешает сама сборка хранилища — звать его отдельно не нужно и нельзя:
|
app := newTestStorage(t)
|
||||||
// второй вызов повесил бы второй слой.
|
|
||||||
app := newTestApp(t)
|
|
||||||
repo := NewTranscriptJobRepository(app)
|
|
||||||
|
|
||||||
withJobs := newAccount(t, app, "keeper@example.com")
|
account := newAccount(t, app)
|
||||||
empty := newAccount(t, app, "empty@example.com")
|
record := newRecordOf(t, app, account.Id)
|
||||||
|
|
||||||
job := newJob(t, repo, entity.StateCreated)
|
err := app.Delete(account)
|
||||||
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
|
require.Error(t, err, "учётная запись с архивом не удаляется")
|
||||||
require.NoError(t, err)
|
assert.Contains(t, err.Error(), "остались записи", "отказ называет причину")
|
||||||
record.Set("owner", withJobs)
|
|
||||||
require.NoError(t, app.Save(record))
|
|
||||||
|
|
||||||
keeper, err := app.FindRecordById(migrations.UsersCollection, withJobs)
|
after, err := NewAudioRecordRepository(app).Get(record.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err, "запись на месте")
|
||||||
|
require.NotNil(t, after.OwnerID, "и владелец у неё прежний")
|
||||||
err = app.Delete(keeper)
|
assert.Equal(t, account.Id, *after.OwnerID)
|
||||||
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), "учётная запись без записей удаляется")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Файл переживает свою задачу: шаг конвейера заводит его до сохранения задачи, и
|
// Считаются все коллекции с владельцем, а не одни записи: файл переживает свою
|
||||||
// потерянный захват оставляет файл с владельцем и без ссылки. Считать одни
|
// запись, а тема живёт в словаре человека.
|
||||||
// задачи значило бы отдать такое аудио на молчаливое обезличивание.
|
func TestGuardOwnerDeletionCountsEveryOwnedCollection(t *testing.T) {
|
||||||
func TestGuardOwnerDeletion_CountsFilesToo(t *testing.T) {
|
cases := map[string]func(t *testing.T, app core.App, ownerID string){
|
||||||
app := newTestApp(t)
|
"аудиозапись": func(t *testing.T, app core.App, ownerID string) {
|
||||||
repo := NewFileRepository(app)
|
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("запись"))
|
topic := core.NewRecord(topics)
|
||||||
require.NoError(t, err)
|
topic.Set("owner", ownerID)
|
||||||
defer func() { require.NoError(t, work.Close()) }()
|
topic.Set("name", "личная тема")
|
||||||
|
require.NoError(t, app.Save(topic))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
_, err = repo.CreateLocal("sample.ogg", work, owner)
|
for name, own := range cases {
|
||||||
require.NoError(t, err)
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = app.Delete(record)
|
field := records.Fields.GetByName("owner")
|
||||||
require.Error(t, err, "учётная запись с одними файлами тоже не удаляется")
|
require.NotNil(t, field, "колонка владельца заведена")
|
||||||
|
|
||||||
after, err := app.FindAllRecords(migrations.FilesCollection)
|
relation, ok := field.(*core.RelationField)
|
||||||
require.NoError(t, err)
|
require.True(t, ok, "владелец — связь с учётной записью, а не строка")
|
||||||
require.Len(t, after, 1)
|
assert.False(t, relation.Required, "пустое значение допустимо ради записей бота")
|
||||||
assert.Equal(t, owner, after[0].GetString("owner"), "владелец файла не снят")
|
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,35 +4,83 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что
|
// BindPanelRules подчиняет правку записи в панели тем же правилам, что и правку
|
||||||
// и правку из кода.
|
// из кода.
|
||||||
//
|
//
|
||||||
// Панель — вход в задачу наравне с конвейером, а не окно просмотра: ради правки
|
// Панель — вход в запись наравне с конвейером, а не окно просмотра: ради правки
|
||||||
// она и покупалась, мёртвая задача оживляется сменой состояния. Но правка полем
|
// она и покупалась, остановленная запись возвращается в работу снятием признака.
|
||||||
// идёт мимо кода, который чистит служебные поля прошлого состояния, и владелец,
|
// Но правка полем идёт мимо кода, который чистит служебные поля, и владелец,
|
||||||
// «вернувший задачу в работу», получил бы задачу с прежним признаком захвата
|
// «вернувший запись в работу», получил бы запись с прежним признаком захвата
|
||||||
// (захвату она не выдастся до конца срока) и с числом попыток на пределе (умрёт
|
// (захвату она не выдастся до конца срока), с числом отказов на пределе
|
||||||
// от первого же отказа). Узнать об этом ему неоткуда.
|
// (остановится от первого же отказа) и со старым временем входа в рубеж
|
||||||
|
// (остановится снова первым же захватом по пределу простоя). Узнать об этом ему
|
||||||
|
// неоткуда.
|
||||||
|
//
|
||||||
|
// Правило живёт **одним местом** — доменными `Resume` и `MoveToState`, — и хук
|
||||||
|
// зовёт именно их, а не повторяет перечень служебных полей колонками. Повтор
|
||||||
|
// перечня был бы вторым домом того же правила: новый сторож попал бы в домен и
|
||||||
|
// не попал в панель, и владелец «вернул бы запись в работу», а она снова выпала
|
||||||
|
// бы из выборки — молча.
|
||||||
//
|
//
|
||||||
// Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное
|
// Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное
|
||||||
// событие не различает, кто пишет, и срабатывало бы на каждом переходе
|
// событие не различает, кто пишет, и срабатывало бы на каждом переходе
|
||||||
// конвейера: тогда задержка, поставленная шагом вместе со сменой состояния,
|
// конвейера: тогда пауза, поставленная шагом вместе со сменой рубежа, стиралась
|
||||||
// стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое
|
// бы тем же сохранением, а число отказов остановленной записи — которое
|
||||||
// переход хранит намеренно — приходило бы владельцу нулём.
|
// остановка хранит намеренно — приходило бы владельцу нулём.
|
||||||
func BindPanelRules(app core.App) {
|
func BindPanelRules(app core.App) {
|
||||||
app.OnRecordUpdateRequest(migrations.JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
|
app.OnRecordUpdateRequest(migrations.RecordsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||||
original := e.Record.Original()
|
original := e.Record.Original()
|
||||||
if original == nil || original.GetString("state") == e.Record.GetString("state") {
|
if original == nil {
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
e.Record.Set("acquisition_id", "")
|
stateChanged := original.GetString("state") != e.Record.GetString("state")
|
||||||
e.Record.Set("acquire_time", "")
|
// Снятие признака остановки — то самое движение, ради которого признак и
|
||||||
e.Record.Set("delay_time", "")
|
// заведён: запись возвращается в работу с сохранённого рубежа.
|
||||||
e.Record.Set("attempts", 0)
|
resumed := !original.GetDateTime("halted_at").IsZero() &&
|
||||||
|
e.Record.GetDateTime("halted_at").IsZero()
|
||||||
|
|
||||||
return e.Next()
|
if !stateChanged && !resumed {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запись читается уже с правкой человека: рубеж здесь тот, который он
|
||||||
|
// выбрал, а признак остановки — тот, который он снял или оставил.
|
||||||
|
record := recordToAudioRecord(e.Record)
|
||||||
|
switch {
|
||||||
|
case resumed:
|
||||||
|
record.Resume()
|
||||||
|
default:
|
||||||
|
record.MoveToState(record.State)
|
||||||
|
}
|
||||||
|
applyOwnedByPipeline(e.Record, record)
|
||||||
|
|
||||||
|
if err := e.Next(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resumed {
|
||||||
|
// Перезапуск виден в журнале событий с указанием, что его сделал
|
||||||
|
// человек: иначе запись, вернувшаяся в работу, выглядела бы как
|
||||||
|
// запись, которая туда и не уходила.
|
||||||
|
//
|
||||||
|
// Строка пишется **после** сохранения: событие о правке, которая не
|
||||||
|
// прошла, соврало бы о состоянии записи. Отказ записи журнала саму
|
||||||
|
// правку не отменяет — журнал никем не читается ради решения.
|
||||||
|
event := &entity.RecordEvent{
|
||||||
|
RecordID: e.Record.Id,
|
||||||
|
Origin: entity.EventOriginHuman,
|
||||||
|
Step: "resume",
|
||||||
|
Outcome: entity.EventOutcomeResumed,
|
||||||
|
}
|
||||||
|
if err := NewRecordEventRepository(e.App).Append(event); err != nil {
|
||||||
|
e.App.Logger().Error("Failed to log record resume", "error", err, "record_id", e.Record.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package pocketbase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Панель — единственный сегодня путь вернуть остановленную запись в работу, и
|
||||||
|
// хук правил стоит на правке **запросом**. Модельное сохранение его не трогает,
|
||||||
|
// поэтому проверки ниже идут через запрос — иначе они зеленели бы, не касаясь
|
||||||
|
// того пути, которым владелец и ходит.
|
||||||
|
|
||||||
|
// newPanelStorage поднимает хранилище с повешенными правилами панели — так же,
|
||||||
|
// как это делает сборка сервиса. Без них проверки судили бы хранилище без
|
||||||
|
// правил, то есть не то, что работает в проде.
|
||||||
|
func newPanelStorage(t *testing.T) core.App {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
app := newTestStorage(t)
|
||||||
|
BindPanelRules(app)
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateByRequest правит запись так, как это делает панель: запросом, а не
|
||||||
|
// сохранением модели.
|
||||||
|
func updateByRequest(t *testing.T, app core.App, recordID string, body map[string]any) *core.Record {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
record, err := app.FindRecordById(migrations.RecordsCollection, recordID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Запись, прочитанная из хранилища, помнит прежние значения сама — по ним
|
||||||
|
// хук и отличает смену рубежа от правки соседнего поля.
|
||||||
|
for key, value := range body {
|
||||||
|
record.Set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Событие правки запросом несёт и запрос, и коллекцию: `RequestEvent` вложен
|
||||||
|
// указателем, а по коллекции хук и отбирается — без неё он не сработает вовсе,
|
||||||
|
// и проверка зеленела бы, не коснувшись правила.
|
||||||
|
collection, err := app.FindCollectionByNameOrId(migrations.RecordsCollection)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
event := &core.RecordRequestEvent{RequestEvent: &core.RequestEvent{}}
|
||||||
|
event.App = app
|
||||||
|
event.Collection = collection
|
||||||
|
event.Record = record
|
||||||
|
|
||||||
|
require.NoError(t, app.OnRecordUpdateRequest(migrations.RecordsCollection).Trigger(event, func(e *core.RecordRequestEvent) error {
|
||||||
|
return e.App.Save(e.Record)
|
||||||
|
}))
|
||||||
|
|
||||||
|
after, err := app.FindRecordById(migrations.RecordsCollection, recordID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return after
|
||||||
|
}
|
||||||
|
|
||||||
|
// haltedRecord заводит остановленную запись со всеми накопленными сторожами —
|
||||||
|
// такой её видит владелец, открывая панель.
|
||||||
|
func haltedRecord(t *testing.T, app core.App) *entity.AudioRecord {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
record := newRecordOf(t, app, "")
|
||||||
|
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
@@ -174,186 +174,211 @@ func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Колонки очереди правятся в четырёх местах пакета хранилища плюс шаг схемы, и
|
// Перечень колонок аудиозаписи компилятор не видит: их пишет `applyOwnedByPipeline`,
|
||||||
// компилятор видит два из них (инвариант CLAUDE.md, «Инварианты», major).
|
// читает `recordToAudioRecord`, и заводит шаг схемы. Колонка, забытая в паре
|
||||||
// Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата
|
// «пишем — читаем», теряется молча: запись, прочитанная не тем путём, приезжает
|
||||||
// нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения —
|
// с нулевым полем, и первое же сохранение пишет этот ноль поверх значения.
|
||||||
// поле теряется только у задачи, попавшей к воркеру.
|
|
||||||
//
|
//
|
||||||
// Правила ниже закрывают все четыре места плюс шаг схемы: перечень запроса,
|
// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет
|
||||||
// структуру захвата, запись коллекции (`applyToRecord`/`recordToJob`) и перенос
|
// колонки поимённо, а возвращает идентификатор и признак своего захвата. Правила
|
||||||
// поля в задачу (`toJob`). Литерал колонки ищется **в телах** нужных функций, а
|
// ниже держат оставшуюся пару плюс шаг схемы.
|
||||||
// не в файле: файл держит и структуру с тегами `db:"…"`, и по ней условие
|
|
||||||
// выполнялось бы само собой.
|
|
||||||
const (
|
const (
|
||||||
repoPkg = "internal/adapter/repo/pocketbase"
|
repoPkg = "internal/adapter/repo/pocketbase"
|
||||||
acquireFile = repoPkg + "/transcript_job_repo.go"
|
mappingFile = repoPkg + "/record_mapping.go"
|
||||||
mappingFile = repoPkg + "/job_mapping.go"
|
|
||||||
migrationsPath = repoPkg + "/migrations"
|
migrationsPath = repoPkg + "/migrations"
|
||||||
|
stageFile = "internal/entity/stage.go"
|
||||||
|
stateFile = "internal/entity/audio_record.go"
|
||||||
|
serviceFile = "internal/service/transcribe.go"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Колонки, которые заводит и заполняет само хранилище: перечня запроса они
|
// Колонки, которые заводит и заполняет само хранилище: нашего кода они не
|
||||||
// касаются, а нашего кода — нет.
|
// касаются.
|
||||||
var storageOwned = map[string]bool{"id": true, "created": true, "updated": true}
|
var storageOwned = map[string]bool{"id": true, "created": true, "updated": true}
|
||||||
|
|
||||||
func TestПереченьЗахватаСовпадаетСоСтруктурой(t *testing.T) {
|
func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) {
|
||||||
query := acquireColumnNames(t)
|
written := writtenColumns(t)
|
||||||
row := rowColumnNames(t)
|
read := readColumns(t)
|
||||||
|
|
||||||
for _, col := range query {
|
for col := range written {
|
||||||
if !row[col] {
|
if storageOwned[col] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !read[col] {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"колонка %q есть в acquireColumns, но не в acquiredRow: из захвата "+
|
"колонку %q пишет отображение записи, но recordToAudioRecord её не "+
|
||||||
"она приедет нулевой, и первый Save затрёт сохранённое значение",
|
"читает: запись приедет из хранилища без этого поля",
|
||||||
col,
|
col,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
delete(row, col)
|
|
||||||
}
|
}
|
||||||
for col := range row {
|
for col := range read {
|
||||||
t.Errorf(
|
if storageOwned[col] {
|
||||||
"колонка %q есть в acquiredRow, но не в acquireColumns: запрос её не "+
|
continue
|
||||||
"читает, и поле остаётся нулевым",
|
}
|
||||||
col,
|
if !written[col] {
|
||||||
)
|
t.Errorf(
|
||||||
|
"колонку %q читает recordToAudioRecord, но её не пишет ни "+
|
||||||
|
"applyOwnedByPipeline, ни applyToRecord: поле не сохранится",
|
||||||
|
col,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestКолонкиЗахватаЗаведеныШагомСхемы(t *testing.T) {
|
func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) {
|
||||||
declared := schemaFieldNames(t)
|
declared := schemaFieldNames(t)
|
||||||
for _, col := range acquireColumnNames(t) {
|
for col := range writtenColumns(t) {
|
||||||
if col == "id" {
|
if storageOwned[col] {
|
||||||
continue // ключ заводит само хранилище, шаг схемы его не объявляет
|
continue
|
||||||
}
|
}
|
||||||
if !declared[col] {
|
if !declared[col] {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"колонка %q читается захватом, но ни один шаг схемы её не заводит: "+
|
"колонка %q пишется отображением записи, но ни один шаг схемы её не "+
|
||||||
"запрос отвалится на живой базе",
|
"заводит: сохранение отвалится на живой базе",
|
||||||
col,
|
col,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Четвёртое место — путь через запись коллекции: `applyToRecord` пишет колонку,
|
// Рубеж объявлен одним дескриптором, но шаг под него пишется в другом месте, и
|
||||||
// `recordToJob` читает. Ищется литерал **в телах этих функций**, а не в файле:
|
// связь между ними компилятор не видит. Рубеж, оставшийся без шага, из работы не
|
||||||
// в файле лежит и структура захвата со своими тегами `db:"…"`, и по ней условие
|
// выходит: воркер его захватит, шага не найдёт и остановит запись — а рубеж,
|
||||||
// выполнялось бы само собой — правило было бы зелёным всегда.
|
// забытый в дескрипторе, не выдаётся захвату вовсе, и пустой прогон по
|
||||||
func TestКолонкиЗахватаЧитаютсяИЧерезЗапись(t *testing.T) {
|
// инварианту проекта не пишется в журнал и не считается в метрику.
|
||||||
write := funcBody(t, mappingFile, "func applyOwnedByPipeline(") +
|
func TestУКаждогоРабочегоРубежаЕстьШаг(t *testing.T) {
|
||||||
|
body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(")
|
||||||
|
for _, stage := range workingStageIdents(t) {
|
||||||
|
if !strings.Contains(body, "entity."+stage) {
|
||||||
|
t.Errorf(
|
||||||
|
"рубеж entity.%s объявлен рабочим в дескрипторе, но шага под него нет "+
|
||||||
|
"в таблице stepFor: запись с этим рубежом остановится, не начав работы",
|
||||||
|
stage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обратное направление того же правила: шаг, написанный под рубеж, которого в
|
||||||
|
// дескрипторе нет, недостижим — захват такую запись не выдаст никогда.
|
||||||
|
func TestШагиОбъявленыРубежамиДескриптора(t *testing.T) {
|
||||||
|
body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(")
|
||||||
|
declared := map[string]bool{}
|
||||||
|
for _, stage := range stageIdents(t) {
|
||||||
|
declared[stage] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
re := regexp.MustCompile(`case entity\.(\w+):`)
|
||||||
|
for _, m := range re.FindAllStringSubmatch(body, -1) {
|
||||||
|
if !declared[m[1]] {
|
||||||
|
t.Errorf(
|
||||||
|
"в таблице stepFor есть ветка для entity.%s, но такого рубежа нет в "+
|
||||||
|
"дескрипторе: запись с этим рубежом захвату не выдаётся",
|
||||||
|
m[1],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writtenColumns — колонки, которые пишет отображение записи в хранилище.
|
||||||
|
func writtenColumns(t *testing.T) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
body := funcBody(t, mappingFile, "func applyOwnedByPipeline(") +
|
||||||
funcBody(t, mappingFile, "func applyToRecord(")
|
funcBody(t, mappingFile, "func applyToRecord(")
|
||||||
read := funcBody(t, mappingFile, "func recordToJob(")
|
|
||||||
|
|
||||||
for _, col := range acquireColumnNames(t) {
|
|
||||||
if storageOwned[col] {
|
|
||||||
continue // эти колонки заводит и заполняет само хранилище
|
|
||||||
}
|
|
||||||
if !strings.Contains(write, `"`+col+`"`) {
|
|
||||||
t.Errorf(
|
|
||||||
"колонку %q читает захват, но её не пишет ни applyOwnedByPipeline, "+
|
|
||||||
"ни applyToRecord: путь через запись коллекции её потеряет",
|
|
||||||
col,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if !strings.Contains(read, `"`+col+`"`) {
|
|
||||||
t.Errorf(
|
|
||||||
"колонку %q читает захват, но recordToJob её не читает: задача, "+
|
|
||||||
"прочитанная не захватом, приедет без этого поля",
|
|
||||||
col,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Пятое условие того же инварианта: колонка, доехавшая до структуры захвата,
|
|
||||||
// обязана попасть в задачу. `toJob` обращается к **полям**, а не к литералам,
|
|
||||||
// поэтому сверяются имена полей, а не имена колонок: поле, забытое здесь,
|
|
||||||
// приезжает из захвата прочитанным и теряется на последнем шаге.
|
|
||||||
func TestПоляСтруктурыЗахватаДоезжаютДоЗадачи(t *testing.T) {
|
|
||||||
body := funcBody(t, mappingFile, "func (r *acquiredRow) toJob()")
|
|
||||||
for _, field := range rowFieldNames(t) {
|
|
||||||
if !strings.Contains(body, "r."+field) {
|
|
||||||
t.Errorf(
|
|
||||||
"поле %s структуры захвата не читается в toJob: колонка приедет из "+
|
|
||||||
"запроса, но в задачу не попадёт",
|
|
||||||
field,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Чтение исходников ------------------------------------------------------
|
|
||||||
|
|
||||||
// acquireColumnNames достаёт имена колонок из константы `acquireColumns`. Она
|
|
||||||
// склеена из строковых литералов, поэтому берётся текстом, а не разбором типов:
|
|
||||||
// значение константы известно на месте.
|
|
||||||
func acquireColumnNames(t *testing.T) []string {
|
|
||||||
t.Helper()
|
|
||||||
body := readFile(t, acquireFile)
|
|
||||||
const marker = "const acquireColumns = "
|
|
||||||
start := strings.Index(body, marker)
|
|
||||||
if start < 0 {
|
|
||||||
t.Fatalf("в %s нет константы acquireColumns: правило потеряло предмет", acquireFile)
|
|
||||||
}
|
|
||||||
tail := body[start+len(marker):]
|
|
||||||
end := strings.Index(tail, "`\n")
|
|
||||||
if end < 0 {
|
|
||||||
t.Fatalf("не нашёл конец константы acquireColumns в %s", acquireFile)
|
|
||||||
}
|
|
||||||
var cols []string
|
|
||||||
for _, chunk := range strings.Split(strings.NewReplacer("`", "", "+", "", "\n", "", "\t", "").Replace(tail[:end]), ",") {
|
|
||||||
if col := strings.TrimSpace(chunk); col != "" {
|
|
||||||
cols = append(cols, col)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(cols) == 0 {
|
|
||||||
t.Fatalf("перечень acquireColumns прочитан пустым: правило потеряло предмет")
|
|
||||||
}
|
|
||||||
return cols
|
|
||||||
}
|
|
||||||
|
|
||||||
// rowColumnNames достаёт колонки из тегов `db:"…"` структуры `acquiredRow`.
|
|
||||||
func rowColumnNames(t *testing.T) map[string]bool {
|
|
||||||
t.Helper()
|
|
||||||
out := map[string]bool{}
|
out := map[string]bool{}
|
||||||
for _, m := range regexp.MustCompile("`db:\"([^\"]+)\"`").FindAllStringSubmatch(rowStruct(t), -1) {
|
for _, m := range regexp.MustCompile(`record\.Set\("([^"]+)"`).FindAllStringSubmatch(body, -1) {
|
||||||
out[m[1]] = true
|
out[m[1]] = true
|
||||||
}
|
}
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
t.Fatalf("у acquiredRow не прочитан ни один тег db: правило потеряло предмет")
|
t.Fatalf("отображение записи не пишет ни одной колонки: правило потеряло предмет")
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// rowFieldNames достаёт имена полей структуры `acquiredRow` — те, к которым
|
// readColumns — колонки, которые читает обратное отображение.
|
||||||
// обращается `toJob`.
|
func readColumns(t *testing.T) map[string]bool {
|
||||||
func rowFieldNames(t *testing.T) []string {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var out []string
|
body := funcBody(t, mappingFile, "func recordToAudioRecord(")
|
||||||
for _, m := range regexp.MustCompile(`(?m)^\t([A-Z]\w*)\s`).FindAllStringSubmatch(rowStruct(t), -1) {
|
out := map[string]bool{}
|
||||||
out = append(out, m[1])
|
for _, m := range regexp.MustCompile(`\.Get\w+\("([^"]+)"\)`).FindAllStringSubmatch(body, -1) {
|
||||||
|
out[m[1]] = true
|
||||||
}
|
}
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
t.Fatalf("у acquiredRow не прочитано ни одно поле: правило потеряло предмет")
|
t.Fatalf("recordToAudioRecord не читает ни одной колонки: правило потеряло предмет")
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// rowStruct — текст объявления структуры `acquiredRow`.
|
// stageIdents — имена констант рубежей, перечисленных дескриптором.
|
||||||
func rowStruct(t *testing.T) string {
|
func stageIdents(t *testing.T) []string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body := readFile(t, mappingFile)
|
out, _ := stageDescriptor(t)
|
||||||
start := strings.Index(body, "type acquiredRow struct {")
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// workingStageIdents — то же, но без конечного рубежа: из него запись в работу
|
||||||
|
// не берут.
|
||||||
|
func workingStageIdents(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
_, working := stageDescriptor(t)
|
||||||
|
return working
|
||||||
|
}
|
||||||
|
|
||||||
|
func stageDescriptor(t *testing.T) (all []string, working []string) {
|
||||||
|
t.Helper()
|
||||||
|
body := readFile(t, stageFile)
|
||||||
|
const marker = "var stages = []Stage{"
|
||||||
|
start := strings.Index(body, marker)
|
||||||
if start < 0 {
|
if start < 0 {
|
||||||
t.Fatalf("в %s нет структуры acquiredRow: правило потеряло предмет", mappingFile)
|
t.Fatalf("в %s нет дескриптора рубежей: правило потеряло предмет", stageFile)
|
||||||
}
|
}
|
||||||
end := strings.Index(body[start:], "\n}")
|
end := strings.Index(body[start:], "\n}")
|
||||||
if end < 0 {
|
if end < 0 {
|
||||||
t.Fatalf("не нашёл конец структуры acquiredRow в %s", mappingFile)
|
t.Fatalf("не нашёл конец дескриптора рубежей в %s", stageFile)
|
||||||
}
|
}
|
||||||
return body[start : start+end]
|
|
||||||
|
declared := declaredStates(t)
|
||||||
|
re := regexp.MustCompile(`\{Name: (\w+)[^}]*\}`)
|
||||||
|
for _, m := range re.FindAllStringSubmatch(body[start:start+end], -1) {
|
||||||
|
if !declared[m[1]] {
|
||||||
|
t.Errorf(
|
||||||
|
"дескриптор называет рубеж %s, которого нет среди объявленных состояний "+
|
||||||
|
"в %s: перечень схемы разошёлся бы с ним молча",
|
||||||
|
m[1], stateFile,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
all = append(all, m[1])
|
||||||
|
if !strings.Contains(m[0], "Terminal: true") {
|
||||||
|
working = append(working, m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(all) == 0 {
|
||||||
|
t.Fatalf("дескриптор рубежей прочитан пустым: правило потеряло предмет")
|
||||||
|
}
|
||||||
|
if len(working) == 0 {
|
||||||
|
t.Fatalf("в дескрипторе нет ни одного рабочего рубежа: правило потеряло предмет")
|
||||||
|
}
|
||||||
|
return all, working
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// declaredStates — константы рубежей, объявленные доменом.
|
||||||
|
func declaredStates(t *testing.T) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
out := map[string]bool{}
|
||||||
|
re := regexp.MustCompile(`(?m)^\t(State\w+)\s*=\s*"`)
|
||||||
|
for _, m := range re.FindAllStringSubmatch(readFile(t, stateFile), -1) {
|
||||||
|
out[m[1]] = true
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
t.Fatalf("в %s не объявлено ни одного рубежа: правило потеряло предмет", stateFile)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Чтение исходников ------------------------------------------------------
|
||||||
|
|
||||||
// funcBody — текст тела функции от её заголовка до закрывающей скобки в первой
|
// funcBody — текст тела функции от её заголовка до закрывающей скобки в первой
|
||||||
// позиции строки. Пропавший заголовок — отказ, а не пустое тело: правило,
|
// позиции строки. Пропавший заголовок — отказ, а не пустое тело: правило,
|
||||||
// потерявшее предмет, обязано краснеть, а не зеленеть.
|
// потерявшее предмет, обязано краснеть, а не зеленеть.
|
||||||
|
|||||||
@@ -7,18 +7,59 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `toml:"server"`
|
Server ServerConfig `toml:"server"`
|
||||||
Storage StorageConfig `toml:"storage"`
|
Storage StorageConfig `toml:"storage"`
|
||||||
|
Pipeline PipelineConfig `toml:"pipeline"`
|
||||||
Yandex YandexConfig `toml:"yandex"`
|
Yandex YandexConfig `toml:"yandex"`
|
||||||
Telegram TelegramConfig `toml:"telegram"`
|
Telegram TelegramConfig `toml:"telegram"`
|
||||||
Auth AuthConfig `toml:"auth"`
|
Auth AuthConfig `toml:"auth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PipelineConfig — настройки конвейера расшифровки.
|
||||||
|
type PipelineConfig struct {
|
||||||
|
// Workers — число одинаковых воркеров. Ноль — законное значение: сервис
|
||||||
|
// поднимается, записи принимаются и не двигаются. Это режим, а не поломка.
|
||||||
|
Workers int `toml:"workers"`
|
||||||
|
// OwnWorkLimitMinutes — предел простоя записи там, где работу делаем мы
|
||||||
|
// сами. Сторож ловит **зависание**, а не долгую работу: живой шаг
|
||||||
|
// наблюдается по самому процессу, а остановка обратима — снятие признака
|
||||||
|
// возвращает запись на её рубеж.
|
||||||
|
OwnWorkLimitMinutes int `toml:"own_work_limit_minutes"`
|
||||||
|
// ForeignWorkLimitMinutes — предел простоя там, где ждём чужую операцию.
|
||||||
|
// Сколько идёт распознавание долгой записи, никто не мерил, поэтому ошибка
|
||||||
|
// идёт в сторону долгого: ложная остановка хуже поздней.
|
||||||
|
ForeignWorkLimitMinutes int `toml:"foreign_work_limit_minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StuckLimits переводит настройки в пределы простоя.
|
||||||
|
func (c PipelineConfig) StuckLimits() entity.StuckLimits {
|
||||||
|
return entity.StuckLimits{
|
||||||
|
Own: time.Duration(c.OwnWorkLimitMinutes) * time.Minute,
|
||||||
|
Foreign: time.Duration(c.ForeignWorkLimitMinutes) * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate проверяет числа конвейера. Отрицательное число воркеров — ошибка
|
||||||
|
// настройки, а не режим: ноль объявлен законным значением, и отличать его от
|
||||||
|
// опечатки обязан старт.
|
||||||
|
func (c PipelineConfig) Validate() error {
|
||||||
|
if c.Workers < 0 {
|
||||||
|
return errors.New("pipeline: число воркеров не может быть отрицательным")
|
||||||
|
}
|
||||||
|
if c.OwnWorkLimitMinutes <= 0 || c.ForeignWorkLimitMinutes <= 0 {
|
||||||
|
return errors.New("pipeline: пределы простоя задаются положительным числом минут")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port int `toml:"port"`
|
Port int `toml:"port"`
|
||||||
ShutdownTimeout int `toml:"shutdown_timeout"`
|
ShutdownTimeout int `toml:"shutdown_timeout"`
|
||||||
@@ -143,6 +184,15 @@ func defaultConfig() *Config {
|
|||||||
Storage: StorageConfig{
|
Storage: StorageConfig{
|
||||||
DataDir: "data",
|
DataDir: "data",
|
||||||
},
|
},
|
||||||
|
Pipeline: PipelineConfig{
|
||||||
|
Workers: 3,
|
||||||
|
// Час на свою работу и сутки на чужую. Час меньше времени, которое
|
||||||
|
// многочасовая запись занимает на приведении, и это принято
|
||||||
|
// сознательно: сторож ловит зависание, живой шаг наблюдается по
|
||||||
|
// самому процессу, а остановка обратима.
|
||||||
|
OwnWorkLimitMinutes: 60,
|
||||||
|
ForeignWorkLimitMinutes: 24 * 60,
|
||||||
|
},
|
||||||
Yandex: YandexConfig{
|
Yandex: YandexConfig{
|
||||||
FolderID: "",
|
FolderID: "",
|
||||||
SpeechKitAPIKey: "",
|
SpeechKitAPIKey: "",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Проверка входа — единственная страховка от того, чтобы сервис поднялся с
|
// Проверка входа — единственная страховка от того, чтобы сервис поднялся с
|
||||||
@@ -274,3 +275,71 @@ func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) {
|
|||||||
t.Fatalf("место отказа не названо, чинить нечего: %v", err)
|
t.Fatalf("место отказа не названо, чинить нечего: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Числа конвейера приезжают из файла, а не выдумываются кодом.
|
||||||
|
func TestLoadConfigReadsPipelineSettings(t *testing.T) {
|
||||||
|
path := writeConfig(t, `
|
||||||
|
[pipeline]
|
||||||
|
workers = 7
|
||||||
|
own_work_limit_minutes = 90
|
||||||
|
foreign_work_limit_minutes = 720
|
||||||
|
|
||||||
|
[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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,10 +24,39 @@ type AudioFileConverter interface {
|
|||||||
Convert(ctx context.Context, src, dest string) error
|
Convert(ctx context.Context, src, dest string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AudioRecognizer — внешний распознаватель речи.
|
||||||
|
//
|
||||||
|
// Заливка и отправка операции разделены: это два обращения с разной ценой
|
||||||
|
// повтора. Повтор заливки бесплатен и кладёт объект под тем же ключом; повтор
|
||||||
|
// отправки оплачивается наружу, и шаг обязан проверить сделанное прежде, чем
|
||||||
|
// платить второй раз.
|
||||||
|
//
|
||||||
|
// Результат отдаётся **доменным** — реплики со временем, плоский текст и байты
|
||||||
|
// ответа на хранение, — а не сырым форматом провайдера: разбор потока это
|
||||||
|
// обязанность адаптера, и ни один шаг конвейера не знает, каким потоком и какими
|
||||||
|
// полями провайдер отвечает.
|
||||||
type AudioRecognizer interface {
|
type AudioRecognizer interface {
|
||||||
Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error)
|
// Provider — имя провайдера, под которым сохраняется попытка.
|
||||||
GetRecognitionText(ctx context.Context, operationID string) (string, error)
|
Provider() string
|
||||||
CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error)
|
// Model — имя модели распознавания.
|
||||||
|
Model() string
|
||||||
|
// Upload кладёт аудио туда, откуда провайдер его прочитает, и отдаёт адрес.
|
||||||
|
// Повтор кладёт объект под тем же ключом и оплаты не стоит.
|
||||||
|
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 {
|
type TelegramMessageSender interface {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package contract
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
@@ -23,6 +22,14 @@ type WorkFile interface {
|
|||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileMeta — что известно о копии сверх её содержимого.
|
||||||
|
type FileMeta struct {
|
||||||
|
// Format — расширение без точки, в нижнем регистре.
|
||||||
|
Format string
|
||||||
|
// DurationMs — длительность, если её удалось прочитать.
|
||||||
|
DurationMs int64
|
||||||
|
}
|
||||||
|
|
||||||
type FileRepository interface {
|
type FileRepository interface {
|
||||||
// Stage принимает содержимое потоком в рабочую копию с заданным
|
// Stage принимает содержимое потоком в рабочую копию с заданным
|
||||||
// расширением: по нему внешняя программа выбирает разбор. В память запись
|
// расширением: по нему внешняя программа выбирает разбор. В память запись
|
||||||
@@ -33,44 +40,96 @@ type FileRepository interface {
|
|||||||
StageEmpty(ext string) (WorkFile, error)
|
StageEmpty(ext string) (WorkFile, error)
|
||||||
// Localize выдаёт рабочую копию хранимого файла.
|
// Localize выдаёт рабочую копию хранимого файла.
|
||||||
Localize(fileID string) (WorkFile, error)
|
Localize(fileID string) (WorkFile, error)
|
||||||
// CreateLocal кладёт рабочую копию в хранилище под именем name и заводит
|
// Create кладёт рабочую копию в хранилище под именем name и заводит запись о
|
||||||
// запись о файле. Имя задаёт сервис: умолчание хранилища, строящее его из
|
// файле. Имя задаёт сервис: умолчание хранилища, строящее его из имени
|
||||||
// имени отправителя, не применяется.
|
// отправителя, не применяется.
|
||||||
//
|
//
|
||||||
// ownerID — владелец записи, которой файл принадлежит; пустой значит «файл
|
// ownerID — владелец записи, которой файл принадлежит; пустой значит «файл
|
||||||
// без владельца», и таков всякий файл записи, принятой ботом. Владелец
|
// без владельца», и таков всякий файл записи, принятой ботом. Владелец
|
||||||
// лежит своей колонкой, а не выводится через задачу: ссылку на файл в
|
// лежит своей колонкой, а не выводится через запись: файл переживает свою
|
||||||
// задаче переставляет каждый шаг конвейера, и исходная копия после
|
// запись — шаг заводит его до сохранения, и потерянный захват оставляет файл
|
||||||
// конвертации не связана с задачей ничем.
|
// с владельцем и без ссылки.
|
||||||
CreateLocal(name string, work WorkFile, ownerID string) (*entity.File, error)
|
Create(name string, work WorkFile, meta FileMeta, ownerID string) (*entity.File, error)
|
||||||
// CreateRemote заводит запись о копии, лежащей во внешнем хранилище.
|
|
||||||
CreateRemote(objectKey string, size int64, ownerID string) (*entity.File, error)
|
|
||||||
GetByID(id string) (*entity.File, error)
|
GetByID(id string) (*entity.File, error)
|
||||||
// Open отдаёт содержимое хранимого файла потоком.
|
// Open отдаёт содержимое хранимого файла потоком.
|
||||||
Open(fileID string) (io.ReadCloser, error)
|
Open(fileID string) (io.ReadCloser, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TranscriptJobRepository interface {
|
// AcquiredRecord — то, что отдаёт захват: идентификатор записи и признак
|
||||||
Create(job *entity.TranscribeJob) error
|
// **этого** захвата.
|
||||||
// Save сохраняет задачу, захват которой держит holder. Захват, доставшийся
|
//
|
||||||
|
// Перечня колонок здесь нет намеренно. Захват, возвращавший колонки поимённо,
|
||||||
|
// требовал править их в четырёх местах сразу, и забытая колонка приезжала
|
||||||
|
// нулевой, а первое же сохранение писало этот ноль поверх значения. Колонки шаг
|
||||||
|
// читает обычным чтением.
|
||||||
|
type AcquiredRecord struct {
|
||||||
|
ID string
|
||||||
|
// Holder — значение, уникальное для каждого захвата. Запись результата
|
||||||
|
// условна по нему, а не по занятости записи: захват, перевыданный другому по
|
||||||
|
// протуханию срока или после снятия остановки человеком, обязан обратить
|
||||||
|
// запись первого в отказ.
|
||||||
|
Holder string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioRecordRepository interface {
|
||||||
|
Create(record *entity.AudioRecord) error
|
||||||
|
// Save сохраняет запись, захват которой держит holder. Захват, доставшийся
|
||||||
// за время работы другому, даёт LostAcquisitionError и запись не проводит.
|
// за время работы другому, даёт LostAcquisitionError и запись не проводит.
|
||||||
// Пустой holder снимает эту условность и в конвейере не употребляется: все
|
// Пустой holder снимает эту условность и в конвейере не употребляется: все
|
||||||
// его шаги получают признак захвата от FindAndAcquire.
|
// его шаги получают признак захвата от FindAndAcquire.
|
||||||
Save(job *entity.TranscribeJob, holder string) error
|
Save(record *entity.AudioRecord, holder string) error
|
||||||
// GetByID отдаёт задачу, только если её владелец — ownerID. Чужая задача,
|
// GetByID отдаёт запись, только если её владелец — ownerID. Чужая запись,
|
||||||
// ничья задача и несуществующая дают одну и ту же ошибку: по разнице
|
// ничья и несуществующая дают одну и ту же ошибку: по разнице ответов иначе
|
||||||
// ответов иначе перебирается список заведённых задач.
|
// перебирается список заведённых записей.
|
||||||
//
|
//
|
||||||
// Владелец здесь обязателен, и пустой ownerID не совпадает ни с чем —
|
// Владелец здесь обязателен, и пустой ownerID не совпадает ни с чем —
|
||||||
// включая задачи без владельца. Правило записано со стороны спрашивающего:
|
// включая записи без владельца. Правило записано со стороны спрашивающего:
|
||||||
// обязательность, которую держит одна лишь подпись метода, пустую строку
|
// обязательность, которую держит одна лишь подпись метода, пустую строку
|
||||||
// пропускает, и вызывающий без учётной записи получил бы ровно множество
|
// пропускает.
|
||||||
// записей бота.
|
GetByID(id, ownerID string) (*entity.AudioRecord, error)
|
||||||
|
// Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья
|
||||||
|
// выборка владельцем не сужается.
|
||||||
|
Get(id string) (*entity.AudioRecord, error)
|
||||||
|
// FindAndAcquire забирает пригодную к работе запись одним неделимым шагом и
|
||||||
|
// увеличивает число её отказов. Отбор идёт по рабочим рубежам, паузе, сроку
|
||||||
|
// протухания захвата и отсутствию признака остановки; срок протухания
|
||||||
|
// приезжает с рубежом и пишется в саму запись.
|
||||||
//
|
//
|
||||||
// Второго читающего метода нет намеренно: он выбирался бы по
|
// Работы нет — JobNotFoundError.
|
||||||
// внимательности вызывающего.
|
FindAndAcquire(stages []entity.Stage) (*AcquiredRecord, error)
|
||||||
GetByID(id, ownerID string) (*entity.TranscribeJob, error)
|
}
|
||||||
// FindAndAcquire забирает задачу одним неделимым шагом и увеличивает число
|
|
||||||
// её попыток. Работы в состоянии нет — JobNotFoundError.
|
// TextRepository — тексты записи. Пара «запись и вид» уникальна: повтор
|
||||||
FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error)
|
// прерванного шага не заводит второй строки.
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestApiRequiresSession(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, files)
|
assert.Empty(t, files)
|
||||||
|
|
||||||
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
|
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, jobs)
|
assert.Empty(t, jobs)
|
||||||
})
|
})
|
||||||
@@ -64,7 +64,7 @@ func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
|
|||||||
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
|
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
|
||||||
require.Equal(t, http.StatusCreated, created.Code)
|
require.Equal(t, http.StatusCreated, created.Code)
|
||||||
|
|
||||||
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
|
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, jobs, 1)
|
require.Len(t, jobs, 1)
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ func TestLoginCreatesAccountAndSession(t *testing.T) {
|
|||||||
|
|
||||||
r, err := apis.NewRouter(env.app)
|
r, err := apis.NewRouter(env.app)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
NewTranscribeHandler(pbrepo.NewTranscriptJobRepository(env.app), nil, nil).Register(r)
|
NewTranscribeHandler(pbrepo.NewAudioRecordRepository(env.app), pbrepo.NewTextRepository(env.app), nil, nil).Register(r)
|
||||||
checkMux, err := r.BuildMux()
|
checkMux, err := r.BuildMux()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
checkMux.ServeHTTP(checkResponse, check)
|
checkMux.ServeHTTP(checkResponse, check)
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"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.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")
|
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()) }()
|
defer func() { require.NoError(t, work.Close()) }()
|
||||||
|
|
||||||
// Файл записи из Telegram владельца тоже не имеет.
|
// Файл записи из Telegram владельца тоже не имеет.
|
||||||
file, err := fileRepo.CreateLocal("voice.ogg", work, "")
|
file, err := fileRepo.Create("voice.ogg", work, contract.FileMeta{Format: "ogg"}, "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
job := &entity.TranscribeJob{
|
job := &entity.AudioRecord{
|
||||||
State: entity.StateCreated,
|
State: entity.StateUploaded,
|
||||||
Source: entity.SourceTelegram,
|
StateEnteredAt: clock.Now(),
|
||||||
FileID: &file.Id,
|
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()
|
w := httptest.NewRecorder()
|
||||||
env.serve(w, httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody))
|
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
|
var response CreateTranscribeJobResponse
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
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)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель")
|
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)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, env.account.Id, fileRecord.GetString("owner"), "владелец файла — он же")
|
assert.Equal(t, env.account.Id, fileRecord.GetString("owner"), "владелец файла — он же")
|
||||||
}
|
}
|
||||||
@@ -143,7 +147,7 @@ func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
|
|||||||
var response CreateTranscribeJobResponse
|
var response CreateTranscribeJobResponse
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||||
|
|
||||||
record, err := env.app.FindRecordById("transcribe_jobs", response.JobID)
|
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.JobID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, env.account.Id, record.GetString("owner"))
|
assert.Equal(t, env.account.Id, record.GetString("owner"))
|
||||||
}
|
}
|
||||||
@@ -187,7 +191,7 @@ func TestFileDownload_NarrowedByOwner(t *testing.T) {
|
|||||||
job := jobWithFile(t, env)
|
job := jobWithFile(t, env)
|
||||||
_, stranger := newSecondAccount(t, env.app)
|
_, 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.NoError(t, err)
|
||||||
require.Equal(t, env.account.Id, record.GetString("owner"))
|
require.Equal(t, env.account.Id, record.GetString("owner"))
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
"владелец сервиса узнаёт об аварии из журнала")
|
||||||
|
}
|
||||||
@@ -18,16 +18,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type TranscribeHandler struct {
|
type TranscribeHandler struct {
|
||||||
jobRepo contract.TranscriptJobRepository
|
recordRepo contract.AudioRecordRepository
|
||||||
|
textRepo contract.TextRepository
|
||||||
trsService *service.TranscribeService
|
trsService *service.TranscribeService
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTranscribeHandler(jobRepo contract.TranscriptJobRepository, trsService *service.TranscribeService, logger *slog.Logger) *TranscribeHandler {
|
func NewTranscribeHandler(
|
||||||
|
recordRepo contract.AudioRecordRepository,
|
||||||
|
textRepo contract.TextRepository,
|
||||||
|
trsService *service.TranscribeService,
|
||||||
|
logger *slog.Logger,
|
||||||
|
) *TranscribeHandler {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = slog.Default()
|
logger = slog.Default()
|
||||||
}
|
}
|
||||||
return &TranscribeHandler{jobRepo: jobRepo, trsService: trsService, logger: logger}
|
return &TranscribeHandler{recordRepo: recordRepo, textRepo: textRepo, trsService: trsService, logger: logger}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateTranscribeJobResponse struct {
|
type CreateTranscribeJobResponse struct {
|
||||||
@@ -35,16 +41,27 @@ type CreateTranscribeJobResponse struct {
|
|||||||
State string `json:"status"`
|
State string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTranscribeJobResponse — ответ об одной записи.
|
||||||
|
//
|
||||||
|
// Имена полей нормативны и остались прежними: контракт HTTP API объявлен
|
||||||
|
// проектом необратимым, и переименование поля ломает внешнюю программу молча.
|
||||||
|
// Изменились **значения** поля состояния — рубеж теперь называет достигнутое, — и
|
||||||
|
// это объявленная ломка.
|
||||||
|
//
|
||||||
|
// Поле `halted` новое: отказ перестал быть состоянием, и без него остановленная
|
||||||
|
// запись выглядела бы как обычная, стоящая на своём рубеже. Машинный текст
|
||||||
|
// отказа в ответ не идёт: он принадлежит журналу владельца сервиса.
|
||||||
type GetTranscribeJobResponse struct {
|
type GetTranscribeJobResponse struct {
|
||||||
JobID string `json:"job_id"`
|
JobID string `json:"job_id"`
|
||||||
State string `json:"status"`
|
State string `json:"status"`
|
||||||
|
Halted bool `json:"halted"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
TranscriptionText *string `json:"transcription_text,omitempty"`
|
TranscriptionText *string `json:"transcription_text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register вешает маршруты сервиса на роутер хранилища. Порт у сервиса и у
|
// Register вешает маршруты сервиса на роутер хранилища. Порт у сервиса и у
|
||||||
// панели один, поэтому и роутер один; имена полей ответа и коды при переезде
|
// панели один, поэтому и роутер один; имена полей ответа и коды при переезде
|
||||||
// сохранены — публичный контракт API объявлен необратимым.
|
// сохранены — публичный контракт HTTP API объявлен необратимым.
|
||||||
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
|
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
|
|
||||||
@@ -80,18 +97,18 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Запись доехала целиком, поэтому задача заводится независимо от того,
|
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
|
||||||
// дождётся ли отправитель ответа: на контексте запроса приём терял бы
|
// ли отправитель ответа: на контексте запроса приём терял бы полностью
|
||||||
// полностью загруженную запись от одного обрыва соединения, а забрать
|
// загруженную запись от одного обрыва соединения, а забрать результат он
|
||||||
// результат он может и позже — по `GET /status/{id}`. Значения контекста
|
// может и позже — по `GET /status/{id}`. Значения контекста (журнал запроса,
|
||||||
// (журнал запроса, сессия) при этом сохраняются, теряется только отмена.
|
// сессия) при этом сохраняются, теряется только отмена.
|
||||||
ctx := context.WithoutCancel(e.Request.Context())
|
ctx := context.WithoutCancel(e.Request.Context())
|
||||||
|
|
||||||
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
|
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
|
||||||
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
|
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
|
||||||
// чужое имя. Проверка предъявителя стоит слоем выше, поэтому здесь `e.Auth`
|
// чужое имя. Проверка предъявителя стоит слоем выше, поэтому здесь `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 {
|
if err != nil {
|
||||||
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
||||||
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
|
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
|
||||||
@@ -100,37 +117,53 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
|
|||||||
|
|
||||||
// Возвращаем успешный ответ
|
// Возвращаем успешный ответ
|
||||||
return e.JSON(http.StatusCreated, CreateTranscribeJobResponse{
|
return e.JSON(http.StatusCreated, CreateTranscribeJobResponse{
|
||||||
JobID: job.Id,
|
JobID: record.Id,
|
||||||
State: job.State,
|
State: record.State,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
|
func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
|
||||||
jobID := e.Request.PathValue("id")
|
recordID := e.Request.PathValue("id")
|
||||||
|
|
||||||
// Чужая задача, задача без владельца и несуществующая отвечают одним и тем
|
// Чужая запись, запись без владельца и несуществующая отвечают одним и тем
|
||||||
// же: хранилище отдаёт на все три ту же ошибку, а транспорт — тот же код и
|
// же: хранилище отдаёт на все три ту же ошибку, а транспорт — тот же код и
|
||||||
// то же тело. Различать их наружу нельзя — по разнице ответов перебирается
|
// то же тело. Различать их наружу нельзя — по разнице ответов перебирается
|
||||||
// список заведённых задач.
|
// список заведённых записей.
|
||||||
job, err := h.jobRepo.GetByID(jobID, e.Auth.Id)
|
record, err := h.recordRepo.GetByID(recordID, e.Auth.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
|
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
|
||||||
// «Задачи нет» и «задача чужая» — штатная работа разграничения, о ней
|
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
|
||||||
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
|
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
|
||||||
// приходит отправителю как «вашей записи нет», а владелец сервиса об
|
// приходит отправителю как «вашей записи нет», а владелец сервиса об
|
||||||
// аварии не узнаёт ниоткуда. Журнал читает владелец, а не тот, кто
|
// аварии не узнаёт ниоткуда.
|
||||||
// перебирает, поэтому различать их здесь можно.
|
|
||||||
var notFound *contract.JobNotFoundError
|
var notFound *contract.JobNotFoundError
|
||||||
if !errors.As(err, ¬Found) {
|
if !errors.As(err, ¬Found) {
|
||||||
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.StatusNotFound, map[string]string{"error": "Job not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.JSON(http.StatusOK, GetTranscribeJobResponse{
|
response := GetTranscribeJobResponse{
|
||||||
JobID: job.Id,
|
JobID: record.Id,
|
||||||
State: job.State,
|
State: record.State,
|
||||||
CreatedAt: job.CreatedAt,
|
Halted: record.IsHalted(),
|
||||||
TranscriptionText: job.TranscriptionText,
|
CreatedAt: record.CreatedAt,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// Вид текста называется **явно**: видов у записи больше одного, и отдача
|
||||||
|
// «последнего записанного» сделала бы ответ функцией порядка записи, а не
|
||||||
|
// состояния записи. В это поле уходит сырая расшифровка, и только она.
|
||||||
|
if record.TranscriptTextID != nil {
|
||||||
|
text, err := h.textRepo.GetByID(*record.TranscriptTextID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to read transcript", "error", err, "record_id", recordID)
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to read transcription"})
|
||||||
|
}
|
||||||
|
if text.Contents != "" {
|
||||||
|
contents := text.Contents
|
||||||
|
response.TranscriptionText = &contents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -24,6 +25,7 @@ import (
|
|||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||||
@@ -167,8 +169,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
|||||||
|
|
||||||
pbrepo.BindPanelRules(app)
|
pbrepo.BindPanelRules(app)
|
||||||
|
|
||||||
fileRepo := pbrepo.NewFileRepository(app)
|
recordRepo := pbrepo.NewAudioRecordRepository(app)
|
||||||
jobRepo := pbrepo.NewTranscriptJobRepository(app)
|
textRepo := pbrepo.NewTextRepository(app)
|
||||||
|
repos := service.Repositories{
|
||||||
|
Records: recordRepo,
|
||||||
|
Files: pbrepo.NewFileRepository(app),
|
||||||
|
Texts: textRepo,
|
||||||
|
Structures: pbrepo.NewStructureRepository(app),
|
||||||
|
Recognitions: pbrepo.NewRecognitionRepository(app),
|
||||||
|
Events: pbrepo.NewRecordEventRepository(app),
|
||||||
|
}
|
||||||
|
|
||||||
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
|
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
|
||||||
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
|
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
|
||||||
@@ -178,16 +188,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
|||||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||||
|
|
||||||
trsService := service.NewTranscribeService(
|
trsService := service.NewTranscribeService(
|
||||||
jobRepo,
|
repos,
|
||||||
fileRepo,
|
|
||||||
metaviewer,
|
metaviewer,
|
||||||
&stubConverter{},
|
&stubConverter{},
|
||||||
&recognizer.MemoryAudioRecognizer{},
|
&recognizer.MemoryAudioRecognizer{},
|
||||||
&TestTgSender{},
|
&TestTgSender{},
|
||||||
|
entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour},
|
||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
handler := NewTranscribeHandler(jobRepo, trsService, logger)
|
handler := NewTranscribeHandler(recordRepo, textRepo, trsService, logger)
|
||||||
|
|
||||||
// Роутер собирается тем же способом, что и боевой: маршруты вешает сам
|
// Роутер собирается тем же способом, что и боевой: маршруты вешает сам
|
||||||
// обработчик, и проверка судит ту же цепочку, что и прод.
|
// обработчик, и проверка судит ту же цепочку, что и прод.
|
||||||
@@ -256,16 +266,16 @@ func countFiles(t *testing.T, env *testEnv) int {
|
|||||||
return len(records)
|
return len(records)
|
||||||
}
|
}
|
||||||
|
|
||||||
// countJobs считает заведённые задачи расшифровки.
|
// countJobs считает заведённые аудиозаписи.
|
||||||
func countJobs(t *testing.T, env *testEnv) int {
|
func countJobs(t *testing.T, env *testEnv) int {
|
||||||
records, err := env.app.FindAllRecords(migrations.JobsCollection)
|
records, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return len(records)
|
return len(records)
|
||||||
}
|
}
|
||||||
|
|
||||||
// jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна
|
// jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна
|
||||||
// схемой, потому что без неё задача не пройдёт ни одного шага.
|
// схемой, потому что без неё задача не пройдёт ни одного шага.
|
||||||
func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob {
|
func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
repo := pbrepo.NewFileRepository(env.app)
|
repo := pbrepo.NewFileRepository(env.app)
|
||||||
@@ -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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
job := &entity.TranscribeJob{
|
record := &entity.AudioRecord{
|
||||||
State: entity.StateCreated,
|
State: entity.StateUploaded,
|
||||||
Source: entity.SourceApi,
|
StateEnteredAt: clock.Now(),
|
||||||
OwnerID: &env.account.Id,
|
Source: entity.SourceApi,
|
||||||
FileID: &file.Id,
|
OwnerID: &env.account.Id,
|
||||||
|
OriginalFileID: &file.Id,
|
||||||
}
|
}
|
||||||
require.NoError(t, env.handler.jobRepo.Create(job))
|
require.NoError(t, env.handler.recordRepo.Create(record))
|
||||||
return job
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
// storedContent читает содержимое файла из хранилища.
|
// storedContent читает содержимое файла из хранилища.
|
||||||
@@ -327,21 +338,21 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.NotEmpty(t, response.JobID)
|
assert.NotEmpty(t, response.JobID)
|
||||||
assert.Equal(t, entity.StateCreated, response.State)
|
assert.Equal(t, entity.StateUploaded, response.State)
|
||||||
|
|
||||||
// Задача действительно заведена, а не только названа в ответе: иначе
|
// Задача действительно заведена, а не только названа в ответе: иначе
|
||||||
// отправитель получит идентификатор записи, которой не будет никогда.
|
// отправитель получит идентификатор записи, которой не будет никогда.
|
||||||
require.Equal(t, 1, countJobs(t, env))
|
require.Equal(t, 1, countJobs(t, env))
|
||||||
|
|
||||||
job, err := env.handler.jobRepo.GetByID(response.JobID, env.account.Id)
|
job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateCreated, job.State)
|
assert.Equal(t, entity.StateUploaded, job.State)
|
||||||
require.NotNil(t, job.FileID)
|
require.NotNil(t, job.OriginalFileID)
|
||||||
assert.NotEmpty(t, *job.FileID)
|
assert.NotEmpty(t, *job.OriginalFileID)
|
||||||
|
|
||||||
// Содержимое лежит в хранилище одним файлом и целиком.
|
// Содержимое лежит в хранилище одним файлом и целиком.
|
||||||
require.Equal(t, 1, countFiles(t, env))
|
require.Equal(t, 1, countFiles(t, env))
|
||||||
assert.Equal(t, content, storedContent(t, env, *job.FileID))
|
assert.Equal(t, content, storedContent(t, env, *job.OriginalFileID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateTranscribeJob_NoFile(t *testing.T) {
|
func TestCreateTranscribeJob_NoFile(t *testing.T) {
|
||||||
@@ -404,7 +415,7 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.NotEmpty(t, response.JobID)
|
assert.NotEmpty(t, response.JobID)
|
||||||
assert.Equal(t, entity.StateCreated, response.State)
|
assert.Equal(t, entity.StateUploaded, response.State)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
|
func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
|
||||||
@@ -513,7 +524,7 @@ const senderNameMarker = "SENDERNAMELEAKMARKER7Q2"
|
|||||||
// долг `docs/conventions/logging.md`: `msg` обязан стать короткой категорией.
|
// долг `docs/conventions/logging.md`: `msg` обязан стать короткой категорией.
|
||||||
// Когда долг закроют, правка будет здесь и одна.
|
// Когда долг закроют, правка будет здесь и одна.
|
||||||
const (
|
const (
|
||||||
msgIntake = "Creating transcribe job"
|
msgIntake = "Creating audio record"
|
||||||
// Отказ пишет доменная граница — приём, — а не транспорт: конвенция просит
|
// Отказ пишет доменная граница — приём, — а не транспорт: конвенция просит
|
||||||
// логировать ошибку один раз, и повторная запись транспорта снята.
|
// логировать ошибку один раз, и повторная запись транспорта снята.
|
||||||
msgIntakeErr = "Failed to get file info"
|
msgIntakeErr = "Failed to get file info"
|
||||||
@@ -603,15 +614,15 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
|
|||||||
var response CreateTranscribeJobResponse
|
var response CreateTranscribeJobResponse
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||||
|
|
||||||
job, err := env.handler.jobRepo.GetByID(response.JobID, env.account.Id)
|
job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, job.FileID)
|
require.NotNil(t, job.OriginalFileID)
|
||||||
|
|
||||||
// Отбор по идентификатору **этого** прогона: иначе утверждение прошло бы по
|
// Отбор по идентификатору **этого** прогона: иначе утверждение прошло бы по
|
||||||
// строке, оставленной соседней проверкой, и прослеживаемость числилась бы
|
// строке, оставленной соседней проверкой, и прослеживаемость числилась бы
|
||||||
// сохранённой при пустом журнале.
|
// сохранённой при пустом журнале.
|
||||||
journal := env.journal.String()
|
journal := env.journal.String()
|
||||||
assert.Contains(t, journal, *job.FileID, "по журналу видно, какой файл заведён")
|
assert.Contains(t, journal, *job.OriginalFileID, "по журналу видно, какой файл заведён")
|
||||||
assert.Contains(t, journal, ".mp3", "расширение принятой записи в журнале остаётся")
|
assert.Contains(t, journal, ".mp3", "расширение принятой записи в журнале остаётся")
|
||||||
|
|
||||||
// Разделитель ключа и значения задаёт обработчик: сегодня текстовый, по
|
// Разделитель ключа и значения задаёт обработчик: сегодня текстовый, по
|
||||||
@@ -698,7 +709,7 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) {
|
|||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||||
|
|
||||||
assert.Equal(t, job.Id, response.JobID)
|
assert.Equal(t, job.Id, response.JobID)
|
||||||
assert.Equal(t, entity.StateCreated, response.State)
|
assert.Equal(t, entity.StateUploaded, response.State)
|
||||||
assert.NotZero(t, response.CreatedAt)
|
assert.NotZero(t, response.CreatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,7 +772,7 @@ func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
|
|||||||
|
|
||||||
require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())
|
require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())
|
||||||
|
|
||||||
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
|
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя")
|
assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
// адаптера» правилом не держится и уже нарушено HTTP-поверхностью
|
// адаптера» правилом не держится и уже нарушено HTTP-поверхностью
|
||||||
// (docs/conventions/go-linters.md, «Что остаётся прозой»).
|
// (docs/conventions/go-linters.md, «Что остаётся прозой»).
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
|
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
)
|
)
|
||||||
@@ -24,7 +23,6 @@ import (
|
|||||||
type TelegramController struct {
|
type TelegramController struct {
|
||||||
// deps
|
// deps
|
||||||
transcribeService *service.TranscribeService
|
transcribeService *service.TranscribeService
|
||||||
jobRepo contract.TranscriptJobRepository
|
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
// params
|
// params
|
||||||
bot *tgbotapi.BotAPI
|
bot *tgbotapi.BotAPI
|
||||||
@@ -44,7 +42,6 @@ func NewTelegramController(
|
|||||||
config TelegramConfig,
|
config TelegramConfig,
|
||||||
bot *tgbotapi.BotAPI,
|
bot *tgbotapi.BotAPI,
|
||||||
transcribeService *service.TranscribeService,
|
transcribeService *service.TranscribeService,
|
||||||
jobRepo contract.TranscriptJobRepository,
|
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
) (*TelegramController, error) {
|
) (*TelegramController, error) {
|
||||||
if bot == nil {
|
if bot == nil {
|
||||||
@@ -54,7 +51,6 @@ func NewTelegramController(
|
|||||||
controller := &TelegramController{
|
controller := &TelegramController{
|
||||||
bot: bot,
|
bot: bot,
|
||||||
transcribeService: transcribeService,
|
transcribeService: transcribeService,
|
||||||
jobRepo: jobRepo,
|
|
||||||
logger: logger,
|
logger: logger,
|
||||||
updateTimeout: config.UpdateTimeout,
|
updateTimeout: config.UpdateTimeout,
|
||||||
userWhiteList: config.UserWhiteList,
|
userWhiteList: config.UserWhiteList,
|
||||||
@@ -178,16 +174,16 @@ func (c *TelegramController) handleAudioMessage(ctx context.Context, message *tg
|
|||||||
defer fileReader.Close()
|
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 {
|
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, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
||||||
c.send(errorMsg)
|
c.send(errorMsg)
|
||||||
return
|
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
|
successMsg.ReplyToMessageID = message.MessageID
|
||||||
c.send(successMsg)
|
c.send(successMsg)
|
||||||
}
|
}
|
||||||
@@ -213,16 +209,16 @@ func (c *TelegramController) handleVoiceMessage(ctx context.Context, message *tg
|
|||||||
defer fileReader.Close()
|
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 {
|
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, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
||||||
c.send(errorMsg)
|
c.send(errorMsg)
|
||||||
return
|
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
|
successMsg.ReplyToMessageID = message.MessageID
|
||||||
c.send(successMsg)
|
c.send(successMsg)
|
||||||
}
|
}
|
||||||
@@ -253,16 +249,16 @@ func (c *TelegramController) handleDocumentMessage(ctx context.Context, message
|
|||||||
defer fileReader.Close()
|
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 {
|
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, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
||||||
c.send(errorMsg)
|
c.send(errorMsg)
|
||||||
return
|
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
|
successMsg.ReplyToMessageID = message.MessageID
|
||||||
c.send(successMsg)
|
c.send(successMsg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("пул не остановился по отмене контекста")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,26 +3,25 @@ package worker
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strconv"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Worker представляет базовый интерфейс для всех воркеров
|
|
||||||
type Worker interface {
|
|
||||||
Start(ctx context.Context)
|
|
||||||
Name() string
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollInterval — пауза между прогонами шага. Полем, а не константой по месту:
|
// pollInterval — пауза между прогонами шага. Полем, а не константой по месту:
|
||||||
// проверке нужен второй прогон, чтобы остановить воркер **после** того, как он
|
// проверке нужен второй прогон, чтобы остановить воркер **после** того, как он
|
||||||
// рассудил об исходе первого. Отменять контекст изнутри шага она не может —
|
// рассудил об исходе первого. Отменять контекст изнутри шага она не может —
|
||||||
// отменённый контекст теперь и значит «нас остановили».
|
// отменённый контекст теперь и значит «нас остановили».
|
||||||
const pollInterval = time.Second
|
const pollInterval = time.Second
|
||||||
|
|
||||||
|
// CallbackWorker крутит один и тот же шаг, опрашивая очередь.
|
||||||
|
//
|
||||||
|
// Специализации у него нет: шаг сам берёт любую пригодную к работе запись и
|
||||||
|
// выбирает работу по её рубежу. Раньше воркеров было три именованных, по одному
|
||||||
|
// на состояние, и каждый новый рубеж требовал четвёртого.
|
||||||
type CallbackWorker struct {
|
type CallbackWorker struct {
|
||||||
name string
|
name string
|
||||||
// Шаг принимает контекст воркера: остановка обязана доходить до чужой
|
// Шаг принимает контекст воркера: остановка обязана доходить до чужой
|
||||||
@@ -64,15 +63,12 @@ func (w *CallbackWorker) Start(ctx context.Context) {
|
|||||||
// первой же обёртки `%w`, которая в проекте — умолчание.
|
// первой же обёртки `%w`, которая в проекте — умолчание.
|
||||||
var noop *contract.NoopJobError
|
var noop *contract.NoopJobError
|
||||||
isNoop := errors.As(err, &noop)
|
isNoop := errors.As(err, &noop)
|
||||||
// Остановка — не отказ шага: контекст отменили мы сами. Считать её
|
// Остановка — не отказ шага: контекст отменили мы сами. Писать
|
||||||
// в метрику и писать владельцу «Worker error» значит красить каждую
|
// владельцу «Worker error» значит красить каждую выкладку как
|
||||||
// выкладку как поломку — по тому же доводу, по которому не считается
|
// поломку — по тому же доводу, по которому не считается
|
||||||
// `NoopJobError`. Судит контекст, а не текст ошибки: убитый процесс
|
// `NoopJobError`. Судит контекст, а не текст ошибки: убитый процесс
|
||||||
// отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
|
// отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
|
||||||
stopped := err != nil && !isNoop && ctx.Err() != nil
|
stopped := err != nil && !isNoop && ctx.Err() != nil
|
||||||
if !isNoop && !stopped {
|
|
||||||
metrics.WorkerJobCounter.WithLabelValues(w.Name(), strconv.FormatBool(err != nil)).Inc()
|
|
||||||
}
|
|
||||||
if err != nil && !isNoop && !stopped {
|
if err != nil && !isNoop && !stopped {
|
||||||
w.logger.Error("Worker error", "worker", w.Name(), "error", err)
|
w.logger.Error("Worker error", "worker", w.Name(), "error", err)
|
||||||
}
|
}
|
||||||
@@ -80,6 +76,9 @@ func (w *CallbackWorker) Start(ctx context.Context) {
|
|||||||
w.logger.Info("Worker step interrupted by shutdown", "worker", w.Name())
|
w.logger.Info("Worker step interrupted by shutdown", "worker", w.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Счётчик работы растит сам шаг: только он знает рубеж, с которого
|
||||||
|
// взята запись, а воркер к рубежу больше не привязан.
|
||||||
|
|
||||||
// Ждем перед следующей итерацией
|
// Ждем перед следующей итерацией
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -91,3 +90,51 @@ func (w *CallbackWorker) Start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pool — пул одинаковых воркеров конвейера.
|
||||||
|
//
|
||||||
|
// Число задаётся настройкой, и ноль — законное значение: сервис поднимается,
|
||||||
|
// записи принимаются и не двигаются. Это режим, а не поломка: он нужен местному
|
||||||
|
// запуску и выкладке, где конвейер надо остановить, не роняя приём.
|
||||||
|
type Pool struct {
|
||||||
|
workers []*CallbackWorker
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPool собирает пул из size одинаковых воркеров, крутящих один и тот же шаг.
|
||||||
|
func NewPool(size int, step func(ctx context.Context) error, logger *slog.Logger) *Pool {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
workers := make([]*CallbackWorker, 0, size)
|
||||||
|
for i := range size {
|
||||||
|
workers = append(workers, NewCallbackWorker(fmt.Sprintf("pipeline_worker_%d", i+1), step, logger))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Pool{workers: workers, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size — сколько воркеров в пуле.
|
||||||
|
func (p *Pool) Size() int { return len(p.workers) }
|
||||||
|
|
||||||
|
// Start поднимает всех воркеров пула и ждёт их остановки.
|
||||||
|
func (p *Pool) Start(ctx context.Context) {
|
||||||
|
if len(p.workers) == 0 {
|
||||||
|
// Молчать нельзя: пустой пул неотличим от поломки, а объявленный режим
|
||||||
|
// обязан быть назван.
|
||||||
|
p.logger.Info("Pipeline workers are disabled by configuration")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, w := range p.workers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(worker *CallbackWorker) {
|
||||||
|
defer wg.Done()
|
||||||
|
worker.Start(ctx)
|
||||||
|
p.logger.Info("Worker stopped gracefully", "worker", worker.Name())
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,14 +11,17 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Проверки этого файла судят одну развилку воркера: пустой прогон против
|
// Проверки этого файла судят одну развилку воркера: пустой прогон против
|
||||||
// отказа. Инвариант проекта — «NoopJobError не ошибка» — стоит ровно на ней, а
|
// отказа. Инвариант проекта — «NoopJobError не ошибка» — стоит ровно на ней, а
|
||||||
// цена срабатывания отложенная: три воркера опрашивают базу раз в секунду, и
|
// цена срабатывания отложенная: воркеры опрашивают базу раз в секунду, и пустой
|
||||||
// пустой прогон, принятый за отказ, даёт три записи в секунду и столько же
|
// прогон, принятый за отказ, даёт запись в секунду с каждого и столько же
|
||||||
// засчитанных сбоев, которых не было.
|
// засчитанных сбоев, которых не было.
|
||||||
|
//
|
||||||
|
// Счёт работы здесь не судится: он переехал в шаг конвейера вместе с меткой
|
||||||
|
// рубежа. Воркер к рубежу не привязан и назвать его не может, а метка,
|
||||||
|
// выведенная из имени потока, перестала что-либо значить с появлением пула.
|
||||||
|
|
||||||
// journalBuffer собирает журнал прогона. Пишут в него из горутины воркера, а
|
// journalBuffer собирает журнал прогона. Пишут в него из горутины воркера, а
|
||||||
// читает проверка — отсюда мьютекс.
|
// читает проверка — отсюда мьютекс.
|
||||||
@@ -113,39 +116,6 @@ func runRecords(journal string) string {
|
|||||||
return strings.Join(kept, "\n")
|
return strings.Join(kept, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// jobCount читает счётчик работы воркера из общего реестра процесса. Судит
|
|
||||||
// реестр, а не переменную пакета: метка, потерянная в точке употребления,
|
|
||||||
// переменную не ломает, а на странице метрик видна.
|
|
||||||
func jobCount(t *testing.T, worker, errLabel string) float64 {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
families, err := prometheus.DefaultGatherer.Gather()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("не удалось собрать метрики: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, mf := range families {
|
|
||||||
if mf.GetName() != "transcriber_worker_job_count" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, m := range mf.GetMetric() {
|
|
||||||
var gotWorker, gotErr string
|
|
||||||
for _, label := range m.GetLabel() {
|
|
||||||
switch label.GetName() {
|
|
||||||
case "name":
|
|
||||||
gotWorker = label.GetValue()
|
|
||||||
case "error":
|
|
||||||
gotErr = label.GetValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if gotWorker == worker && gotErr == errLabel {
|
|
||||||
return m.GetCounter().GetValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обёртка `%w` объявлена конвенцией проекта умолчанием, и до этой задачи первая
|
// Обёртка `%w` объявлена конвенцией проекта умолчанием, и до этой задачи первая
|
||||||
// же обёртка на пути сломала бы распознавание молча. Оракул держит именно
|
// же обёртка на пути сломала бы распознавание молча. Оракул держит именно
|
||||||
// обёрнутое значение: на голом признак узнавался и приведением типа, то есть
|
// обёрнутое значение: на голом признак узнавался и приведением типа, то есть
|
||||||
@@ -153,11 +123,8 @@ func jobCount(t *testing.T, worker, errLabel string) float64 {
|
|||||||
func TestWrappedNoopIsNotAFailure(t *testing.T) {
|
func TestWrappedNoopIsNotAFailure(t *testing.T) {
|
||||||
const name = "wrapped_noop_worker"
|
const name = "wrapped_noop_worker"
|
||||||
|
|
||||||
before := jobCount(t, name, "false")
|
|
||||||
beforeErr := jobCount(t, name, "true")
|
|
||||||
|
|
||||||
journal := runOnce(t, name, func(context.Context) error {
|
journal := runOnce(t, name, func(context.Context) error {
|
||||||
return fmt.Errorf("find and acquire job: %w", &contract.NoopJobError{State: "created"})
|
return fmt.Errorf("find and acquire record: %w", &contract.NoopJobError{State: "uploaded"})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Записи о старте и остановке воркера законны и к прогону не относятся —
|
// Записи о старте и остановке воркера законны и к прогону не относятся —
|
||||||
@@ -165,21 +132,13 @@ func TestWrappedNoopIsNotAFailure(t *testing.T) {
|
|||||||
if got := runRecords(journal); got != "" {
|
if got := runRecords(journal); got != "" {
|
||||||
t.Errorf("пустой прогон попал в журнал: %q", got)
|
t.Errorf("пустой прогон попал в журнал: %q", got)
|
||||||
}
|
}
|
||||||
if got := jobCount(t, name, "false"); got != before {
|
|
||||||
t.Errorf("счётчик успешных прогонов вырос на пустом прогоне: было %v, стало %v", before, got)
|
|
||||||
}
|
|
||||||
if got := jobCount(t, name, "true"); got != beforeErr {
|
|
||||||
t.Errorf("пустой прогон засчитан отказом: было %v, стало %v", beforeErr, got)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Без этой проверки оракул был бы зелен и на коде, который не считает отказом
|
// Без этой проверки оракул был бы зелен и на коде, который не пишет об отказе
|
||||||
// вообще ничего.
|
// вообще ничего. Счёт отказа судит проверка шага: метку рубежа знает он.
|
||||||
func TestFailureIsLoggedAndCounted(t *testing.T) {
|
func TestFailureIsLogged(t *testing.T) {
|
||||||
const name = "failing_worker"
|
const name = "failing_worker"
|
||||||
|
|
||||||
before := jobCount(t, name, "true")
|
|
||||||
|
|
||||||
journal := runOnce(t, name, func(context.Context) error {
|
journal := runOnce(t, name, func(context.Context) error {
|
||||||
return errors.New("database is gone")
|
return errors.New("database is gone")
|
||||||
})
|
})
|
||||||
@@ -187,42 +146,28 @@ func TestFailureIsLoggedAndCounted(t *testing.T) {
|
|||||||
if !strings.Contains(journal, "database is gone") {
|
if !strings.Contains(journal, "database is gone") {
|
||||||
t.Errorf("отказ не виден владельцу: журнал %q", journal)
|
t.Errorf("отказ не виден владельцу: журнал %q", journal)
|
||||||
}
|
}
|
||||||
if got := jobCount(t, name, "true"); got != before+1 {
|
|
||||||
t.Errorf("отказ не засчитан: было %v, стало %v", before, got)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Счёт успешных прогонов — знаменатель доли отказов. Реализация, снявшая его,
|
// Успешный прогон отказом не записывается.
|
||||||
// проходит обе проверки выше, а владелец теряет способность отличить «три
|
func TestSuccessIsNotLoggedAsFailure(t *testing.T) {
|
||||||
// прогона в секунду, все отказали» от «три отказа среди тысячи прогонов».
|
|
||||||
func TestSuccessIsCounted(t *testing.T) {
|
|
||||||
const name = "successful_worker"
|
const name = "successful_worker"
|
||||||
|
|
||||||
before := jobCount(t, name, "false")
|
|
||||||
|
|
||||||
journal := runOnce(t, name, func(context.Context) error {
|
journal := runOnce(t, name, func(context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if got := jobCount(t, name, "false"); got != before+1 {
|
|
||||||
t.Errorf("успешный прогон не засчитан: было %v, стало %v", before, got)
|
|
||||||
}
|
|
||||||
if strings.Contains(journal, "Worker error") {
|
if strings.Contains(journal, "Worker error") {
|
||||||
t.Errorf("успешный прогон записан отказом: журнал %q", journal)
|
t.Errorf("успешный прогон записан отказом: журнал %q", journal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Остановка сервиса — не отказ шага: контекст отменили мы сами. Без этой
|
// Остановка сервиса — не отказ шага: контекст отменили мы сами. Без этой
|
||||||
// развилки каждая выкладка красит журнал владельца отказами и накручивает
|
// развилки каждая выкладка красит журнал владельца отказами, — тот же довод, по
|
||||||
// счётчик сбоев, которых не было, — тот же довод, по которому не считается
|
// которому не пишется `NoopJobError`. Судит контекст, а не текст ошибки: убитый по контексту
|
||||||
// `NoopJobError`. Судит контекст, а не текст ошибки: убитый по контексту
|
|
||||||
// процесс отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
|
// процесс отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
|
||||||
func TestShutdownIsNotAFailure(t *testing.T) {
|
func TestShutdownIsNotAFailure(t *testing.T) {
|
||||||
const name = "stopped_worker"
|
const name = "stopped_worker"
|
||||||
|
|
||||||
beforeErr := jobCount(t, name, "true")
|
|
||||||
beforeOk := jobCount(t, name, "false")
|
|
||||||
|
|
||||||
journal := &journalBuffer{}
|
journal := &journalBuffer{}
|
||||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||||
|
|
||||||
@@ -251,10 +196,4 @@ func TestShutdownIsNotAFailure(t *testing.T) {
|
|||||||
if got := journal.String(); strings.Contains(got, "Worker error") {
|
if got := journal.String(); strings.Contains(got, "Worker error") {
|
||||||
t.Errorf("остановка записана отказом: журнал %q", got)
|
t.Errorf("остановка записана отказом: журнал %q", got)
|
||||||
}
|
}
|
||||||
if got := jobCount(t, name, "true"); got != beforeErr {
|
|
||||||
t.Errorf("остановка засчитана отказом: было %v, стало %v", beforeErr, got)
|
|
||||||
}
|
|
||||||
if got := jobCount(t, name, "false"); got != beforeOk {
|
|
||||||
t.Errorf("остановка засчитана успешным прогоном: было %v, стало %v", beforeOk, got)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -21,16 +21,23 @@ const (
|
|||||||
// дойдёт до обработчика — без строки в журнале приёма.
|
// дойдёт до обработчика — без строки в журнале приёма.
|
||||||
const MaxRecordSize int64 = 8 << 30 // 8 ГиБ
|
const MaxRecordSize int64 = 8 << 30 // 8 ГиБ
|
||||||
|
|
||||||
// File — одна физическая копия: исходник, результат конвертации и копия во
|
// File — одна физическая копия записи. Их ровно две: принятая и приведённая к
|
||||||
// внешнем хранилище — три разные записи.
|
// рабочему формату. Копия во внешнем хранилище файлом записи не считается — она
|
||||||
|
// существует только потому, что провайдер читает аудио по адресу, и её ключ
|
||||||
|
// живёт в строке попытки распознавания.
|
||||||
type File struct {
|
type File struct {
|
||||||
Id string
|
Id string
|
||||||
Location string
|
Location string
|
||||||
// FileName — имя, под которым файл лежит: у местной копии это имя, заданное
|
// FileName — имя, под которым файл лежит: имя задаёт сервис. Своего суффикса
|
||||||
// сервисом, у внешней — ключ объекта. Своего суффикса хранилище к заданному
|
// хранилище к заданному имени не дописывает: суффикс появляется только у
|
||||||
// имени не дописывает: суффикс появляется только у имён, которые оно строит
|
// имён, которые оно строит само из имени отправителя, а это умолчание не
|
||||||
// само из имени отправителя, а это умолчание не применяется.
|
// применяется.
|
||||||
FileName string
|
FileName string
|
||||||
Size int64
|
Size int64
|
||||||
CreatedAt time.Time
|
// Format — расширение без точки, приведённое к нижнему регистру. Наружу оно
|
||||||
|
// выходит только через метку метрики, приведённую к перечню известных.
|
||||||
|
Format string
|
||||||
|
// DurationMs — длительность записи, если её удалось прочитать.
|
||||||
|
DurationMs int64
|
||||||
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package entity
|
package entity
|
||||||
|
|
||||||
// RecognitionStatus представляет статус операции транскрипции
|
import "time"
|
||||||
|
|
||||||
|
// RecognitionStatus представляет статус операции распознавания у провайдера.
|
||||||
type RecognitionStatus int
|
type RecognitionStatus int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -26,7 +28,7 @@ func (s RecognitionStatus) String() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecognitionResult представляет результат операции транскрипции
|
// RecognitionResult представляет исход опроса операции распознавания.
|
||||||
type RecognitionResult struct {
|
type RecognitionResult struct {
|
||||||
Status RecognitionStatus
|
Status RecognitionStatus
|
||||||
Error string // Текст ошибки (заполняется при StatusFailed)
|
Error string // Текст ошибки (заполняется при StatusFailed)
|
||||||
@@ -76,3 +78,43 @@ func (r *RecognitionResult) GetError() string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recognition — попытка распознавания у внешнего провайдера.
|
||||||
|
//
|
||||||
|
// Всё провайдерское живёт здесь, а не колонками записи: идентификатор операции —
|
||||||
|
// самое провайдерское, что есть в модели, а копия аудио во внешнем хранилище
|
||||||
|
// существует только потому, что сегодняшний провайдер читает запись по адресу.
|
||||||
|
// Другой провайдер её не потребует, и смена провайдера не трогает доменную
|
||||||
|
// сущность вовсе.
|
||||||
|
//
|
||||||
|
// Сырой ответ хранится вложением, а не колонкой этой строки: шаг опроса читает
|
||||||
|
// её раз в несколько секунд, а хранилище читает запись целиком — ответ на
|
||||||
|
// многочасовую запись ехал бы в память при каждом опросе. Хранится он потому,
|
||||||
|
// что результат операции у провайдера не переспрашивается.
|
||||||
|
type Recognition struct {
|
||||||
|
Id string
|
||||||
|
RecordID string
|
||||||
|
Provider string
|
||||||
|
Model string
|
||||||
|
// ExternalID — идентификатор операции у провайдера. Заводится **до**
|
||||||
|
// обращения к нему: окно между ответом провайдера и записью идентификатора —
|
||||||
|
// то место, где теряется оплаченное.
|
||||||
|
ExternalID string
|
||||||
|
// SourceURI — адрес, по которому провайдер читает аудио.
|
||||||
|
SourceURI string
|
||||||
|
StartedAt *time.Time
|
||||||
|
FinishedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecognitionOutcome — доменный результат распознавания, каким его отдаёт
|
||||||
|
// адаптер. Формата провайдера здесь нет: разбор потока — обязанность адаптера, и
|
||||||
|
// ни один шаг конвейера не знает, каким потоком и какими полями провайдер
|
||||||
|
// отвечает.
|
||||||
|
type RecognitionOutcome struct {
|
||||||
|
// Replicas — реплики со временем от начала записи.
|
||||||
|
Replicas []Replica
|
||||||
|
// PlainText — плоский текст расшифровки.
|
||||||
|
PlainText string
|
||||||
|
// Raw — ответ провайдера целиком, как он пришёл, на хранение вложением.
|
||||||
|
Raw []byte
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
// MaxTopicsPerRecord — потолок числа тем у одной записи. Без него часовой
|
||||||
|
// разговор даёт два десятка тем, и словарь распухает за неделю; это же число
|
||||||
|
// уезжает в запрос к языковой модели.
|
||||||
|
const MaxTopicsPerRecord = 5
|
||||||
|
|
||||||
|
// Topic — тема из словаря одного человека. Пара «владелец и название»
|
||||||
|
// уникальна: словарь тем свой у каждого.
|
||||||
|
//
|
||||||
|
// Коллекцией, а не набором строк в записи, потому что перечень тем человека
|
||||||
|
// нужен целиком перед каждым обращением к модели, а собрать его из наборов строк
|
||||||
|
// можно только перебором всех его записей.
|
||||||
|
//
|
||||||
|
// Ни один шаг этой работы тем не пишет и не читает: место заведено вперёд, чтобы
|
||||||
|
// задача, считающая темы языковой моделью, не платила вторым необратимым шагом
|
||||||
|
// схемы.
|
||||||
|
type Topic struct {
|
||||||
|
Id string
|
||||||
|
OwnerID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
@@ -6,12 +6,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
// Работа конвейера с разрезом по **рубежу**, с которого взята запись, а не по
|
||||||
|
// имени потока. Воркеры одинаковы, и имя потока перестало что-либо значить;
|
||||||
|
// а счётчик отказов — единственный сигнал, по которому владелец сервиса
|
||||||
|
// замечает поломку, и без разреза по шагу «падает приведение» и «падает
|
||||||
|
// распознавание» стали бы неразличимы.
|
||||||
WorkerJobCounter = promauto.NewCounterVec(
|
WorkerJobCounter = promauto.NewCounterVec(
|
||||||
prometheus.CounterOpts{
|
prometheus.CounterOpts{
|
||||||
Name: "transcriber_worker_job_count",
|
Name: "transcriber_worker_job_count",
|
||||||
Help: "Count of jobs handled by each worker",
|
Help: "Count of pipeline steps by the stage a record was taken from",
|
||||||
},
|
},
|
||||||
[]string{"name", "error"},
|
[]string{"stage", "error"},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Размер принятых на обработку файлов (в байтах)
|
// Размер принятых на обработку файлов (в байтах)
|
||||||
|
|||||||
@@ -5,67 +5,71 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Путь признака «работы нет» состоит из двух звеньев: репозиторий рождает
|
// Путь признака «работы нет» состоит из двух звеньев: репозиторий рождает
|
||||||
// «подходящей задачи не нашлось», сервис переводит это в «работы нет», и уже
|
// «пригодной записи не нашлось», сервис переводит это в «работы нет», и уже его
|
||||||
// его читает воркер. Проверки воркера подменяют работу целиком и второе звено
|
// читает воркер. Проверки воркера подменяют работу целиком и второе звено не
|
||||||
// не видят — без этого файла правку в сервисе принимал бы только линтер, а он
|
// видят — без этого файла правку в сервисе принимал бы только линтер, а он судит
|
||||||
// судит форму записи, а не то, узнаётся ли признак на самом деле.
|
// форму записи, а не то, узнаётся ли признак на самом деле.
|
||||||
|
|
||||||
// stubJobRepo отдаёт заданную ошибку на запрос задачи. Прочих методов запроса
|
// stubRecordRepo отдаёт заданную ошибку на запрос записи. Прочих методов
|
||||||
// задачи проверки этого файла не зовут.
|
// проверки этого файла не зовут.
|
||||||
type stubJobRepo struct {
|
type stubRecordRepo struct {
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *stubJobRepo) Create(*entity.TranscribeJob) error { return nil }
|
func (r *stubRecordRepo) Create(*entity.AudioRecord) error { return nil }
|
||||||
func (r *stubJobRepo) Save(*entity.TranscribeJob, string) error { return nil }
|
func (r *stubRecordRepo) Save(*entity.AudioRecord, string) error { return nil }
|
||||||
|
|
||||||
func (r *stubJobRepo) GetByID(string, string) (*entity.TranscribeJob, error) {
|
func (r *stubRecordRepo) GetByID(string, string) (*entity.AudioRecord, error) {
|
||||||
return nil, errors.New("не зовётся этими проверками")
|
return nil, errors.New("не зовётся этими проверками")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *stubJobRepo) FindAndAcquire(string, string, time.Time) (*entity.TranscribeJob, error) {
|
func (r *stubRecordRepo) Get(string) (*entity.AudioRecord, error) {
|
||||||
|
return nil, errors.New("не зовётся этими проверками")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubRecordRepo) FindAndAcquire([]entity.Stage) (*contract.AcquiredRecord, error) {
|
||||||
return nil, r.err
|
return nil, r.err
|
||||||
}
|
}
|
||||||
|
|
||||||
func serviceWithRepo(repo contract.TranscriptJobRepository) *TranscribeService {
|
func serviceWithRepo(repo contract.AudioRecordRepository) *TranscribeService {
|
||||||
logger := slog.New(slog.DiscardHandler)
|
return NewTranscribeService(
|
||||||
return NewTranscribeService(repo, nil, nil, nil, nil, nil, logger)
|
Repositories{Records: repo},
|
||||||
|
nil, nil, nil, nil,
|
||||||
|
entity.StuckLimits{},
|
||||||
|
slog.New(slog.DiscardHandler),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Репозиторий вправе добавить своему отказу пояснение — соседние ветки того же
|
// Репозиторий вправе добавить своему отказу пояснение — соседние ветки того же
|
||||||
// метода уже оборачивают ошибки `%w` подряд. Пока признак узнавался приведением
|
// метода уже оборачивают ошибки `%w` подряд. Пока признак узнавался приведением
|
||||||
// типа, первая такая обёртка превратила бы пустой прогон в отказ: воркер начал
|
// типа, первая такая обёртка превратила бы пустой прогон в отказ: воркер начал
|
||||||
// бы писать в журнал раз в секунду на каждом из трёх воркеров.
|
// бы писать в журнал раз в секунду на каждом воркере пула.
|
||||||
func TestFindJobTranslatesWrappedNotFoundToNoop(t *testing.T) {
|
func TestAcquireTranslatesWrappedNotFoundToNoop(t *testing.T) {
|
||||||
svc := serviceWithRepo(&stubJobRepo{
|
svc := serviceWithRepo(&stubRecordRepo{
|
||||||
err: fmt.Errorf("find and acquire job: %w",
|
err: fmt.Errorf("find and acquire record: %w",
|
||||||
&contract.JobNotFoundError{State: "created", Message: "appropriate job not found"}),
|
&contract.JobNotFoundError{Message: "no record is ready for work"}),
|
||||||
})
|
})
|
||||||
|
|
||||||
_, _, err := svc.findJob("created", time.Minute)
|
_, _, err := svc.acquire()
|
||||||
|
|
||||||
var noop *contract.NoopJobError
|
var noop *contract.NoopJobError
|
||||||
if !errors.As(err, &noop) {
|
if !errors.As(err, &noop) {
|
||||||
t.Fatalf("обёрнутое «задачи нет» не переведено в пустой прогон: получено %v", err)
|
t.Fatalf("обёрнутое «работы нет» не переведено в пустой прогон: получено %v", err)
|
||||||
}
|
|
||||||
if noop.State != "created" {
|
|
||||||
t.Errorf("состояние потеряно при переводе: %q", noop.State)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Оборотная сторона: настоящий отказ хранилища пустым прогоном считаться не
|
// Оборотная сторона: настоящий отказ хранилища пустым прогоном считаться не
|
||||||
// должен, иначе задача молча крутилась бы в цикле без единой записи.
|
// должен, иначе запись молча крутилась бы в цикле без единой записи в журнале.
|
||||||
func TestFindJobKeepsRealFailure(t *testing.T) {
|
func TestAcquireKeepsRealFailure(t *testing.T) {
|
||||||
svc := serviceWithRepo(&stubJobRepo{err: errors.New("database is gone")})
|
svc := serviceWithRepo(&stubRecordRepo{err: errors.New("database is gone")})
|
||||||
|
|
||||||
_, _, err := svc.findJob("created", time.Minute)
|
_, _, err := svc.acquire()
|
||||||
|
|
||||||
var noop *contract.NoopJobError
|
var noop *contract.NoopJobError
|
||||||
if errors.As(err, &noop) {
|
if errors.As(err, &noop) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package service
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"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{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
first, err := env.service.CreateJobFromApi(t.Context(),
|
first, err := env.service.CreateJobFromApi(t.Context(),
|
||||||
@@ -28,42 +27,48 @@ func TestWorkerTakesJobsOfEveryOwner(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Третья пришла ботом, и владельца у неё нет вовсе.
|
// Третья пришла ботом, и владельца у неё нет вовсе.
|
||||||
third := newTelegramJob(t, env)
|
third := newTelegramRecord(t, env)
|
||||||
|
|
||||||
// Срок протухания в прошлом: захваченная задача остаётся за держателем, и
|
// Захваченная запись остаётся за держателем, и следующий вызов берёт
|
||||||
// следующий вызов берёт следующую, а не ту же самую.
|
// следующую, а не ту же самую.
|
||||||
taken := map[string]bool{}
|
taken := map[string]bool{}
|
||||||
for _, holder := range []string{"one", "two", "three"} {
|
for range 3 {
|
||||||
job, err := env.jobRepo.FindAndAcquire(entity.StateCreated, holder, time.Now().Add(-time.Hour))
|
acquired, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
|
||||||
require.NoError(t, err, "воркер берёт задачи подряд, владельцем не сужаясь")
|
require.NoError(t, err, "воркер берёт записи подряд, владельцем не сужаясь")
|
||||||
taken[job.Id] = true
|
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[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
|
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{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
owner := newOwner(t, env.app)
|
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)
|
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.NoError(t, err)
|
||||||
require.Equal(t, job.Id, acquired.Id)
|
|
||||||
|
|
||||||
require.NotNil(t, acquired.OwnerID, "владелец приехал из захвата")
|
assert.Equal(t, record.Id, acquired.ID, "захват назвал запись")
|
||||||
assert.Equal(t, owner, *acquired.OwnerID)
|
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{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
owner := newOwner(t, env.app)
|
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)
|
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)
|
after := readRecord(t, env, record.Id)
|
||||||
require.NoError(t, err)
|
require.True(t, after.IsHalted(), "шаг записал свой приговор")
|
||||||
require.Equal(t, entity.StateFailed, after.State, "шаг записал свой приговор")
|
|
||||||
require.NotNil(t, after.OwnerID, "владелец пережил шаг")
|
require.NotNil(t, after.OwnerID, "владелец пережил шаг")
|
||||||
assert.Equal(t, owner, *after.OwnerID)
|
assert.Equal(t, owner, *after.OwnerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Приём из веба без владельца задачи не заводит. Обязательность держит здесь
|
// Приём из веба без владельца записи не заводит. Обязательность держит здесь
|
||||||
// код, а не схема: колонка допускает пустое значение ради записей бота.
|
// код, а не схема: колонка допускает пустое значение ради записей бота.
|
||||||
func TestCreateJobFromApiRequiresOwner(t *testing.T) {
|
func TestCreateJobFromApiRequiresOwner(t *testing.T) {
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", "")
|
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", "")
|
||||||
|
|
||||||
require.ErrorIs(t, err, contract.ErrOwnerRequired)
|
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)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, records, "задачи не заведено")
|
|
||||||
|
// Запись без владельца — принятая ботом.
|
||||||
|
orphan := newTelegramRecord(t, env)
|
||||||
|
|
||||||
|
var missing *contract.JobNotFoundError
|
||||||
|
|
||||||
files, err := env.app.FindAllRecords("files")
|
_, err = env.recordRepo.GetByID(record.Id, stranger)
|
||||||
require.NoError(t, err)
|
require.ErrorAs(t, err, &missing, "чужая запись неотличима от несуществующей")
|
||||||
assert.Empty(t, files, "и файла тоже: отказ наступает раньше укладки")
|
|
||||||
|
_, 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
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,10 +25,22 @@ import (
|
|||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Проверки конвейера идут против настоящего хранилища: захват, число попыток и
|
// Проверки конвейера идут против настоящего хранилища: захват, число отказов и
|
||||||
// переход в «мертва» держатся на запросе, и подставной репозиторий проверял бы
|
// остановка держатся на запросе, и подставной репозиторий проверял бы
|
||||||
// собственную заглушку, а не то, что делает база.
|
// собственную заглушку, а не то, что делает база.
|
||||||
|
|
||||||
|
// okConverter переливает исходник в результат — так это выглядит у настоящего
|
||||||
|
// приведения к рабочему формату.
|
||||||
|
type okConverter struct{}
|
||||||
|
|
||||||
|
func (c *okConverter) Convert(_ context.Context, src, dest string) error {
|
||||||
|
content, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(dest, content, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
// failingConverter отказывает на каждой попытке.
|
// failingConverter отказывает на каждой попытке.
|
||||||
type failingConverter struct{}
|
type failingConverter struct{}
|
||||||
|
|
||||||
@@ -47,26 +60,50 @@ func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInf
|
|||||||
return nil, errors.New("запись не читается")
|
return nil, errors.New("запись не читается")
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordingSender запоминает, что и куда отправлено.
|
// recordingSender запоминает, что отправлено.
|
||||||
type recordingSender struct {
|
type recordingSender struct {
|
||||||
|
mu sync.Mutex
|
||||||
messages []string
|
messages []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *recordingSender) Send(text string, chatId int64, replyMsgId *int) error {
|
func (s *recordingSender) Send(text string, chatId int64, replyMsgId *int) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
s.messages = append(s.messages, text)
|
s.messages = append(s.messages, text)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type pipelineEnv struct {
|
func (s *recordingSender) sent() []string {
|
||||||
app core.App
|
s.mu.Lock()
|
||||||
service *TranscribeService
|
defer s.mu.Unlock()
|
||||||
jobRepo *pbrepo.TranscriptJobRepository
|
return append([]string(nil), s.messages...)
|
||||||
fileRepo *pbrepo.FileRepository
|
|
||||||
sender *recordingSender
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter contract.AudioFileConverter) *pipelineEnv {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
return newPipelineEnvWith(t, metaviewer, converter, &recognizer.MemoryAudioRecognizer{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPipelineEnvWith(
|
||||||
|
t *testing.T,
|
||||||
|
metaviewer contract.AudioMetaViewer,
|
||||||
|
converter contract.AudioFileConverter,
|
||||||
|
rec contract.AudioRecognizer,
|
||||||
|
) *pipelineEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
app, err := pbrepo.New(t.TempDir())
|
app, err := pbrepo.New(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -81,158 +118,410 @@ func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter
|
|||||||
// нет.
|
// нет.
|
||||||
pbrepo.BindPanelRules(app)
|
pbrepo.BindPanelRules(app)
|
||||||
|
|
||||||
jobRepo := pbrepo.NewTranscriptJobRepository(app)
|
recordRepo := pbrepo.NewAudioRecordRepository(app)
|
||||||
fileRepo := pbrepo.NewFileRepository(app)
|
fileRepo := pbrepo.NewFileRepository(app)
|
||||||
|
repos := Repositories{
|
||||||
|
Records: recordRepo,
|
||||||
|
Files: fileRepo,
|
||||||
|
Texts: pbrepo.NewTextRepository(app),
|
||||||
|
Structures: pbrepo.NewStructureRepository(app),
|
||||||
|
Recognitions: pbrepo.NewRecognitionRepository(app),
|
||||||
|
Events: pbrepo.NewRecordEventRepository(app),
|
||||||
|
}
|
||||||
sender := &recordingSender{}
|
sender := &recordingSender{}
|
||||||
|
|
||||||
svc := NewTranscribeService(
|
svc := NewTranscribeService(repos, metaviewer, converter, rec, sender, testLimits, slog.New(slog.DiscardHandler))
|
||||||
jobRepo,
|
|
||||||
fileRepo,
|
|
||||||
metaviewer,
|
|
||||||
converter,
|
|
||||||
&recognizer.MemoryAudioRecognizer{},
|
|
||||||
sender,
|
|
||||||
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 заводит задачу с записью — так, как её завёл бы приём.
|
// newTelegramRecord заводит запись — так, как её завёл бы приём из бота.
|
||||||
func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
|
func newTelegramRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
chatId := int64(100)
|
record, err := env.service.CreateJobFromTelegram(t.Context(), strings.NewReader("запись"), "voice.ogg", 100, 1)
|
||||||
job, err := env.service.CreateJobFromTelegram(t.Context(), strings.NewReader("запись"), "voice.ogg", chatId, 1)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return job
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
// clearDelay снимает паузу, чтобы следующий прогон взял задачу сразу: проверка
|
// clearDelay снимает паузу, чтобы следующий прогон взял запись сразу.
|
||||||
// судит счётчик попыток, а не то, умеет ли она ждать.
|
func clearDelay(t *testing.T, env *pipelineEnv, recordID string) {
|
||||||
func clearDelay(t *testing.T, env *pipelineEnv, jobID string) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(migrations.JobsCollection, jobID)
|
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
record.Set("delay_time", "")
|
record.Set("delay_time", "")
|
||||||
require.NoError(t, env.app.Save(record))
|
require.NoError(t, env.app.Save(record))
|
||||||
}
|
}
|
||||||
|
|
||||||
// rotAcquisition отодвигает время захвата так, чтобы он протух: так это
|
// enteredStateAt отодвигает время входа записи в рубеж: так это выглядит, когда
|
||||||
// выглядит, когда шаг оборвался вместе с процессом.
|
// запись простояла в нём дольше предела.
|
||||||
func rotAcquisition(t *testing.T, env *pipelineEnv, jobID string) {
|
func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time.Time) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(migrations.JobsCollection, jobID)
|
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
record.Set("acquire_time", types.NowDateTime().Add(-24*time.Hour))
|
record.Set("state_entered_at", types.DateTime{}.Add(0))
|
||||||
|
stamp, err := types.ParseDateTime(moment)
|
||||||
|
require.NoError(t, err)
|
||||||
|
record.Set("state_entered_at", stamp)
|
||||||
require.NoError(t, env.app.Save(record))
|
require.NoError(t, env.app.Save(record))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Задача, падающая на каждой попытке, уходит в «мертва»: из выборки исчезает,
|
// drain крутит конвейер, пока он двигает записи. Паузы опроса снимаются: они
|
||||||
// видна отбором по состоянию, а отправитель узнаёт о неудаче. Инвариант
|
// проверяются отдельно, а здесь мешают дойти до конца.
|
||||||
// «Принятая запись не теряется молча» допускает два исхода, и молчаливая смерть
|
func drain(t *testing.T, env *pipelineEnv, recordID string) {
|
||||||
// не подходит ни под один.
|
t.Helper()
|
||||||
func TestJobDiesAfterAttemptLimit(t *testing.T) {
|
|
||||||
|
for range 20 {
|
||||||
|
clearDelay(t, env, recordID)
|
||||||
|
err := env.service.RunStep(t.Context())
|
||||||
|
var noop *contract.NoopJobError
|
||||||
|
if errors.As(err, &noop) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
after := readRecord(t, env, recordID)
|
||||||
|
if after.State == entity.StateDone || after.IsHalted() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("конвейер не дошёл до исхода за отведённое число прогонов")
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRecord читает запись мимо сужения владельцем.
|
||||||
|
//
|
||||||
|
// Читающий метод сервиса отдаёт запись только её владельцу, а проверки конвейера
|
||||||
|
// смотрят записи, принятые ботом: владельца у таких нет вовсе. Проверке нужно не
|
||||||
|
// право, а состояние записи после шага.
|
||||||
|
func readRecord(t *testing.T, env *pipelineEnv, id string) *entity.AudioRecord {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
record, err := env.recordRepo.Get(id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запись, отказывающая на каждой попытке, останавливается признаком: из выборки
|
||||||
|
// исчезает, рубеж сохраняет, а отправитель узнаёт о неудаче. Инвариант «Принятая
|
||||||
|
// запись не теряется молча» допускает два исхода, и молчаливая остановка не
|
||||||
|
// подходит ни под один.
|
||||||
|
func TestRecordHaltsAfterAttemptLimit(t *testing.T) {
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
job := newTelegramJob(t, env)
|
record := newTelegramRecord(t, env)
|
||||||
|
|
||||||
// Отказ конвертации переводит задачу в `failed` сразу, поэтому предел
|
// Захват без выполнения шага — так это выглядит при гибели процесса: отказа
|
||||||
// попыток проверяем на шаге, который отказывает *не* приговором: подменяем
|
// шаг объявить не успевает, а попытка засчитана.
|
||||||
// его отказом источника метаданных внутри самого шага конвертации нельзя, и
|
for range maxAttempts {
|
||||||
// вместо этого гоняем захват без выполнения шага — так же, как это выглядит
|
_, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
|
||||||
// при гибели процесса.
|
|
||||||
for i := 0; i < maxAttempts; i++ {
|
|
||||||
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
expireAcquisition(t, env, record.Id)
|
||||||
}
|
}
|
||||||
rotAcquisition(t, env, job.Id)
|
|
||||||
|
|
||||||
// Следующий захват видит перебор и хоронит задачу.
|
err := env.service.RunStep(t.Context())
|
||||||
err := env.service.FindAndRunConversionJob(t.Context())
|
|
||||||
|
|
||||||
var noop *contract.NoopJobError
|
var noop *contract.NoopJobError
|
||||||
require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся")
|
require.ErrorAs(t, err, &noop, "остановленная запись шагу не отдаётся")
|
||||||
|
|
||||||
after, err := readJob(env.app, job.Id)
|
after := readRecord(t, env, record.Id)
|
||||||
require.NoError(t, err)
|
assert.True(t, after.IsHalted(), "запись остановлена признаком")
|
||||||
assert.Equal(t, entity.StateDead, after.State, "задача видна отбором по состоянию")
|
require.NotNil(t, after.HaltReason)
|
||||||
assert.Greater(t, after.Attempts, maxAttempts, "число попыток сохранено")
|
assert.Equal(t, entity.HaltReasonAttempts, *after.HaltReason)
|
||||||
|
assert.Equal(t, entity.StateUploaded, after.State, "рубеж пережил остановку")
|
||||||
|
assert.Greater(t, after.Attempts, maxAttempts, "число отказов сохранено")
|
||||||
|
|
||||||
require.Len(t, env.sender.messages, 1, "отправитель узнал о неудаче")
|
require.Len(t, env.sender.sent(), 1, "отправитель узнал о неудаче")
|
||||||
assert.Contains(t, env.sender.messages[0], "попытки исчерпаны")
|
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
|
var missing *contract.JobNotFoundError
|
||||||
assert.ErrorAs(t, err, &missing)
|
assert.ErrorAs(t, err, &missing)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Мёртвая задача возвращается в работу правкой состояния.
|
// expireAcquisition отодвигает срок протухания захвата в прошлое: так это
|
||||||
func TestDeadJobReturnsAfterStateEdit(t *testing.T) {
|
// выглядит, когда шаг оборвался вместе с процессом.
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
func expireAcquisition(t *testing.T, env *pipelineEnv, recordID string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
job := newTelegramJob(t, env)
|
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
record.Set("acquire_expires_at", types.NowDateTime().Add(-time.Hour))
|
||||||
|
require.NoError(t, env.app.Save(record))
|
||||||
|
}
|
||||||
|
|
||||||
for i := 0; i < maxAttempts; i++ {
|
// Критерий приёмки 1. Остановленная на шаге запись перезапускается снятием
|
||||||
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
|
// признака и продолжает с того рубежа, где стояла, — следующим идёт отправка на
|
||||||
|
// распознавание, а не повторное приведение.
|
||||||
|
func TestHaltedRecordResumesFromItsStage(t *testing.T) {
|
||||||
|
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||||
|
|
||||||
|
record := 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)
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, content)
|
||||||
|
require.NoError(t, reader.Close())
|
||||||
}
|
}
|
||||||
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
|
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
record.Set("state", entity.StateCreated)
|
|
||||||
require.NoError(t, env.app.Save(record))
|
|
||||||
|
|
||||||
again, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(time.Hour))
|
|
||||||
require.NoError(t, err, "снятое состояние возвращает задачу в работу")
|
|
||||||
assert.Equal(t, job.Id, again.Id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отказ шага не оставляет задачу захваченной до конца срока: захват снимается,
|
// Критерий приёмки 3. Поведение записи не зависит от числа воркеров: при одном
|
||||||
// и задача ждёт нарастающую паузу. Иначе повтор наступал бы через восемь часов.
|
// и при нескольких она доходит до конечного рубежа.
|
||||||
func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
|
func TestOutcomeDoesNotDependOnWorkerCount(t *testing.T) {
|
||||||
// Источник метаданных отказывает — это отказ шага, а не приговор записи.
|
for _, workers := range []int{1, 4} {
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||||
|
record := newTelegramRecord(t, env)
|
||||||
|
|
||||||
job := newTelegramJob(t, env)
|
for range 20 {
|
||||||
|
clearDelay(t, env, record.Id)
|
||||||
|
|
||||||
// Ссылку переставляем на запись без содержимого: шаг отказывает на получении
|
var wg sync.WaitGroup
|
||||||
// рабочей копии — то есть отказом, а не приговором записи.
|
for range workers {
|
||||||
empty, err := env.fileRepo.CreateRemote("object-key", 1, "")
|
wg.Add(1)
|
||||||
require.NoError(t, err)
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
// Исход прогона здесь не судится: воркеров несколько, и всем,
|
||||||
|
// кроме одного, работы не достаётся. Судится состояние записи.
|
||||||
|
if err := env.service.RunStep(t.Context()); err != nil {
|
||||||
|
t.Log(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
if readRecord(t, env, record.Id).State == entity.StateDone {
|
||||||
require.NoError(t, err)
|
break
|
||||||
record.Set("file", empty.Id)
|
}
|
||||||
require.NoError(t, env.app.Save(record))
|
}
|
||||||
|
|
||||||
// Первый отказ.
|
after := readRecord(t, env, record.Id)
|
||||||
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
|
assert.Equalf(t, entity.StateDone, after.State, "запись дошла до конца при %d воркерах", workers)
|
||||||
|
assert.Falsef(t, after.IsHalted(), "запись не остановлена при %d воркерах", workers)
|
||||||
after, err := 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, "вторая пауза длиннее первой")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пауза растёт с числом попыток и упирается в потолок.
|
// Тот же критерий, вторая половина: нулевое число воркеров — законное значение.
|
||||||
|
// Записи принимаются и не двигаются, и это режим, а не поломка.
|
||||||
|
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) {
|
func TestRetryDelayGrowsAndCaps(t *testing.T) {
|
||||||
assert.Equal(t, retryDelayBase, retryDelay(1))
|
assert.Equal(t, retryDelayBase, retryDelay(1))
|
||||||
assert.Equal(t, 2*retryDelayBase, retryDelay(2))
|
assert.Equal(t, 2*retryDelayBase, retryDelay(2))
|
||||||
@@ -241,6 +530,42 @@ func TestRetryDelayGrowsAndCaps(t *testing.T) {
|
|||||||
assert.Equal(t, retryDelayBase, retryDelay(0), "нулевая попытка не даёт нулевой паузы")
|
assert.Equal(t, retryDelayBase, retryDelay(0), "нулевая попытка не даёт нулевой паузы")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Отказ шага не оставляет запись захваченной до конца срока: захват снимается,
|
||||||
|
// и запись ждёт нарастающую паузу. Иначе повтор наступал бы через часы.
|
||||||
|
func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
|
||||||
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
|
record := 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) {
|
func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
|
||||||
@@ -273,24 +598,43 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
|
|||||||
assert.Empty(t, leftovers, "рабочей копии после успеха не остаётся")
|
assert.Empty(t, leftovers, "рабочей копии после успеха не остаётся")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Задача не остаётся ссылающейся на файл, которого нет: ссылка переставляется
|
// Уборка проверяется и на шаге приведения: репозиторий даёт единственный способ
|
||||||
// только после того, как запись о новом файле существует.
|
// убрать копию, но зовёт его шаг. Копий здесь две — исходник и результат.
|
||||||
func TestJobNeverPointsToMissingFile(t *testing.T) {
|
func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
t.Setenv("TMPDIR", tempDir)
|
||||||
|
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
job := newTelegramJob(t, env)
|
newTelegramRecord(t, env)
|
||||||
|
|
||||||
// Конвертация отказывает — задача уходит в `failed`, но ссылка остаётся на
|
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||||
// исходную запись, а не на несозданный результат.
|
|
||||||
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
|
|
||||||
|
|
||||||
after, err := readJob(env.app, job.Id)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateFailed, after.State)
|
require.Empty(t, leftovers, "приём убрал свою рабочую копию")
|
||||||
require.NotNil(t, after.FileID)
|
|
||||||
|
|
||||||
file, err := env.fileRepo.GetByID(*after.FileID)
|
require.NoError(t, env.service.RunStep(t.Context()))
|
||||||
require.NoError(t, err, "ссылка задачи ведёт на существующую запись о файле")
|
|
||||||
|
leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, leftovers, "ни исходной копии, ни копии под результат не осталось")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запись не остаётся ссылающейся на файл, которого нет: ссылка ставится только
|
||||||
|
// после того, как запись о файле существует.
|
||||||
|
func TestRecordNeverPointsToMissingFile(t *testing.T) {
|
||||||
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
|
record := 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)
|
assert.NotEmpty(t, file.FileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,11 +644,11 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
|||||||
|
|
||||||
content := strings.Repeat("запись ", 1000)
|
content := strings.Repeat("запись ", 1000)
|
||||||
|
|
||||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", 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.NoError(t, err)
|
||||||
require.NotNil(t, job.FileID)
|
require.NotNil(t, record.OriginalFileID)
|
||||||
|
|
||||||
reader, err := env.fileRepo.Open(*job.FileID)
|
reader, err := env.fileRepo.Open(*record.OriginalFileID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer reader.Close()
|
defer reader.Close()
|
||||||
|
|
||||||
@@ -312,22 +656,22 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, content, string(stored))
|
assert.Equal(t, content, string(stored))
|
||||||
|
|
||||||
// И длина в учёте совпадает с длиной принятого.
|
file, err := env.fileRepo.GetByID(*record.OriginalFileID)
|
||||||
file, err := env.fileRepo.GetByID(*job.FileID)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int64(len(content)), file.Size)
|
assert.Equal(t, int64(len(content)), file.Size)
|
||||||
|
assert.Equal(t, "mp3", file.Format, "формат копии записан")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Рабочая копия хранимого файла отдаётся именем на диске — так её получают
|
// Рабочая копия хранимого файла отдаётся именем на диске — так её получают шаги,
|
||||||
// шаги, отдающие файл внешней программе.
|
// отдающие файл внешней программе.
|
||||||
func TestLocalizeGivesReadableCopy(t *testing.T) {
|
func TestLocalizeGivesReadableCopy(t *testing.T) {
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env.app))
|
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env.app))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, job.FileID)
|
require.NotNil(t, record.OriginalFileID)
|
||||||
|
|
||||||
work, err := env.fileRepo.Localize(*job.FileID)
|
work, err := env.fileRepo.Localize(*record.OriginalFileID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
content, err := os.ReadFile(work.Path())
|
content, err := os.ReadFile(work.Path())
|
||||||
@@ -339,86 +683,10 @@ func TestLocalizeGivesReadableCopy(t *testing.T) {
|
|||||||
assert.True(t, os.IsNotExist(err), "закрытая копия убрана")
|
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 заводит учётную запись и отдаёт её идентификатор.
|
// newOwner заводит учётную запись и отдаёт её идентификатор.
|
||||||
//
|
//
|
||||||
// Владелец — связь с коллекцией пользователей, и хранилище проверяет, что такая
|
// Владелец — связь с коллекцией пользователей, и хранилище проверяет, что такая
|
||||||
// запись есть: выдуманный идентификатор задачу завести не даст.
|
// запись есть: выдуманный идентификатор запись завести не даст.
|
||||||
func newOwner(t *testing.T, app core.App) string {
|
func newOwner(t *testing.T, app core.App) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -428,8 +696,77 @@ func newOwner(t *testing.T, app core.App) string {
|
|||||||
record := core.NewRecord(users)
|
record := core.NewRecord(users)
|
||||||
record.Set("email", uuid.NewString()+"@example.test")
|
record.Set("email", uuid.NewString()+"@example.test")
|
||||||
record.Set("verified", true)
|
record.Set("verified", true)
|
||||||
record.SetPassword(uuid.NewString())
|
record.Set("password", uuid.NewString())
|
||||||
require.NoError(t, app.Save(record))
|
require.NoError(t, app.Save(record))
|
||||||
|
|
||||||
return record.Id
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,267 +2,196 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Шаги распознавания переписаны переездом на новое хранилище целиком: они берут
|
// countingRecognizer считает обращения наружу: за них платят по факту, и повтор
|
||||||
// содержимое по записи, заводят запись о копии во внешнем хранилище и пишут
|
// оплаченного шага — самый дорогой класс дефекта в этом сервисе.
|
||||||
// результат условием по держателю захвата. Подставной распознаватель проекта
|
type countingRecognizer struct {
|
||||||
// умеет только «завершено с фиксированным текстом», поэтому ветки ожидания,
|
uploads atomic.Int64
|
||||||
// отказа операции и пустого текста изобразить нечем — для них нужен управляемый
|
submits atomic.Int64
|
||||||
// двойник.
|
fetches atomic.Int64
|
||||||
|
parses atomic.Int64
|
||||||
// scriptedRecognizer отдаёт заданный исход проверки операции и заданный текст.
|
objectHere atomic.Bool
|
||||||
type scriptedRecognizer struct {
|
inProgress atomic.Int64
|
||||||
result *entity.RecognitionResult
|
|
||||||
text string
|
|
||||||
recognizeErr error
|
|
||||||
|
|
||||||
recognizeCalls int
|
|
||||||
lastObjectKey string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *scriptedRecognizer) Recognize(_ context.Context, file io.Reader, fileName string) (string, error) {
|
func (r *countingRecognizer) Provider() string { return "counting" }
|
||||||
r.recognizeCalls++
|
|
||||||
r.lastObjectKey = fileName
|
func (r *countingRecognizer) Model() string { return "counting" }
|
||||||
if r.recognizeErr != nil {
|
|
||||||
return "", r.recognizeErr
|
func (r *countingRecognizer) Upload(_ context.Context, _ io.Reader, objectKey string) (string, error) {
|
||||||
|
r.uploads.Add(1)
|
||||||
|
r.objectHere.Store(true)
|
||||||
|
return "counting://" + objectKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *countingRecognizer) ObjectExists(context.Context, string, int64) (bool, error) {
|
||||||
|
return r.objectHere.Load(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *countingRecognizer) SourceURI(objectKey string) string {
|
||||||
|
return "counting://" + objectKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *countingRecognizer) Submit(context.Context, string) (string, error) {
|
||||||
|
r.submits.Add(1)
|
||||||
|
return uuid.NewString(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *countingRecognizer) CheckStatus(context.Context, string) (*entity.RecognitionResult, error) {
|
||||||
|
if r.inProgress.Load() > 0 {
|
||||||
|
r.inProgress.Add(-1)
|
||||||
|
return entity.NewInProgressResult(), nil
|
||||||
}
|
}
|
||||||
// Содержимое обязано быть читаемым: шаг отдаёт его наружу потоком.
|
return entity.NewCompletedResult(), nil
|
||||||
if _, err := io.Copy(io.Discard, file); err != nil {
|
}
|
||||||
return "", err
|
|
||||||
|
func (r *countingRecognizer) Fetch(context.Context, string) (*entity.RecognitionOutcome, error) {
|
||||||
|
r.fetches.Add(1)
|
||||||
|
return r.Parse(countingPayload(t0Replicas))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *countingRecognizer) Parse(raw []byte) (*entity.RecognitionOutcome, error) {
|
||||||
|
r.parses.Add(1)
|
||||||
|
|
||||||
|
var replicas []entity.Replica
|
||||||
|
if err := json.Unmarshal(raw, &replicas); err != nil {
|
||||||
|
return nil, errors.New("не разобрать сохранённый ответ")
|
||||||
}
|
}
|
||||||
return "operation-id", nil
|
|
||||||
|
var plain []byte
|
||||||
|
for _, replica := range replicas {
|
||||||
|
if len(plain) > 0 {
|
||||||
|
plain = append(plain, ' ')
|
||||||
|
}
|
||||||
|
plain = append(plain, replica.Text...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &entity.RecognitionOutcome{Replicas: replicas, PlainText: string(plain), Raw: raw}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *scriptedRecognizer) GetRecognitionText(context.Context, string) (string, error) {
|
var t0Replicas = []entity.Replica{
|
||||||
return r.text, nil
|
{StartMs: 0, EndMs: 900, Text: "Первая реплика."},
|
||||||
|
{StartMs: 900, EndMs: 1800, Text: "Вторая реплика."},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *scriptedRecognizer) CheckRecognitionStatus(context.Context, string) (*entity.RecognitionResult, error) {
|
func countingPayload(replicas []entity.Replica) []byte {
|
||||||
return r.result, nil
|
raw, err := json.Marshal(replicas)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertedJob доводит задачу до состояния, с которого работает шаг
|
// Критерий приёмки 4. Структура реплик строится из сохранённого ответа
|
||||||
// распознавания: запись принята и сконвертирована.
|
// провайдера без единого обращения к нему: результат операции не
|
||||||
func convertedJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
|
// переспрашивается, и архив пересчитывается из сохранённого без рубля.
|
||||||
t.Helper()
|
func TestStructureIsBuiltFromStoredPayload(t *testing.T) {
|
||||||
|
rec := &countingRecognizer{}
|
||||||
|
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
|
||||||
|
|
||||||
job := newTelegramJob(t, env)
|
record := 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)
|
require.NoError(t, err)
|
||||||
acquired.MoveToState(entity.StateConverted)
|
require.NotEmpty(t, raw, "сырой ответ провайдера сохранён")
|
||||||
require.NoError(t, env.jobRepo.Save(acquired, "setup"))
|
|
||||||
|
|
||||||
return job
|
// А теперь — построение структуры из сохранённого, без обращений наружу.
|
||||||
}
|
fetchesBefore := rec.fetches.Load()
|
||||||
|
submitsBefore := rec.submits.Load()
|
||||||
|
|
||||||
// withRecognizer пересобирает сервис с управляемым распознавателем поверх того
|
outcome, err := env.service.recognizer.Parse(raw)
|
||||||
// же хранилища.
|
|
||||||
func withRecognizer(env *pipelineEnv, rec contract.AudioRecognizer) *TranscribeService {
|
|
||||||
return NewTranscribeService(
|
|
||||||
env.jobRepo,
|
|
||||||
env.fileRepo,
|
|
||||||
&okMetaViewer{},
|
|
||||||
&failingConverter{},
|
|
||||||
rec,
|
|
||||||
env.sender,
|
|
||||||
env.service.logger,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Шаг распознавания отдаёт содержимое наружу, заводит запись о копии во внешнем
|
|
||||||
// хранилище и переставляет на неё ссылку задачи — только после того, как запись
|
|
||||||
// о копии существует.
|
|
||||||
func TestTranscribeJobHandsRecordOverAndMovesOn(t *testing.T) {
|
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
|
||||||
job := convertedJob(t, env)
|
|
||||||
|
|
||||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
|
||||||
svc := withRecognizer(env, rec)
|
|
||||||
|
|
||||||
require.NoError(t, svc.FindAndRunTranscribeJob(t.Context()))
|
|
||||||
|
|
||||||
assert.Equal(t, 1, rec.recognizeCalls, "содержимое отдано распознавателю")
|
|
||||||
assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван")
|
|
||||||
|
|
||||||
after, err := readJob(env.app, job.Id)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateTranscribe, after.State)
|
require.Len(t, outcome.Replicas, len(t0Replicas), "структура собрана")
|
||||||
require.NotNil(t, after.RecognitionOpID)
|
assert.Equal(t, t0Replicas[0].Text, outcome.Replicas[0].Text)
|
||||||
assert.Equal(t, "operation-id", *after.RecognitionOpID)
|
assert.Equal(t, t0Replicas[0].StartMs, outcome.Replicas[0].StartMs, "время реплики сохранено")
|
||||||
require.NotNil(t, after.DelayTime, "задержка перед первой проверкой поставлена")
|
|
||||||
|
|
||||||
// Ссылка задачи ведёт на существующую запись о копии, а не на несозданную.
|
assert.Equal(t, fetchesBefore, rec.fetches.Load(), "к провайдеру за результатом не ходили")
|
||||||
require.NotNil(t, after.FileID)
|
assert.Equal(t, submitsBefore, rec.submits.Load(), "и новой операции не заводили")
|
||||||
copyRecord, err := env.fileRepo.GetByID(*after.FileID)
|
|
||||||
|
// Структура записи собрана из того же ответа и лежит своей строкой.
|
||||||
|
require.NotNil(t, after.StructureID)
|
||||||
|
structure, err := env.repos.Structures.GetByID(*after.StructureID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.LocationS3, copyRecord.Location)
|
assert.Equal(t, entity.StructureVersion, structure.Version)
|
||||||
|
require.Len(t, structure.Replicas, len(t0Replicas))
|
||||||
|
assert.Equal(t, t0Replicas[1].Text, structure.Replicas[1].Text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отказ распознавателя не двигает задачу: она остаётся пригодной к повтору.
|
// Шаг с внешней оплатой проверяет сделанное: объект нужного размера на месте —
|
||||||
func TestTranscribeJobKeepsJobRetryableOnRecognizerFailure(t *testing.T) {
|
// заливку не повторяем; операция заведена — не платим второй раз.
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
func TestPaidWorkIsNotRepeated(t *testing.T) {
|
||||||
job := convertedJob(t, env)
|
rec := &countingRecognizer{}
|
||||||
|
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
|
||||||
|
|
||||||
rec := &scriptedRecognizer{recognizeErr: errors.New("распознаватель недоступен")}
|
record := newTelegramRecord(t, env)
|
||||||
svc := withRecognizer(env, rec)
|
|
||||||
|
|
||||||
require.Error(t, svc.FindAndRunTranscribeJob(t.Context()))
|
// Приведение.
|
||||||
|
require.NoError(t, env.service.RunStep(t.Context()))
|
||||||
|
require.Equal(t, entity.StateNormalized, readRecord(t, env, record.Id).State)
|
||||||
|
|
||||||
after, err := readJob(env.app, job.Id)
|
// Отправка.
|
||||||
require.NoError(t, err)
|
require.NoError(t, env.service.RunStep(t.Context()))
|
||||||
assert.Equal(t, entity.StateConverted, after.State, "задача осталась на своём шаге")
|
require.Equal(t, int64(1), rec.uploads.Load(), "залили один раз")
|
||||||
assert.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору")
|
require.Equal(t, int64(1), rec.submits.Load(), "и заплатили один раз")
|
||||||
assert.NotNil(t, after.DelayTime, "пауза перед повтором поставлена")
|
|
||||||
assert.Empty(t, env.sender.messages, "отправителю про повторимый отказ не пишут")
|
// Возвращаем запись на рубеж отправки — так это выглядит, когда шаг оборвался
|
||||||
|
// после оплаты, а захват протух.
|
||||||
|
after := readRecord(t, env, record.Id)
|
||||||
|
after.MoveToState(entity.StateNormalized)
|
||||||
|
require.NoError(t, env.recordRepo.Save(after, ""))
|
||||||
|
|
||||||
|
require.NoError(t, env.service.RunStep(t.Context()))
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1), rec.uploads.Load(), "объект на месте — заливка не повторилась")
|
||||||
|
assert.Equal(t, int64(1), rec.submits.Load(), "операция заведена — второй раз не платим")
|
||||||
}
|
}
|
||||||
|
|
||||||
// transcribingJob доводит задачу до состояния ожидания операции.
|
// Ожидание чужой операции откладывается своей задержкой, отказов не тратит и
|
||||||
func transcribingJob(t *testing.T, env *pipelineEnv, rec contract.AudioRecognizer) *entity.TranscribeJob {
|
// рубежа не двигает.
|
||||||
t.Helper()
|
func TestPollingPostponesWithoutSpendingAttempts(t *testing.T) {
|
||||||
|
rec := &countingRecognizer{}
|
||||||
|
rec.inProgress.Store(3)
|
||||||
|
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
|
||||||
|
|
||||||
job := convertedJob(t, env)
|
record := newTelegramRecord(t, env)
|
||||||
require.NoError(t, withRecognizer(env, rec).FindAndRunTranscribeJob(t.Context()))
|
|
||||||
|
|
||||||
clearDelay(t, env, job.Id)
|
require.NoError(t, env.service.RunStep(t.Context())) // приведение
|
||||||
return job
|
require.NoError(t, env.service.RunStep(t.Context())) // отправка
|
||||||
}
|
require.Equal(t, entity.StateSubmitted, readRecord(t, env, record.Id).State)
|
||||||
|
|
||||||
// Ожидание чужой операции попытку не тратит и опрос не учащает: шаг отработал
|
entered := readRecord(t, env, record.Id).StateEnteredAt
|
||||||
// без отказа, и задержка у него своя, числом.
|
|
||||||
func TestCheckJobWaitsWithoutSpendingAttempts(t *testing.T) {
|
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
|
||||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
|
||||||
job := transcribingJob(t, env, rec)
|
|
||||||
|
|
||||||
svc := withRecognizer(env, rec)
|
var delays []int64
|
||||||
|
for range 3 {
|
||||||
|
clearDelay(t, env, record.Id)
|
||||||
|
require.NoError(t, env.service.RunStep(t.Context()))
|
||||||
|
|
||||||
for i := 0; i < 3; i++ {
|
after := readRecord(t, env, record.Id)
|
||||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
require.Equal(t, entity.StateSubmitted, after.State, "рубеж не сдвинут откладыванием")
|
||||||
|
assert.Equal(t, 0, after.Attempts, "ожидание чужой операции отказа не тратит")
|
||||||
|
assert.WithinDuration(t, entered, after.StateEnteredAt, 0, "и отсчёт застревания не двигает")
|
||||||
|
|
||||||
after, err := 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)
|
require.NotNil(t, after.DelayTime)
|
||||||
assert.InDelta(t, nextCheckDelay.Seconds(), time.Until(*after.DelayTime).Seconds(), 2,
|
delays = append(delays, after.DelayTime.Unix())
|
||||||
"задержка опроса не выродилась в наименьшую паузу повтора")
|
|
||||||
|
|
||||||
clearDelay(t, env, job.Id)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Отказ операции распознавания — приговор записи: задача уходит в `failed`, а
|
assert.Len(t, delays, 3, "все три прогона отложили работу")
|
||||||
// отправитель узнаёт причину человеческим текстом.
|
|
||||||
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, "отправителю ничего не отправлено")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
@@ -28,43 +27,43 @@ func (c *killedConverter) Convert(ctx context.Context, _, _ string) error {
|
|||||||
return errors.New("ffmpeg conversion failed: signal: killed")
|
return errors.New("ffmpeg conversion failed: signal: killed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Остановка сервиса посреди конвертации не выносит записи приговора: задача
|
// Критерий приёмки 9. Остановка сервиса посреди приведения не выносит записи
|
||||||
// остаётся пригодной к повтору, попытку не тратит и отправителю о сбое,
|
// приговора и **не тратит отказа**: запись не виновата в том, что нас
|
||||||
// которого не было, не сообщает. Прежде любой отказ `Convert` уводил задачу в
|
// перезапустили, и несколько выкладок подряд иначе останавливают здоровую
|
||||||
// терминальное `failed`, откуда её возвращает только владелец правкой в панели.
|
// многочасовую запись с приговором «попытки исчерпаны».
|
||||||
func TestShutdownDuringConversionKeepsJobRetryable(t *testing.T) {
|
func TestShutdownDuringConversionKeepsRecordRetryable(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
converter := &killedConverter{cancel: cancel}
|
converter := &killedConverter{cancel: cancel}
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, converter)
|
env := newPipelineEnv(t, &okMetaViewer{}, converter)
|
||||||
job := newTelegramJob(t, env)
|
record := newTelegramRecord(t, env)
|
||||||
|
|
||||||
err := env.service.FindAndRunConversionJob(ctx)
|
err := env.service.RunStep(ctx)
|
||||||
|
|
||||||
require.Error(t, err, "шаг обязан сообщить об обрыве наверх")
|
require.Error(t, err, "шаг обязан сообщить об обрыве наверх")
|
||||||
require.ErrorIs(t, err, context.Canceled, "обрыв узнаётся по смыслу, а не по тексту")
|
require.ErrorIs(t, err, context.Canceled, "обрыв узнаётся по смыслу, а не по тексту")
|
||||||
|
|
||||||
after, err := readJob(env.app, job.Id)
|
after := readRecord(t, env, record.Id)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, entity.StateCreated, after.State, "задача осталась на повтор, а не похоронена")
|
assert.Equal(t, entity.StateUploaded, after.State, "запись осталась на повтор")
|
||||||
assert.Nil(t, after.AcquisitionID, "захват снят: задачу возьмёт следующий прогон")
|
assert.False(t, after.IsHalted(), "приговора не выносили")
|
||||||
assert.Equal(t, 0, after.Attempts, "остановка попытки не тратит")
|
assert.Nil(t, after.AcquisitionID, "захват снят: запись возьмёт следующий прогон")
|
||||||
assert.Nil(t, after.ErrorText, "приговора не выносили")
|
assert.Equal(t, 0, after.Attempts, "остановка отказа не тратит")
|
||||||
assert.Empty(t, env.sender.messages, "отправителю о несуществующем сбое не сообщают")
|
assert.Nil(t, after.ErrorText)
|
||||||
|
assert.Empty(t, env.sender.sent(), "отправителю о несуществующем сбое не сообщают")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Задача, которую шаг не успел взять, потому что нас уже остановили, остаётся
|
// Запись, которую шаг не успел взять, потому что нас уже остановили, остаётся
|
||||||
// нетронутой: захват не случился, попытка не потрачена.
|
// нетронутой: захват не случился, отказ не потрачен.
|
||||||
func TestShutdownBeforeStepLeavesJobUntouched(t *testing.T) {
|
func TestShutdownBeforeStepLeavesRecordUntouched(t *testing.T) {
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
job := newTelegramJob(t, env)
|
record := newTelegramRecord(t, env)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
err := env.service.FindAndRunConversionJob(ctx)
|
err := env.service.RunStep(ctx)
|
||||||
|
|
||||||
// Исход «шаг не сделал ничего» — это `NoopJobError`: воркер не пишет о нём
|
// Исход «шаг не сделал ничего» — это `NoopJobError`: воркер не пишет о нём
|
||||||
// владельцу и не считает его в метрику.
|
// владельцу и не считает его в метрику.
|
||||||
@@ -72,12 +71,8 @@ func TestShutdownBeforeStepLeavesJobUntouched(t *testing.T) {
|
|||||||
var noop *contract.NoopJobError
|
var noop *contract.NoopJobError
|
||||||
require.ErrorAs(t, err, &noop)
|
require.ErrorAs(t, err, &noop)
|
||||||
|
|
||||||
after, err := readJob(env.app, job.Id)
|
after := readRecord(t, env, record.Id)
|
||||||
require.NoError(t, err)
|
assert.Equal(t, entity.StateUploaded, after.State)
|
||||||
assert.Equal(t, entity.StateCreated, after.State)
|
assert.Equal(t, 0, after.Attempts, "захвата не было — отказу взяться неоткуда")
|
||||||
assert.Equal(t, 0, after.Attempts, "захвата не было — попытке взяться неоткуда")
|
assert.Nil(t, after.AcquisitionID)
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, record.GetString("acquisition_id"))
|
|
||||||
}
|
}
|
||||||
|
|||||||
+658
-337
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,10 @@ import (
|
|||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ответ отправителю уходит после того, как достигнутое состояние сохранено.
|
// Ответ отправителю уходит после того, как достигнутый рубеж сохранён. Значит,
|
||||||
// Значит, недоставка не может быть отказом шага: объявленный отказ засчитался
|
// недоставка не может быть отказом шага: объявленный отказ засчитался бы воркеру
|
||||||
// бы воркеру сбоем, лёг бы владельцу записью отказа и переписал бы служебные
|
// сбоем, лёг бы владельцу записью отказа и переписал бы служебные поля
|
||||||
// поля завершённой задачи. Причин недоставки две, исход у них общий.
|
// доведённой записи. Причин недоставки две, исход у них общий.
|
||||||
|
|
||||||
// downSender изображает неподнятый канал доставки: так ведёт себя заглушка,
|
// downSender изображает неподнятый канал доставки: так ведёт себя заглушка,
|
||||||
// которую ядро получает вместо отправителя Telegram.
|
// которую ядро получает вместо отправителя Telegram.
|
||||||
@@ -31,91 +31,105 @@ func (s *downSender) Send(string, int64, *int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// journalEnv пересобирает сервис с названным отправителем и своим журналом:
|
// journalEnv пересобирает сервис с названным отправителем и своим журналом:
|
||||||
// утверждения судят и состояние задачи, и то, что увидел владелец.
|
// утверждения судят и состояние записи, и то, что увидел владелец.
|
||||||
func journalEnv(
|
func journalEnv(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
env *pipelineEnv,
|
env *pipelineEnv,
|
||||||
rec contract.AudioRecognizer,
|
|
||||||
sender contract.TelegramMessageSender,
|
sender contract.TelegramMessageSender,
|
||||||
) (*TranscribeService, *bytes.Buffer) {
|
) (*TranscribeService, *bytes.Buffer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
journal := &bytes.Buffer{}
|
journal := &bytes.Buffer{}
|
||||||
svc := NewTranscribeService(
|
svc := NewTranscribeService(
|
||||||
env.jobRepo,
|
env.repos,
|
||||||
env.fileRepo,
|
|
||||||
&okMetaViewer{},
|
&okMetaViewer{},
|
||||||
&failingConverter{},
|
&okConverter{},
|
||||||
rec,
|
env.service.recognizer,
|
||||||
sender,
|
sender,
|
||||||
|
testLimits,
|
||||||
slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})),
|
slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})),
|
||||||
)
|
)
|
||||||
|
|
||||||
return svc, journal
|
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) {
|
func TestUndeliveredOnDownChannelKeepsRecordDone(t *testing.T) {
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
|
||||||
job := transcribingJob(t, env, rec)
|
|
||||||
|
|
||||||
rec.result = entity.NewCompletedResult()
|
|
||||||
rec.text = "расшифровка записи"
|
|
||||||
sender := &downSender{}
|
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, "ответ до отправителя доехал")
|
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)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateDone, after.State, "задача осталась в достигнутом состоянии")
|
require.NotEmpty(t, text.Contents)
|
||||||
require.NotNil(t, after.TranscriptionText)
|
|
||||||
assert.Equal(t, "расшифровка записи", *after.TranscriptionText, "расшифровка сохранена")
|
|
||||||
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
|
|
||||||
|
|
||||||
written := journal.String()
|
written := journal.String()
|
||||||
assert.Contains(t, written, "Reply was not delivered", "недоставка названа")
|
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.Contains(t, written, "level=WARN", "объявленный режим — «может стать проблемой»")
|
||||||
assert.NotContains(t, written, "расшифровка записи", "текста расшифровки в журнале нет")
|
assert.NotContains(t, written, text.Contents, "текста расшифровки в журнале нет")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Адресат у задачи не назван: исход тот же. Прежде эта ветка объявляла отказ
|
// Адресат у записи не назван: исход тот же. Прежде эта ветка объявляла отказ
|
||||||
// шага на уже завершённой работе.
|
// шага на уже завершённой работе.
|
||||||
func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) {
|
func TestUndeliveredWithoutChatKeepsRecordDone(t *testing.T) {
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
|
||||||
job := transcribingJob(t, env, rec)
|
|
||||||
|
|
||||||
// Задача из Telegram, у которой чат не назван: такую отдаёт правка в панели.
|
|
||||||
// Колонка чистится мимо захвата — иначе setup унёс бы задачу у шага.
|
|
||||||
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
record.Set("tg_chat_id", nil)
|
|
||||||
require.NoError(t, env.app.Save(record))
|
|
||||||
|
|
||||||
rec.result = entity.NewCompletedResult()
|
|
||||||
rec.text = "расшифровка записи"
|
|
||||||
sender := &downSender{}
|
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, "до отправителя дело не дошло: адресата нет")
|
assert.Equal(t, 0, sender.calls, "до отправителя дело не дошло: адресата нет")
|
||||||
|
|
||||||
after, err := readJob(env.app, job.Id)
|
after := readRecord(t, env, record.Id)
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, entity.StateDone, after.State)
|
assert.Equal(t, entity.StateDone, after.State)
|
||||||
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
|
assert.False(t, after.IsHalted(), "отказ записи не приписан")
|
||||||
|
|
||||||
written := journal.String()
|
written := journal.String()
|
||||||
assert.Contains(t, written, "Reply was not delivered")
|
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, "chat is not specified", "причина названа")
|
||||||
assert.Contains(t, written, "level=ERROR",
|
assert.Contains(t, written, "level=ERROR",
|
||||||
"порча записи громче штатного «бот не настроен»: иначе сигнал утонет")
|
"порча записи громче штатного «бот не настроен»: иначе сигнал утонет")
|
||||||
@@ -123,17 +137,17 @@ func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) {
|
|||||||
|
|
||||||
// Запись, принятая по HTTP, до отправителя не доходит вовсе: недоставки нет, и
|
// Запись, принятая по HTTP, до отправителя не доходит вовсе: недоставки нет, и
|
||||||
// записи о ней в журнале быть не должно — иначе журнал владельца заполнят
|
// записи о ней в журнале быть не должно — иначе журнал владельца заполнят
|
||||||
// строки о задачах основного входа.
|
// строки о записях основного входа.
|
||||||
func TestApiJobDoesNotReachSenderAndLogsNothing(t *testing.T) {
|
func TestApiRecordDoesNotReachSenderAndLogsNothing(t *testing.T) {
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
sender := &downSender{}
|
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.Equal(t, 0, sender.calls, "отправителя не звали")
|
||||||
assert.NotContains(t, journal.String(), "Reply was not delivered", "недоставки не было")
|
assert.NotContains(t, journal.String(), "Reply was not delivered", "недоставки не было")
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ func main() {
|
|||||||
os.Exit(1)
|
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 файла
|
// Загружаем переменные окружения из .env файла
|
||||||
if err := godotenv.Load(); err != nil {
|
if err := godotenv.Load(); err != nil {
|
||||||
logger.Warn("Warning: .env file not found, using system environment variables")
|
logger.Warn("Warning: .env file not found, using system environment variables")
|
||||||
@@ -90,8 +98,15 @@ func main() {
|
|||||||
pbrepo.BindPanelRules(storage)
|
pbrepo.BindPanelRules(storage)
|
||||||
|
|
||||||
// Создаем репозитории
|
// Создаем репозитории
|
||||||
fileRepo := pbrepo.NewFileRepository(storage)
|
recordRepo := pbrepo.NewAudioRecordRepository(storage)
|
||||||
jobRepo := pbrepo.NewTranscriptJobRepository(storage)
|
repos := service.Repositories{
|
||||||
|
Records: recordRepo,
|
||||||
|
Files: pbrepo.NewFileRepository(storage),
|
||||||
|
Texts: pbrepo.NewTextRepository(storage),
|
||||||
|
Structures: pbrepo.NewStructureRepository(storage),
|
||||||
|
Recognitions: pbrepo.NewRecognitionRepository(storage),
|
||||||
|
Events: pbrepo.NewRecordEventRepository(storage),
|
||||||
|
}
|
||||||
|
|
||||||
// Создаем адаптеры
|
// Создаем адаптеры
|
||||||
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
|
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
|
||||||
@@ -128,12 +143,12 @@ func main() {
|
|||||||
|
|
||||||
// Создаем сервисы
|
// Создаем сервисы
|
||||||
transcribeService := service.NewTranscribeService(
|
transcribeService := service.NewTranscribeService(
|
||||||
jobRepo,
|
repos,
|
||||||
fileRepo,
|
|
||||||
metaviewer,
|
metaviewer,
|
||||||
converter,
|
converter,
|
||||||
recognizer,
|
recognizer,
|
||||||
tgSender,
|
tgSender,
|
||||||
|
cfg.Pipeline.StuckLimits(),
|
||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -154,7 +169,7 @@ func main() {
|
|||||||
// же факте.
|
// же факте.
|
||||||
var tgController *tgcontroller.TelegramController
|
var tgController *tgcontroller.TelegramController
|
||||||
if tgBot != nil {
|
if tgBot != nil {
|
||||||
tgController, err = tgcontroller.NewTelegramController(tgConfig, tgBot, transcribeService, jobRepo, logger)
|
tgController, err = tgcontroller.NewTelegramController(tgConfig, tgBot, transcribeService, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to create Telegram controller", "error", err)
|
logger.Error("Failed to create Telegram controller", "error", err)
|
||||||
os.Exit(1)
|
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)
|
pool := worker.NewPool(cfg.Pipeline.Workers, transcribeService.RunStep, logger)
|
||||||
checkWorker := worker.NewCallbackWorker("check_worker", transcribeService.FindAndRunTranscribeCheckJob, logger)
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
workers := []worker.Worker{
|
defer wg.Done()
|
||||||
conversionWorker,
|
pool.Start(ctx)
|
||||||
transcribeWorker,
|
}()
|
||||||
checkWorker,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запускаем воркеры в отдельных горутинах
|
|
||||||
for _, w := range workers {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(worker worker.Worker) {
|
|
||||||
defer wg.Done()
|
|
||||||
worker.Start(ctx)
|
|
||||||
logger.Info("Worker stopped gracefully", "worker", worker.Name())
|
|
||||||
}(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вход по HTTP поднимается всегда: он основной, и отдельного разреза у него
|
// Вход по HTTP поднимается всегда: он основной, и отдельного разреза у него
|
||||||
// нет. Признак ставится рядом с признаком Telegram, чтобы владелец судил об
|
// нет. Признак ставится рядом с признаком 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{
|
authHandler := httpcontroller.NewAuthHandler(storage, httpcontroller.AuthHandlerConfig{
|
||||||
AuthURL: cfg.Auth.AuthURL,
|
AuthURL: cfg.Auth.AuthURL,
|
||||||
RedirectURL: cfg.Auth.RedirectURL,
|
RedirectURL: cfg.Auth.RedirectURL,
|
||||||
@@ -291,8 +294,7 @@ func main() {
|
|||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
logger.Info("Transcriber service started with background workers")
|
logger.Info("Transcriber service started", "pipeline_workers", pool.Size())
|
||||||
logger.Info("Workers: ConversionWorker, TranscribeWorker, CheckWorker")
|
|
||||||
logger.Info("Press Ctrl+C to stop...")
|
logger.Info("Press Ctrl+C to stop...")
|
||||||
|
|
||||||
// Ждем сигнал завершения либо отказ сервера
|
// Ждем сигнал завершения либо отказ сервера
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -18,18 +18,22 @@ Telegram, дописывает его сюда.
|
|||||||
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
|
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
|
||||||
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
|
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
|
||||||
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
|
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
|
||||||
ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть
|
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
|
||||||
сохранена и получить заведённую под неё задачу расшифровки в состоянии
|
получить заведённую под неё аудиозапись на рубеже `uploaded`; ответ MUST нести
|
||||||
`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние
|
идентификатор записи полем `job_id` и её рубеж полем `status`.
|
||||||
полем `status`.
|
|
||||||
|
Значение рубежа в ответе изменилось: прежде приём отдавал `created`. Перечень
|
||||||
|
состояний назван проектом необратимым, и ломка объявлена прямо — состояние
|
||||||
|
теперь называет достигнутое, а не предстоящее, и `created` в новом перечне нет
|
||||||
|
вовсе.
|
||||||
|
|
||||||
|
Имена полей ответа нормативны и MUST остаться прежними: контракт HTTP API
|
||||||
|
объявлен проектом необратимым, и переименование поля ломает внешнюю программу
|
||||||
|
молча. Меняются значения поля рубежа, а не его имя.
|
||||||
|
|
||||||
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
|
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
|
||||||
не заплатит узнанный отправитель, не должна попасть даже в память.
|
не заплатит узнанный отправитель, не должна попасть даже в память.
|
||||||
|
|
||||||
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
|
|
||||||
и переименование поля ломает внешнюю программу молча. Появление отказа без
|
|
||||||
сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу.
|
|
||||||
|
|
||||||
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
||||||
пригодность содержимого узнаёт у источника метаданных.
|
пригодность содержимого узнаёт у источника метаданных.
|
||||||
|
|
||||||
@@ -61,30 +65,30 @@ Telegram, дописывает его сюда.
|
|||||||
- **AND** отправитель предъявил сессию
|
- **AND** отправитель предъявил сессию
|
||||||
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
|
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
|
||||||
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
|
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
|
||||||
со значением `created`
|
со значением `uploaded`
|
||||||
- **AND** содержимое записи целиком лежит в хранилище одним файлом
|
- **AND** содержимое записи целиком лежит в хранилище одним файлом
|
||||||
- **AND** владельцем заведённой задачи стоит предъявитель сессии
|
- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии
|
||||||
|
|
||||||
#### Scenario: Сессия не даёт учётной записи пользователя
|
#### Scenario: Сессия не даёт учётной записи пользователя
|
||||||
|
|
||||||
- **GIVEN** предъявлена сессия владельца панели
|
- **GIVEN** предъявлена сессия владельца панели
|
||||||
- **WHEN** он шлёт `POST /api/audio` с полем `audio`
|
- **WHEN** он шлёт `POST /api/audio` с полем `audio`
|
||||||
- **THEN** ответ имеет код `403`
|
- **THEN** ответ имеет код `403`
|
||||||
- **AND** ни файла, ни задачи не заводится
|
- **AND** ни файла, ни аудиозаписи не заводится
|
||||||
|
|
||||||
#### Scenario: Сессии нет
|
#### Scenario: Сессии нет
|
||||||
|
|
||||||
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
|
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
|
||||||
- **THEN** ответ имеет код `401`
|
- **THEN** ответ имеет код `401`
|
||||||
- **AND** ни файла, ни задачи не заводится
|
- **AND** ни файла, ни аудиозаписи не заводится
|
||||||
- **AND** тело ответа не несёт данных задачи
|
- **AND** тело ответа не несёт данных записи
|
||||||
|
|
||||||
#### Scenario: Поля с записью нет
|
#### Scenario: Поля с записью нет
|
||||||
|
|
||||||
- **GIVEN** отправитель предъявил сессию
|
- **GIVEN** отправитель предъявил сессию
|
||||||
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
|
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
|
||||||
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
|
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
|
||||||
- **AND** ни файла, ни задачи не заводится
|
- **AND** ни файла, ни аудиозаписи не заводится
|
||||||
|
|
||||||
#### Scenario: Размеру записи приём не судья
|
#### Scenario: Размеру записи приём не судья
|
||||||
|
|
||||||
@@ -237,58 +241,86 @@ Telegram, дописывает его сюда.
|
|||||||
|
|
||||||
### Requirement: Опрос готовности задачи
|
### Requirement: Опрос готовности задачи
|
||||||
|
|
||||||
Сервис SHALL отдавать состояние задачи расшифровки по запросу
|
Сервис SHALL отдавать рубеж аудиозаписи по запросу `GET /api/status/:id`
|
||||||
`GET /api/status/:id` **только её владельцу**. Запрос без сессии MUST получать
|
**только её владельцу**. Запрос без сессии MUST получать код `401`, и тело
|
||||||
код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни текста
|
такого ответа MUST не нести ни рубежа записи, ни текста расшифровки. Ответ
|
||||||
расшифровки. Ответ владельцу MUST нести идентификатор полем `job_id`, состояние
|
владельцу MUST нести идентификатор полем `job_id`, рубеж полем `status` и время
|
||||||
полем `status` и время заведения полем `created_at`, а текст расшифровки полем
|
заведения полем `created_at`, а текст расшифровки полем `transcription_text`, и
|
||||||
`transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет:
|
это поле MUST отсутствовать в ответе, пока текста нет: пустая строка на месте
|
||||||
пустая строка на месте отсутствующего текста читается как «расшифровка пуста».
|
отсутствующего текста читается как «расшифровка пуста».
|
||||||
|
|
||||||
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
|
Видов текста у записи больше одного, поэтому ответ MUST называть вид, который
|
||||||
кодам ответа перебирается список заведённых задач.
|
отдаёт: в поле `transcription_text` уходит **сырая расшифровка**, и только она.
|
||||||
|
Вычитанный текст этим полем MUST не подменяться — иначе значение поля менялось бы
|
||||||
|
у одной и той же записи от того, успел ли отработать необязательный шаг, а
|
||||||
|
контракт объявлен необратимым. Отдача «последнего записанного» текста MUST не
|
||||||
|
применяться: она делает ответ функцией порядка записи, а не состояния записи.
|
||||||
|
|
||||||
Задача, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
|
Перечень значений поля `status` MUST совпадать с перечнем рубежей конвейера:
|
||||||
идентификатор, — кодом `404` и тем же телом. То же 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** отправитель предъявил сессию
|
- **GIVEN** отправитель предъявил сессию
|
||||||
- **WHEN** он спрашивает состояние своей задачи
|
- **WHEN** он спрашивает рубеж своей записи
|
||||||
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
|
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
|
||||||
|
- **AND** значение `status` принадлежит перечню рубежей конвейера
|
||||||
|
|
||||||
|
#### Scenario: Запись остановлена
|
||||||
|
|
||||||
|
- **GIVEN** запись остановлена признаком на рубеже приведения
|
||||||
|
- **WHEN** владелец спрашивает её рубеж
|
||||||
|
- **THEN** поле `status` несёт рубеж приведения
|
||||||
|
- **AND** поле `halted` несёт истину
|
||||||
|
- **AND** машинного текста отказа в ответе нет
|
||||||
|
|
||||||
#### Scenario: Сессии нет
|
#### Scenario: Сессии нет
|
||||||
|
|
||||||
- **WHEN** программа спрашивает состояние заведённой задачи без сессии
|
- **WHEN** программа спрашивает рубеж заведённой записи без сессии
|
||||||
- **THEN** ответ имеет код `401`
|
- **THEN** ответ имеет код `401`
|
||||||
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
|
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
|
||||||
|
|
||||||
#### Scenario: Без сессии неизвестная задача неотличима от заведённой
|
#### Scenario: Без сессии неизвестная запись неотличима от заведённой
|
||||||
|
|
||||||
- **WHEN** программа без сессии спрашивает состояние заведённой задачи, а затем
|
- **WHEN** программа без сессии спрашивает рубеж заведённой записи, а затем
|
||||||
состояние по неизвестному идентификатору
|
рубеж по неизвестному идентификатору
|
||||||
- **THEN** оба ответа имеют код `401`
|
- **THEN** оба ответа имеют код `401`
|
||||||
|
|
||||||
#### Scenario: Чужая задача неотличима от неизвестной
|
#### Scenario: Чужая запись неотличима от неизвестной
|
||||||
|
|
||||||
- **GIVEN** задача заведена одним вошедшим
|
- **GIVEN** запись заведена одним вошедшим
|
||||||
- **WHEN** её состояние спрашивает другой вошедший
|
- **WHEN** её рубеж спрашивает другой вошедший
|
||||||
- **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному
|
- **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному
|
||||||
идентификатору
|
идентификатору
|
||||||
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
|
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
|
||||||
|
|
||||||
#### Scenario: Расшифровки ещё нет
|
#### Scenario: Расшифровки ещё нет
|
||||||
|
|
||||||
- **GIVEN** отправитель предъявил сессию
|
- **GIVEN** отправитель предъявил сессию
|
||||||
- **WHEN** он спрашивает состояние своей задачи, которая ещё не дошла до текста
|
- **WHEN** он спрашивает рубеж своей записи, которая ещё не дошла до текста
|
||||||
- **THEN** поля `transcription_text` в ответе нет вовсе
|
- **THEN** поля `transcription_text` в ответе нет вовсе
|
||||||
|
|
||||||
#### Scenario: Задачи с таким идентификатором нет
|
#### Scenario: Записи с таким идентификатором нет
|
||||||
|
|
||||||
- **GIVEN** отправитель предъявил сессию
|
- **GIVEN** отправитель предъявил сессию
|
||||||
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
|
- **WHEN** программа спрашивает рубеж по неизвестному идентификатору
|
||||||
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
|
- **THEN** ответ имеет код `404` и сообщение о ненайденной записи
|
||||||
|
|
||||||
### Requirement: Поднятые входы видны наблюдателю
|
### Requirement: Поднятые входы видны наблюдателю
|
||||||
|
|
||||||
|
|||||||
+516
-162
@@ -2,28 +2,36 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
|
Конвейер расшифровки: как аудиозапись движется по рубежам, что делает воркер,
|
||||||
когда работы нет, что считается отказом шага и что бывает с ответом отправителю,
|
когда работы нет, что считается отказом шага и что бывает с ответом отправителю,
|
||||||
когда доставить его некуда.
|
когда доставить его некуда.
|
||||||
|
|
||||||
Описаны пустой прогон воркера, неделимость захвата и срок его протухания, число
|
Описаны цепочка рубежей и смысл рубежа, остановка признаком и её причины, оба
|
||||||
попыток и состояние «мертва», нарастающая пауза перед повтором, условие записи
|
сторожа — число отказов и время в рубеже, — откладывание работы отдельно от
|
||||||
результата держателем захвата и недоставка ответа при неподнятом входе.
|
перехода, неделимость захвата и срок его протухания, условие записи результата
|
||||||
Сознательно не описаны: цепочка переходов `created → converted → transcribe →
|
держателем захвата, нарастающая пауза перед повтором, число воркеров настройкой,
|
||||||
done | failed`, отмена контекста посреди шага и освобождение ресурсов внешних
|
журнал событий записи и недоставка ответа при неподнятом входе.
|
||||||
клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а
|
|
||||||
требования на него не написаны, потому что требование без проверки —
|
Сознательно не описаны: освобождение ресурсов внешних клиентов и **какие отказы
|
||||||
предположение, а не норма. Первая задача, которая трогает любое из
|
считаются приговором записи, а какие поводом к повтору**. Второе — не пробел
|
||||||
перечисленного, дописывает его сюда.
|
формулировки, а неразобранный вопрос: сегодня отказ приведения останавливает
|
||||||
|
запись с первой попытки, и предел отказов на нём не работает никогда. Это не
|
||||||
|
значит, что поведения нет: оно живёт в коде, а требования на него не написаны,
|
||||||
|
потому что требование без проверки — предположение, а не норма. Первая задача,
|
||||||
|
которая трогает любое из перечисленного, дописывает его сюда.
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Пустой прогон воркера — не отказ
|
### Requirement: Пустой прогон воркера — не отказ
|
||||||
|
|
||||||
Воркер SHALL отличать «работы в этом состоянии сейчас нет» от отказа шага. На
|
Воркер SHALL отличать «пригодной к работе записи сейчас нет» от отказа шага. На
|
||||||
пустом прогоне он MUST не считать прогон отказом: не увеличивать счётчик работы
|
пустом прогоне он MUST не считать прогон отказом: не увеличивать счётчик работы
|
||||||
и не писать о нём на уровне владельца сервиса. Признак пустого прогона MUST
|
и не писать о нём на уровне владельца сервиса. Признак пустого прогона MUST
|
||||||
узнаваться по смыслу значения, а не по его точной форме, и MUST переживать
|
узнаваться по смыслу значения, а не по его точной форме, и MUST переживать
|
||||||
пояснения, добавленные к этому значению на любом промежуточном шаге пути.
|
пояснения, добавленные к этому значению на любом промежуточном шаге пути.
|
||||||
|
|
||||||
|
Формулировка сменилась вместе с моделью: воркер больше не привязан к рубежу и
|
||||||
|
опрашивает не «своё состояние», а очередь целиком, поэтому пустой прогон значит
|
||||||
|
«работы нет ни на одном рубеже», а не «работы нет в этом состоянии».
|
||||||
|
|
||||||
Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: воркеры
|
Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: воркеры
|
||||||
опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт от
|
опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт от
|
||||||
каждого запись отказа в секунду и столько же засчитанных сбоев, которых не было.
|
каждого запись отказа в секунду и столько же засчитанных сбоев, которых не было.
|
||||||
@@ -31,12 +39,12 @@ done | failed`, отмена контекста посреди шага и ос
|
|||||||
Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же
|
Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же
|
||||||
шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак
|
шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак
|
||||||
в цепочке своей ошибки. Воркер узнаёт признак по смыслу на любой глубине, поэтому
|
в цепочке своей ошибки. Воркер узнаёт признак по смыслу на любой глубине, поэтому
|
||||||
отказ, к которому признак примешался, тоже зачёл бы пустым прогоном: задача
|
отказ, к которому признак примешался, тоже зачёл бы пустым прогоном: запись
|
||||||
осталась бы в своём состоянии и переопрашивалась раз в секунду без единой записи
|
осталась бы на своём рубеже и переопрашивалась раз в секунду без единой записи
|
||||||
— ровно то, что запрещает инвариант «Принятая запись не теряется молча».
|
— ровно то, что запрещает инвариант «Принятая запись не теряется молча».
|
||||||
|
|
||||||
Отказ шага, наоборот, MUST быть виден владельцу сервиса записью в журнале и MUST
|
Отказ шага, наоборот, MUST быть виден владельцу сервиса записью в журнале и MUST
|
||||||
быть засчитан в счётчик работы с пометкой отказа.
|
быть засчитан в счётчик работы с пометкой отказа и с меткой рубежа.
|
||||||
|
|
||||||
**Сколько раз он записывается и каким уровнем — это требование не нормирует, и
|
**Сколько раз он записывается и каким уровнем — это требование не нормирует, и
|
||||||
умолчанием тут считать нечего.** Сегодня один отказ даёт две записи: пишет шаг
|
умолчанием тут считать нечего.** Сегодня один отказ даёт две записи: пишет шаг
|
||||||
@@ -48,16 +56,16 @@ done | failed`, отмена контекста посреди шага и ос
|
|||||||
двойную, — закрыло бы долг контрактом. Задача, которая возьмётся за этот долг,
|
двойную, — закрыло бы долг контрактом. Задача, которая возьмётся за этот долг,
|
||||||
дописывает норму сюда.
|
дописывает норму сюда.
|
||||||
|
|
||||||
#### Scenario: Работы в состоянии нет
|
#### Scenario: Пригодной к работе записи нет
|
||||||
|
|
||||||
- **GIVEN** ни одной задачи в опрашиваемом состоянии нет
|
- **GIVEN** ни одной записи, пригодной к работе, нет ни на одном рубеже
|
||||||
- **WHEN** воркер делает свой прогон
|
- **WHEN** воркер делает свой прогон
|
||||||
- **THEN** на уровне владельца сервиса об этом прогоне не пишется ничего
|
- **THEN** на уровне владельца сервиса об этом прогоне не пишется ничего
|
||||||
- **AND** счётчик работы воркера не растёт
|
- **AND** счётчик работы воркера не растёт
|
||||||
|
|
||||||
#### Scenario: Признак пустого прогона дошёл с пояснением
|
#### Scenario: Признак пустого прогона дошёл с пояснением
|
||||||
|
|
||||||
- **GIVEN** работы в опрашиваемом состоянии нет
|
- **GIVEN** пригодной к работе записи нет
|
||||||
- **AND** промежуточный шаг добавил к этому признаку своё пояснение
|
- **AND** промежуточный шаг добавил к этому признаку своё пояснение
|
||||||
- **WHEN** воркер делает свой прогон
|
- **WHEN** воркер делает свой прогон
|
||||||
- **THEN** прогон по-прежнему считается пустым: счётчик не растёт, записи на
|
- **THEN** прогон по-прежнему считается пустым: счётчик не растёт, записи на
|
||||||
@@ -68,29 +76,45 @@ done | failed`, отмена контекста посреди шага и ос
|
|||||||
- **GIVEN** шаг конвейера вернул отказ
|
- **GIVEN** шаг конвейера вернул отказ
|
||||||
- **WHEN** воркер завершает прогон
|
- **WHEN** воркер завершает прогон
|
||||||
- **THEN** отказ виден владельцу сервиса записью в журнале
|
- **THEN** отказ виден владельцу сервиса записью в журнале
|
||||||
- **AND** счётчик работы воркера растёт с пометкой отказа
|
- **AND** счётчик работы воркера растёт с пометкой отказа и меткой рубежа
|
||||||
|
|
||||||
#### Scenario: Шаг сделал работу
|
#### Scenario: Шаг сделал работу
|
||||||
|
|
||||||
- **GIVEN** шаг конвейера отработал задачу без отказа
|
- **GIVEN** шаг конвейера отработал запись без отказа
|
||||||
- **WHEN** воркер завершает прогон
|
- **WHEN** воркер завершает прогон
|
||||||
- **THEN** счётчик работы воркера растёт с пометкой успеха
|
- **THEN** счётчик работы воркера растёт с пометкой успеха
|
||||||
- **AND** записи об отказе в журнале нет
|
- **AND** записи об отказе в журнале нет
|
||||||
|
|
||||||
### Requirement: Захват задачи неделим
|
### Requirement: Захват задачи неделим
|
||||||
|
|
||||||
Захват задачи воркером SHALL быть одним неделимым шагом хранилища: выбор
|
Захват записи воркером SHALL быть одним неделимым шагом хранилища: выбор
|
||||||
подходящей задачи и пометка её захваченной MUST происходить вместе, и захваченная
|
подходящей записи и пометка её захваченной MUST происходить вместе.
|
||||||
задача MUST возвращаться тем же шагом.
|
|
||||||
|
|
||||||
Одна и та же задача MUST доставаться ровно одному захватившему. Двум вызывающим,
|
Захват MUST возвращать **идентификатор записи и признак этого захвата**, а не
|
||||||
пришедшим за одним состоянием одновременно, запись MUST достаться одному, а
|
перечень её колонок. Колонки записи шаг читает сам, обычным чтением. Иначе
|
||||||
второй MUST получить признак «работы в этом состоянии нет».
|
всякая новая колонка аудиозаписи попадала бы под инвариант проекта о колонках
|
||||||
|
очереди, и забытая в захвате колонка приезжала бы нулевой, а первое же
|
||||||
|
сохранение писало бы этот ноль поверх сохранённого значения.
|
||||||
|
|
||||||
|
**Признак захвата MUST быть значением, уникальным для каждого захвата**, а не
|
||||||
|
признаком занятости. Условие записи результата сверяет именно это значение:
|
||||||
|
захват, перевыданный другому — по протуханию срока или после того, как человек
|
||||||
|
снял признак остановки в панели, — обязан обращать запись первого в отказ.
|
||||||
|
Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и
|
||||||
|
два шага записали бы в одну запись и оба ответили бы отправителю.
|
||||||
|
|
||||||
|
Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим,
|
||||||
|
пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST
|
||||||
|
получить признак «работы сейчас нет».
|
||||||
|
|
||||||
|
Срок протухания захвата MUST ехать с рубежом записи, а не с воркером: воркер не
|
||||||
|
привязан к шагу и не знает заранее, что вытянет. Срок MUST записываться числом
|
||||||
|
при самом захвате.
|
||||||
|
|
||||||
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному
|
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному
|
||||||
значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок
|
значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок
|
||||||
обработки невоспроизводим, а проверка, опирающаяся на «следующую» задачу,
|
обработки невоспроизводим, а проверка, опирающаяся на «следующую» запись, зелена
|
||||||
зелена через раз.
|
через раз.
|
||||||
|
|
||||||
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
|
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
|
||||||
захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа
|
захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа
|
||||||
@@ -99,41 +123,57 @@ done | failed`, отмена контекста посреди шага и ос
|
|||||||
Признак «работы нет» этим требованием не переопределяется — его нормирует
|
Признак «работы нет» этим требованием не переопределяется — его нормирует
|
||||||
требование «Пустой прогон воркера — не отказ».
|
требование «Пустой прогон воркера — не отказ».
|
||||||
|
|
||||||
#### Scenario: За задачей пришли трое разом
|
#### Scenario: За работой пришли трое разом
|
||||||
|
|
||||||
- **GIVEN** в опрашиваемом состоянии лежит ровно одна задача
|
- **GIVEN** к работе пригодна ровно одна запись
|
||||||
- **WHEN** три захвата этого состояния идут одновременно
|
- **WHEN** три захвата идут одновременно
|
||||||
- **THEN** запись получает ровно один из них
|
- **THEN** запись получает ровно один из них
|
||||||
- **AND** двое остальных получают признак «работы в этом состоянии нет»
|
- **AND** двое остальных получают признак «работы сейчас нет»
|
||||||
|
|
||||||
#### Scenario: Захваченная задача не выдаётся второй раз
|
#### Scenario: Захваченная запись не выдаётся второй раз
|
||||||
|
|
||||||
- **GIVEN** задача захвачена и срок захвата не истёк
|
- **GIVEN** запись захвачена и срок захвата не истёк
|
||||||
- **WHEN** за тем же состоянием приходит следующий захват
|
- **WHEN** приходит следующий захват
|
||||||
- **THEN** эта задача ему не выдаётся
|
- **THEN** эта запись ему не выдаётся
|
||||||
|
|
||||||
|
#### Scenario: Захват отдаёт идентификатор и свой признак
|
||||||
|
|
||||||
|
- **GIVEN** к работе пригодна запись
|
||||||
|
- **WHEN** воркер её захватывает
|
||||||
|
- **THEN** захват возвращает идентификатор записи и признак этого захвата
|
||||||
|
- **AND** колонки записи шаг читает отдельным чтением
|
||||||
|
|
||||||
|
#### Scenario: Признак перевыданного захвата отличается от прежнего
|
||||||
|
|
||||||
|
- **GIVEN** запись захвачена, и признак первого захвата известен
|
||||||
|
- **WHEN** человек снимает признак остановки, и запись захватывает другой воркер
|
||||||
|
- **THEN** признак нового захвата отличается от признака первого
|
||||||
|
|
||||||
### Requirement: Результат пишет только держатель захвата
|
### Requirement: Результат пишет только держатель захвата
|
||||||
|
|
||||||
Шаг конвейера SHALL записывать свой результат только тогда, когда захват задачи
|
Шаг конвейера SHALL записывать свой результат только тогда, когда захват записи
|
||||||
всё ещё принадлежит ему. Запись MUST быть условна по признаку захвата, а шаг,
|
всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата** —
|
||||||
чей захват за время работы достался другому, MUST завершиться без записи
|
значению, уникальному для каждого захвата, — а не по занятости записи вообще.
|
||||||
|
Шаг, чей захват за время работы достался другому, MUST завершиться без записи
|
||||||
результата и без ответа отправителю.
|
результата и без ответа отправителю.
|
||||||
|
|
||||||
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
|
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
|
||||||
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
|
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
|
||||||
теряет задачу, продолжая работать. Без этого условия два воркера пишут в одну
|
теряет запись, продолжая работать. Снять захват может и человек, вернувший
|
||||||
задачу по очереди, счётчик попыток сбрасывает тот, кто уже не владелец, а
|
остановленную запись в работу. Без условия по уникальному признаку два воркера
|
||||||
отправитель получает два ответа на одну запись.
|
пишут в одну запись по очереди, счётчик отказов сбрасывает тот, кто уже не
|
||||||
|
владелец, а отправитель получает два ответа на одну запись.
|
||||||
|
|
||||||
Шаг MUST записывать только те поля, которыми распоряжается сам. Задачу он держит
|
Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит
|
||||||
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
|
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
|
||||||
стёрла бы всё, что владелец правил в панели за это время: молча, без строки в
|
стёрла бы всё, что владелец правил в панели за это время: молча, без строки в
|
||||||
журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы
|
журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы
|
||||||
уверен, что правка на месте.
|
уверен, что правка на месте. Владелец записи, заголовок, краткое описание и темы
|
||||||
|
конвейер MUST не трогать.
|
||||||
|
|
||||||
#### Scenario: Правка владельца пережила сохранение шага
|
#### Scenario: Правка владельца пережила сохранение шага
|
||||||
|
|
||||||
- **GIVEN** шаг держит захваченную задачу
|
- **GIVEN** шаг держит захваченную запись
|
||||||
- **AND** владелец за это время изменил в панели поле, которого шаг не касается
|
- **AND** владелец за это время изменил в панели поле, которого шаг не касается
|
||||||
- **WHEN** шаг записывает свой результат
|
- **WHEN** шаг записывает свой результат
|
||||||
- **THEN** результат шага записан
|
- **THEN** результат шага записан
|
||||||
@@ -141,157 +181,121 @@ done | failed`, отмена контекста посреди шага и ос
|
|||||||
|
|
||||||
#### Scenario: Захват ушёл под работающим шагом
|
#### Scenario: Захват ушёл под работающим шагом
|
||||||
|
|
||||||
- **GIVEN** шаг работает над захваченной задачей
|
- **GIVEN** шаг работает над захваченной записью
|
||||||
- **AND** за это время та же задача досталась другому захвату
|
- **AND** за это время та же запись досталась другому захвату
|
||||||
- **WHEN** первый шаг доходит до записи результата
|
- **WHEN** первый шаг доходит до записи результата
|
||||||
- **THEN** результат не записывается
|
- **THEN** результат не записывается
|
||||||
- **AND** отправителю ничего не отправляется
|
- **AND** отправителю ничего не отправляется
|
||||||
|
|
||||||
|
#### Scenario: Человек снял остановку под работающим шагом
|
||||||
|
|
||||||
|
- **GIVEN** шаг работает над захваченной записью
|
||||||
|
- **AND** человек за это время снял с неё признак остановки, освободив захват
|
||||||
|
- **AND** запись досталась другому воркеру
|
||||||
|
- **WHEN** первый шаг доходит до записи результата
|
||||||
|
- **THEN** результат не записывается
|
||||||
|
|
||||||
### Requirement: Брошенная задача возвращается в работу
|
### Requirement: Брошенная задача возвращается в работу
|
||||||
|
|
||||||
Задача, захваченная и брошенная на середине, SHALL доставаться снова по
|
Запись, захваченная и брошенная на середине, SHALL доставаться снова по
|
||||||
истечении срока захвата. Срок MUST считаться от времени захвата, а истёкший
|
истечении срока захвата. Срок MUST считаться от времени захвата, а истёкший
|
||||||
захват MUST не мешать выдать задачу следующему.
|
захват MUST не мешать выдать запись следующему.
|
||||||
|
|
||||||
Срок задаётся шагом конвейера и MUST быть не меньше того времени, которое этот
|
Срок задаётся рубежом, с которого запись взята, и MUST быть не меньше того
|
||||||
шаг может занять на самом длинном допустимом входе. Срок короче делает
|
времени, которое шаг этого рубежа может занять на самом длинном допустимом
|
||||||
протухание штатным событием живого шага, а не признаком беды.
|
входе. Срок короче делает протухание штатным событием живого шага, а не
|
||||||
|
признаком беды. Срок MUST записываться в саму запись при захвате: воркер шага не
|
||||||
|
знает и вывести срок из себя не может.
|
||||||
|
|
||||||
Все значения времени, по которым идёт этот отбор, MUST записываться и сравниваться
|
Все значения времени, по которым идёт этот отбор, MUST записываться и
|
||||||
в одном виде — том же, в каком хранилище пишет собственные времена записи.
|
сравниваться в одном виде — том же, в каком хранилище пишет собственные времена
|
||||||
Сравнение идёт побайтово, и вид, разошедшийся хоть разделителем, обращает
|
записи. Сравнение идёт побайтово, и вид, разошедшийся хоть разделителем,
|
||||||
условие в постоянную истину или постоянную ложь, причём молча.
|
обращает условие в постоянную истину или постоянную ложь, причём молча.
|
||||||
|
|
||||||
#### Scenario: Захват протух
|
#### Scenario: Захват протух
|
||||||
|
|
||||||
- **GIVEN** задача захвачена, а время захвата отстоит дальше срока
|
- **GIVEN** запись захвачена, а время захвата отстоит дальше срока
|
||||||
- **WHEN** за её состоянием приходит захват
|
- **WHEN** приходит захват
|
||||||
- **THEN** задача выдаётся ему
|
- **THEN** запись выдаётся ему
|
||||||
|
|
||||||
#### Scenario: Срок сравнивается с временем, записанным хранилищем
|
#### Scenario: Срок сравнивается с временем, записанным хранилищем
|
||||||
|
|
||||||
- **GIVEN** задача захвачена, и время захвата записано в том же виде, в каком
|
- **GIVEN** запись захвачена, и время захвата записано в том же виде, в каком
|
||||||
хранилище пишет время изменения записи
|
хранилище пишет время изменения записи
|
||||||
- **WHEN** за её состоянием приходит захват до истечения срока
|
- **WHEN** приходит захват до истечения срока
|
||||||
- **THEN** задача ему не выдаётся
|
- **THEN** запись ему не выдаётся
|
||||||
|
|
||||||
### Requirement: Число попыток и состояние «мертва»
|
#### Scenario: Срок протухания приехал с рубежом
|
||||||
|
|
||||||
У задачи SHALL быть число попыток. Оно MUST расти при каждом захвате и MUST
|
- **GIVEN** записи двух рубежей с разными сроками захвата пригодны к работе
|
||||||
возвращаться к нулю, когда шаг завершился без отказа. Рост при захвате, а не при
|
- **WHEN** их захватывает один и тот же воркер
|
||||||
отказе, засчитывает попытку и задаче, брошенной на середине: шаг, уносящий с
|
- **THEN** у каждой записан срок её рубежа
|
||||||
собой процесс, до объявления отказа не доходит никогда, и без этого такая задача
|
|
||||||
крутилась бы вечно.
|
|
||||||
|
|
||||||
Задача, захваченная с числом попыток сверх заданного предела, 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** следующий захват выдаёт её снова
|
|
||||||
|
|
||||||
### Requirement: Пауза перед повтором нарастает
|
### Requirement: Пауза перед повтором нарастает
|
||||||
|
|
||||||
Перед повтором **отказавшей** задачи сервис SHALL выдерживать паузу, и пауза
|
Перед повтором **отказавшей** записи сервис SHALL выдерживать паузу, и пауза
|
||||||
MUST расти с числом её попыток до объявленного потолка. Задача MUST не
|
MUST расти с числом её отказов до объявленного потолка. Запись MUST не
|
||||||
выдаваться захвату, пока пауза не кончилась.
|
выдаваться захвату, пока пауза не кончилась.
|
||||||
|
|
||||||
Ожидание чужой операции этой паузой MUST не выражаться. Шаг, увидевший, что
|
Ожидание чужой операции этой паузой MUST не выражаться. Шаг, увидевший, что
|
||||||
внешняя операция ещё идёт, отработал без отказа: он назначает **свою** задержку
|
внешняя операция ещё идёт, отработал без отказа: он **откладывает** работу своей
|
||||||
опроса, заданную числом, и попытки при этом не тратит. Пауза, выведенная из
|
задержкой, заданной числом, и отказов при этом не тратит. Пауза, выведенная из
|
||||||
числа попыток, на таком шаге вырождается в наименьшее своё значение и учащает
|
числа отказов, на таком шаге вырождается в наименьшее своё значение и учащает
|
||||||
опрос внешнего сервиса во столько раз, во сколько задержка опроса длиннее секунды.
|
опрос внешнего сервиса во столько раз, во сколько задержка опроса длиннее
|
||||||
|
секунды.
|
||||||
|
|
||||||
#### Scenario: Отказавшая задача ждёт
|
#### Scenario: Отказавшая запись ждёт
|
||||||
|
|
||||||
- **GIVEN** задача отказала на шаге конвейера
|
- **GIVEN** запись отказала на шаге конвейера
|
||||||
- **WHEN** захват приходит раньше конца её паузы
|
- **WHEN** захват приходит раньше конца её паузы
|
||||||
- **THEN** задача ему не выдаётся
|
- **THEN** запись ему не выдаётся
|
||||||
|
|
||||||
#### Scenario: Вторая пауза длиннее первой
|
#### Scenario: Вторая пауза длиннее первой
|
||||||
|
|
||||||
- **GIVEN** задача отказала дважды подряд
|
- **GIVEN** запись отказала дважды подряд
|
||||||
- **WHEN** сравнивают паузу после второго отказа с паузой после первого
|
- **WHEN** сравнивают паузу после второго отказа с паузой после первого
|
||||||
- **THEN** вторая длиннее
|
- **THEN** вторая длиннее
|
||||||
|
|
||||||
#### Scenario: Ожидание операции не учащается и не тратит попыток
|
#### Scenario: Ожидание операции не учащается и не тратит отказов
|
||||||
|
|
||||||
- **GIVEN** внешняя операция распознавания ещё идёт
|
- **GIVEN** внешняя операция распознавания ещё идёт
|
||||||
- **WHEN** шаг проверки отрабатывает подряд несколько раз
|
- **WHEN** шаг опроса отрабатывает подряд несколько раз
|
||||||
- **THEN** задержка до следующей проверки каждый раз одна и та же
|
- **THEN** задержка до следующей проверки каждый раз одна и та же
|
||||||
- **AND** число попыток задачи не растёт
|
- **AND** число отказов записи не растёт
|
||||||
|
|
||||||
### Requirement: Недоставленный ответ не роняет шаг
|
### Requirement: Недоставленный ответ не роняет шаг
|
||||||
|
|
||||||
Шаг конвейера SHALL доводить задачу до достигнутого состояния, когда ответ
|
Шаг конвейера SHALL доводить запись до достигнутого рубежа, когда ответ
|
||||||
отправителю доставить не удалось, и MUST не считать недоставку отказом шага.
|
отправителю доставить не удалось, и MUST не считать недоставку отказом шага.
|
||||||
Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор
|
Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор
|
||||||
задачи, MUST называть причину и MUST считаться отдельной метрикой с причиной
|
записи, MUST называть причину и MUST считаться отдельной метрикой с причиной
|
||||||
меткой.
|
меткой.
|
||||||
|
|
||||||
Причин у недоставки две, и исход у них общий: **вход отправителя не поднят** —
|
Причин у недоставки две, и исход у них общий: **вход отправителя не поднят** —
|
||||||
задача заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
|
запись заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
|
||||||
у задачи не назван** — источником значится Telegram, а чата в задаче нет.
|
у записи не назван** — источником значится Telegram, а чата в записи нет.
|
||||||
|
|
||||||
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим,
|
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим,
|
||||||
и его уровень «может стать проблемой». Неназванный адресат — симптом порчи
|
и его уровень «может стать проблемой». Неназванный адресат — симптом порчи
|
||||||
записи: у задачи из Telegram чат есть всегда, и пропасть он может только от
|
записи: у записи из Telegram чат есть всегда, и пропасть он может только от
|
||||||
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
|
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
|
||||||
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
|
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
|
||||||
записей о ненастроенном боте.
|
записей о ненастроенном боте.
|
||||||
|
|
||||||
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
|
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
|
||||||
достигнутое состояние сохранено. Работа к этой минуте сделана, и объявленный
|
достигнутый рубеж сохранён. Работа к этой минуте сделана, и объявленный отказ
|
||||||
отказ засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть
|
засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть соврал бы
|
||||||
соврал бы про исход дважды. Повтор делу не помогает: ни бот, ни адресат от
|
про исход дважды. Повтор делу не помогает: ни бот, ни адресат от ожидания не
|
||||||
ожидания не появятся. Поэтому задача остаётся в достигнутом состоянии, в повтор
|
появятся. Поэтому запись остаётся на достигнутом рубеже, в повтор не уходит и
|
||||||
не уходит и в `failed` не переводится, а причина недоставки живёт в записи
|
**признака остановки не получает**, а причина недоставки живёт в записи журнала,
|
||||||
журнала, а не в состоянии задачи.
|
а не в рубеже записи.
|
||||||
|
|
||||||
Идентификатор задачи в записи обязателен: без него владелец видит, что ответ не
|
То же MUST относиться к недоставке сообщения об **остановке**: остановка уже
|
||||||
ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя в эту
|
сохранена, и недоставка её MUST не отменять.
|
||||||
запись MUST не попадать — приватность содержимого записи требование не
|
|
||||||
|
Идентификатор записи в этой строке обязателен: без него владелец видит, что
|
||||||
|
ответ не ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя
|
||||||
|
в эту запись MUST не попадать — приватность содержимого записи требование не
|
||||||
ослабляет.
|
ослабляет.
|
||||||
|
|
||||||
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
|
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
|
||||||
@@ -299,60 +303,410 @@ MUST расти с числом её попыток до объявленног
|
|||||||
|
|
||||||
#### Scenario: Вход отправителя не поднят
|
#### Scenario: Вход отправителя не поднят
|
||||||
|
|
||||||
- **GIVEN** задача принята входом Telegram прошлым запуском сервиса
|
- **GIVEN** запись принята входом Telegram прошлым запуском сервиса
|
||||||
- **AND** сервис поднялся без этого входа
|
- **AND** сервис поднялся без этого входа
|
||||||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||||||
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
|
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
|
||||||
- **AND** задача остаётся в достигнутом состоянии, в повтор не уходит и в
|
- **AND** запись остаётся на достигнутом рубеже, в повтор не уходит и признака
|
||||||
`failed` не переводится
|
остановки не получает
|
||||||
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
|
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
|
||||||
задачи и причиной
|
записи и причиной
|
||||||
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
|
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
|
||||||
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
|
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
|
||||||
|
|
||||||
#### Scenario: Адресат у задачи не назван
|
#### Scenario: Адресат у записи не назван
|
||||||
|
|
||||||
- **GIVEN** у задачи источником значится Telegram, а чат не назван
|
- **GIVEN** у записи источником значится Telegram, а чат не назван
|
||||||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||||||
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
|
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
|
||||||
- **AND** задача остаётся в достигнутом состоянии
|
- **AND** запись остаётся на достигнутом рубеже
|
||||||
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
|
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
|
||||||
задачи и причиной: неназванный адресат — симптом порчи записи
|
записи и причиной: неназванный адресат — симптом порчи записи
|
||||||
|
|
||||||
|
#### Scenario: Не доехало сообщение об остановке
|
||||||
|
|
||||||
|
- **GIVEN** запись остановлена признаком
|
||||||
|
- **AND** вход отправителя не поднят
|
||||||
|
- **WHEN** шаг доходит до ответа отправителю
|
||||||
|
- **THEN** признак остановки у записи остаётся
|
||||||
|
- **AND** в журнале есть запись о недоставке с идентификатором записи и причиной
|
||||||
|
|
||||||
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
|
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
|
||||||
|
|
||||||
- **GIVEN** задача принята по HTTP
|
- **GIVEN** запись принята по HTTP
|
||||||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||||||
- **THEN** шаг завершается без отказа и без записи о недоставке
|
- **THEN** шаг завершается без отказа и без записи о недоставке
|
||||||
|
|
||||||
### Requirement: Выборка воркера владельцем не сужается
|
### Requirement: Выборка воркера владельцем не сужается
|
||||||
|
|
||||||
Воркер SHALL брать задачи всех владельцев подряд и MUST не учитывать владельца
|
Воркер SHALL брать записи всех владельцев подряд и MUST не учитывать владельца
|
||||||
при выборе очередной задачи. Задача без владельца — принятая ботом — MUST
|
при выборе очередной записи. Запись без владельца — принятая ботом — MUST
|
||||||
обрабатываться наравне с прочими.
|
обрабатываться наравне с прочими.
|
||||||
|
|
||||||
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
|
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
|
||||||
владельцем остановило бы расшифровку записей бота вовсе, а записи остальных
|
владельцем остановило бы расшифровку записей бота вовсе, а записи остальных
|
||||||
поставило бы в зависимость от того, кто первым завёл учётную запись.
|
поставило бы в зависимость от того, кто первым завёл учётную запись.
|
||||||
|
|
||||||
Владелец задачи MUST переживать работу конвейера: шаг, сохраняющий свой
|
Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой
|
||||||
результат, владельца не трогает и не затирает.
|
результат, владельца не трогает и не затирает.
|
||||||
|
|
||||||
#### Scenario: Задачи двух владельцев проходят одним воркером
|
#### Scenario: Записи двух владельцев проходят одним воркером
|
||||||
|
|
||||||
- **GIVEN** заведены задачи двух разных владельцев в одном состоянии
|
- **GIVEN** заведены записи двух разных владельцев на одном рубеже
|
||||||
- **WHEN** воркер забирает задачи этого состояния
|
- **WHEN** воркер забирает работу
|
||||||
- **THEN** ему достаются обе, в порядке заведения
|
- **THEN** ему достаются обе, в порядке заведения
|
||||||
|
|
||||||
#### Scenario: Задача без владельца обрабатывается
|
#### Scenario: Запись без владельца обрабатывается
|
||||||
|
|
||||||
- **GIVEN** заведена задача, принятая ботом, — без владельца
|
- **GIVEN** заведена запись, принятая ботом, — без владельца
|
||||||
- **WHEN** воркер забирает задачи её состояния
|
- **WHEN** воркер забирает работу
|
||||||
- **THEN** она достаётся ему наравне с прочими
|
- **THEN** она достаётся ему наравне с прочими
|
||||||
|
|
||||||
#### Scenario: Шаг конвейера владельца не затирает
|
#### Scenario: Шаг конвейера владельца не затирает
|
||||||
|
|
||||||
- **GIVEN** задача с владельцем прошла шаг конвейера
|
- **GIVEN** запись с владельцем прошла шаг конвейера
|
||||||
- **WHEN** шаг сохраняет свой результат
|
- **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** оно не приблизилось к пределу
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -2,14 +2,17 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Где живут запись, её метаданные и её файл: раскладка каталога данных, приведение
|
Где живут аудиозапись, её приложения и её файлы: раскладка каталога данных,
|
||||||
схемы при подъёме, отдача файла ссылкой по токену, собственная поверхность
|
приведение схемы при подъёме, отдача файла ссылкой по токену, собственная
|
||||||
хранилища и панель владельца.
|
поверхность хранилища и панель владельца.
|
||||||
|
|
||||||
Приём и опрос готовности нормирует `intake`, вход и сессию — `access`.
|
Приём и опрос готовности нормирует `intake`, вход и сессию — `access`, попытку
|
||||||
Сознательно не описаны: перенос прежних данных — его нет по решению задачи
|
распознавания у внешнего провайдера — `recognition`.
|
||||||
`pocketbase-storage`; удаление записей и файлов — сервис объявлен архивом
|
|
||||||
2026-08-11, а удаление приносит задача `delete-record`.
|
Сознательно не описаны: перенос прежних данных — его нет ни по решению задачи
|
||||||
|
`pocketbase-storage`, ни по решению владельца 2026-08-14, которым прежние записи
|
||||||
|
удалены вместе с остановкой сервиса; удаление записей и файлов — сервис объявлен
|
||||||
|
архивом 2026-08-11, а удаление приносит задача `delete-record`.
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Сервис поднимается на чистом каталоге данных
|
### Requirement: Сервис поднимается на чистом каталоге данных
|
||||||
|
|
||||||
@@ -208,22 +211,24 @@ MUST завести свою схему и принимать записи об
|
|||||||
|
|
||||||
### Requirement: Владелец видит записи в панели
|
### Requirement: Владелец видит записи в панели
|
||||||
|
|
||||||
Сервис SHALL давать владельцу панель, где задача видна строкой, отбирается по
|
Сервис SHALL давать владельцу панель, где аудиозапись видна строкой, отбирается
|
||||||
своему идентификатору и правится, а её файл слушается и скачивается.
|
по своему идентификатору и правится, а её файлы слушаются и скачиваются.
|
||||||
|
|
||||||
Панель MUST отдаваться тем же сервисом по своему адресу и MUST не требовать
|
Панель MUST отдаваться тем же сервисом по своему адресу и MUST не требовать
|
||||||
второго процесса.
|
второго процесса.
|
||||||
|
|
||||||
Панель — вход в задачу наравне с конвейером, а не окно просмотра, и правка
|
Панель — вход в запись наравне с конвейером, а не окно просмотра. Снятие
|
||||||
состояния задачи в ней MUST подчиняться тем же правилам перехода, что и правка
|
признака остановки в панели MUST возвращать запись в работу с сохранённого
|
||||||
из кода: служебные поля прошлого состояния — признак захвата, время захвата,
|
рубежа и MUST очищать служебные поля прошлого захвата — признак захвата, срок
|
||||||
пауза, число попыток — MUST очищаться. Иначе владелец, вернувший мёртвую задачу в
|
его протухания, паузу, число отказов — и MUST заново ставить время входа в
|
||||||
работу, получит задачу, которая не выдаётся захвату до конца прежнего срока и
|
рубеж. Правка рубежа руками MUST делать то же самое. Иначе владелец, вернувший
|
||||||
умирает от первого же отказа, — и не узнает об этом.
|
запись в работу, получит запись, которая не выдаётся захвату до конца прежнего
|
||||||
|
срока, останавливается от первого же отказа или останавливается снова первым же
|
||||||
|
захватом по пределу времени, — и не узнает об этом.
|
||||||
|
|
||||||
Задача, заведённая в панели руками, MUST не уносить сервис: поля, без которых
|
Запись, заведённая в панели руками, MUST не уносить сервис: поля, без которых
|
||||||
шаг конвейера не может работать, MUST быть обязательными в самой схеме, а
|
шаг конвейера не может работать, MUST быть обязательными в самой схеме, а
|
||||||
перечень состояний — закрытым.
|
перечень рубежей — закрытым.
|
||||||
|
|
||||||
Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все
|
Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все
|
||||||
записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не
|
записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не
|
||||||
@@ -231,18 +236,20 @@ MUST завести свою схему и принимать записи об
|
|||||||
|
|
||||||
#### Scenario: Принятая запись видна владельцу
|
#### Scenario: Принятая запись видна владельцу
|
||||||
|
|
||||||
- **GIVEN** запись принята и её задача заведена
|
- **GIVEN** запись принята и заведена
|
||||||
- **WHEN** владелец отбирает задачи по идентификатору принятой
|
- **WHEN** владелец отбирает записи по идентификатору принятой
|
||||||
- **THEN** он видит её строкой со своим состоянием
|
- **THEN** он видит её строкой со своим рубежом
|
||||||
- **AND** файл этой записи скачивается из той же строки
|
- **AND** её файл скачивается из той же строки
|
||||||
|
|
||||||
#### Scenario: Мёртвую задачу вернули в работу правкой в панели
|
#### Scenario: Остановленную запись вернули в работу правкой в панели
|
||||||
|
|
||||||
- **GIVEN** задача в состоянии «мертва» с исчерпанными попытками и признаком
|
- **GIVEN** запись остановлена признаком, с накопленными отказами и признаком
|
||||||
прежнего захвата
|
прежнего захвата
|
||||||
- **WHEN** владелец меняет её состояние на рабочее
|
- **AND** остановленной она простояла дольше предела времени в рубеже
|
||||||
- **THEN** признак захвата, время захвата, пауза и число попыток очищены
|
- **WHEN** владелец снимает признак остановки
|
||||||
- **AND** ближайший захват выдаёт задачу
|
- **THEN** признак захвата, срок его протухания, пауза и число отказов очищены
|
||||||
|
- **AND** время входа в рубеж поставлено заново
|
||||||
|
- **AND** ближайший захват выдаёт запись с сохранённого рубежа
|
||||||
|
|
||||||
### Requirement: Пароль владельца от панели не лежит в конфигурации
|
### Requirement: Пароль владельца от панели не лежит в конфигурации
|
||||||
|
|
||||||
@@ -278,8 +285,8 @@ MUST завести свою схему и принимать записи об
|
|||||||
|
|
||||||
### Requirement: Владелец задачи лежит связью с учётной записью
|
### Requirement: Владелец задачи лежит связью с учётной записью
|
||||||
|
|
||||||
Хранилище SHALL держать владельца задачи расшифровки отдельной колонкой — связью
|
Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с
|
||||||
с учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
|
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
|
||||||
не назван, не достаётся никому по недосмотру схемы.
|
не назван, не достаётся никому по недосмотру схемы.
|
||||||
|
|
||||||
Колонка MUST допускать пустое значение, и это решение с названной ценой: записи,
|
Колонка MUST допускать пустое значение, и это решение с названной ценой: записи,
|
||||||
@@ -287,35 +294,33 @@ MUST завести свою схему и принимать записи об
|
|||||||
записью сервис не ведёт. Обязательность для приёма по HTTP держит сама
|
записью сервис не ведёт. Обязательность для приёма по HTTP держит сама
|
||||||
capability `intake`, а не схема.
|
capability `intake`, а не схема.
|
||||||
|
|
||||||
Колонка приезжает **новым шагом схемы**: применённый шаг не переписывается.
|
Владелец MUST не назначаться и не меняться конвейером.
|
||||||
Записей, заведённых до этого шага, сервис не переносит — проект заводится с
|
|
||||||
чистого листа.
|
|
||||||
|
|
||||||
#### Scenario: Колонка появляется на пустой базе
|
#### Scenario: Колонка появляется на пустой базе
|
||||||
|
|
||||||
- **WHEN** сервис поднимается на чистом каталоге данных
|
- **WHEN** сервис поднимается на чистом каталоге данных
|
||||||
- **THEN** у таблицы задач есть колонка владельца
|
- **THEN** у аудиозаписи есть колонка владельца
|
||||||
- **AND** умолчания у неё нет
|
- **AND** умолчания у неё нет
|
||||||
|
|
||||||
|
#### Scenario: Конвейер владельца не назначает
|
||||||
|
|
||||||
|
- **GIVEN** запись с владельцем прошла шаг конвейера
|
||||||
|
- **WHEN** смотрят её владельца
|
||||||
|
- **THEN** он прежний
|
||||||
|
|
||||||
### Requirement: Файл записи сужается владельцем наравне с задачей
|
### Requirement: Файл записи сужается владельцем наравне с задачей
|
||||||
|
|
||||||
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
|
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
|
||||||
записью, тем же шагом схемы, — и правило просмотра файлов MUST пускать к файлу
|
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
|
||||||
только его владельца. Прежнее правило пускало всякого узнанного, и знание
|
|
||||||
идентификатора файловой записи равнялось праву скачать чужое аудио.
|
|
||||||
|
|
||||||
Без этого требования разграничение закрывает метаданные задачи и оставляет
|
Владелец файла MUST назначаться там же, где владелец записи, — при приёме, из
|
||||||
открытым содержимое — то самое, что оно и заведено прятать. Хуже самой дыры была
|
|
||||||
бы отметка о закрытии: паспорт и модель угроз называют исполнителем этой работы
|
|
||||||
именно эту задачу, и слово «закрыто» скрыло бы открытый путь.
|
|
||||||
|
|
||||||
Владелец файла MUST назначаться там же, где владелец задачи, — при приёме, из
|
|
||||||
предъявленной сессии, — и MUST оставаться пустым у файлов, заведённых конвейером
|
предъявленной сессии, — и MUST оставаться пустым у файлов, заведённых конвейером
|
||||||
для записи без владельца.
|
для записи без владельца.
|
||||||
|
|
||||||
Ссылка на файл в задаче переставляется каждым шагом конвейера, поэтому владелец
|
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
|
||||||
файла MUST лежать своей колонкой, а не выводиться через задачу: исходная копия
|
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
|
||||||
после конвертации не связана с задачей ничем.
|
выводиться через запись: файл переживает свою запись, и заведённый шагом до
|
||||||
|
сохранения записи он остаётся с владельцем и без ссылки.
|
||||||
|
|
||||||
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
|
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
|
||||||
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
|
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
|
||||||
@@ -343,15 +348,21 @@ capability `intake`, а не схема.
|
|||||||
|
|
||||||
### Requirement: Учётная запись с записями не удаляется
|
### Requirement: Учётная запись с записями не удаляется
|
||||||
|
|
||||||
Хранилище SHALL отвергать удаление учётной записи, у которой остались задачи
|
Хранилище SHALL отвергать удаление учётной записи, у которой остались
|
||||||
расшифровки **либо файлы**. Отказ MUST называть причину, и MUST доезжать до
|
аудиозаписи **либо файлы**. Отказ MUST называть причину, и MUST доезжать до
|
||||||
спрашивающего: хранилище пропускает наружу только свою ошибку роутера, а всякую
|
спрашивающего: хранилище пропускает наружу только свою ошибку роутера, а всякую
|
||||||
другую подменяет сообщением про обязательную связь — подсказкой, по которой
|
другую подменяет сообщением про обязательную связь — подсказкой, по которой
|
||||||
владелец панели пойдёт удалять записи руками.
|
владелец панели пойдёт удалять записи руками.
|
||||||
|
|
||||||
Считаются обе коллекции с владельцем. Файл переживает свою задачу: шаг конвейера
|
Считаются **все** коллекции с колонкой владельца, и перечень их MUST жить одним
|
||||||
заводит его до сохранения задачи, и потерянный захват оставляет файл с владельцем
|
местом: коллекция, пропущенная в счёте, пропускает удаление вперёд, и наружу
|
||||||
и без ссылки.
|
приезжает не наш отказ с причиной, а подсказка библиотеки про обязательную связь
|
||||||
|
— та самая, по которой владелец панели пойдёт удалять записи руками. Сегодня их
|
||||||
|
три: аудиозаписи, файлы и словарь тем.
|
||||||
|
|
||||||
|
Файл переживает свою запись: шаг конвейера заводит его до сохранения записи, и
|
||||||
|
потерянный захват оставляет файл с владельцем и без ссылки. Тема переживает её
|
||||||
|
так же: словарь принадлежит человеку, а не записи.
|
||||||
|
|
||||||
Запрет MUST ставить сама сборка хранилища, а не вызывающий: сборка, забывшая его
|
Запрет MUST ставить сама сборка хранилища, а не вызывающий: сборка, забывшая его
|
||||||
позвать, теряет защиту молча — и теряла, пока запрет вешался отдельной строкой
|
позвать, теряет защиту молча — и теряла, пока запрет вешался отдельной строкой
|
||||||
@@ -361,12 +372,6 @@ capability `intake`, а не схема.
|
|||||||
удалить **свою** учётную запись запросом, так что запрет закрывает и публичную
|
удалить **свою** учётную запись запросом, так что запрет закрывает и публичную
|
||||||
поверхность.
|
поверхность.
|
||||||
|
|
||||||
Требование заведено вместо прежнего «удаление не уносит задачи следом»: оно
|
|
||||||
выглядело выполненным, а на деле хранилище при выключенном каскаде **снимает
|
|
||||||
ссылку** — задачи остаются, но становятся ничьими, а ничья задача не достаётся
|
|
||||||
по API никому. Архив человека исчезал бы молча и восстановлению не подлежал:
|
|
||||||
прежнего владельца не остаётся нигде.
|
|
||||||
|
|
||||||
Цена требования названа прямо: владелец панели упирается в отказ, а способа
|
Цена требования названа прямо: владелец панели упирается в отказ, а способа
|
||||||
удалить записи в сервисе пока нет вовсе — его приносит задача про удаление
|
удалить записи в сервисе пока нет вовсе — его приносит задача про удаление
|
||||||
записи. До неё удаление учётной записи с записями невозможно, и это осознанный
|
записи. До неё удаление учётной записи с записями невозможно, и это осознанный
|
||||||
@@ -374,20 +379,175 @@ capability `intake`, а не схема.
|
|||||||
|
|
||||||
#### Scenario: Удаление учётной записи с записями отвергается
|
#### Scenario: Удаление учётной записи с записями отвергается
|
||||||
|
|
||||||
- **GIVEN** у учётной записи есть задачи расшифровки
|
- **GIVEN** у учётной записи есть аудиозаписи
|
||||||
- **WHEN** её удаляют
|
- **WHEN** её удаляют
|
||||||
- **THEN** удаление не проходит, а отказ называет причину
|
- **THEN** удаление не проходит, а отказ называет причину
|
||||||
- **AND** задачи и их владелец остаются прежними
|
- **AND** записи и их владелец остаются прежними
|
||||||
|
|
||||||
#### Scenario: Учётная запись с одними файлами тоже не удаляется
|
#### Scenario: Учётная запись с одними файлами тоже не удаляется
|
||||||
|
|
||||||
- **GIVEN** у учётной записи остались файлы, но задач нет
|
- **GIVEN** у учётной записи остались файлы, но записей нет
|
||||||
- **WHEN** её удаляют
|
- **WHEN** её удаляют
|
||||||
- **THEN** удаление не проходит, а владелец файлов остаётся прежним
|
- **THEN** удаление не проходит, а владелец файлов остаётся прежним
|
||||||
|
|
||||||
|
#### Scenario: Учётная запись с одними темами тоже не удаляется
|
||||||
|
|
||||||
|
- **GIVEN** у учётной записи остались темы словаря, но ни записей, ни файлов нет
|
||||||
|
- **WHEN** её удаляют
|
||||||
|
- **THEN** удаление не проходит, а отказ называет причину нашими словами
|
||||||
|
|
||||||
#### Scenario: Учётная запись без записей удаляется
|
#### Scenario: Учётная запись без записей удаляется
|
||||||
|
|
||||||
- **GIVEN** у учётной записи нет ни задач, ни файлов
|
- **GIVEN** у учётной записи нет ни аудиозаписей, ни файлов, ни тем
|
||||||
- **WHEN** её удаляют
|
- **WHEN** её удаляют
|
||||||
- **THEN** удаление проходит
|
- **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** назначение не проходит
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user